# raresats.net API > Public JSON API of raresats.net, a non-custodial rare-sat marketplace and data site on Bitcoin mainnet. Read listings, floors, sales, sats and addresses; list, buy, bid and cancel with your own wallet (prepare, sign a PSBT, submit). No authentication. Base URL https://raresats.net. - Base URL `https://raresats.net`; no auth, no API key; JSON in and out. - POST bodies: `content-type: application/json`, at most 256 KiB. A POST with a foreign `Origin` header is refused; server-side clients send none. - Amounts are integer sats; unit prices are numbers (hundredths of a sat); PSBTs are base64; mainnet only. - Errors: `{error, code}` on `/api/market/*`, `{error}` elsewhere. - Rate limits: 50 requests per 10 s per IP on `/api/*`; 30 POSTs a minute per client. - Buyers pay a 2.5% platform fee (at least 546 sats) on top of the listing price; sellers pay nothing. Every purchase carries an `OP_RETURN raresats.net` marker. - Offers (bids): anyone may bid on a tracked UTXO, listed or not; the bidder pays the 2.5% fee (at least 546 sats) on top of the offer price, signs first, and the holder accepts by signing their UTXO. Offers expire after 1, 3, 7 (default) or 30 days, or never. - Affiliates: any address's `?ref=
` or `/r/
` links, or the `affiliate` field of a purchase, split the platform fee 50/50 with the referrer in the purchase transaction; self-referrals are not paid. - Chat and display names: reading is public; posting needs a session opened by a BIP-322 signature (an HttpOnly cookie), and every display-name change its own signature. Source: https://raresats.net/docs (each page also at https://raresats.net/docs/.md). OpenAPI: https://raresats.net/openapi.json. --- Page: https://raresats.net/docs/index.md # raresats.net API raresats.net is a non-custodial marketplace and data site for rare sats on Bitcoin mainnet. Its public JSON API is the one the site itself uses: read listings, floors, sales, sats and addresses, and list, buy or cancel with your own wallet. The server never holds keys or funds. Every trade is three calls: **prepare** (the server builds a PSBT), **sign** (your wallet or library signs it), **submit** (the server checks, completes and broadcasts it). ## Base URL ```text https://raresats.net ``` - **No authentication**, no API key, no account. - **JSON only.** Responses are `application/json` (except the event stream and one redirect). POST bodies must be JSON with `content-type: application/json`, at most 256 KiB. - **Mainnet only.** Addresses are mainnet addresses; `GET /api/market/config` reports the network. - **Amounts** are integers in sats. **Unit prices** are numbers to the hundredth of a sat. **PSBTs** are base64. **Times** are unix seconds. - **Errors** are `{"error": "...", "code": "..."}` on the marketplace routes (`/api/market/*`) and `{"error": "..."}` on the data routes. See [Errors and limits](https://raresats.net/docs/errors.md). - **Rate limits:** 50 requests per 10 seconds per IP on `/api/*`, and 30 POSTs a minute per client. See [Errors and limits](https://raresats.net/docs/errors.md). - **Browsers:** a POST from another site (a foreign `Origin` header) is refused. Call the API from your server or script; those send no `Origin`. ## A first request The five cheapest uncommon sats for sale, by price per sat: ```bash title="cURL" curl 'https://raresats.net/api/market/listings?row=uncommon&sort=unit&order=asc&limit=5' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/market/listings?row=uncommon&sort=unit&order=asc&limit=5'); const { listings, total } = await response.json(); for (const listing of listings) { console.log(listing.id, listing.outpoint, listing.price, 'sats,', listing.unit_price, 'sats per sat'); } ``` ```python title="Python" import requests page = requests.get("https://raresats.net/api/market/listings", params={"row": "uncommon", "sort": "unit", "order": "asc", "limit": 5}).json() for listing in page["listings"]: print(listing["id"], listing["outpoint"], listing["price"], listing["unit_price"]) ``` Each item is a [Listing](https://raresats.net/docs/listing-object.md). Its `price` is what the seller receives; a buyer pays that plus the 2.5% platform fee and the network fee. ## What is where - [Concepts](https://raresats.net/docs/concepts.md): rows (collections), unit prices, the fee, the transaction layout, listing statuses. - **Read the marketplace:** [listings](https://raresats.net/docs/get-listings.md), [one listing](https://raresats.net/docs/get-listing.md), [by outpoint](https://raresats.net/docs/get-listing-by-outpoint.md), [floors](https://raresats.net/docs/get-floors.md), [settings](https://raresats.net/docs/get-config.md), [fee rates](https://raresats.net/docs/get-fees.md), [activity](https://raresats.net/docs/get-activity.md). - **Trade:** the guides [List a UTXO](https://raresats.net/docs/guide-list.md), [Buy listings](https://raresats.net/docs/guide-buy.md) and [Cancel listings](https://raresats.net/docs/guide-cancel.md), and the reference for each call under Trading API. - **Market data** across every marketplace: [market overview](https://raresats.net/docs/get-market.md), [sales](https://raresats.net/docs/get-sales.md), [sale parties](https://raresats.net/docs/get-sales-parties.md), [sales stats](https://raresats.net/docs/get-sales-stats.md), [a sat](https://raresats.net/docs/get-sat.md), [an address](https://raresats.net/docs/get-address.md), [an address's activity](https://raresats.net/docs/get-address-activity.md). ## For LLMs and tools - [/llms.txt](https://raresats.net/llms.txt): an index of these pages, as links to their Markdown. - [/llms-full.txt](https://raresats.net/llms-full.txt): every page in one Markdown file. - [/openapi.json](https://raresats.net/openapi.json): the OpenAPI 3.1 document, the source of every parameter, schema, example and error code on these pages. - Every page is also Markdown: add `.md` to its URL (this one is [/docs/index.md](https://raresats.net/docs/index.md)). --- Page: https://raresats.net/docs/concepts.md # Concepts ## Rows (collections) The index tracks rare sats in **rows**: `uncommon`, `rare`, `black`, `alpha_uncommon`, `block9`, `450x`, `vintage_alpha`, `pizza_palindrome` and many more. A row is a kind of sat; on the site a market row is a **collection** (`/collection/`). `GET /api/supply` lists every row's definition (`id`, `label`, `all_of` types, `total`, and `market`). - A **listing's own row** (`row`) is the rarest market row holding *every* tracked sat of the UTXO (a "pure" row). When no row holds them all, it is the rarest market row present. This is how sales are placed too, so rows don't overlap: a plain uncommon is `uncommon`, a 2012 uncommon is `alpha_uncommon`, a block 9 chunk is `block9`, a 450x chunk is `450x`. - **The primary-sat rule (sales).** A sale that no single row holds whole (a bundle) is placed under its **primary** row: the rarest market row it sold sats of, and on a tie in rarity a first-sat row (the uncommon side: `uncommon`, `rare`, `epic`, `alpha_uncommon`, …) before a last-sat row (the black side: `black`, `black_rare`, `black_omega`, …). So the classic pair, the last sat of one bitcoin and the first of the next, is an `alpha_uncommon` sale, and its black omega is a **secondary row**: every sale names its secondary rows (`secondary`: `{"black_omega": 1}`), and a secondary row's markets, medians and volumes never count it, so nothing is counted twice. `GET /api/sales?row=black_omega&led=1` lists those sales as context. - A **label row** (`market: false`, e.g. `block9_1st_tx`) is never a listing's own row. Filtering listings by a label row lists everything lying in it. - A **rarer sub-category** of a row is a row whose sats it also holds whole (an `alpha_uncommon` is also an uncommon). `GET /api/market/listings?row=uncommon&sub=1` adds them. The milestone alphas, the first sat of a thousand, ten thousand, a hundred thousand or a million bitcoin (`kilosat_alpha`, `decasat_alpha`, `centasat_alpha`, `megasat_alpha`), are shown as Kilosat, Decasat, Centasat and Megasat uncommon, Uncommon's rarest sub-categories (`sub_of` in the `GET /api/supply` rows). All but 20 of them (in blocks after height 2,100,000) are the first sat of a block, but the 79 of them that are rare, epic or mythic are not also uncommon: they are never listed or counted under `uncommon`. Likewise the milestone omegas, the last sat of a thousand bitcoin and so on (`kilosat_omega`, `decasat_omega`, `centasat_omega`, `megasat_omega`), are shown as Kilosat, Decasat, Centasat and Megasat black, Black's rarest sub-categories: all but 20 are the last sat of a block, but the 78 that are black rare or black epic (the last sat before a difficulty adjustment or a halving) are never listed or counted under `black`. The pair rule treats them as black sats: a Kilosat black sold or listed with a Kilosat uncommon (or any alpha uncommon) is the uncommon's, with the Kilosat black as its secondary row, though Megasat black (20 sats) is rarer than Megasat uncommon (21). - `rows` on a listing gives every row the UTXO's sats count toward, label rows included; `types` groups its sats by exact type combination. ## Price, unit price and bulk - **`price`** is the listing price in sats: exactly what the seller receives (the seller's payment output). Sellers pay no fee. - **`value`** is the UTXO's own value. It moves to the buyer whole. - **`count`** is how many sats of the listing's own row the UTXO holds. A UTXO of 100 black sats is one listing with `count: 100` ("×100"). - **`unit_price`** = `(price − value) / count`, to the hundredth of a sat: the premium per sat of the row, over the UTXO's own value. It is the same measure as the sales' unit prices, so listings and sales compare directly. Example: a block 9 chunk of 6,280 sats in a 6,280-sat UTXO listed at 94,200 sats has `unit_price` = (94,200 − 6,280) / 6,280 = **14** sats per sat. A row's **floor** is the lowest `unit_price` among its active listings ([floors](https://raresats.net/docs/get-floors.md)). A listing is **in bundle** under a row when it holds some of that row's sats but not all of its tracked sats: a 330-sat Block 9 chunk holding one Block 9 palindrome is a `block9` listing, in bundle under `block9_palindrome` and `nakamoto_palindrome`; the omega of an alpha+omega pair is in bundle under the omega's row. It shows on those rows' pages ("Also contains", `contains=1`), and its events and offers in their activity, marked `in_bundle`, but it is priced only in its own row: it never sets their floor, unit price or statistics ([floors](https://raresats.net/docs/get-floors.md) count it apart, as `contains`). ## What can be listed A UTXO can be listed when all of these hold: - it is confirmed and unspent (mempool included); - it holds at least one tracked rare sat of a market row; - it carries no inscription and no runes (rare sats only); - it belongs to the seller address, which is **Taproot** (`bc1p…`), **native SegWit** (`bc1q…`) or **nested SegWit** (`3…`, P2SH-P2WPKH). Legacy `1…` addresses are not supported; - its price is a whole number of sats, above its own value, at least 546 and at least the dust limit of the payout address. ## The platform fee - The **buyer** pays a platform fee of **2.5%** of the summed listing prices, on top of them, with a minimum of **546 sats**: `fee = max(ceil(Σ price × 250 / 10000), 546)`. - It is one output per purchase to the fee address in `GET /api/market/config` (`fee_address`), currently `bc1qycdj49gt04zvc07xyzzgsvgglc7qyhz8r3sfs6`. - A purchase referred by an affiliate (any address but the fee address, no registration; not the buyer's own wallet) splits that fee 50/50 between the fee address and the affiliate, as a second output in the same transaction (both at least 546 sats); the buyer pays the same. See [Affiliates](https://raresats.net/docs/guide-affiliates.md). - Every purchase also carries exactly one **OP_RETURN marker**, value 0: `OP_RETURN `, script `6a0c72617265736174732e6e6574`. It is not a runestone and relays on default node policies. - The buyer also pays the network fee, at a rate they choose (at least `minimum` from `GET /api/market/fees`). ## The transaction layout Listings and purchases use Magisat's standard on-chain layout, signed SIGHASH_SINGLE|ANYONECANPAY by the seller. **Listing PSBT** (the seller signs): version 2, locktime 0. Input *i* is listed UTXO *i* (sequence `0xfffffffd`, `sighashType` 0x83); output *i* pays its price to the payout address. A seller's 0x83 signature commits to its own input and to the output at the same index, and nothing else, so a buyer can add their inputs and outputs around it. **Purchase** of *k* listings (the buyer signs their inputs; the server adds the sellers' signatures): | Inputs | | | --- | --- | | `0 … k` | k+1 **padding** UTXOs of the buyer's payment address (600-sat ones preferred) | | `k+1 … 2k` | the listed UTXOs, exactly as signed | | `2k+1 …` | the buyer's funding UTXOs | | Outputs | | | --- | --- | | `0` | the padding's sats, back to the buyer's payment address | | `1 … k` | each listed UTXO's value, whole and in order, to the buyer's **ordinals** address | | `k+1 … 2k` | the sellers' payments, byte for byte as listed (output index = seller input index) | | `2k+1` | the platform fee (its platform share when referred) | | `2k+2` | only when referred: the affiliate's share of the fee | | next k+1 | new 600-sat padding outputs to the buyer's payment address (the next purchase needs no setup) | | next | change to the buyer's payment address, when above dust | | last | the OP_RETURN marker | The padding is what makes the sats land right: sats flow first-in first-out, so output 0 takes exactly the padding sats and each listed UTXO's sats land at offset 0 of their own output. When the payment address lacks k+1 padding UTXOs plus funding, prepare adds a **setup transaction** that makes them first (no fee, no marker); the purchase spends its outputs. See [Buy listings](https://raresats.net/docs/guide-buy.md). The buyer pays only with **plain cardinal** UTXOs of the payment address: confirmed, unspent, no inscription or rune, nothing the index tracks, and no sat of a collectible range. A buyer's rare sats are never spent as padding or funding. ## Listing statuses | Status | Meaning | | --- | --- | | `active` | For sale. | | `pending` | A purchase is in the mempool (`txid`). Back to `active` if it drops out unconfirmed while the UTXO is unspent. | | `sold` | The purchase confirmed (`txid`). Also when another transaction spent it paying the listing in full (a fee bump, or a reuse of the signature). | | `cancelled` | Cancelled by the seller, or replaced by a newer listing of the same UTXO. | | `moved` | The UTXO was spent without paying the listing. | Every live listing is rechecked against the chain on every new block and every minute; `as_of` on list answers says when. **Signature custody.** The sellers' signatures stay on the server; no response ever contains them, and purchase PSBTs carry none until submit. A cancelled, replaced, sold or moved listing's signature is deleted. Once a purchase is broadcast, though, its signatures are on the network (true of every SIGHASH_SINGLE|ANYONECANPAY marketplace): the listing then says `signature_public: true`, and only moving the UTXO makes a cancel certain. See [Cancel listings](https://raresats.net/docs/guide-cancel.md). --- Page: https://raresats.net/docs/errors.md # Errors and limits ## The error object Every `/api/market/*` route answers an error with its HTTP status and: ```json title="Error" { "error": "the cancel message is more than 10 minutes old: sign a new one", "code": "stale_message" } ``` - `code` is stable and machine-readable: branch on it. `error` is for people and may change. - Some codes carry extra fields: `outpoint`, `id` and `status` (`not_active`), `txid` (`broadcast_unknown`), `reason` and `setup_txid` (`conflict`, `rejected`), `available`, `prices` and `platform_fee` (`insufficient_funds`). - The data routes (`/api/market`, `/api/sales`, `/api/sat/…`, `/api/address/…`, `/api/rows/…` and the feeds) answer `{"error": "..."}` with no code. | Field | Type | Required | Description | | --- | --- | --- | --- | | `error` | string | yes | A human-readable message. Show it; do not parse it. | | `code` | string | yes | A stable machine-readable reason (see the error code table). | | `outpoint` | string | no | The outpoint the error is about, when there is one. | | `id` | string | no | The listing the error is about, when there is one. | | `status` | string | no | That listing's status (`not_active`). | | `txid` | string | no | The purchase transaction (`broadcast_unknown`). | | `setup_txid` | string | no | The setup transaction, when it was already broadcast (`conflict`, `rejected`). | | `reason` | string | no | bitcoind's rejection reason (`conflict`, `rejected`). | | `available` | integer | no | Sats the payment address can spend (`insufficient_funds`). | | `prices` | integer | no | Sum of the listing prices (`insufficient_funds`). | | `platform_fee` | integer | no | The platform fee (`insufficient_funds`). | | `violations` | array of object | no | Broken purchase invariants (`invariant_violated`). | | `violations[].rule` | string | yes | The rule broken | | `violations[].message` | string | yes | What is wrong | | `next_change_at` | integer | no | When the address may change its display name again, unix seconds (`name_change_too_soon`). | | `retired` | object | no | On a 404 for a retired row (`retired_row`): the row and where its sats are now. | | `retired.row` | string | yes | The retired row asked for. | | `retired.successor` | object \| null | yes | The parent row and years that hold its sats; null for a row retired outright (`alpha_jan_feb_2009`). | | `retired.successor.row` | string | yes | | | `retired.successor.years` | array of integer | yes | | | `category` | string | no | Why the AI moderation refused the text (`moderated`): harassment, hate, sexual, violence, doxxing, scam, spam, malware, illegal, impersonation, manipulation or other. | | `until` | integer | no | When the mute ends, unix seconds (`muted`; on `moderated` when that refusal muted the address). | ## Statuses | Status | Meaning | Retry? | | --- | --- | --- | | 400 | A malformed request. | No: fix it. | | 403 | A cross-origin browser POST. | No: call from a server. | | 404 | No such route, listing, outpoint listing or prepared purchase. | No. | | 405 | Wrong method; see the `allow` header. | No. | | 409 | A conflict with the chain or the store: spent, sold, changed, expired, a lost race. | Re-read, then prepare again. | | 413 | Body over 256 KiB. | No. | | 415 | A POST that is not `application/json`. | No. | | 422 | A marketplace rule the request breaks. | No: change the request. | | 429 | Rate limited. | After `retry-after` seconds. | | 500 | A server bug. | Report it. | | 503 | bitcoind, the rare-sat index or the ord indexer is unavailable or catching up; or `broadcast_unknown`. | Yes, after a short wait (`broadcast_unknown`: submit the same purchase again). | ## Every error code | Code | Status | Meaning | | --- | --- | --- | | `bad_address` | 400 | An address is not a mainnet address (or not one at all). | | `bad_addresses` | 400 | `addresses` must list 1 to 200 addresses. | | `bad_bidder` | 400 | The offer's `bidder` is missing, not an object, or lacks `ordinals_public_key`. | | `bad_buyer` | 400 | `buyer` is not an object. | | `bad_config` | 500 | The marketplace is misconfigured. | | `bad_contains` | 400 | `contains` is not 0, 1 or `only`, or is set without `row`. | | `bad_expiry` | 400 | `expires_in` is not 86400, 259200, 604800 or 2592000 (seconds), `null` or `"never"`. | | `bad_fee_rate` | 400 | `fee_rate` is not a positive number up to 5000. | | `bad_id` | 400 | `id` is not a prepared purchase or transfer id. | | `bad_ids` | 400 | `ids` / `listing_ids` is empty, too long, malformed, or lists an id twice. | | `bad_items` | 400 | `items` is not a list of 1-50 UTXOs (or the PSBT lists more), or an item's `sats` is not null or a list of sat numbers. | | `bad_json` | 400 | The body is not valid JSON. | | `bad_message` | 400 | The cancel message is not `raresats.net cancel ` or names other listings than `ids`; or the registration message is not `raresats.net affiliate
` for `address`. | | `bad_nonce` | 422 | The login nonce was not handed out for this address, has expired (10 minutes) or was used: ask for a new one. | | `bad_order` | 400 | `order` is not `asc` or `desc`. | | `bad_outpoint` | 400 | Not an outpoint (`txid:vout`). | | `bad_outpoints` | 400 | `outpoints` lists none, or more than 100. | | `bad_paging` | 400 | `limit` or `offset` out of range. | | `bad_path` | 400 | The path is not valid percent-encoding. | | `bad_payout` | 422 | A listed input is paired with an output that pays no address, or the payout address is the marketplace's fee address. | | `bad_price` | 400 | A price is not a whole number of sats. | | `bad_psbt` | 400 | Not a base64 PSBT, or one that does not parse. | | `bad_public_key` | 400 | A public key is not hex, or not a valid secp256k1 key of the right size. | | `bad_request` | 400 | The body is not a JSON object, or the engine refused a malformed request (e.g. a purchase submitted without its signed setup PSBT). | | `bad_row` | 400 | `row` is not a row id. | | `bad_sequence` | 422 | A listed input is not signed with sequence 0xfffffffd. | | `bad_sighash` | 422 | A signature uses the wrong sighash type (listings: 0x83; purchases: DEFAULT or ALL). | | `bad_signature` | 400, 422 | 400: `signature` is missing or too long. 422: a signature does not verify (cancel or registration message, listing or purchase input). | | `bad_sort` | 400 | `sort` is not `unit`, `price` or `new`. | | `bad_status` | 400 | `status` is not `all` or a comma-separated list of offer statuses, or (wallet transfers) not `pending` or `all`. | | `bad_structure` | 422 | The listing PSBT is not the expected layout (pairing, version 2, locktime 0, one seller, sequences), or a purchase would pay the fee address. | | `bad_sub` | 400 | `sub` is not 0 or 1. | | `bad_types` | 400 | `types` is not a comma-separated list of activity event types. | | `bad_wallet_addresses` | 400 | `wallet_addresses` is not a list of at most 4 mainnet addresses. | | `banned` | 403 | The address is banned from chat: it may not post or set a display name. | | `bitcoind_unavailable` | 503 | bitcoind is not available. | | `broadcast_unknown` | 503 | The broadcast's outcome is unknown: submit the same purchase or transfer again to settle it. Carries `txid`. | | `busy` | 503 | Too many purchases, offers or transfers are being prepared: try again in a minute. Each client (IPv4 address or IPv6 /64) keeps its 10 latest prepared purchases, offers and transfers; an older one answers `unknown_purchase` / `unknown_offer` / `unknown_transfer`. | | `changed` | 409 | The UTXO's value is not what the PSBT says, or (wallet tools) a UTXO, its sats or the transaction being replaced changed since prepare: prepare again. | | `chat_busy` | 429 | Too many chat messages and names are being checked across the site right now (over the global cap of model calls a minute); wait `retry-after` seconds. | | `chat_disabled` | 404 | Chat and display names are off on this server. | | `config_changed` | 409 | The marketplace settings changed since this purchase or transfer was prepared (prepare it again), or since this offer was made (it is now `invalid`, reason `config_changed`). | | `conflict` | 409 | A listing or one of your UTXOs was spent meanwhile (lost race), or bitcoind would not replace a transaction already spending it (`insufficient fee`), or a transfer's inputs are spent by another transaction. | | `cross_origin` | 403 | A POST from a browser on another site (a foreign `Origin`, or `sec-fetch-site: cross-site`). Server-side clients send no `Origin` and are not affected. | | `duplicate` | 409 | The same message (ignoring case) from the same address within 2 minutes. | | `duplicate_outpoint` | 400 | The same UTXO twice in one listing. | | `dust_output` | 422 | A bought UTXO is below the dust limit of your ordinals address type: use a Taproot or native SegWit ordinals address. | | `empty_text` | 422 | Nothing is left of the message once control, zero-width and bidi characters are dropped and spaces collapsed. | | `engine_unavailable` | 503 | The trade engine is not available. | | `expired` | 409 | The prepared purchase, offer or transfer expired, or the offer itself has: prepare it again. | | `fee_rate_too_low` | 422 | `fee_rate` is below the current minimum (`GET /api/market/fees`). | | `fee_too_high` | 422 | The network fee would exceed the per-transaction cap (200,000 sats): lower the fee rate or move fewer. | | `fees_unavailable` | 503 | No fee estimate is available. | | `fork_behind` | 503 | The rare-sat index has not caught up with bitcoind: try again in a minute. | | `fork_unavailable` | 503 | The rare-sat index is not available. | | `funding_spent` | 409 | A UTXO of the bidder's that funds the offer was spent (or is no longer plain cardinal): the offer is no longer valid. | | `future_message` | 422 | The cancel or registration message is dated more than 2 minutes ahead: check the clock. | | `indexer_behind` | 503 | The ord indexer is catching up: try again shortly. | | `inscribed` | 422 | The UTXO carries an inscription: only rare sats are listed; the wallet tools only send it whole. | | `insufficient_funds` | 422 | The payment address's plain UTXOs cannot pay prices, fee, padding and network fee (or a transfer's fees). Carries `available`, `prices`, `platform_fee`. | | `internal` | 500 | A server bug. Report it. | | `invariant_violated` | 500 | A planned or finalized purchase or transfer broke an invariant (`violations`); nothing was broadcast. | | `key_mismatch` | 422 | The public key does not control the address. | | `legacy_address` | 422 | A legacy (P2PKH, `1…`) address: use Taproot or SegWit. | | `listing_changed` | 409 | A listing's stored terms no longer match the plan: prepare it again. | | `market_unavailable` | 503 | The marketplace is closed right now (its backends disagree on the network, or are unreachable). | | `method_not_allowed` | 405 | Wrong method for the route; the `allow` header lists the right ones. | | `missing_signature` | 422 | An input that must be signed is not. | | `moderated` | 422 | The AI moderation refused the chat message or display name; `category` says why (the message is not posted, the name not taken). Three refusals in 10 minutes mute the address for an hour (`until`). | | `moderation_unavailable` | 503 | The AI moderation could not check the text right now (timeout or model error): nothing was posted. Try again in a moment. | | `muted` | 403 | The address is muted (by the AI moderation after repeated refusals: 3 in 10 minutes), or its network (an IPv4 address or IPv6 /48) is paused after 6 refusals within an hour, until `until`, unix seconds: it may not post or set a display name until then. | | `name_address` | 422 | A display name must not look like an address (`bc1…`, base58) or a hash. | | `name_change_too_soon` | 429 | A display name changes at most once in 24 hours; `next_change_at` says when. | | `name_chars` | 422 | A display name uses only letters, digits, spaces and `_ . -`, and starts and ends with a letter or digit. | | `name_length` | 422 | A display name is 3 to 24 characters. | | `name_reserved` | 422 | The display name is reserved (raresats, admin, moderator, support, official, satoshi and the like). | | `name_taken` | 409 | Another address has that display name (compared without case, separators or look-alike digits), gave it up less than 7 days ago, or it was removed by a moderator and is never given out again. | | `no_endpoint` | 404 | No such `/api/market/*` route. | | `no_row` | 422 | The UTXO holds no sat of a market row. | | `no_session` | 401 | No chat session: sign in (`POST /api/session`) and send its cookie. | | `not_active` | 409 | A listing (or offer) is not active: being bought (or accepted), sold, cancelled, moved, expired or invalid. Carries `id` and `status`. | | `not_admin` | 403 | Only a chat admin (`SUPPLY_CHAT_ADMINS`) may do that. | | `not_bannable` | 422 | An admin cannot be banned. | | `not_bidder` | 422 | An offer was not made by `address` (neither its payment nor its ordinals address). | | `not_found` | 404 | No such listing or offer. In chat: no such message. A display name to remove that does not exist. | | `not_json` | 415 | A POST without `content-type: application/json`. | | `not_listed` | 404 | That outpoint has no live listing. | | `not_owner` | 422 | The UTXO does not belong to the seller address, or (wallet tools) to the owner's addresses, or the transfer was made by another wallet. | | `not_seller` | 422 | A listing was not listed by `address`. | | `offer_changed` | 409 | The offer's stored signatures no longer verify: it cannot be accepted. | | `oracle_unavailable` | 503 | The ord oracle is not available. | | `own_utxo` | 422 | The UTXO is held by one of the bidder's own addresses. | | `pending` | 409 | The UTXO's listing is being bought, or an offer of it accepted, right now. | | `price_too_high` | 422 | The price is above 21,000,000 BTC. | | `price_too_low` | 422 | The price is at most the UTXO's own value, below 546 sats, or below the payout output's dust limit. | | `psbt_mismatch` | 422 | The signed PSBT is not the prepared transaction. | | `pubkey_mismatch` | 422 | The trade engine found the public key is not the address's key. | | `rate_limited` | 429 | Over 30 POSTs a minute from your IPv4 address or IPv6 /64 (in chat, counted per IPv4 address or IPv6 /64: 30 writes and 20 login nonces a minute, 20 sign-ins and 10 display-name changes an hour; one message every 3 s and 20 a minute per address); wait `retry-after` seconds. With the AI moderation on, at most 6 messages and names a minute are checked per address and per IPv4 address or IPv6 /48. | | `rejected` | 422 | bitcoind refused the transaction (`reason`). | | `replayed` | 409 | That cancel or registration message was used already. | | `retired_row` | 404 | `row` is a row the site retired (`uncommon_2009`, `rare_2009`): its listings are its parent row's. `retired` names the row and the parent and years to ask for. | | `room_busy` | 429 | Over 5 chat messages a second across the whole chat; wait `retry-after` seconds. | | `runes` | 422 | The UTXO carries runes (the wallet tools only send it whole). | | `spent` | 409 | The UTXO (or a listing's UTXO) is spent or does not exist. | | `stale_message` | 422 | The cancel or registration message is more than 10 minutes old: sign a new one. | | `suspicious_link` | 422 | A link in the message names a host that is not plain ASCII: look-alike letters from other scripts, punycode (`xn--`), or `user@host`. Write the link with its real host name. | | `bad_topic` | 400 | A topic id that is not one: 1-32 of a-z, 0-9 and inner hyphens (and, to create one, not `main`). On a reply: a `topic` other than its root's. | | `unknown_topic` | 404 | No such chat topic, or an archived one (which takes no new messages). | | `bad_topic_text` | 422 | A topic's name must be 1-40 characters, its description at most 140. | | `topic_exists` | 409 | A topic (or an archived one) has that id already. | | `too_many_topics` | 422 | At most 50 chat topics: archive one first. | | `not_archivable` | 422 | General cannot be archived. | | `text_too_long` | 422 | A chat message is at most 500 characters. | | `too_large` | 413 | The body is over 256 KiB. | | `too_many_items` | 422 | Too many UTXOs in a listing, or listings in a purchase (`max_bulk`); for a wallet transfer, too many rare sats (and the runs between them) to lay out in one UTXO: choose fewer. | | `unchanged` | 409 | That is already the display name. | | `unconfirmed` | 409 | The UTXO is not confirmed yet. | | `unindexed` | 409 | The ord indexer does not know the UTXO yet. | | `unknown_offer` | 404 | No such prepared offer (it expired, or the server restarted): prepare it again. | | `unknown_purchase` | 404 | No such prepared purchase (it was dropped: your 11th prepared since, a busy server, or a restart): prepare it again. | | `unsupported_address` | 422 | An address type the marketplace does not trade (only Taproot, native SegWit and nested SegWit). | | `unsupported_script` | 422 | The trade engine met a script type it does not support. | | `untracked` | 422 | The UTXO holds no rare sat the index tracks (send it whole instead). | | `utxo_mismatch` | 409 | The PSBT's amount or script of a UTXO is not the chain's. | | `utxos_required` | 500 | A server bug in listing verification. | | `bad_kind` | 400 | `kind` is not one of the wallet tools (`extract`, `send`, `send_n`, `chop`, `consolidate`, `multisend`, `speedup`, `cancel`), or a send `mode` is unknown. | | `bad_owner` | 400 | `owner` must give the payment and ordinals addresses and their public keys. | | `bad_destination` | 422 | The destination (or a multisend recipient) is the marketplace's fee address, or missing. | | `bad_type` | 400 | `type` (send N) is not a sat type, types joined with `+`, or `block9` / `450x`. | | `bad_count` | 400 | `count` (send N) is not a whole number of sats of at least 1. | | `bad_chop_size` | 400 | `chop_size` is not a whole number from 330 to 100,000,000. | | `bad_split` | 400 | `split` (extract) is not `{type, per_output \| chunks, trim?}`: an exact type combination that is not a whole range, `per_output` 1–2,000 or `chunks` 2–100 (exactly one), `trim` a boolean; or an item names `sats` (a split takes every sat of its type). | | `bad_sends` | 400 | `sends` must list 1-50 payments of `{address, value}`, each at least its address type's minimum (330, or 546 for P2SH and P2PKH). | | `payment_destination` | 422 | Rare sats, inscriptions or runes would go to your own payment address, which your wallet spends as plain bitcoin: send them to your ordinals address, or say `confirm_payment_destination: true`. | | `address_too_large` | 422 | An owner address holds more than 5,000 outputs: the wallet tools cannot plan for it. | | `not_plain` | 422 | A UTXO to consolidate is not plain bitcoin (it holds tracked, exotic or inscribed sats, or runes). | | `already_extracted` | 422 | The UTXO is extracted already (its rare sats sit in an output of their own). | | `split_chunk` | 422 | Skip code of an "extract all" over several UTXOs: the UTXO is a chunk a split on this site made (extracting it again would undo the split). Name it alone to extract it. | | `not_enough_sats` | 422 | Your UTXOs do not hold `count` sats of that type that can be sent. Carries `available`. | | `nothing_to_do` | 422 | Nothing to move: every item was skipped (`skipped`), or a consolidation has fewer than 2 UTXOs, or a chop leaves one piece. | | `nothing_to_separate` | 422 | Extracting would leave the UTXO one output: its rare sats are apart already, or too close to its ends. | | `keep_in_destination` | 422 | Exotic or unpicked tracked sats sit so close to a picked one that they would have to leave with it: send the whole UTXO instead. | | `range_too_small` | 422 | A range chunk (send N of `block9` or `450x`) must be at least the destination's padding: 330 sats, 546 for P2SH and P2PKH. | | `listed_public_alone` | 422 | A UTXO whose listing signature is public must be moved on its own (and fast). | | `inscribed_consolidate` | 422 | Inscribed UTXOs are sent whole and 1:1, never merged into one output. | | `runes_multi` | 422 | At most one rune UTXO per transfer (except a whole send merged into one output). | | `dust_destination` | 422 | A whole UTXO is too small for the destination's address type. | | `too_many_outputs` | 422 | The transfer would have more than 100 outputs: move fewer UTXOs at once. | | `tx_too_large` | 422 | The transfer would exceed 100,000 vB: move fewer UTXOs at once. | | `unknown_transfer` | 404 | No such transfer: an unknown or forgotten prepared id, or a txid the wallet tools did not build. | | `not_pending` | 409 | The transfer to speed up or cancel is not waiting in the mempool any more (confirmed, replaced). | | `has_descendants` | 409 | A transaction spends the transfer's outputs already: it can no longer be replaced. | | `offer_overlap` | 409 | Since the prepare, a planned input started funding one of your offers, or a UTXO the server picked got an offer: prepare the transfer again. | | `nothing_to_cancel` | 409 | A cancel that could save nothing: the transfer was refused (never sent), or it (and, once dropped, every transfer it replaced) only pays your own addresses. | | `replacement_fee_too_low` | 422 | The replacement pays too little to replace the pending transaction (BIP 125): raise the fee rate to at least `min_fee_rate`. | ## Limits - **Edge rate limit:** 50 requests per 10 seconds per IP on `/api/*` (Cloudflare). Requests over it are blocked at the edge for a short while. - **POST rate limit:** 30 POSTs a minute per client (an IPv4 address or an IPv6 /64) on `/api/market/*`, a fixed window: 429 `rate_limited` with a `retry-after` header in seconds. - **Prepared purchases and offers:** each client keeps its 10 latest of each; preparing an 11th drops its oldest (which then answers `unknown_purchase` / `unknown_offer` at submit). - **Body:** JSON, `content-type: application/json`, at most 256 KiB (413 `too_large`, 415 `not_json`, 400 `bad_json`). - **Origin:** a POST with an `Origin` header of another host, or `sec-fetch-site: cross-site`, is refused (403 `cross_origin`). No CORS headers are sent, so browsers on other sites cannot read answers either. Server-side clients (curl, Node, Python) send no `Origin` and are unaffected. - **Sizes:** at most 50 UTXOs per listing PSBT, 20 listings per purchase (`max_bulk`), 100 ids per cancel, 100 outpoints per batch lookup, 200 listings or events per page, 500 sequence numbers per `/api/sales/parties`. - **Caching:** marketplace answers are `no-store`. The data routes may be cached briefly (`/api/market`, `/api/sales` and `/api/sales/parties` 30 s, `/api/sat/{n}` 15 s, `/api/address/{address}` 30 s). Large answers are gzipped when you send `accept-encoding: gzip`. --- Page: https://raresats.net/docs/listing-object.md # The Listing object A listing is one UTXO for sale at a fixed price. Every listing endpoint returns this object, and so does a sat's page (`listing`). The seller's signature is never part of it. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | The listing id: 16 lowercase hex characters. | | `outpoint` | string | yes | The listed UTXO, `txid:vout`. | | `value` | integer | yes | The UTXO's own value in sats (it moves to the buyer whole). | | `price` | integer | yes | The listing price in sats: exactly what the seller receives. The buyer pays this plus the platform fee and the network fee. | | `unit_price` | number | yes | Price per sat of the listing's own row: `(price - value) / count`, to the hundredth of a sat. Floors and the `unit` sort use it. | | `row` | string | yes | The listing's own row (collection): the rarest market row holding every tracked sat of the UTXO, else the rarest market row present. Never a label row. | | `count` | integer | yes | How many sats of `row` the UTXO holds (the "×N" of a bulk listing). | | `rows` | map of integer | yes | Every row the UTXO's tracked sats count toward, with their counts (label rows included). | | `types` | array of [TypeGroup](https://raresats.net/docs/listing-object.md) \| null | yes | The UTXO's sats grouped by exact type combination, rarest first. | | `sats` | integer \| null | yes | Tracked sats in the UTXO (all rows). | | `ranges` | array of array of integer \| null | yes | The tracked sats as inclusive `[first, last]` ranges, ascending. | | `years` | map of integer \| null | yes | Tracked sats per mint year (UTC year of the block that mined them). | | `seller_address` | string | yes | The address that holds the UTXO and signed the listing. | | `payout_address` | string | yes | Where the price is paid (the seller's payment output). | | `status` | string | yes | `active` (for sale), `pending` (a purchase is in the mempool), `sold`, `cancelled` (or replaced by a newer listing of the same UTXO), `moved` (spent elsewhere). A listing whose UTXO was sold through an accepted offer ends `sold` with `sold_via: "offer"`. | | `created_at` | integer | yes | Unix seconds. | | `updated_at` | integer | yes | Unix seconds of the last status change. | | `txid` | string \| null | yes | The purchase transaction, while `pending` and once `sold`; otherwise `null`. | | `signature_public` | boolean | yes | `true` once a broadcast purchase put the seller's signature on the network: the listing can then be bought at this price anywhere until the UTXO moves, even after a cancel here. | | `sold_via` | string \| null | yes | `offer` when the listing ended `sold` through an accepted offer of its UTXO: `price` and `unit_price` are then the offer's (what was paid), `offer_id` names it. `null` otherwise. | | `offer_id` | string \| null | yes | The accepted offer it was sold through (`sold_via: "offer"`), else `null`. | | `best_offer` | [BestOffer](https://raresats.net/docs/offer-object.md) \| null | yes | The best active offer on the UTXO, or `null`. | | `spent_by_pending` | object | no | A transaction still in the mempool that spends an output (`spent_by_pending`). Absent when nothing pending spends it. | | `spent_by_pending.txid` | string | yes | The spending transaction. | | `spent_by_pending.vin` | integer \| null | no | Its input that spends the output. | | `spent_by_pending.first_seen` | integer \| null | yes | When the transaction was first seen in the mempool (bitcoind's entry time), unix seconds. | | `spent_by_pending.fee_rate` | number \| null | yes | Its fee rate, sat/vB (one decimal). | | `spent_by_pending.sale` | boolean | yes | Whether the spend sells this output (a pending sale with it as the seller's input). | | `spent_by_pending.replaceable` | boolean | no | Always true (full RBF): the spend may still be replaced. | | `in_bundle` | boolean | no | Only on a listing read for a row it holds some sats of among others (in bundle: `contains=1`'s `contains`, or a row's activity): `true`. Its `price` is the whole UTXO's and its `unit_price` its own row's, never the row asked for. | | `contains_count` | integer \| null | no | With `in_bundle`: how many sats of the row asked for it holds. | ## Type groups `types` holds the UTXO's sats grouped by their exact combination of sat types, rarest first: what the site draws as badges. | Field | Type | Required | Description | | --- | --- | --- | --- | | `types` | array of string | yes | The exact combination of sat types these sats share (e.g. `["block9", "nakamoto", "vintage"]`). | | `count` | integer | yes | How many of the UTXO's (the sale's) tracked sats have exactly these types. | | `sats` | array of integer | yes | Sample sat numbers of the group, ascending: its first 3 (an address's or a UTXO's page). A listing or an offer names up to 100: its first 3, then the first sat of each further run, so every special sat of a group of up to 100 (a whole range's run adds only its first sat). (0-100 items) | | `inscribed` | integer | no | How many of them carry an inscription (absent when none). | ## Example ```json title="Listing" { "id": "3324d08569806818", "outpoint": "8b09d6914ae17431940ce8b6cadae378a5d9b3c90c73c3a5b0b8c8e7edc9e86a:0", "value": 6280, "price": 94200, "unit_price": 14, "row": "block9", "count": 6280, "rows": {"block9": 6280, "block9_1st_tx": 6280}, "types": [ { "types": ["block9", "nakamoto", "vintage"], "count": 6280, "sats": [45100023043, 45100023044, 45100023045] } ], "sats": 6280, "ranges": [[45100023043, 45100029322]], "years": {"2009": 6280}, "seller_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "payout_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "status": "active", "created_at": 1790952985, "updated_at": 1790952985, "txid": null, "signature_public": false, "sold_via": null, "offer_id": null, "best_offer": { "id": "dbf866a1e7e209e7", "price": 100000, "unit_price": 15.92, "bidder_payment_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "bidder_ordinals_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "created_at": 1790953585, "expires_at": 1791558385 } } ``` This UTXO holds 6,280 block 9 sats (2009, Nakamoto, vintage) in a 6,280-sat output, listed at 94,200 sats: `unit_price` = (94,200 − 6,280) / 6,280 = 14 sats per sat. The seller receives 94,200 sats; a buyer pays 94,200 + 2,355 (2.5%) + the network fee. ## Statuses `active` → `pending` (a purchase in the mempool) → `sold`; or `cancelled` (by the seller, or replaced by a newer listing of the UTXO); or `moved` (spent without paying the listing). A listing whose UTXO the seller sold by accepting an [offer](https://raresats.net/docs/offer-object.md) is reserved (`pending`) while that transaction is in the mempool, then `sold` with `sold_via: "offer"`, the offer's `offer_id`, and the offer's `price` and `unit_price`: what was paid, not the ask. That sale's one activity event is the offer's `offer_accepted`. See [Concepts](https://raresats.net/docs/concepts.md#listing-statuses). --- Page: https://raresats.net/docs/guide-list.md # List a UTXO Listing is three steps: **prepare** a PSBT, **sign** it with SIGHASH_SINGLE|ANYONECANPAY (`0x83`), **submit** it. Your key never leaves your wallet or script; the server keeps only the signature, and never shows it to anyone. ## Before you start - The UTXO must be confirmed, hold tracked rare sats of a market row, and carry no inscription or runes. Find yours with `GET /api/address/{address}`. - It must belong to a **Taproot**, **native SegWit** or **nested SegWit** address. Legacy (`1…`) is refused with `legacy_address`. - Choose a `price` in sats: what you receive. It must be above the UTXO's own value and at least 546. Buyers pay 2.5% on top; you pay nothing. ## 1. Prepare `POST /api/market/listings/prepare` with your address, its public key and the UTXOs (up to 50 in one PSBT, one signature prompt): ```json title="Request" { "seller_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "seller_public_key": "0283dfe85a3151d2517290da461fe2815591ef69f2b18a2ce63f01697a8b313145", "items": [{ "outpoint": "8b09d6914ae17431940ce8b6cadae378a5d9b3c90c73c3a5b0b8c8e7edc9e86a:0", "price": 94200 }] } ``` - `seller_public_key`: for Taproot, the **internal** (untweaked) key, x-only (32 bytes) or compressed (33 bytes); for SegWit, the compressed key. Wallets return it with the address. - `payout_address` (optional) is where the price goes; it defaults to `seller_address`. Xverse-style wallets usually pay out to their payment address. The answer is the PSBT, the inputs to sign (`sighash: 131` = `0x83`) and what each listing will show: ```json title="Response" { "psbt": "cHNidP8BAF4CAAAAAWroye3n…", "sign": [{ "index": 0, "address": "bc1p4qhj…fqzwh", "sighash": 131 }], "items": [{ "outpoint": "8b09…e86a:0", "value": 6280, "price": 94200, "unit_price": 14, "row": "block9", "count": 6280 }] } ``` The PSBT pairs input *i* (your UTXO) with output *i* (your price to your payout address). Version 2, locktime 0, sequence `0xfffffffd`. Check those outputs before signing. ## 2. Sign with `0x83` Sign every input in `sign` with sighash `0x83` (SIGHASH_SINGLE|ANYONECANPAY). Your signature then commits only to your UTXO and to your payment output: a buyer can add their inputs and outputs, but cannot change what you receive. Do not finalize or broadcast. - **UniSat:** `unisat.signPsbt(hex, {autoFinalized: false, toSignInputs: [{index, address, sighashTypes: [0x83]}]})`. - **Xverse (Sats Connect):** `signPsbt` with `signInputs: {[address]: [indexes]}` and `broadcast: false`; the sighash comes from the PSBT input's `sighashType`, which prepare set to `0x83`. - **Your own key**: the Node script below. ## 3. Submit `POST /api/market/listings` with `{"psbt": ""}`. The server checks every rule again, verifies each signature against the chain's amount and script, and answers your new listings, `active`: ```json title="Response" { "listings": [{ "id": "3324d08569806818", "status": "active", "price": 94200, "unit_price": 14, "row": "block9", "count": 6280, "…": "…" }] } ``` To **change a price**, list the same UTXO again: the new listing replaces the old one (now `cancelled`, its signature deleted). To take it off sale, [cancel it](https://raresats.net/docs/guide-cancel.md). ## A complete Node example A script that lists a UTXO of a Taproot key: Node 18+, `npm install bitcoinjs-lib tiny-secp256k1 ecpair`. ```js title="list.mjs" // node list.mjs // WIF: the private key (mainnet WIF) of the Taproot address holding the UTXO. import * as bitcoin from 'bitcoinjs-lib'; import * as ecc from 'tiny-secp256k1'; import { ECPairFactory } from 'ecpair'; bitcoin.initEccLib(ecc); const ECPair = ECPairFactory(ecc); const BASE = process.env.RARESATS_URL ?? 'https://raresats.net'; const [outpoint, price] = process.argv.slice(2); const key = ECPair.fromWIF(process.env.WIF); const internalKey = key.publicKey.subarray(1, 33); const { address } = bitcoin.payments.p2tr({ internalPubkey: internalKey }); async function post(path, body) { const response = await fetch(`${BASE}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); const json = await response.json(); if (!response.ok) { throw Object.assign(new Error(`${path}: ${response.status} ${json.code}: ${json.error}`), { code: json.code }); } return json; } // 1. Prepare: the server checks the UTXO and builds the PSBT. const prepared = await post('/api/market/listings/prepare', { seller_address: address, seller_public_key: Buffer.from(key.publicKey).toString('hex'), items: [{ outpoint, price: Number(price) }] }); // 2. Check what you sign, then sign every input with 0x83 (SINGLE|ANYONECANPAY). const psbt = bitcoin.Psbt.fromBase64(prepared.psbt); psbt.txOutputs.forEach((output, i) => { if (Number(output.value) !== prepared.items[i].price || bitcoin.address.fromOutputScript(output.script) !== address) { throw new Error(`output ${i} does not pay the price to ${address}`); } }); const signer = key.tweak(bitcoin.crypto.taggedHash('TapTweak', internalKey)); for (const { index, sighash } of prepared.sign) { psbt.signInput(index, signer, [sighash]); } // 3. Submit: the listing is live. const { listings } = await post('/api/market/listings', { psbt: psbt.toBase64() }); for (const listing of listings) { console.log(`${listing.id} ${listing.outpoint}: ${listing.price} sats, ${listing.unit_price} per sat of ${listing.row} x${listing.count}`); } ``` For a native SegWit (P2WPKH) key, use `bitcoin.payments.p2wpkh({ pubkey: key.publicKey })` for the address and sign with `key` itself (no tweak). ## Errors you may meet | Code | What to do | | --- | --- | | `not_owner`, `key_mismatch` | The UTXO or the key is not the address's. | | `untracked`, `no_row` | It holds no rare sat a market row tracks. | | `inscribed`, `runes` | Only plain rare sats are listed. | | `unconfirmed`, `unindexed`, `indexer_behind`, `fork_behind` | Wait for a confirmation and the indexers; try again in a minute. | | `price_too_low` | Price above the UTXO's value and at least 546 (and the payout's dust limit). | | `spent`, `changed`, `utxo_mismatch` | The UTXO moved, or the PSBT's amount is not the chain's. Prepare again. | | `pending` | It is being bought right now. | | `bad_signature`, `bad_sighash`, `missing_signature` | Sign every input in `sign`, with `0x83`. | Every code: [Errors](https://raresats.net/docs/errors.md). --- Page: https://raresats.net/docs/guide-buy.md # Buy listings Buying is three steps: **prepare** (the server plans the purchase), **sign** your inputs, **submit** (the server adds the sellers' signatures and broadcasts). One purchase buys 1 to 20 listings. ## Before you start - Your wallet needs a **payment address** (pays) and an **ordinals address** (receives the bought UTXOs). Xverse-style wallets have both; a single-address wallet (UniSat-style) uses one address for both. Taproot, native SegWit or nested SegWit; not legacy. - Only **plain cardinal** UTXOs of the payment address are spent: confirmed, no inscription or runes, and no rare sat the index tracks. Your rare sats are never used as padding or funding. - Pick a fee rate from `GET /api/market/fees` (`slow`, `normal`, `fast`; at least `minimum`). ## 1. Prepare ```json title="Request" { "listing_ids": ["1031931af1592e1c"], "buyer": { "payment_address": "37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", "payment_public_key": "039b3b694b8fc5b5e07fb069c783cac754f5d38c3e08bed1960e31fdb1dda35c24", "ordinals_address": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", "ordinals_public_key": "03cc8a4bc64d897bddc5fbc2f670f7a8ba0b386779106cf1223c6fc5d7cd6fc115" }, "fee_rate": 4, "affiliate": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh" } ``` `affiliate` is optional: the affiliate (any address) that referred you (it wins over the site's `rs_ref` referral cookie). The answer: ```json title="Response" { "id": "5c9e5c410688cd85", "purchase": { "psbt": "cHNidP8BAP3lAQIAAAAE…", "sign": [{ "index": 0, "address": "37Vuc…Shgf" }, { "index": 1, "address": "37Vuc…Shgf" }, { "index": 3, "address": "37Vuc…Shgf" }] }, "summary": { "prices": 60546, "platform_fee": 1514, "platform_share": 757, "affiliate_fee": 757, "affiliate_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "affiliate_status": "applied", "affiliate_reason": null, "affiliate_share_bps": 5000, "network_fee": 2652, "purchase_network_fee": 2652, "setup_network_fee": 0, "total": 64712, "vsize": 663, "setup_vsize": 0, "fee_rate": 4, "change": 84088, "padding_value": 600, "items": [{ "id": "1031931af1592e1c", "outpoint": "2fb6…d3a9:0", "value": 546, "price": 60546, "unit_price": 60000, "row": "black_rare", "count": 1 }] }, "expires_at": 1790953585 } ``` ### The fee breakdown - `prices`: the listings' prices, paid to the sellers. - `platform_fee`: `max(ceil(prices × 2.5%), 546)`, one output to the fee address. With an applied referral it is split in two outputs, `platform_share` and `affiliate_fee` (see [Affiliates](https://raresats.net/docs/guide-affiliates.md)); you pay the same either way. - `network_fee`: the purchase's network fee plus the setup's, if any. - `total` = `prices + platform_fee + network_fee`: what the purchase costs you. The padding you spend comes back to you as new padding outputs; `change` returns to your payment address. ### Padding and the setup transaction The purchase follows the standard layout (see [Concepts](https://raresats.net/docs/concepts.md#the-transaction-layout)): it spends k+1 small **padding** UTXOs of yours first, so that the sats of each listed UTXO land whole, at offset 0, in their own output to your ordinals address; and it makes k+1 new 600-sat padding outputs for your next purchase. If your payment address does not have k+1 padding UTXOs plus funding, the answer also has a **`setup`** PSBT: a transaction that makes the padding first (no platform fee, no marker), which the purchase then spends. Sign both; the server broadcasts the setup first. `summary.setup_network_fee` and `setup_vsize` are its cost. After one purchase you have padding for the next. ### Expiry The prepared plan is held for 10 minutes (`expires_at`, unix seconds). Submit before then. Afterwards submit answers 409 `expired`: prepare again (the listings may have sold meanwhile). ## 2. Sign Sign **exactly** the inputs in `purchase.sign` (and `setup.sign`) with your wallet's default sighash: SIGHASH_DEFAULT for Taproot, SIGHASH_ALL for SegWit v0. The sellers' inputs carry no signature yet; leave them. Don't broadcast. Finalized or not is fine. - **UniSat:** `unisat.signPsbts([setupHex, purchaseHex], [{autoFinalized: false, toSignInputs}, …])` signs both in one prompt. - **Xverse:** `signPsbt` once per PSBT with `signInputs: {[payment address]: [indexes]}` and `broadcast: false`. ## 3. Submit ```json title="Request" { "id": "5c9e5c410688cd85", "setup_psbt": "", "purchase_psbt": "" } ``` The server checks that the signed transactions are exactly the prepared ones, verifies your signatures, adds the sellers', rechecks every rule (the sats' landing places, the pairing, the fee, the marker, the network fee), runs `testmempoolaccept` on both, and broadcasts: setup first, then the purchase. ```json title="Response" { "txid": "6897451c42f02de2594abfdd6a5d4fcf4794a62cbbd9fbec1ec983893ddc5df4" } ``` The listings are now `pending` with that `txid`, and `sold` once it confirms. The UTXOs arrive at your ordinals address. ### When something goes wrong | Code | Status | What to do | | --- | --- | --- | | `not_active`, `spent` | 409 | A listing sold or moved meanwhile. Drop it and prepare again with the rest. | | `conflict` | 409 | A lost race: a listing (or one of your UTXOs) was spent between prepare and broadcast. Prepare again. | | `expired` | 409 | Over 10 minutes: prepare again. | | `config_changed` | 409 | The marketplace's fee settings changed since prepare: prepare again and review the new summary. | | `psbt_mismatch` | 422 | The signed PSBT is not the prepared transaction (a wallet changed it): sign the PSBT as given. | | `insufficient_funds` | 422 | Not enough plain funds at the payment address; the error says how much is `available`. | | `fee_rate_too_low` | 422 | Use at least `minimum` from `/api/market/fees`. | | `dust_output` | 422 | A bought UTXO is too small for your ordinals address type: use a Taproot or native SegWit ordinals address. | | `broadcast_unknown` | 503 | The broadcast's outcome is not known yet. **Submit the same body again**: resubmitting is idempotent, and answers `{txid}` once bitcoind has it. | A lost answer (a timeout, a dropped connection) is handled the same way as `broadcast_unknown`: submit again. Your listings stay reserved for you meanwhile, and return to sale if the transaction never reaches the mempool. ## A complete Node example A script for a single-address Taproot wallet (it pays and receives with one key): Node 18+, `npm install bitcoinjs-lib tiny-secp256k1 ecpair`. ```js title="buy.mjs" // node buy.mjs [ …] // WIF: the private key (mainnet WIF) of the Taproot address that pays and receives. import * as bitcoin from 'bitcoinjs-lib'; import * as ecc from 'tiny-secp256k1'; import { ECPairFactory } from 'ecpair'; bitcoin.initEccLib(ecc); const ECPair = ECPairFactory(ecc); const BASE = process.env.RARESATS_URL ?? 'https://raresats.net'; const ids = process.argv.slice(2); const key = ECPair.fromWIF(process.env.WIF); const internalKey = key.publicKey.subarray(1, 33); const { address } = bitcoin.payments.p2tr({ internalPubkey: internalKey }); const publicKey = Buffer.from(key.publicKey).toString('hex'); const signer = key.tweak(bitcoin.crypto.taggedHash('TapTweak', internalKey)); async function call(path, body) { const response = await fetch(`${BASE}${path}`, body === undefined ? {} : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); const json = await response.json(); if (!response.ok) { throw Object.assign(new Error(`${path}: ${response.status} ${json.code}: ${json.error}`), { code: json.code }); } return json; } // 1. Prepare at the "normal" fee rate. const fees = await call('/api/market/fees'); const prepared = await call('/api/market/purchase/prepare', { listing_ids: ids, buyer: { payment_address: address, payment_public_key: publicKey, ordinals_address: address, ordinals_public_key: publicKey }, fee_rate: fees.normal }); const { summary } = prepared; console.log(`prices ${summary.prices} + platform fee ${summary.platform_fee} + network fee ${summary.network_fee} = ${summary.total} sats`); // 2. Sign your inputs only (default sighash), the setup too when there is one. const sign = ({ psbt, sign: inputs }) => { const parsed = bitcoin.Psbt.fromBase64(psbt); for (const { index } of inputs) { parsed.signInput(index, signer); } return parsed.toBase64(); }; const body = { id: prepared.id, purchase_psbt: sign(prepared.purchase) }; if (prepared.setup) { body.setup_psbt = sign(prepared.setup); } // 3. Submit. Resubmitting is idempotent: retry while the outcome is unknown. for (let attempt = 1; ; attempt++) { try { const result = await call('/api/market/purchase/submit', body); console.log('broadcast', result.setup_txid ? `setup ${result.setup_txid}, purchase ${result.txid}` : result.txid); break; } catch (error) { if (error.code !== 'broadcast_unknown' || attempt === 5) { throw error; } await new Promise(resolve => setTimeout(resolve, 10_000)); } } ``` Every code: [Errors](https://raresats.net/docs/errors.md). --- Page: https://raresats.net/docs/guide-cancel.md # Cancel listings A cancel proves you are the seller with a **BIP-322** signature of a short message by the seller address. The server then deletes the listings' signatures and marks them `cancelled`. ## The message ```text raresats.net cancel ``` - `raresats.net cancel` (`cancel_prefix` in `GET /api/market/config`), a space, the listing ids comma-separated with no spaces **in the same order as `ids`**, a space, and the current time in unix seconds. - Example: `raresats.net cancel 3324d08569806818,1031931af1592e1c 1790949400`. - Up to 100 listings at once, all listed by the signing address. ## Signing Sign the message with the **seller address** (the listing's `seller_address`) using BIP-322 ("simple"). Wallets do this: - **UniSat:** `unisat.signMessage(message, 'bip322-simple')`. - **Xverse (Sats Connect):** `signMessage` with `{address, message, protocol: 'BIP322'}`. - **Your own key:** any BIP-322 library, or the Node script below. Taproot, native SegWit and nested SegWit addresses are verified. The signature is base64 (the encoded witness of BIP-322's `to_sign` transaction). ## The request `POST /api/market/listings/cancel`: ```json title="Request" { "ids": ["3324d08569806818"], "address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "message": "raresats.net cancel 3324d08569806818 1790949400", "signature": "AUBGQvdc2H+7HTo1…" } ``` The answer is `{"listings": [...]}`, each now `cancelled`. ## Freshness and replays - The message must be **at most 10 minutes old** (`stale_message` otherwise) and at most 2 minutes ahead of the server's clock (`future_message`). Sign a fresh one each time. - A message and signature are accepted **once**: sending the same pair again answers 409 `replayed`. - Only `active` listings can be cancelled. A `pending` one (a purchase in the mempool) answers 409 `not_active`: it is being bought. A listing already sold, cancelled or moved answers the same. - `not_seller` (422): a listing was listed by another address. `bad_signature` (422): the signature does not verify for that address and message. ## Exposed listings: `signature_public` A seller's SIGHASH_SINGLE|ANYONECANPAY signature is private until a purchase is broadcast. From then on it is in that transaction, on the network, for anyone to read. If that purchase is then dropped (never confirmed), the listing returns to `active` with **`signature_public: true`**: anyone holding the signature can still buy the UTXO at the listed price, here or in their own transaction, as long as it pays you the listed price. Cancelling such a listing takes it off sale here and deletes the server's copy, but cannot recall the copies on the network. The answer then carries `warning` and `exposed` (the ids concerned). **To be certain, move the UTXO** (spend it to yourself): that invalidates every signature over it. The same holds when a new listing replaces an exposed one. This is the nature of every SIGHASH_SINGLE|ANYONECANPAY marketplace, not a raresats.net choice: you are always paid in full, but at the old price. ## A complete Node example BIP-322 "simple" signing for a Taproot key with bitcoinjs-lib, and the cancel request: Node 18+, `npm install bitcoinjs-lib tiny-secp256k1 ecpair`. ```js title="cancel.mjs" // node cancel.mjs [ …] // WIF: the private key (mainnet WIF) of the Taproot address that listed them. import * as bitcoin from 'bitcoinjs-lib'; import * as ecc from 'tiny-secp256k1'; import { ECPairFactory } from 'ecpair'; bitcoin.initEccLib(ecc); const ECPair = ECPairFactory(ecc); const BASE = process.env.RARESATS_URL ?? 'https://raresats.net'; const ids = process.argv.slice(2); const key = ECPair.fromWIF(process.env.WIF); const internalKey = key.publicKey.subarray(1, 33); const { address, output } = bitcoin.payments.p2tr({ internalPubkey: internalKey }); // BIP-322 simple: sign the virtual to_sign transaction; the signature is its witness. function signBip322(message) { const tag = bitcoin.crypto.sha256(Buffer.from('BIP0322-signed-message')); const hash = bitcoin.crypto.sha256(Buffer.concat([tag, tag, Buffer.from(message, 'utf8')])); const toSpend = new bitcoin.Transaction(); toSpend.version = 0; toSpend.addInput(new Uint8Array(32), 0xffffffff, 0, Uint8Array.from([0x00, 0x20, ...hash])); toSpend.addOutput(output, 0n); const toSign = new bitcoin.Psbt(); toSign.setVersion(0); toSign.addInput({ hash: toSpend.getHash(), index: 0, sequence: 0, witnessUtxo: { script: output, value: 0n }, tapInternalKey: internalKey }); toSign.addOutput({ script: Uint8Array.from([0x6a]), value: 0n }); toSign.signInput(0, key.tweak(bitcoin.crypto.taggedHash('TapTweak', internalKey))); toSign.finalizeAllInputs(); const witness = toSign.extractTransaction(true).ins[0].witness; return Buffer.concat([Buffer.from([witness.length]), ...witness.flatMap(item => [Buffer.from([item.length]), Buffer.from(item)])]).toString('base64'); } const message = `raresats.net cancel ${ids.join(',')} ${Math.floor(Date.now() / 1000)}`; const response = await fetch(`${BASE}/api/market/listings/cancel`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ids, address, message, signature: signBip322(message) }) }); const json = await response.json(); if (!response.ok) { throw new Error(`${response.status} ${json.code}: ${json.error}`); } for (const listing of json.listings) { console.log(`${listing.id}: ${listing.status}`); } if (json.exposed) { console.warn(`${json.warning}: ${json.exposed.join(', ')}. Move the UTXO to be certain.`); } ``` --- Page: https://raresats.net/docs/guide-offers.md # Make, accept and cancel offers An **offer** (a bid) is a price you propose for one UTXO, listed or not. You sign your side of the whole trade now; the UTXO's holder completes it later by signing theirs. Nothing leaves your wallet until they do: your funding UTXOs stay yours, and spending any of them cancels the offer for certain. ## The transaction You sign the complete transaction when you make the offer (version 2, locktime 0, every input sequence `0xfffffffd`), the same layout as Magisat's accepted offers: | Input | | Output | | | --- | --- | --- | --- | | 0 | the seller's UTXO (they sign it to accept) | 0 | the UTXO's sats, all of them, to your ordinals address | | 1… | your funding: plain cardinal UTXOs of your payment address | 1 | `price` to the address that holds the UTXO | | | | 2 | the platform fee: 2.5% of `price`, at least 546 sats | | | | 3 | your change, when it is above dust | | | | last | the `OP_RETURN raresats.net` marker | Ordinal theory's first-in-first-out rule puts input 0's sats exactly in output 0, so no padding is needed. Your signatures (SIGHASH_DEFAULT on Taproot, SIGHASH_ALL on SegWit v0) fix every output: the seller cannot change what they receive or where the sats go. They stay on the server, which no API returns, and are added only once the seller has signed. ## Before you start - **What you can bid on:** any UTXO the site tracks rare sats in (`GET /api/utxo/{outpoint}` knows it), unspent and confirmed, with no inscription or rune, and not held by your own address. Taproot, native SegWit or nested SegWit holders. - **Your funds:** plain cardinal, confirmed UTXOs of your payment address (no inscription, rune or rare sat). Several offers may use the same funds; when one is accepted, the others that share them become `invalid`. - **The price** is what the seller receives. You pay it, plus the platform fee, plus the network fee at the rate you choose: `summary.total`. ## Make an offer 1. [`POST /api/market/offers/prepare`](https://raresats.net/docs/prepare-offer.md) with the outpoint, the price, your wallet (`bidder`), a fee rate (see [`GET /api/market/fees`](https://raresats.net/docs/get-fees.md)) and `expires_in`. 2. Sign the PSBT's inputs listed in `sign` (yours: input 1 and up) with your wallet's default sighash. Do not finalize. 3. [`POST /api/market/offers`](https://raresats.net/docs/submit-offer.md) with `{id, psbt}` before `submit_by` (10 minutes). The answer is the [Offer](https://raresats.net/docs/offer-object.md), `active`. ### Expiry `expires_in` is 86400 (1 day), 259200 (3 days), 604800 (7 days, the default) or 2592000 (30 days) seconds, or `null` / `"never"` for **no expiry**: unlike Magisat's, such an offer stays acceptable until you cancel it, it is accepted, or it becomes invalid. Either way, the fee rate is fixed when you sign; if the network is busy when the seller accepts, they can speed it up by spending their output 1 (CPFP). The server enforces the expiry and then deletes your signatures. The signed transaction itself has no expiry: **to cancel for certain, spend the funding UTXO(s)**. ## Accept an offer Only the address that holds the UTXO can accept, and only while the offer is `active`: 1. [`POST /api/market/offers/{id}/accept/prepare`](https://raresats.net/docs/prepare-accept-offer.md) with your address and public key. The answer is the trade's summary and the PSBT, input 0 ready for your wallet. 2. Sign input 0 with your wallet's default sighash. Do not finalize. 3. [`POST /api/market/offers/{id}/accept`](https://raresats.net/docs/accept-offer.md) with `{psbt}`. The server checks again that your UTXO and the bidder's funds are unspent, adds the bidder's signatures and broadcasts. The answer is `{txid}`. The offer is then `pending`, and `accepted` once confirmed. If you had listed the UTXO, the listing is reserved meanwhile and `sold` once confirmed; the other offers on it become `invalid`. Resubmitting the same accept is idempotent: do it when the answer is lost or says `broadcast_unknown`. ## Cancel offers [`POST /api/market/offers/cancel`](https://raresats.net/docs/cancel-offers.md) with the ids and a BIP-322 signature of `raresats.net cancel-offer ` by your payment or ordinals address: at most 10 minutes old, accepted once (409 `replayed` after), as for [listings](https://raresats.net/docs/guide-cancel.md). ## Reading offers - A UTXO's offers, best first: [`GET /api/market/offers?outpoint=`](https://raresats.net/docs/get-offers.md); the best of many: [`GET /api/market/offers/best?outpoints=`](https://raresats.net/docs/get-best-offers.md). Listings carry theirs as `best_offer`. - A wallet's offers made and received: `?bidder=
&status=all` and `?seller=
&status=all`. - The activity: [`GET /api/market/activity?types=offer_made,offer_cancelled,offer_accepted`](https://raresats.net/docs/get-activity.md). ## A complete Node example One Taproot key that pays, receives and holds (as UniSat has), with bitcoinjs-lib: Node 18+, `npm install bitcoinjs-lib tiny-secp256k1 ecpair`. ```js title="offers.mjs" // node offers.mjs make [|never] // node offers.mjs accept // node offers.mjs cancel [ …] // WIF: the private key (mainnet WIF) of your Taproot address. import * as bitcoin from 'bitcoinjs-lib'; import * as ecc from 'tiny-secp256k1'; import { ECPairFactory } from 'ecpair'; bitcoin.initEccLib(ecc); const ECPair = ECPairFactory(ecc); const BASE = process.env.RARESATS_URL ?? 'https://raresats.net'; const [command, ...args] = process.argv.slice(2); const key = ECPair.fromWIF(process.env.WIF); const internalKey = key.publicKey.subarray(1, 33); const { address, output } = bitcoin.payments.p2tr({ internalPubkey: internalKey }); const publicKey = Buffer.from(key.publicKey).toString('hex'); const signer = key.tweak(bitcoin.crypto.taggedHash('TapTweak', internalKey)); async function call(path, body) { const response = await fetch(`${BASE}${path}`, body === undefined ? {} : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); const json = await response.json(); if (!response.ok) { throw Object.assign(new Error(`${path}: ${response.status} ${json.code}: ${json.error}`), { code: json.code }); } return json; } // Sign exactly the inputs listed, with the default sighash; never finalize. function sign({ psbt, sign: inputs }) { const parsed = bitcoin.Psbt.fromBase64(psbt); for (const { index } of inputs) { parsed.signInput(index, signer); } return parsed.toBase64(); } // BIP-322 simple: sign the virtual to_sign transaction; the signature is its witness. function signBip322(message) { const tag = bitcoin.crypto.sha256(Buffer.from('BIP0322-signed-message')); const hash = bitcoin.crypto.sha256(Buffer.concat([tag, tag, Buffer.from(message, 'utf8')])); const toSpend = new bitcoin.Transaction(); toSpend.version = 0; toSpend.addInput(new Uint8Array(32), 0xffffffff, 0, Uint8Array.from([0x00, 0x20, ...hash])); toSpend.addOutput(output, 0n); const toSign = new bitcoin.Psbt(); toSign.setVersion(0); toSign.addInput({ hash: toSpend.getHash(), index: 0, sequence: 0, witnessUtxo: { script: output, value: 0n }, tapInternalKey: internalKey }); toSign.addOutput({ script: Uint8Array.from([0x6a]), value: 0n }); toSign.signInput(0, signer); toSign.finalizeAllInputs(); const witness = toSign.extractTransaction(true).ins[0].witness; return Buffer.concat([Buffer.from([witness.length]), ...witness.flatMap(item => [Buffer.from([item.length]), Buffer.from(item)])]).toString('base64'); } if (command === 'make') { const [outpoint, price, days = '7'] = args; const fees = await call('/api/market/fees'); const prepared = await call('/api/market/offers/prepare', { outpoint, price: Number(price), bidder: { payment_address: address, payment_public_key: publicKey, ordinals_address: address, ordinals_public_key: publicKey }, fee_rate: fees.normal, expires_in: days === 'never' ? 'never' : Number(days) * 86400 }); const { summary } = prepared; console.log(`price ${summary.price} + platform fee ${summary.platform_fee} + network fee ${summary.network_fee} = ${summary.total} sats`); const { offer } = await call('/api/market/offers', { id: prepared.id, psbt: sign(prepared) }); console.log(`${offer.id} ${offer.outpoint}: ${offer.status}, expires ${offer.expires_at === null ? 'never' : new Date(offer.expires_at * 1000).toISOString()}`); } else if (command === 'accept') { const [id] = args; const prepared = await call(`/api/market/offers/${id}/accept/prepare`, { seller_address: address, seller_public_key: publicKey }); console.log(`you receive ${prepared.summary.price} sats for ${prepared.summary.outpoint}`); const body = { psbt: sign(prepared) }; // Resubmitting is idempotent: retry while the outcome is unknown. for (let attempt = 1; ; attempt++) { try { console.log('accepted', (await call(`/api/market/offers/${id}/accept`, body)).txid); break; } catch (error) { if (error.code !== 'broadcast_unknown' || attempt === 5) { throw error; } await new Promise(resolve => setTimeout(resolve, 10_000)); } } } else if (command === 'cancel') { const message = `raresats.net cancel-offer ${args.join(',')} ${Math.floor(Date.now() / 1000)}`; const { offers } = await call('/api/market/offers/cancel', { ids: args, address, message, signature: signBip322(message) }); for (const offer of offers) { console.log(`${offer.id}: ${offer.status}`); } } else { console.error('usage: node offers.mjs make [|never] | accept | cancel …'); process.exit(1); } ``` --- Page: https://raresats.net/docs/guide-extract.md # Extract rare sats **Extracting** moves the rare sats of a UTXO into small outputs of their own, so each can be held, listed or sent alone (see [Extraction](https://raresats.net/types/extraction)). The wallet tools do it in one transaction you sign with your own wallet: the server plans and checks it, your wallet signs, the server verifies and broadcasts. The server never holds a key. It is three steps: **prepare** (`kind: "extract"`), **sign** every input listed in `sign`, **submit**. ## Before you start - Your wallet's **ordinals address** holds the rare sats; its **payment address** pays the fees. Xverse-style wallets have both; a single-address wallet (UniSat-style) uses one Taproot address for both. Taproot, native SegWit or nested SegWit payment addresses; not legacy. - The fees come **only from plain bitcoin** of your payment address: confirmed UTXOs with no inscription, no runes and no rare sat the index tracks. No sat of the UTXOs you extract is ever paid as fee. - Inscribed and rune UTXOs are never extracted (send them whole instead). - Pick a fee rate from `GET /api/market/fees`. ## 1. Prepare ```json title="Request" { "kind": "extract", "owner": { "payment_address": "37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", "payment_public_key": "039b3b694b8fc5b5e07fb069c783cac754f5d38c3e08bed1960e31fdb1dda35c24", "ordinals_address": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", "ordinals_public_key": "03cc8a4bc64d897bddc5fbc2f670f7a8ba0b386779106cf1223c6fc5d7cd6fc115" }, "items": [{ "outpoint": "2fb6…d3a9:0" }], "fee_rate": 3 } ``` - `items`: 1 to 50 UTXOs of yours. An item's `sats` (absolute sat numbers) picks some of its tracked sats; absent, every tracked sat is extracted ("extract all"). - `destination`: where the extracted outputs go; **your ordinals address** when absent. Unpicked tracked sats always go back to the address of the UTXO they came from. - `plain_to`: where a slice holding no tracked sat goes: `"payment"` (default, your payment address) or `"source"`. ### The layout Each picked sat (or each whole [block 9](https://raresats.net/types/block-9) range) gets an output of its own, at offset 0 where it can: **330 sats** of padding for a Taproot or native SegWit destination, **546** for a nested SegWit (P2SH) or legacy one. Sats too close together share an output (a `merged` warning, and `summary.layout[].merged`). The sats before, between and after them go back: to the UTXO's own address, or as plain slices to your payment address (`plain_to`). The padding and the fees come from your plain bitcoin. With many items, the ones that cannot be extracted are skipped, not fatal: they are listed in `summary.skipped` with a reason (`already_extracted`, `split_chunk`, `inscribed`, `runes`, `unconfirmed`, `pending`, `untracked`, `nothing_to_separate`, `keep_in_destination`, `listed`, `offered`). A UTXO you are selling or that has offers is skipped by an "extract all"; name it in `items` yourself to move it (that cancels its listing). ### Split into chunks A UTXO holding many sats of one type, say 200 uncommons, would need 200 outputs to separate each one, more than a transaction holds (100). **Split** cuts them into chunks instead, each chunk an output of its own: ```json title="Request" { "kind": "extract", "owner": { "…": "as above" }, "items": [{ "outpoint": "2fb6…d3a9:0" }], "split": { "type": "uncommon", "per_output": 10 }, "fee_rate": 3 } ``` - `split.type`: the exact type combination to chunk (`uncommon`, `black+uncommon`; 1 to 8 lowercase types joined with `+`), matched as `send_n` matches it. The address API's `split_counts` says how many sats of each combination a UTXO can split. Every sat of that type counts, including each sat of a long tracked run of it (a run of `vintage` sats is that many sats). A whole range (`block9`, `450x`) is not split by sat: [chop](https://raresats.net/docs/prepare-transfer.md) it. - `split.per_output`: sats of the type per output (1–2,000), in offset order; the last chunk holds the remainder (205 uncommons in tens: 20 chunks of 10 and one of 5). Or `split.chunks`: how many chunks (2–100), near-equal, the first ones one larger (200 in 7: 29, 29, 29, 29, 28, 28, 28). Exactly one of the two. - `split.trim`: the sats of the type sit at scattered offsets, so each chunk is the span from its first sat of the type to its last. By default (`false`) the plain sats between two chunks stay with the earlier one: each chunk's output runs from its first sat to the next chunk, and other rare or exotic sats between two chunks ride along with it too when the destination is your own address (to another address, they stay home with the plain sats around them). With `true` the plain sats between chunks come back as plain change (to `plain_to`) when they make an output of their own (330 sats, 546 for a nested SegWit or legacy address); a smaller gap rides along with a chunk. - A chunk worth more than about 10,000 sats per rare sat in it does not count as [extracted](https://raresats.net/types/extraction): the plan warns (`chunk_not_extracted`), and the site's dialog turns `trim` on once. An "extract all" over several UTXOs skips the chunks a split here made (skip code `split_chunk`), so it never undoes a split; name a chunk alone to extract it. - The plain sats before the first chunk and after the last come back as for any extraction, and no tracked sat is ever in the fee. - **Other rare sats** in the UTXO (another type, an exotic range, a whole block 9 range) are never cut and never sent to plain change: they stay home in an output back to the UTXO's address, or ride along with a chunk, which only your own address may receive (an external `destination` with one inside a chunk's span is refused with `keep_in_destination`). A tracked run that touches block 9 stays whole in an output of its own: its sats are never chunked. A whole range never rides along with a chunk: a chunk whose sats lie on both sides of one is cut around it into two outputs (a `chunk_cut` warning), and when the range sits too close to keep apart the split is refused (`keep_in_destination`). - The items take no `sats`; with several items each UTXO is split on its own, and a UTXO that cannot make 2 chunks (too few sats of the type) is skipped (`summary.skipped`: `not_enough_sats` or `nothing_to_do`). At most 2,000 sats of the type per request, and per UTXO at most 2,000 of them and its other tracked runs and exotic intervals together (`too_many_items`); outputs past 100 (counted as laid out: chunks too close together share one) are refused before anything is built (`too_many_outputs`). - Chunks too close together to separate (each output needs its padding) share an output: a `merged` warning, and `summary.layout[].chunk_outputs` below `chunks`. When they would all stay one output: `nothing_to_separate`. Fewer sats of the type than chunks: `not_enough_sats`. More chunks than outputs allow: `too_many_outputs`. When the split sends back small outputs worth less than the fees, the plan warns (`fee_exceeds_freed`). The plan checks each chunk output against the FIFO flow of the transaction: it holds exactly its chunk's sats of the type, and no other output holds any ("Each output holds exactly the sats you asked for"), at prepare and again at submit. `summary.split` echoes the split as planned. ### The summary Read it before you sign. Each output says what it holds and where it goes: | `to` | Meaning | | --- | --- | | `ordinals` | Your ordinals address (the default destination). | | `payment` | Your payment address (plain slices, change). | | `source` | Back to the address the sats came from. | | `external` | An address that is not yours. | | `fee` | The raresats.net tools fee: one flat output. | `checks` lists what the server verified on the unsigned transaction ("No rare sat is paid as fee", …); `network_fee + tools_fee = total_cost`. ## 2. Sign Sign **every** input in `sign`, each with the key of its `address`, with your wallet's default sighash (SIGHASH_DEFAULT for Taproot, SIGHASH_ALL for SegWit v0). Don't finalize, don't broadcast. - **Xverse:** `signPsbt` with `signInputs: {[ordinals address]: [...], [payment address]: [...]}` and `broadcast: false`: one prompt. - **UniSat:** `signPsbt(hex, {autoFinalized: false, toSignInputs: [{index, address}]})`. ## 3. Submit ```json title="Request" { "id": "a3f0c2…", "psbt": "" } ``` The server checks that the signed transaction is exactly the prepared one, verifies every signature, rechecks the UTXOs against bitcoind, the rare-sat index and the ord indexer, runs `testmempoolaccept` and broadcasts. The answer is `{txid, transfer}`. | Code | Status | What to do | | --- | --- | --- | | `changed`, `spent` | 409 | A UTXO moved or its sats changed since prepare: prepare again. | | `expired` | 409 | Over 10 minutes: prepare again. | | `config_changed` | 409 | The tools fee changed since prepare: prepare again and review the new summary. | | `psbt_mismatch` | 422 | Your wallet changed the transaction: sign the PSBT as given. | | `insufficient_funds` | 422 | Not enough plain bitcoin at your payment address. | | `broadcast_unknown` | 503 | Submit the same body again; it answers `{txid}` once bitcoind has it. | Then follow it with [Get a wallet transfer](https://raresats.net/docs/get-wallet-transfer.md), or [speed it up](https://raresats.net/docs/guide-speed-up.md). --- Page: https://raresats.net/docs/guide-send.md # Send rare sats and bitcoin The wallet tools send from your address with your own wallet's signature: rare sats (only them, or the whole UTXOs), N sats of a type, chopped ranges, and plain bitcoin. The flow is the one of [Extract rare sats](https://raresats.net/docs/guide-extract.md): prepare, sign every input in `sign`, submit. ## Destinations `destination` must be an address on the market's network of type P2TR, P2WPKH, P2SH, P2WSH or P2PKH; the platform's fee address is refused. A destination that is not Taproot gets a warning: make sure the wallet behind it keeps rare sats apart. Outputs to a P2SH or P2PKH destination are padded to 546 sats instead of 330. Sending rare sats, inscriptions or runes to your own **payment** address (when it differs from your ordinals address) is refused with `payment_destination` unless the body says `confirm_payment_destination: true`: your wallet spends that address as plain bitcoin. ## Send UTXOs: `kind: "send"` ```json title="Request" { "kind": "send", "owner": { "payment_address": "…", "payment_public_key": "…", "ordinals_address": "…", "ordinals_public_key": "…" }, "items": [{ "outpoint": "2fb6…d3a9:0" }], "destination": "bc1p…", "mode": "rare", "fee_rate": 3 } ``` - `mode: "rare"` sends **only the rare sats**, laid out as an extraction; the rest comes back to you. - `mode: "whole"` sends each UTXO as it is, one output each, or one output for all of them with `consolidate: true`. Inscribed UTXOs only move whole and 1:1; rune UTXOs may be bundled. ## Send N sats of a type: `kind: "send_n"` `type` is an exact type combination (`"uncommon"`, `"black+uncommon"`: exactly those types) or a range type (`"block9"`, `"450x"`), and `count` how many. The server picks them from your UTXOs, the fewest UTXOs first; `items` limits it to some. A range type is cut as an aligned sub-range of `count` sats, so `count` must be at least the destination's padding (330, or 546). Not enough → `not_enough_sats` with the `available` count. An automatic pick never takes a listed, offered, inscribed or rune UTXO. ## Chop ranges: `kind: "chop"` Cuts the whole ranges of a UTXO (block 9 style) at sats divisible by `chop_size` (330 to 100,000,000), every piece to `destination` (your ordinals address by default). ## Plain bitcoin - `kind: "consolidate"`: merges 2 or more **plain** UTXOs of yours (`items`) into one output (your payment address by default). Leave `items` out and the server picks them: your payment address's plain UTXOs, up to 50, smallest first, never listed ones or your offers' funding, and not a largest one worth more than all the others together. - `kind: "multisend"`: `sends: [{address, value}]` pays 1 to 50 recipients exactly, the change back to your payment address. ## Fees Every transfer pays the network fee, from plain bitcoin only, and a flat **tools fee** (`GET /api/market/config` → `tools.fee_sats`) as one output to the platform's fee address. A speed-up or cancel keeps the original's fee output, so a transfer is never charged twice. Tools transactions carry no OP_RETURN marker. ## Listings and offers Moving a UTXO you have listed cancels the listing just before the broadcast (a listing being bought is refused with 409 `pending`). A UTXO whose listing signature is public must be moved alone (`listed_public_alone`), and its `listed` warning carries `recommended_fee_rate`, the fast rate: use it, so no purchase with that signature can replace your transfer. Offers on a moved UTXO become invalid. --- Page: https://raresats.net/docs/guide-speed-up.md # Speed up or cancel a transfer Transfers the site built signal replace-by-fee (BIP 125). While one is pending, its owner can **speed it up** (the same transaction at a higher fee rate) or **cancel** it (send every source UTXO back where it came from). Both are a prepare with `kind: "speedup"` or `kind: "cancel"` and the pending `txid`, then sign and submit as usual. ```json title="Request" { "kind": "speedup", "txid": "6897451c42f02de2594abfdd6a5d4fcf4794a62cbbd9fbec1ec983893ddc5df4", "owner": { "payment_address": "…", "payment_public_key": "…", "ordinals_address": "…", "ordinals_public_key": "…" }, "fee_rate": 12 } ``` ## Speed up The same inputs and outputs; only the change shrinks. When the change cannot pay the higher fee, the server adds plain bitcoin of your payment address. The new fee must be at least the original's fee plus the node's incremental relay fee for the new size, at a rate at least 1 sat/vB above the original's: the summary's `min_fee_rate` says the lowest rate that works. Too low → `replacement_fee_too_low`. ## Cancel Spends the same source UTXOs, each back 1:1 to the address it came from, plus the original funding. The original tools-fee output is kept (you are not charged twice). A cancel of a transfer that is no longer in the mempool (`dropped`, `unknown`) is a **void**: a signed transaction can resurface until something else spends its inputs, so voiding it is how you make sure it never confirms. A cancel is only prepared where it can save something, judged by what bitcoind's mempool shows at that moment, else 409 `nothing_to_cancel`. A transfer in the mempool (whatever its recorded status) can be cancelled when it pays someone else itself: a cancel, or a speed-up of one, already sends everything home. One out of the mempool can be cancelled (voided) when anything of its chain of replacements pays someone else, since any of them could resurface. A transfer bitcoind refused (`rejected`) was never sent: nothing to cancel. The records' `actions.cancel` follows one rule: **it never hides a cancel the prepare would allow.** Without asking the mempool for every record, it is shown for every transfer that could still confirm whose chain pays someone else; the prepare then decides, so a pending cancel shows the button but answers `nothing_to_cancel` until it leaves the mempool. ## When it is refused | Code | Status | Meaning | | --- | --- | --- | | `unknown_transfer` | 404 | No transfer with that `txid` was built here. | | `not_owner` | 422 | The owner addresses are not the transfer's. | | `not_pending` | 409 | It is confirmed or replaced already. | | `has_descendants` | 409 | Something spends its outputs already; a replacement would have to pay for those too. | | `conflict` | 409 | Its inputs are spent by another transaction. | | `replacement_fee_too_low` | 422 | Raise the fee rate. | Follow the result with [Get a wallet transfer](https://raresats.net/docs/get-wallet-transfer.md): the original becomes `replaced`, with `replaced_by`. --- Page: https://raresats.net/docs/guide-filters.md # Filtering & birthday sats Every sat was mined in a block, and every block on a UTC day: its **mint day**. Every block also has a **luck**, and so every sat in it. Sales and listings can be filtered by the mint days and the luck of the sats they hold, and by the row (collection) and rarer sub-category those sats belong to. | Endpoint | Returns | | --- | --- | | `GET /api/sales/query` | sales on every marketplace, newest first, paged by `before` | | `GET /api/listings/query` | active listings here, sorted by unit price, price or age | | `GET /api/listings/events` | listed, cancelled and sold events here | | `GET /api/birthday/counts` | per row, how many tracked sats were mined on a day | | `GET /api/days` | the mint-day table itself | ## The filters All optional, combined with AND: - `row`: the collection, e.g. `uncommon`, `block9`, `black_rare`. Without it, every row (the facets then count by row). - `sub`: one rarer sub-category of `row`, e.g. `sub=pizza_uncommon` under `row=uncommon`, or `sub=450x` under `row=block9`. Only items holding sats of it. Sub-category items count under their own rows, so `sub` implies `subs=1`. - `subs=1`: include the row's sub-category items (an alpha uncommon is its own row, `alpha_uncommon`, and only shows under `uncommon` with `subs=1`). - `years=2009,2010`: items holding a sat mined in one of those years. - `month=2017-12`: items holding a sat mined in that month (UTC, by its block's own time). - `blocks=1000-5000`: items holding a sat mined in blocks 1,000 to 5,000 (heights, inclusive); `blocks=9`: in block 9 alone. Up to the tip: a range past it, reversed (`5000-1000`) or malformed is a 400. - `date=2009-01-09`: items holding a sat mined that day (UTC). - `md=01-03`: items holding a sat mined on that month-day of **any** year: birthday sats. Add `years` to narrow. `date` and `md` are exclusive. - `luck=8`: items holding a sat of luck 8 or more; `luck=8-12`: of luck 8 to 12. Any row: luck is a sat's, whatever its type. An item passes when one of its sats passes them all. That sat is given back as `match`: `{sat, height, date, luck}`. ## Sat luck A sat's luck is its block's: how many more leading zero bits the block's hash has than the block's difficulty target (from the header's `bits`) required. A block whose hash only just met its target has luck 0; each step up is about half as common: of the first 969,647 blocks (to October 2026), 697,563 have luck 1 or more, 5,400 luck 8 or more, and 10 luck 17 or more (the luckiest, 756,951, has 21). Every sat of a block shares its luck, and it never changes once the block is buried. The sat and UTXO pages show it, and listings name their lead sat's (`lead.luck`). ## Examples Uncommon sats of luck 10 or more for sale now: ```bash title="cURL" curl 'https://raresats.net/api/listings/query?row=uncommon&luck=10' ``` Uncommon sats mined in 2009, sold anywhere, newest first: ```bash title="cURL" curl 'https://raresats.net/api/sales/query?row=uncommon&years=2009&limit=20' ``` The next page: pass the answer's `next` as `before`: ```bash title="cURL" curl 'https://raresats.net/api/sales/query?row=uncommon&years=2009&limit=20&before=4412' ``` Uncommon sats mined in blocks 1,000 to 5,000 for sale now: ```bash title="cURL" curl 'https://raresats.net/api/listings/query?row=uncommon&blocks=1000-5000' ``` Sats mined on your birthday (any year) for sale now, cheapest per sat first: ```bash title="cURL" curl 'https://raresats.net/api/listings/query?md=07-14&sort=unit' ``` Pizza uncommons for sale: ```bash title="cURL" curl 'https://raresats.net/api/listings/query?row=uncommon&sub=pizza_uncommon' ``` What was mined on 9 January 2009 that the index tracks, and where it is: ```bash title="cURL" curl 'https://raresats.net/api/birthday/counts?date=2009-01-09' ``` ## Facets Sales and listings answers carry `facets`, counts to refine with: - `years`: matching items per mint year; - `luck`: per minimum N, the items holding a sat of luck N or more (what `luck=N` would match); - `subs`: the row's sub-categories with their matching counts (`[]` without a row); - `rows`: without a row, matching items per row. ```json title="facets" { "years": { "2009": 17, "2010": 21, "2011": 16 }, "subs": [{ "row": "alpha_uncommon", "count": 154 }, { "row": "vintage_uncommon", "count": 3 }] } ``` ## Notes - **Dates are UTC** and come from block timestamps. A few blocks' timestamps are earlier than their parent's; the table files each such block under the day its time says (`exceptions` in `/api/days`). - **Right after a restart** the server reads the mint-day and luck tables for a few seconds; date and luck filters then answer 503 `{"error": …, "retry": true}`. Retry shortly. - **Errors** are `{error}`: 400 for a malformed filter, 404 for an unknown `row` or `sub` (and on the listing routes while the marketplace is off: `{"enabled": false}`). - **Caching:** sales queries, counts and the day table may be reused 30 s; listing queries are never cached. --- Page: https://raresats.net/docs/guide-affiliates.md # Affiliates An affiliate is paid half of the platform fee of the purchases it refers, **in the purchase transaction itself**, on chain, as it confirms. There is no account and no sign-up: **any bitcoin address** of the market's network (Taproot, SegWit, nested SegWit or legacy) is an affiliate, except the platform's fee address. Put it in a referral link and share it; the site's `/affiliates` page builds the link for you. ## Referral links - `https://raresats.net/?ref=`: any page of the site takes `?ref=`. - `https://raresats.net/r/`: a short form, to the home page. The server remembers the referrer (any usable address, never the fee address) in a first-party cookie, `rs_ref=
` (Path=/, SameSite=Lax, Secure), for **24 hours** (`affiliate_cookie_seconds` in `GET /api/market/config`), and redirects to the same page without the referral, so the visitor never sees it in the address bar. **The last link followed wins**: a newer referral replaces yours. When the buyer prepares a purchase, the server reads the cookie. ## From the API A client of the API names the affiliate directly with the optional `affiliate` field of [Prepare a purchase](https://raresats.net/docs/prepare-purchase.md). It **wins over the cookie**. ```json title="Request" { "listing_ids": ["1031931af1592e1c"], "buyer": { "payment_address": "…", "payment_public_key": "…", "ordinals_address": "…" }, "fee_rate": 4, "affiliate": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh" } ``` ## The split The buyer's platform fee is 2.5% of the prices (at least 546 sats), **the same with or without a referrer**: there is no discount. With an applied referral it is paid as **two outputs** instead of one: | Output | Pays | | --- | --- | | `2k+1` | `platform_share` to the fee address | | `2k+2` | `affiliate_fee` to the affiliate: `floor(fee × affiliate_share_bps / 10000)`, half by default | Everything else in the [transaction layout](https://raresats.net/docs/concepts.md#the-transaction-layout) is unchanged: the sellers' signed pairs keep their indexes, and the marker stays last. The summary of prepare says what happened: | `affiliate_status` | Meaning | | --- | --- | | `none` | No referrer. | | `applied` | Paid: `affiliate_fee` sats to `affiliate_address`. | | `below_minimum` | Each part must be at least **546 sats** (and above its script's dust limit), so a fee under 1,092 sats (prices under about 44,000 sats) is not split: the platform keeps it. | | `ignored_self` | A self-referral; `affiliate_reason` says which (below). | | `invalid` | Not a usable address of the network, or the fee address itself. Never a reason to refuse the purchase. | ```json title="summary (excerpt)" { "prices": 60546, "platform_fee": 1514, "platform_share": 757, "affiliate_fee": 757, "affiliate_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "affiliate_status": "applied", "affiliate_reason": null, "affiliate_share_bps": 5000 } ``` A purchase is finalized only under the terms it was prepared with: if they change in between, submit answers 409 `config_changed`. ## Self-referral Self-referrals are not paid. A referral is `ignored_self` when: | `affiliate_reason` | The check | | --- | --- | | `same_address` | The referrer is the buyer's payment or ordinals address. | | `affiliate_wallet` | The buyer's address is one of the wallet addresses recorded when the affiliate registered (only for an address that used the optional [registration](https://raresats.net/docs/register-affiliate.md)). | | `linked_funds` | The buyer's coins chosen for the purchase came from a transaction that spent from the affiliate's address (or its registering wallet's, when it registered), or the affiliate's unspent coins came from the buyer's. | | `unchecked` | That check could not finish in time (it reads a handful of transactions): no split, rather than a split it could not check. | | `seller` | The referrer is a seller's address or payout in the purchase. | What cannot be detected: one person using two wallets that never share an address and never exchange coins looks like two unrelated people on chain. ## Your earnings `GET /api/market/affiliates/{address}` gives your totals (confirmed and pending earnings, volume) and your newest referred purchases: [Get an affiliate](https://raresats.net/docs/get-affiliate.md). Sold events in the [activity](https://raresats.net/docs/get-activity.md) name the `affiliate` they paid. The site's `/affiliates` page builds your link and shows the same. ## Registration (optional) Registering is no longer needed and pays nothing more. [Register an affiliate](https://raresats.net/docs/register-affiliate.md) still works, for clients written when it was required: the wallet addresses it records only ever count as the affiliate's own wallet (`affiliate_wallet` above). --- Page: https://raresats.net/docs/guide-chat.md # Chat & profiles raresats.net has a chat, at [/chat](https://raresats.net/chat), and display names for addresses. Anyone can read. Posting and naming need a wallet that signs **BIP-322** messages (Xverse, UniSat, or your own key): there are no accounts and no passwords, only signatures. ## Topics The chat is one room for now: **General**. [/chat](https://raresats.net/chat) shows its messages directly; `/chat/general` works too, and a link to any other topic (`/chat/market`, from the first weeks' topics) goes to `/chat`, its `?thread=` and `?m=` kept. Topics stay in the API, so more can come back: when there is more than one, each has its page, `/chat/`, its own messages and threads, and the page lists them down the left (on a phone, `/chat` is the list). The first weeks' other topics (Market & prices, Palindromes and the rest) are archived: hidden and closed to posts. Their messages are kept, and a link to one still opens its thread, read only. - `GET /api/chat/topics` lists them in order, each with `last_message_id` and `last_activity_at` (just General for now). The page keeps the last message id you read in each topic (in your browser only) and marks a topic unread when its `last_message_id` is larger; the header's **Chat** link has a dot while one is. - `GET /api/chat/messages?topic=market` lists a topic's messages; `POST /api/chat/messages` takes `{text, topic}`. Without `topic`, both are General's (`main`, the one room before topics, is General too: its messages are General's now). - Only moderators create topics (`POST /api/chat/topics`) and archive them (`DELETE /api/chat/topics/{id}`: hidden and closed to new messages; the messages stay). A topic's id is its URL: 1 to 32 of `a-z`, `0-9` and inner hyphens. Creating one of the first weeks' archived topics brings it back, its messages with it. ## Links to messages Every message's time links to it: `/chat?m=` (`/chat/?m=` while there are topics) scrolls to the message and highlights it, and a reply's link, `/chat?thread=&m=`, opens its thread. `?thread=` alone opens a thread. A link naming the wrong topic finds the right one: `GET /api/chat/messages/{id}/replies` answers any message's thread with its `topic`. ## Signing in 1. `GET /api/session/nonce?address=
` returns a `nonce`, usable once, for 10 minutes, by that address only. 2. Sign `raresats.net login
` with the address (BIP-322 simple or full). 3. `POST /api/session` with `{address, message, signature}`. The answer sets the session cookie `__Host-rs_chat`: HttpOnly, Secure, SameSite=Lax, Path=/, for **30 days**. The server keeps only a hash of its token. `GET /api/session` says who is signed in; `DELETE /api/session` signs out. The site signs in with your wallet's ordinals address by default; the payment address works too. Whichever address signs is the one shown in chat. ## Posting `POST /api/chat/messages` with `{text, topic}` and the cookie. Messages are **plain text**: - normalized to NFC; control, zero-width, bidi-override and private-use characters are dropped, and line breaks become spaces, so a message cannot spoof its direction or hide text; - 1 to 500 characters after that; - shown as text only: never HTML or Markdown, no images. Links are shown as links that open in a new tab, marked `nofollow ugc`, labelled with their real host. A sat number written `sat 1953421509541`, or a bare number of 10 to 16 digits up to the last sat, links to its [sat page](https://raresats.net/sat/1953421509541). Limits: one message every 3 seconds and 20 a minute per address, and the same text twice within 2 minutes is refused (`duplicate`); the whole chat takes at most 5 messages a second (`room_busy`). Per IPv4 address or IPv6 /64: 20 sign-ins and 10 display-name changes an hour. Messages are kept for 30 days, at most 10,000 a topic. ## Replies and threads Send `{text, reply_to: }` to reply to a message; the reply is in its root's topic (a `topic` sent with it must be that one). Threads are **one level deep**: a reply to a reply joins its root's thread, and keeps the reply it answered as its `quote` (text only, cut at 140 characters). Every message has `reply_to` (its root, or null on a root), and a root has `reply_count` and `last_reply_at`. - `GET /api/chat/messages` lists a topic's roots only, each with its thread's counts. - `GET /api/chat/messages/{id}/replies` lists a thread: its `root` and its replies, newest first, paged like a topic. - A reply's answer, and its live `chat` event, carry the root's new counts as `thread: {id, reply_count, last_reply_at}`. Deleting a root deletes its whole thread: replies read wrong without what they answered, and what a moderator deletes is often what they quote. Bans, mutes and deletions apply to replies like any message. ## Reading and live updates `GET /api/chat/messages` pages newest first; pass the answer's `next` as `before` for older messages. New messages and replies, deletions, topics and name changes are pushed on [the event stream](https://raresats.net/docs/site-feeds.md#live-events) as `chat` and `profile` events: `{type: "message", topic, message, thread?}`, `{type: "delete", ids, threads, topics}` (the roots whose replies went, with their new counts, and the topics touched) and `{type: "topic", topic, archived}`. A page showing another topic marks the message's topic unread. ## Display names Sign `raresats.net profile
display_name= ` and send it to [Set a display name](https://raresats.net/docs/chat-profiles.md#set-a-display-name) with `{address, display_name, message, signature}`. **Each change is signed on its own**: a chat session is not enough. An empty name clears it. | Rule | | | --- | --- | | Length | 3 to 24 characters, trimmed, inner spaces collapsed | | Characters | letters, digits, spaces and `_ . -`; starts and ends with a letter or digit | | Unique | compared without case, separators or look-alike digits: `Rare_Hunter` is `rare hunter`, `B0B` is `bob` | | Not an address | nothing starting `bc1`, no base58 address-like strings, hex blobs or `..` (a shortened address) | | Not reserved | raresats, admin, moderator, support, official, satoshi and the like, anywhere in the name | | How often | at most one change in 24 hours | A name given up stays its last owner's for 7 days. ## Moderation Chat is on only where the operator turned it on with moderation: AI moderation, moderators (admins), or both. `GET /api/session` says whether the AI moderation is on (`moderated`). **AI moderation.** Every message and every new display name is checked by a language model before it is posted or taken, against the chat's rules. Refused: harassment and hate, sexual content, threats and violence, doxxing and personal data, scams and phishing (fake support, "send BTC to ...", asking for a seed phrase or private key, wallet-drainer and claim links, posing as raresats.net staff), spam and flooding, malware links, illegal goods. Allowed: trading talk, prices, sat numbers, addresses and txids in context, links to well-known explorers and marketplaces, mild profanity and criticism. | Answer | Meaning | | --- | --- | | 422 `moderated` | Refused; `category` says why. Nothing was posted. | | 403 `muted` | Three refusals in 10 minutes mute the address for an hour; six from one IPv4 address or IPv6 /48 within an hour pause that network. `until` is when it ends. | | 429 `rate_limited` | At most 6 texts a minute are checked per address and per IPv4 address or IPv6 /48. | | 503 `moderation_unavailable` | The text could not be checked right now (timeout or model error). Nothing was posted: try again. | | 429 `chat_busy` | Too many texts are being checked across the site at once; wait `retry-after` seconds. | Links must name their real host in plain ASCII: a host with letters from other scripts (a look-alike of a known site), punycode (`xn--`) or `user@host` is refused before the check (422 `suspicious_link`), and the page shows every link by its host name. The check never fails open: a message that could not be checked is not posted. The rate limits come first, so a refused flood costs nothing. Moderators' own messages are not checked. **Moderators** sign in like anyone else. Their sessions may delete messages (a thread's root with its replies), remove a display name (it is never given out again) and ban addresses: a banned address cannot post or set a display name, its name is removed, and its recent messages can be removed with the ban. Unbanning also ends a mute. Every moderator action is logged. --- Page: https://raresats.net/docs/get-listings.md # List active listings `GET /api/market/listings` Active listings, one page at a time, filtered by row and seller and sorted by unit price, price or age. Read-only, no rate limit beyond the edge's. #### Query parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `row` | string | no | Only listings of this row (collection), e.g. `uncommon`, `block9`, `black_rare`. A label row (e.g. `block9_1st_tx`) lists every listing lying in it. | | `sub` | string | no | With `row`: `1` also lists the row's rarer sub-categories (listings whose sats the row holds whole, e.g. alpha uncommons under `uncommon`). One of: `0`, `1`. Default: `0`. | | `contains` | string | no | With `row`: `1` also answers, apart, the listings holding only some sats of the row among others (in bundle: a Block 9 chunk holding one Block 9 palindrome under `block9_palindrome`) as `contains` (the first `limit`, in the asked `sort` and `order`; `sort=unit` orders them by total price, a bundle having no unit price of this row) and `contains_total`; `only` answers those alone as `listings`/`total`, paged by `limit` and `offset`. They are priced as the whole UTXO, never in the row's floor. One of: `0`, `1`, `only`. Default: `0`. | | `sort` | string | no | `unit` (unit price), `price` (listing price) or `new` (creation time). One of: `unit`, `price`, `new`. Default: `unit`. | | `order` | string | no | `asc` or `desc`. Default: `asc`, except `desc` for `sort=new`. Ties: newest first, then by id. One of: `asc`, `desc`. | | `limit` | integer | no | Page size, 1-200. Default: `50`. (min 1, max 200) | | `offset` | integer | no | Listings to skip, 0-1,000,000. Default: `0`. (min 0, max 1000000) | | `seller` | string | no | Only listings by this seller address. | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/market/listings?row=black_rare&sort=unit&order=asc&limit=2' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/market/listings?row=black_rare&sort=unit&order=asc&limit=2'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/market/listings", params={"row": "black_rare", "sort": "unit", "order": "asc", "limit": 2}).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `listings` | array of [Listing](https://raresats.net/docs/listing-object.md) | One page of active listings, in the requested order. | | `total` | integer | Active listings matching the filters (all pages). | | `as_of` | object \| null | When every live listing was last checked against the chain (`{height, checked_at}`), or `null`. | | `as_of.height` | integer \| null | Block height | | `as_of.checked_at` | integer | Unix seconds | | `contains` | array of [Listing](https://raresats.net/docs/listing-object.md) | Only with `row` and `contains=1`: the first `limit` active listings holding some sats of the row among others (in bundle, each `in_bundle: true`), by total price, the cheapest first. Never in `listings`, `total` or the row's floor. | | `contains_total` | integer | Only with `contains=1`: how many such listings there are (all pages; `contains=only` pages them). | ```json title="Response" { "listings": [ { "id": "1031931af1592e1c", "outpoint": "2fb6fb60ece3672924a373d7479d144d9ce4d3591185bfb5a00e62aedd53d3a9:0", "value": 546, "price": 60546, "unit_price": 60000, "row": "black_rare", "count": 1, "rows": {"black_rare": 1}, "types": [ { "types": ["black_rare", "omega"], "count": 1, "sats": [1657319999999999] } ], "sats": 1, "ranges": [[1657319999999999, 1657319999999999]], "years": {"2017": 1}, "seller_address": "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu", "payout_address": "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu", "status": "active", "created_at": 1790952985, "updated_at": 1790952985, "txid": null, "signature_public": false, "sold_via": null, "offer_id": null, "best_offer": null } ], "total": 1, "as_of": {"height": 969001, "checked_at": 1790952985} } ``` #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 400 | `bad_address` | An address is not a mainnet address (or not one at all). | | 400 | `bad_contains` | `contains` is not 0, 1 or `only`, or is set without `row`. | | 400 | `bad_order` | `order` is not `asc` or `desc`. | | 400 | `bad_paging` | `limit` or `offset` out of range. | | 400 | `bad_row` | `row` is not a row id. | | 400 | `bad_sort` | `sort` is not `unit`, `price` or `new`. | | 400 | `bad_sub` | `sub` is not 0 or 1. | | 404 | `retired_row` | `row` is a row the site retired (`uncommon_2009`, `rare_2009`): its listings are its parent row's. `retired` names the row and the parent and years to ask for. | ```json title="Error (400)" {"error": "sort must be unit, price or new", "code": "bad_sort"} ``` ## Sorting and paging - `sort=unit` (the default) orders by `unit_price`, the price per sat of the listing's own row: the way to find a collection's cheapest sats. `sort=price` orders by the listing price, `sort=new` by creation time. - `order` defaults to `asc`, except `desc` for `sort=new` (newest first). Equal values come newest first, then by id, so pages are stable. - Page with `limit` (1-200) and `offset`; `total` counts every match. Listings can sell or move between two pages; re-read the first page when it matters. ## Filters - `row` filters by a listing's own row. With `sub=1` it also lists the row's rarer sub-categories (listings whose sats the row holds whole). A label row such as `block9_1st_tx` lists every listing lying in it. - `contains=1` (with `row`) adds, apart, the listings that hold only some sats of the row among others (in bundle: a 330-sat Block 9 chunk holding one Block 9 palindrome, under `block9_palindrome`) as `contains`, the first `limit` by total price, with `contains_total`. Each is marked `in_bundle: true` with `contains_count`, its sats of the row. They are priced as the whole UTXO: their `unit_price` is their own row's, so they are never in `listings`, `total` or the row's floor. `contains=only` answers them alone as `listings` and `total`, paged by `limit` and `offset`. - `seller` lists one seller's active listings (the site's "My listings"). - Only `active` listings are listed. A `pending`, sold, cancelled or moved listing is still readable by id ([Get a listing](https://raresats.net/docs/get-listing.md)). To filter listings by mint year, mint day, birthday (month-day) or one rarer sub-category, use [Query listings](https://raresats.net/docs/get-listings-query.md); see [Filtering & birthday sats](https://raresats.net/docs/guide-filters.md). --- Page: https://raresats.net/docs/get-listing.md # Get a listing `GET /api/market/listings/{id}` One listing by id, whatever its status (active, pending, sold, cancelled or moved). #### Path parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | The listing id (16 hex characters). | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/market/listings/3324d08569806818' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/market/listings/3324d08569806818'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/market/listings/3324d08569806818").json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `id` | string | The listing id: 16 lowercase hex characters. | | `outpoint` | string | The listed UTXO, `txid:vout`. | | `value` | integer | The UTXO's own value in sats (it moves to the buyer whole). | | `price` | integer | The listing price in sats: exactly what the seller receives. The buyer pays this plus the platform fee and the network fee. | | `unit_price` | number | Price per sat of the listing's own row: `(price - value) / count`, to the hundredth of a sat. Floors and the `unit` sort use it. | | `row` | string | The listing's own row (collection): the rarest market row holding every tracked sat of the UTXO, else the rarest market row present. Never a label row. | | `count` | integer | How many sats of `row` the UTXO holds (the "×N" of a bulk listing). | | `rows` | map of integer | Every row the UTXO's tracked sats count toward, with their counts (label rows included). | | `types` | array of [TypeGroup](https://raresats.net/docs/listing-object.md) \| null | The UTXO's sats grouped by exact type combination, rarest first. | | `sats` | integer \| null | Tracked sats in the UTXO (all rows). | | `ranges` | array of array of integer \| null | The tracked sats as inclusive `[first, last]` ranges, ascending. | | `years` | map of integer \| null | Tracked sats per mint year (UTC year of the block that mined them). | | `seller_address` | string | The address that holds the UTXO and signed the listing. | | `payout_address` | string | Where the price is paid (the seller's payment output). | | `status` | string | `active` (for sale), `pending` (a purchase is in the mempool), `sold`, `cancelled` (or replaced by a newer listing of the same UTXO), `moved` (spent elsewhere). A listing whose UTXO was sold through an accepted offer ends `sold` with `sold_via: "offer"`. | | `created_at` | integer | Unix seconds. | | `updated_at` | integer | Unix seconds of the last status change. | | `txid` | string \| null | The purchase transaction, while `pending` and once `sold`; otherwise `null`. | | `signature_public` | boolean | `true` once a broadcast purchase put the seller's signature on the network: the listing can then be bought at this price anywhere until the UTXO moves, even after a cancel here. | | `sold_via` | string \| null | `offer` when the listing ended `sold` through an accepted offer of its UTXO: `price` and `unit_price` are then the offer's (what was paid), `offer_id` names it. `null` otherwise. | | `offer_id` | string \| null | The accepted offer it was sold through (`sold_via: "offer"`), else `null`. | | `best_offer` | [BestOffer](https://raresats.net/docs/offer-object.md) \| null | The best active offer on the UTXO, or `null`. | | `spent_by_pending` | object | A transaction still in the mempool that spends an output (`spent_by_pending`). Absent when nothing pending spends it. | | `spent_by_pending.txid` | string | The spending transaction. | | `spent_by_pending.vin` | integer \| null | Its input that spends the output. | | `spent_by_pending.first_seen` | integer \| null | When the transaction was first seen in the mempool (bitcoind's entry time), unix seconds. | | `spent_by_pending.fee_rate` | number \| null | Its fee rate, sat/vB (one decimal). | | `spent_by_pending.sale` | boolean | Whether the spend sells this output (a pending sale with it as the seller's input). | | `spent_by_pending.replaceable` | boolean | Always true (full RBF): the spend may still be replaced. | | `in_bundle` | boolean | Only on a listing read for a row it holds some sats of among others (in bundle: `contains=1`'s `contains`, or a row's activity): `true`. Its `price` is the whole UTXO's and its `unit_price` its own row's, never the row asked for. | | `contains_count` | integer \| null | With `in_bundle`: how many sats of the row asked for it holds. | ```json title="Response" { "id": "3324d08569806818", "outpoint": "8b09d6914ae17431940ce8b6cadae378a5d9b3c90c73c3a5b0b8c8e7edc9e86a:0", "value": 6280, "price": 94200, "unit_price": 14, "row": "block9", "count": 6280, "rows": {"block9": 6280, "block9_1st_tx": 6280}, "types": [ { "types": ["block9", "nakamoto", "vintage"], "count": 6280, "sats": [45100023043, 45100023044, 45100023045] } ], "sats": 6280, "ranges": [[45100023043, 45100029322]], "years": {"2009": 6280}, "seller_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "payout_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "status": "active", "created_at": 1790952985, "updated_at": 1790952985, "txid": null, "signature_public": false, "sold_via": null, "offer_id": null, "best_offer": null } ``` #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 400 | `bad_path` | The path is not valid percent-encoding. | | 404 | `not_found` | No such listing or offer. In chat: no such message. A display name to remove that does not exist. | ```json title="Error (404)" {"error": "no such listing", "code": "not_found"} ``` Use it to follow a listing through its life: `active`, then `pending` with the purchase `txid`, then `sold`; or `cancelled` or `moved`. See [The Listing object](https://raresats.net/docs/listing-object.md). --- Page: https://raresats.net/docs/get-listing-by-outpoint.md # Get listings by outpoint Look up whether UTXOs are listed, one or up to 100 at a time. Both forms return only **live** listings: `active`, or `pending` while a purchase is in the mempool. A sold, cancelled or moved listing is no longer the outpoint's. ## One outpoint `GET /api/market/listings/by-outpoint/{outpoint}` The live listing (active, or pending while a purchase is in the mempool) of one outpoint, or 404 `not_listed`. #### Path parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `outpoint` | string | yes | The UTXO, `txid:vout`. | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/market/listings/by-outpoint/8b09d6914ae17431940ce8b6cadae378a5d9b3c90c73c3a5b0b8c8e7edc9e86a:0' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/market/listings/by-outpoint/8b09d6914ae17431940ce8b6cadae378a5d9b3c90c73c3a5b0b8c8e7edc9e86a:0'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/market/listings/by-outpoint/8b09d6914ae17431940ce8b6cadae378a5d9b3c90c73c3a5b0b8c8e7edc9e86a:0").json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `id` | string | The listing id: 16 lowercase hex characters. | | `outpoint` | string | The listed UTXO, `txid:vout`. | | `value` | integer | The UTXO's own value in sats (it moves to the buyer whole). | | `price` | integer | The listing price in sats: exactly what the seller receives. The buyer pays this plus the platform fee and the network fee. | | `unit_price` | number | Price per sat of the listing's own row: `(price - value) / count`, to the hundredth of a sat. Floors and the `unit` sort use it. | | `row` | string | The listing's own row (collection): the rarest market row holding every tracked sat of the UTXO, else the rarest market row present. Never a label row. | | `count` | integer | How many sats of `row` the UTXO holds (the "×N" of a bulk listing). | | `rows` | map of integer | Every row the UTXO's tracked sats count toward, with their counts (label rows included). | | `types` | array of [TypeGroup](https://raresats.net/docs/listing-object.md) \| null | The UTXO's sats grouped by exact type combination, rarest first. | | `sats` | integer \| null | Tracked sats in the UTXO (all rows). | | `ranges` | array of array of integer \| null | The tracked sats as inclusive `[first, last]` ranges, ascending. | | `years` | map of integer \| null | Tracked sats per mint year (UTC year of the block that mined them). | | `seller_address` | string | The address that holds the UTXO and signed the listing. | | `payout_address` | string | Where the price is paid (the seller's payment output). | | `status` | string | `active` (for sale), `pending` (a purchase is in the mempool), `sold`, `cancelled` (or replaced by a newer listing of the same UTXO), `moved` (spent elsewhere). A listing whose UTXO was sold through an accepted offer ends `sold` with `sold_via: "offer"`. | | `created_at` | integer | Unix seconds. | | `updated_at` | integer | Unix seconds of the last status change. | | `txid` | string \| null | The purchase transaction, while `pending` and once `sold`; otherwise `null`. | | `signature_public` | boolean | `true` once a broadcast purchase put the seller's signature on the network: the listing can then be bought at this price anywhere until the UTXO moves, even after a cancel here. | | `sold_via` | string \| null | `offer` when the listing ended `sold` through an accepted offer of its UTXO: `price` and `unit_price` are then the offer's (what was paid), `offer_id` names it. `null` otherwise. | | `offer_id` | string \| null | The accepted offer it was sold through (`sold_via: "offer"`), else `null`. | | `best_offer` | [BestOffer](https://raresats.net/docs/offer-object.md) \| null | The best active offer on the UTXO, or `null`. | | `spent_by_pending` | object | A transaction still in the mempool that spends an output (`spent_by_pending`). Absent when nothing pending spends it. | | `spent_by_pending.txid` | string | The spending transaction. | | `spent_by_pending.vin` | integer \| null | Its input that spends the output. | | `spent_by_pending.first_seen` | integer \| null | When the transaction was first seen in the mempool (bitcoind's entry time), unix seconds. | | `spent_by_pending.fee_rate` | number \| null | Its fee rate, sat/vB (one decimal). | | `spent_by_pending.sale` | boolean | Whether the spend sells this output (a pending sale with it as the seller's input). | | `spent_by_pending.replaceable` | boolean | Always true (full RBF): the spend may still be replaced. | | `in_bundle` | boolean | Only on a listing read for a row it holds some sats of among others (in bundle: `contains=1`'s `contains`, or a row's activity): `true`. Its `price` is the whole UTXO's and its `unit_price` its own row's, never the row asked for. | | `contains_count` | integer \| null | With `in_bundle`: how many sats of the row asked for it holds. | ```json title="Response" { "id": "3324d08569806818", "outpoint": "8b09d6914ae17431940ce8b6cadae378a5d9b3c90c73c3a5b0b8c8e7edc9e86a:0", "value": 6280, "price": 94200, "unit_price": 14, "row": "block9", "count": 6280, "rows": {"block9": 6280, "block9_1st_tx": 6280}, "types": [ { "types": ["block9", "nakamoto", "vintage"], "count": 6280, "sats": [45100023043, 45100023044, 45100023045] } ], "sats": 6280, "ranges": [[45100023043, 45100029322]], "years": {"2009": 6280}, "seller_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "payout_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "status": "active", "created_at": 1790952985, "updated_at": 1790952985, "txid": null, "signature_public": false, "sold_via": null, "offer_id": null, "best_offer": null } ``` #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 400 | `bad_outpoint` | Not an outpoint (`txid:vout`). | | 400 | `bad_path` | The path is not valid percent-encoding. | | 404 | `not_listed` | That outpoint has no live listing. | ```json title="Error (400)" {"error": "not an outpoint (txid:vout)", "code": "bad_outpoint"} ``` ## Many outpoints `GET /api/market/listings/by-outpoint` The batch form: the live listings of up to 100 outpoints at once, keyed by outpoint. Outpoints that are not listed are simply absent. #### Query parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `outpoints` | string | yes | 1-100 outpoints (`txid:vout`), comma-separated. | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/market/listings/by-outpoint?outpoints=8b09d6914ae17431940ce8b6cadae378a5d9b3c90c73c3a5b0b8c8e7edc9e86a:0,2fb6fb60ece3672924a373d7479d144d9ce4d3591185bfb5a00e62aedd53d3a9:0' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/market/listings/by-outpoint?outpoints=8b09d6914ae17431940ce8b6cadae378a5d9b3c90c73c3a5b0b8c8e7edc9e86a:0,2fb6fb60ece3672924a373d7479d144d9ce4d3591185bfb5a00e62aedd53d3a9:0'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/market/listings/by-outpoint", params={"outpoints": "8b09d6914ae17431940ce8b6cadae378a5d9b3c90c73c3a5b0b8c8e7edc9e86a:0,2fb6fb60ece3672924a373d7479d144d9ce4d3591185bfb5a00e62aedd53d3a9:0"}).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `listings` | map of [Listing](https://raresats.net/docs/listing-object.md) | The live (active or pending) listing of each outpoint asked for, keyed by outpoint. Outpoints with none are absent. | | `as_of` | object \| null | When the marketplace last checked every live listing against the chain: the block height and unix seconds. `null` until the first check. | | `as_of.height` | integer \| null | Block height | | `as_of.checked_at` | integer | Unix seconds | ```json title="Response" { "listings": { "8b09d6914ae17431940ce8b6cadae378a5d9b3c90c73c3a5b0b8c8e7edc9e86a:0": { "id": "3324d08569806818", "outpoint": "8b09d6914ae17431940ce8b6cadae378a5d9b3c90c73c3a5b0b8c8e7edc9e86a:0", "value": 6280, "price": 94200, "unit_price": 14, "row": "block9", "count": 6280, "rows": {"block9": 6280, "block9_1st_tx": 6280}, "types": [ { "types": ["block9", "nakamoto", "vintage"], "count": 6280, "sats": [45100023043, 45100023044, 45100023045] } ], "sats": 6280, "ranges": [[45100023043, 45100029322]], "years": {"2009": 6280}, "seller_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "payout_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "status": "active", "created_at": 1790952985, "updated_at": 1790952985, "txid": null, "signature_public": false, "sold_via": null, "offer_id": null, "best_offer": null } }, "as_of": {"height": 969001, "checked_at": 1790952985} } ``` #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 400 | `bad_outpoint` | Not an outpoint (`txid:vout`). | | 400 | `bad_outpoints` | `outpoints` lists none, or more than 100. | ```json title="Error (400)" {"error": "outpoints must list 1-100 outpoints", "code": "bad_outpoints"} ``` An outpoint with no live listing is simply absent from `listings`; one malformed outpoint fails the whole request (400 `bad_outpoint`). --- Page: https://raresats.net/docs/get-floors.md # Get floors `GET /api/market/floors` For every row with active listings: its floor (the lowest unit price), how many listings and how many of its sats are listed. #### Example request ```bash title="cURL" curl 'https://raresats.net/api/market/floors' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/market/floors'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/market/floors").json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `rows` | map of object | Each row with at least one active listing, priced in it or holding some of its sats in a bundle. | | `rows..floor_unit` | number \| null | The lowest `unit_price` among the row's active listings (its own and its sub-categories'); `null` when only listings in bundle hold its sats. | | `rows..listed` | integer | Active listings of the row (own and sub-categories'; never those in bundle). | | `rows..listed_sats` | integer | Sats of the row those listings hold (sum of `count`). | | `rows..contains` | integer | Only when above 0: active listings of other rows holding some sats of this one among others (in bundle). Never in `floor_unit`, `listed` or `listed_sats`. | | `rows..contains_min_price` | integer | With `contains`: the lowest total price among them (a bundle's unit price is another row's). | | `as_of` | object \| null | When the marketplace last checked every live listing against the chain: the block height and unix seconds. `null` until the first check. | | `as_of.height` | integer \| null | Block height | | `as_of.checked_at` | integer | Unix seconds | ```json title="Response" { "rows": { "black_rare": {"floor_unit": 12000, "listed": 1, "listed_sats": 1}, "block9": {"floor_unit": 14, "listed": 1, "listed_sats": 6280} }, "as_of": {"height": 969001, "checked_at": 1790952985} } ``` #### Errors None of its own (only transport errors such as 404 `no_endpoint` or 429 at the edge). `floor_unit` is a unit price (sats per sat of the row, over the UTXO's own value), not a listing price. To find the listing at the floor: `GET /api/market/listings?row=&sort=unit&limit=1`. `contains` (present when above 0) counts the active listings of other rows that hold some of the row's sats in a bundle, and `contains_min_price` the lowest whole price among them; they are never in `floor_unit`, `listed` or `listed_sats`. A row only bundles hold has `floor_unit: null` and `listed: 0`. To read them: `GET /api/market/listings?row=&contains=only&sort=price`. --- Page: https://raresats.net/docs/get-config.md # Get settings `GET /api/market/config` The marketplace's live settings: fee, fee address, marker, limits, and whether it is ready to trade. Read it before trading; the purchase is checked against the settings it was prepared with. #### Example request ```bash title="cURL" curl 'https://raresats.net/api/market/config' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/market/config'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/market/config").json() ``` #### Response `200` **When the marketplace is on** | Field | Type | Description | | --- | --- | --- | | `enabled` | `true` | The marketplace is on. | | `network` | string | The one network it trades on (`mainnet` in production). | | `fee_bps` | integer | Buyer fee in basis points of the summed prices (250 = 2.5%). | | `min_fee` | integer | The platform fee's floor in sats (546). | | `fee_address` | string | The address of the platform fee output. | | `op_return` | string | The OP_RETURN marker payload every purchase carries (`raresats.net`). | | `max_bulk` | integer | Most listings one purchase may buy (20). | | `padding_value` | integer | Value of each padding output in sats (600). | | `max_listing_items` | integer | Most UTXOs one listing PSBT may list (50). | | `cancel_prefix` | string | The start of a cancel message: `raresats.net cancel`. | | `affiliate_share_bps` | integer | A referring affiliate's part of the platform fee, in basis points (5000 = half). | | `affiliate_prefix` | string | The start of an affiliate's registration message: `raresats.net affiliate
`. | | `affiliate_cookie_seconds` | integer | How long a referral link counts: the `rs_ref` cookie's lifetime (86400 = 24 hours). | | `offer_cancel_prefix` | string | The start of an offer's cancel message: `raresats.net cancel-offer`. | | `offer_expiries` | array of integer | The offer lifetimes a bidder may choose, in seconds (1, 3, 7 and 30 days); an offer may also have none. | | `offer_default_expiry` | integer | An offer's lifetime when `expires_in` is absent, in seconds (7 days). | | `tools` | object | The wallet tools ([Extract rare sats](https://raresats.net/docs/guide-extract.md)). | | `tools.fee_sats` | integer | The flat tools fee per transfer, in sats (0 = none). | | `tools.max_items` | integer | Most UTXOs one transfer moves (50). | | `tools.max_outputs` | integer | Most outputs of one transfer (100). | | `tools.max_sends` | integer | Most recipients of a multisend (50). | | `tools.kinds` | array of string | The kinds prepare takes. | | `tools.pad` | object | Sats per extracted output: `default` (330), `legacy` (546, P2SH and P2PKH destinations). | | `tools.pad.default` | integer | | | `tools.pad.legacy` | integer | | | `ready` | boolean | Whether bitcoind, the rare-sat index and the ord indexer all follow `network` right now. While `false`, trading answers 503 `market_unavailable`. | **When it is off** | Field | Type | Description | | --- | --- | --- | | `enabled` | `false` | The marketplace is off. | ```json title="Response" { "enabled": true, "network": "mainnet", "fee_bps": 250, "min_fee": 546, "fee_address": "bc1qycdj49gt04zvc07xyzzgsvgglc7qyhz8r3sfs6", "op_return": "raresats.net", "max_bulk": 20, "padding_value": 600, "max_listing_items": 50, "cancel_prefix": "raresats.net cancel", "affiliate_share_bps": 5000, "affiliate_prefix": "raresats.net affiliate", "affiliate_cookie_seconds": 86400, "offer_cancel_prefix": "raresats.net cancel-offer", "offer_expiries": [86400, 259200, 604800, 2592000], "offer_default_expiry": 604800, "tools": { "fee_sats": 1000, "max_items": 50, "max_outputs": 100, "max_sends": 50, "kinds": [ "extract", "send", "send_n", "chop", "consolidate", "multisend", "speedup", "cancel" ], "pad": {"default": 330, "legacy": 546} }, "ready": true } ``` #### Errors None of its own (only transport errors such as 404 `no_endpoint` or 429 at the edge). - When the marketplace is off the answer is `{"enabled": false}` and no other `/api/market/*` route is served (404, or 405 for a POST). - `ready: false` means the marketplace is closed for now (its backends are unreachable or disagree on the network): reads work, trades answer 503 `market_unavailable`. - A purchase is finalized only under the settings it was prepared with: if `fee_bps`, `min_fee`, `fee_address`, `op_return` or `padding_value` change in between, submit answers 409 `config_changed` and you prepare again. - `affiliate_share_bps` and `affiliate_cookie_seconds` are the [affiliate programme](https://raresats.net/docs/guide-affiliates.md)'s terms (`affiliate_share_bps` is part of submit's `config_changed` check too); `affiliate_prefix` starts the optional affiliate [registration message](https://raresats.net/docs/register-affiliate.md). --- Page: https://raresats.net/docs/get-fees.md # Get fee rates `GET /api/market/fees` Network fee rates in sat/vB for a purchase: bitcoind's estimates for 6, 3 and 1 blocks, else mempool.space's; cached 30 seconds. `minimum` is the lowest `fee_rate` purchase/prepare accepts. #### Example request ```bash title="cURL" curl 'https://raresats.net/api/market/fees' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/market/fees'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/market/fees").json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `slow` | number | About 6 blocks, sat/vB. | | `normal` | number | About 3 blocks, sat/vB. | | `fast` | number | Next block, sat/vB. | | `minimum` | number | The lowest rate a purchase may use, sat/vB (never below 1). | | `source` | string | Where the estimates came from. One of: `bitcoind`, `mempool.space`. | ```json title="Response" {"slow": 2, "normal": 4, "fast": 8, "minimum": 1, "source": "bitcoind"} ``` #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 503 | `fees_unavailable` | No fee estimate is available. | ```json title="Error (503)" {"error": "no fee estimate is available", "code": "fees_unavailable"} ``` Pass one of these (or any rate from `minimum` to 5000) as `fee_rate` to [Prepare a purchase](https://raresats.net/docs/prepare-purchase.md). A rate below `minimum` is refused with 422 `fee_rate_too_low`; `minimum` is never below 1 sat/vB, which some nodes require to relay. --- Page: https://raresats.net/docs/get-activity.md # Get activity `GET /api/market/activity` Recent marketplace events, newest first: listed, cancelled and sold. Sales on other marketplaces are in `/api/sales`. #### Query parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `row` | string | no | Only events of listings and offers under this row: its own, its sub-categories' and label's, and those holding some of its sats in a bundle (marked `sub` or `in_bundle`). | | `limit` | integer | no | Events, 1-200. Default: `50`. (min 1, max 200) | | `types` | string | no | Comma-separated event types: `listed`, `cancelled`, `sold` (the default: those three), `offer_made`, `offer_cancelled`, `offer_accepted`. | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/market/activity?row=black_rare&limit=1&types=sold,offer_accepted' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/market/activity?row=black_rare&limit=1&types=sold,offer_accepted'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/market/activity", params={"row": "black_rare", "limit": 1, "types": "sold,offer_accepted"}).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `events` | array of object | Newest first. | | `events[].seq` | integer | Event sequence number (rising). | | `events[].type` | string | What happened: a listing's `listed`, `cancelled`, `sold`; an offer's `offer_made`, `offer_cancelled`, `offer_accepted` (only when asked for with `types`). | | `events[].listing_id` | string \| null | The listing; on an offer event, the UTXO's live listing then, or `null`. | | `events[].outpoint` | string | The listed UTXO. | | `events[].row` | string | The listing's own row. | | `events[].count` | integer | Sats of that row. | | `events[].price` | integer | The listing price in sats (an offer's price on an offer event). | | `events[].unit_price` | number | The listing's unit price. | | `events[].seller_address` | string | The seller (same as `seller`). | | `events[].txid` | string \| null | The purchase (or accepting) transaction of a `sold` or `offer_accepted` event; `null` otherwise. | | `events[].time` | integer | Unix seconds. | | `events[].seller` | string \| null | The seller's address. | | `events[].buyer` | string \| null | A sale's buyer (or an accepted offer's bidder): the address the sats went to. `null` for other events, or when unknown. | | `events[].affiliate` | string \| null | A sale bought here with an applied referral: the affiliate it paid. `null` otherwise. | | `events[].offer_id` | string \| null | The offer, on an offer event; `null` otherwise. | | `events[].bidder` | string \| null | The bidder's payment address, on an offer event; `null` otherwise. | | `events[].sub` | boolean | Only on an event read for a row (`row=`) that is not its own row but holds every tracked sat of the item (a sub-category or a label row): `true`. | | `events[].in_bundle` | boolean | Only on an event read for a row (`row=`) whose listing or offer holds some sats of that row among others (in bundle, e.g. a Block 9 chunk under `block9_palindrome`): `true`. Its price and unit price are those of its own `row`. | | `events[].contains_count` | integer \| null | With `in_bundle`: how many sats of the row asked for the item holds. | ```json title="Response" { "events": [ { "seq": 3, "type": "sold", "listing_id": "1031931af1592e1c", "outpoint": "2fb6fb60ece3672924a373d7479d144d9ce4d3591185bfb5a00e62aedd53d3a9:0", "row": "black_rare", "count": 1, "price": 60546, "unit_price": 60000, "seller_address": "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu", "txid": "6897451c42f02de2594abfdd6a5d4fcf4794a62cbbd9fbec1ec983893ddc5df4", "time": 1790952985, "seller": "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu", "buyer": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", "affiliate": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "offer_id": null, "bidder": null } ] } ``` #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 400 | `bad_paging` | `limit` or `offset` out of range. | | 400 | `bad_row` | `row` is not a row id. | | 400 | `bad_types` | `types` is not a comma-separated list of activity event types. | | 404 | `retired_row` | `row` is a row the site retired (`uncommon_2009`, `rare_2009`): its listings are its parent row's. `retired` names the row and the parent and years to ask for. | ```json title="Error (404)" { "error": "row uncommon_2009 is retired: ask for row uncommon with years 2009", "code": "retired_row", "retired": {"row": "uncommon_2009", "successor": {"row": "uncommon", "years": [2009]}} } ``` Events are this marketplace's only. With `row`, they are the events of every listing and offer under the row: its own, its sub-categories' and label's (marked `sub: true`), and those holding some of its sats in a bundle (marked `in_bundle: true`, with `contains_count`; their price and unit price are their own row's). A `sold` event names the buyer when known (the purchase's ordinals address, else the buyer the sales index found for that transaction). For sales on every marketplace, use [Sales](https://raresats.net/docs/get-sales.md) or the [market overview](https://raresats.net/docs/get-market.md). A purchase made here shows as a `pending` event while its transaction waits in the mempool. The index sees the same transaction as a pending sale (`pending` in [Sales](https://raresats.net/docs/get-sales.md), keyed `txid:vin`); the site lists it once, as the index's pending sale. --- Page: https://raresats.net/docs/get-listings-query.md # Query listings Active listings and marketplace activity, filtered by row, rarer sub-category, mint years, a mint day or a month-day, and sat luck: the marketplace counterpart of [Query sales](https://raresats.net/docs/get-sales-query.md). How the filters combine: [Filtering & birthday sats](https://raresats.net/docs/guide-filters.md). Each listing of the query also names its lead sat with its block, mint day and luck (`lead`), and the block that created its UTXO (`utxo`): the columns of the site's listings grid. ## Listings `GET /api/listings/query` Active marketplace listings filtered like `/api/sales/query`, sorted like `/api/market/listings`, with facets. Not cached. 404 `{enabled: false}` while the marketplace is off. #### Query parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `row` | string | no | Only this row (collection). None: every row. | | `sub` | string | no | A rarer sub-category of `row` (e.g. `pizza_uncommon` under `uncommon`): only items holding sats of it. Implies `subs=1`. | | `subs` | string | no | `1` includes the row's sub-category items (sold or listed under their own rows). One of: `0`, `1`. Default: `0`. | | `contains` | string | no | With `row`: `1` also answers, apart, the listings holding only some sats of the row among others (in bundle: a Block 9 chunk holding one Block 9 palindrome under `block9_palindrome`) as `contains` (the first `limit`, in the asked `sort` and `order`; `sort=unit` orders them by total price, a bundle having no unit price of this row) and `contains_total`; `only` answers those alone as `listings`/`total`, paged by `limit` and `offset`. They are priced as the whole UTXO, never in the row's floor. One of: `0`, `1`, `only`. Default: `0`. | | `years` | string | no | Mint years, comma-separated (`2009,2010`; up to 40): items holding a sat mined in one of them. Each must be a year sats were mined in, 2009 to this one (400 otherwise); repeats and order do not matter. `year` is accepted too. | | `month` | string | no | A mint month, `YYYY-MM` (UTC, by each block's own time): items holding a sat mined that month. Combines with the other filters. | | `blocks` | string | no | A block range: heights `from-to`, inclusive (`1000-5000`), or one block (`9`): items holding a sat mined in those blocks. Up to the tip (400 past it or for a malformed or reversed range). Combines with the other filters. | | `date` | string | no | A mint day, `YYYY-MM-DD` (UTC), 2009 to this year: items holding a sat mined that day. Exclusive with `md`. | | `md` | string | no | A month-day, `MM-DD`: items holding a sat mined that day of any year (with `years`, of those years). Birthday sats. | | `luck` | string | no | Sat luck, a minimum (`8`: luck 8 or more) or an inclusive range (`8-12`), 0 to 254: items holding a sat of that luck. A sat's luck is its block's: the leading zero bits of the block hash less those of the block's target. Combines with the other filters (the matched sat passes them all). 503 for the few seconds after a first start while the luck of every block is read. | | `sort` | string | no | `unit`, `price` or `new`. One of: `unit`, `price`, `new`. Default: `unit`. | | `order` | string | no | `asc` or `desc`. Default: `asc`, except `desc` for `sort=new`. One of: `asc`, `desc`. | | `limit` | integer | no | Page size, 1-200. Default: `50`. (min 1, max 200) | | `offset` | integer | no | Listings to skip, 0-100,000. Default: `0`. (min 0, max 100000) | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/listings/query?row=uncommon&years=2009&month=2017-12&blocks=1000-5000&luck=8' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/listings/query?row=uncommon&years=2009&month=2017-12&blocks=1000-5000&luck=8'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/listings/query", params={"row": "uncommon", "years": "2009", "month": "2017-12", "blocks": "1000-5000", "luck": "8"}).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `row` | string \| null | The row asked for. | | `filter` | object | The filter as the server understood it. | | `filter.years` | array of integer | The years asked for (ascending), `[]` for none. | | `filter.month` | string \| null | The month asked for (`YYYY-MM`), `null` for none. | | `filter.date` | string \| null | The day asked for. | | `filter.md` | string \| null | The month-day asked for. | | `filter.blocks` | object \| null | The blocks asked for: `{from, to}`, heights inclusive (one block: `from` = `to`), `null` for none. | | `filter.blocks.from` | integer | | | `filter.blocks.to` | integer | | | `filter.luck` | object \| null | The luck asked for: `{min, max}` (`max` `null` for a minimum alone), `null` for none. | | `filter.luck.min` | integer | | | `filter.luck.max` | integer \| null | | | `filter.sub` | string \| null | The sub-category asked for. | | `filter.subs` | boolean | Whether sub-category items are included (absent on events and counts). | | `filter.led` | boolean | Whether sales led by another row are included (`led=1`; sales only). | | `total` | integer | Matching active listings. | | `facets` | object \| null | Counts for refining the filter; `null` with `contains=only`. | | `listings` | array of object | One page, in the requested order. | | `listings[].id` | string | The listing id: 16 lowercase hex characters. | | `listings[].outpoint` | string | The listed UTXO, `txid:vout`. | | `listings[].value` | integer | The UTXO's own value in sats (it moves to the buyer whole). | | `listings[].price` | integer | The listing price in sats: exactly what the seller receives. The buyer pays this plus the platform fee and the network fee. | | `listings[].unit_price` | number | Price per sat of the listing's own row: `(price - value) / count`, to the hundredth of a sat. Floors and the `unit` sort use it. | | `listings[].row` | string | The listing's own row (collection): the rarest market row holding every tracked sat of the UTXO, else the rarest market row present. Never a label row. | | `listings[].count` | integer | How many sats of `row` the UTXO holds (the "×N" of a bulk listing). | | `listings[].rows` | map of integer | Every row the UTXO's tracked sats count toward, with their counts (label rows included). | | `listings[].types` | array of [TypeGroup](https://raresats.net/docs/listing-object.md) \| null | The UTXO's sats grouped by exact type combination, rarest first. | | `listings[].sats` | integer \| null | Tracked sats in the UTXO (all rows). | | `listings[].ranges` | array of array of integer \| null | The tracked sats as inclusive `[first, last]` ranges, ascending. | | `listings[].years` | map of integer \| null | Tracked sats per mint year (UTC year of the block that mined them). | | `listings[].seller_address` | string | The address that holds the UTXO and signed the listing. | | `listings[].payout_address` | string | Where the price is paid (the seller's payment output). | | `listings[].status` | string | `active` (for sale), `pending` (a purchase is in the mempool), `sold`, `cancelled` (or replaced by a newer listing of the same UTXO), `moved` (spent elsewhere). | | `listings[].created_at` | integer | Unix seconds. | | `listings[].updated_at` | integer | Unix seconds of the last status change. | | `listings[].txid` | string \| null | The purchase transaction, while `pending` and once `sold`; otherwise `null`. | | `listings[].signature_public` | boolean | `true` once a broadcast purchase put the seller's signature on the network: the listing can then be bought at this price anywhere until the UTXO moves, even after a cancel here. | | `listings[].sold_via` | string \| null | `offer` when the listing ended `sold` through an accepted offer of its UTXO: `price` and `unit_price` are then the offer's (what was paid), `offer_id` names it. `null` otherwise. | | `listings[].offer_id` | string \| null | The accepted offer it was sold through (`sold_via: "offer"`), else `null`. | | `listings[].match` | object \| null | The sat that made the item pass a date or luck filter: its number, block, mint day (UTC) and luck. `null` without such a filter. | | `listings[].match.sat` | integer | The sat. | | `listings[].match.height` | integer | The block that mined it. | | `listings[].match.date` | string \| null | Its mint day, YYYY-MM-DD. | | `listings[].match.luck` | integer \| null | Its luck (its block's: the leading zero bits of the block hash less the target's); `null` while the luck table is being read. | | `listings[].best_offer` | [BestOffer](https://raresats.net/docs/offer-object.md) \| null | The best active offer on the UTXO, or `null`. | | `listings[].lead` | object \| null | The listing's lead sat: the sat that matched a date or luck filter, else the first sat of its rarest combination of types; its number, block, mint day (UTC) and luck (the grid's Luck column). `null` when the listing names no sat. | | `listings[].lead.sat` | integer | The sat. | | `listings[].lead.height` | integer | The block that mined it. | | `listings[].lead.date` | string \| null | Its mint day, YYYY-MM-DD (UTC); `null` while the day table is being read. | | `listings[].lead.luck` | integer \| null | Its luck (its block's: the leading zero bits of the block hash less the target's); `null` while the luck table is being read. | | `listings[].utxo` | object \| null | The block that created the listed UTXO and that block's day (UTC), as the marketplace's last check of its listings saw it. `null` before the first check after the listing, or while the UTXO is unconfirmed. | | `listings[].utxo.height` | integer | The block height. | | `listings[].utxo.date` | string \| null | Its day, YYYY-MM-DD (UTC); `null` while the day table is being read. | | `listings[].in_bundle` | boolean | Only on a listing read for a row it holds some sats of among others (in bundle: `contains=1`'s `contains`, or a row's activity): `true`. Its `price` is the whole UTXO's and its `unit_price` its own row's, never the row asked for. | | `listings[].contains_count` | integer \| null | With `in_bundle`: how many sats of the row asked for it holds. | | `as_of` | object \| null | When the marketplace last checked every live listing against the chain: the block height and unix seconds. `null` until the first check. | | `as_of.height` | integer \| null | Block height | | `as_of.checked_at` | integer | Unix seconds | | `ms` | integer | Server time spent, ms. | | `contains` | array of object | Only with `contains=1`: the first `limit` listings passing the filter that hold some sats of the row among others (in bundle, each `in_bundle: true`, its `lead` its first sat of the row), by total price, the cheapest first. Never in `listings`, `total` or `facets`. | | `contains[].id` | string | The listing id: 16 lowercase hex characters. | | `contains[].outpoint` | string | The listed UTXO, `txid:vout`. | | `contains[].value` | integer | The UTXO's own value in sats (it moves to the buyer whole). | | `contains[].price` | integer | The listing price in sats: exactly what the seller receives. The buyer pays this plus the platform fee and the network fee. | | `contains[].unit_price` | number | Price per sat of the listing's own row: `(price - value) / count`, to the hundredth of a sat. Floors and the `unit` sort use it. | | `contains[].row` | string | The listing's own row (collection): the rarest market row holding every tracked sat of the UTXO, else the rarest market row present. Never a label row. | | `contains[].count` | integer | How many sats of `row` the UTXO holds (the "×N" of a bulk listing). | | `contains[].rows` | map of integer | Every row the UTXO's tracked sats count toward, with their counts (label rows included). | | `contains[].types` | array of [TypeGroup](https://raresats.net/docs/listing-object.md) \| null | The UTXO's sats grouped by exact type combination, rarest first. | | `contains[].sats` | integer \| null | Tracked sats in the UTXO (all rows). | | `contains[].ranges` | array of array of integer \| null | The tracked sats as inclusive `[first, last]` ranges, ascending. | | `contains[].years` | map of integer \| null | Tracked sats per mint year (UTC year of the block that mined them). | | `contains[].seller_address` | string | The address that holds the UTXO and signed the listing. | | `contains[].payout_address` | string | Where the price is paid (the seller's payment output). | | `contains[].status` | string | `active` (for sale), `pending` (a purchase is in the mempool), `sold`, `cancelled` (or replaced by a newer listing of the same UTXO), `moved` (spent elsewhere). | | `contains[].created_at` | integer | Unix seconds. | | `contains[].updated_at` | integer | Unix seconds of the last status change. | | `contains[].txid` | string \| null | The purchase transaction, while `pending` and once `sold`; otherwise `null`. | | `contains[].signature_public` | boolean | `true` once a broadcast purchase put the seller's signature on the network: the listing can then be bought at this price anywhere until the UTXO moves, even after a cancel here. | | `contains[].sold_via` | string \| null | `offer` when the listing ended `sold` through an accepted offer of its UTXO: `price` and `unit_price` are then the offer's (what was paid), `offer_id` names it. `null` otherwise. | | `contains[].offer_id` | string \| null | The accepted offer it was sold through (`sold_via: "offer"`), else `null`. | | `contains[].match` | object \| null | The sat that made the item pass a date or luck filter: its number, block, mint day (UTC) and luck. `null` without such a filter. | | `contains[].match.sat` | integer | The sat. | | `contains[].match.height` | integer | The block that mined it. | | `contains[].match.date` | string \| null | Its mint day, YYYY-MM-DD. | | `contains[].match.luck` | integer \| null | Its luck (its block's: the leading zero bits of the block hash less the target's); `null` while the luck table is being read. | | `contains[].best_offer` | [BestOffer](https://raresats.net/docs/offer-object.md) \| null | The best active offer on the UTXO, or `null`. | | `contains[].lead` | object \| null | The listing's lead sat: the sat that matched a date or luck filter, else the first sat of its rarest combination of types; its number, block, mint day (UTC) and luck (the grid's Luck column). `null` when the listing names no sat. | | `contains[].lead.sat` | integer | The sat. | | `contains[].lead.height` | integer | The block that mined it. | | `contains[].lead.date` | string \| null | Its mint day, YYYY-MM-DD (UTC); `null` while the day table is being read. | | `contains[].lead.luck` | integer \| null | Its luck (its block's: the leading zero bits of the block hash less the target's); `null` while the luck table is being read. | | `contains[].utxo` | object \| null | The block that created the listed UTXO and that block's day (UTC), as the marketplace's last check of its listings saw it. `null` before the first check after the listing, or while the UTXO is unconfirmed. | | `contains[].utxo.height` | integer | The block height. | | `contains[].utxo.date` | string \| null | Its day, YYYY-MM-DD (UTC); `null` while the day table is being read. | | `contains[].in_bundle` | boolean | Only on a listing read for a row it holds some sats of among others (in bundle: `contains=1`'s `contains`, or a row's activity): `true`. Its `price` is the whole UTXO's and its `unit_price` its own row's, never the row asked for. | | `contains[].contains_count` | integer \| null | With `in_bundle`: how many sats of the row asked for it holds. | | `contains_total` | integer | Only with `contains=1`: how many such listings pass (all pages; `contains=only` pages them). | #### Errors `{error}` (a message, no code): | Status | When | | --- | --- | | 400 | A malformed parameter (e.g. a `date` that is no date, `date` with `md`). | | 404 | An unknown `row` or `sub`, or the marketplace is off (`enabled: false`). A retired row (`uncommon_2009`, `rare_2009`) answers 404 too, its `retired` field naming the row and years to ask for instead. | | 503 | Mint dates or sat luck are still being read (`retry: true`): try again in a few seconds. Without `retry`: this server has no sat luck for its chain. | ## Activity `GET /api/listings/events` The marketplace's listed, cancelled and sold events of listings that pass the filter, newest first (the newest 2,000 events searched). Not cached. 404 while the marketplace is off. #### Query parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `row` | string | no | Only this row (collection). None: every row. Events of every listing under the row, any kind (marked `sub` or `in_bundle` when its own row is another). | | `sub` | string | no | A rarer sub-category of `row` (e.g. `pizza_uncommon` under `uncommon`): only items holding sats of it. Implies `subs=1`. | | `years` | string | no | Mint years, comma-separated (`2009,2010`; up to 40): items holding a sat mined in one of them. Each must be a year sats were mined in, 2009 to this one (400 otherwise); repeats and order do not matter. `year` is accepted too. | | `month` | string | no | A mint month, `YYYY-MM` (UTC, by each block's own time): items holding a sat mined that month. Combines with the other filters. | | `blocks` | string | no | A block range: heights `from-to`, inclusive (`1000-5000`), or one block (`9`): items holding a sat mined in those blocks. Up to the tip (400 past it or for a malformed or reversed range). Combines with the other filters. | | `date` | string | no | A mint day, `YYYY-MM-DD` (UTC), 2009 to this year: items holding a sat mined that day. Exclusive with `md`. | | `md` | string | no | A month-day, `MM-DD`: items holding a sat mined that day of any year (with `years`, of those years). Birthday sats. | | `luck` | string | no | Sat luck, a minimum (`8`: luck 8 or more) or an inclusive range (`8-12`), 0 to 254: items holding a sat of that luck. A sat's luck is its block's: the leading zero bits of the block hash less those of the block's target. Combines with the other filters (the matched sat passes them all). 503 for the few seconds after a first start while the luck of every block is read. | | `limit` | integer | no | Events, 1-200. Default: `60`. (min 1, max 200) | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/listings/events?row=uncommon&years=2009&month=2017-12&blocks=1000-5000&luck=8&limit=5' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/listings/events?row=uncommon&years=2009&month=2017-12&blocks=1000-5000&luck=8&limit=5'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/listings/events", params={"row": "uncommon", "years": "2009", "month": "2017-12", "blocks": "1000-5000", "luck": "8", "limit": 5}).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `row` | string \| null | The row asked for. | | `filter` | object | The filter as the server understood it. | | `filter.years` | array of integer | The years asked for (ascending), `[]` for none. | | `filter.month` | string \| null | The month asked for (`YYYY-MM`), `null` for none. | | `filter.date` | string \| null | The day asked for. | | `filter.md` | string \| null | The month-day asked for. | | `filter.blocks` | object \| null | The blocks asked for: `{from, to}`, heights inclusive (one block: `from` = `to`), `null` for none. | | `filter.blocks.from` | integer | | | `filter.blocks.to` | integer | | | `filter.luck` | object \| null | The luck asked for: `{min, max}` (`max` `null` for a minimum alone), `null` for none. | | `filter.luck.min` | integer | | | `filter.luck.max` | integer \| null | | | `filter.sub` | string \| null | The sub-category asked for. | | `filter.subs` | boolean | Whether sub-category items are included (absent on events and counts). | | `filter.led` | boolean | Whether sales led by another row are included (`led=1`; sales only). | | `events` | array of object | Newest first. | | `events[].seq` | integer | Event sequence number (rising). | | `events[].type` | string | What happened. One of: `listed`, `cancelled`, `sold`. | | `events[].listing_id` | string | The listing. | | `events[].outpoint` | string | The listed UTXO. | | `events[].row` | string | The listing's own row. | | `events[].count` | integer | Sats of that row. | | `events[].price` | integer | The listing price in sats. | | `events[].unit_price` | number | The listing's unit price. | | `events[].seller_address` | string | The seller (same as `seller`). | | `events[].txid` | string \| null | The purchase transaction of a `sold` event; `null` otherwise. | | `events[].time` | integer | Unix seconds. | | `events[].seller` | string \| null | The seller's address. | | `events[].buyer` | string \| null | A sale's buyer: the address the sats went to. `null` for other events, or when unknown. | | `events[].affiliate` | string \| null | A sale bought here with an applied referral: the affiliate it paid. `null` otherwise. | | `events[].sat` | integer | The sat that matched (absent without one). | | `events[].match` | object \| null | The sat that made the item pass a date or luck filter: its number, block, mint day (UTC) and luck. `null` without such a filter. | | `events[].match.sat` | integer | The sat. | | `events[].match.height` | integer | The block that mined it. | | `events[].match.date` | string \| null | Its mint day, YYYY-MM-DD. | | `events[].match.luck` | integer \| null | Its luck (its block's: the leading zero bits of the block hash less the target's); `null` while the luck table is being read. | | `events[].offer_id` | string \| null | The offer, on an offer event; `null` otherwise. | | `events[].bidder` | string \| null | The bidder's payment address, on an offer event; `null` otherwise. | | `events[].sub` | boolean | Only on an event read for a row (`row=`) that is not its own row but holds every tracked sat of the item (a sub-category or a label row): `true`. | | `events[].in_bundle` | boolean | Only on an event read for a row (`row=`) whose listing or offer holds some sats of that row among others (in bundle, e.g. a Block 9 chunk under `block9_palindrome`): `true`. Its price and unit price are those of its own `row`. | | `events[].contains_count` | integer \| null | With `in_bundle`: how many sats of the row asked for the item holds. | #### Errors `{error}` (a message, no code): | Status | When | | --- | --- | | 400 | A malformed parameter (e.g. a `date` that is no date, `date` with `md`). | | 404 | An unknown `row` or `sub`, or the marketplace is off (`enabled: false`). A retired row (`uncommon_2009`, `rare_2009`) answers 404 too, its `retired` field naming the row and years to ask for instead. | | 503 | Mint dates or sat luck are still being read (`retry: true`): try again in a few seconds. Without `retry`: this server has no sat luck for its chain. | --- Page: https://raresats.net/docs/get-affiliate.md # Get an affiliate `GET /api/market/affiliates/{address}` Any address of the network is an affiliate (no registration). Its totals and its newest referred purchases: purchases broadcast here whose referral applied. `registered` is informational, kept for compatibility. #### Path parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `address` | string | yes | The affiliate's mainnet address. | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/market/affiliates/bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/market/affiliates/bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/market/affiliates/bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh").json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `address` | string | The affiliate's address, normalized. | | `registered` | boolean | Informational, kept for compatibility: whether the address used the optional registration. Not needed to be paid: any address is an affiliate. | | `registered_at` | integer \| null | When it used the optional registration (unix seconds), or `null`. | | `share_bps` | integer | The affiliate's share of the platform fee in force, basis points. | | `totals` | object | | | `totals.purchases` | integer | Referred purchases broadcast through the marketplace (pending or confirmed). | | `totals.confirmed` | integer | Of those, confirmed. | | `totals.pending` | integer | Of those, in the mempool. | | `totals.earned` | integer | Sats paid to the affiliate in confirmed purchases. | | `totals.pending_earned` | integer | Sats in purchases still in the mempool. | | `totals.volume` | integer | Summed listing prices of the referred purchases. | | `purchases` | array of object | The 25 newest referred purchases, newest first. | | `purchases[].txid` | string | The purchase transaction. | | `purchases[].items` | integer | Listings bought. | | `purchases[].prices` | integer | Their summed prices. | | `purchases[].platform_fee` | integer | The platform fee the buyer paid. | | `purchases[].affiliate_fee` | integer | The affiliate's output. | | `purchases[].status` | string | `pending` or `confirmed`. | | `purchases[].time` | integer | Unix seconds. | ```json title="Response" { "address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "registered": false, "registered_at": null, "share_bps": 5000, "totals": { "purchases": 1, "confirmed": 1, "pending": 0, "earned": 757, "pending_earned": 0, "volume": 60546 }, "purchases": [ { "txid": "6897451c42f02de2594abfdd6a5d4fcf4794a62cbbd9fbec1ec983893ddc5df4", "items": 1, "prices": 60546, "platform_fee": 1514, "affiliate_fee": 757, "status": "confirmed", "time": 1790952985 } ] } ``` #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 400 | `bad_address` | An address is not a mainnet address (or not one at all). | | 400 | `bad_path` | The path is not valid percent-encoding. | ```json title="Error (400)" {"error": "not a mainnet address", "code": "bad_address"} ``` Any address of the network is an affiliate. `registered` is informational, kept for compatibility: whether the address used the optional [registration](https://raresats.net/docs/register-affiliate.md), which is not needed to be paid. Only purchases whose referral **applied** count: an affiliate output is in the transaction. A referral that was `ignored_self`, `invalid` or `below_minimum` paid nothing and is not listed. The registering wallets' addresses are kept private. How the programme works: [Affiliates](https://raresats.net/docs/guide-affiliates.md). --- Page: https://raresats.net/docs/prepare-listing.md # Prepare a listing `POST /api/market/listings/prepare` Step 1 of listing: checks each UTXO (confirmed, unspent, yours, tracked rare sats, no inscription or rune, a valid price) and returns the PSBT to sign with SIGHASH_SINGLE|ANYONECANPAY. Nothing is stored yet. #### Request body `content-type: application/json` | Field | Type | Required | Description | | --- | --- | --- | --- | | `seller_address` | string | yes | The address holding the UTXOs: Taproot (`bc1p…`), native SegWit (`bc1q…`) or nested SegWit (`3…`, P2SH-P2WPKH). Legacy `1…` is refused. | | `seller_public_key` | string | yes | The public key of `seller_address`, hex: for Taproot the internal (untweaked) key, 32-byte x-only or 33-byte compressed; for SegWit the 33-byte compressed key. | | `payout_address` | string | no | Where the price is paid. Optional: defaults to `seller_address`. Any standard mainnet address. | | `items` | array of object | yes | 1 to 50 UTXOs, each listed at its own price. One PSBT, one wallet prompt. (1-50 items) | | `items[].outpoint` | string | yes | The UTXO to list, `txid:vout`. | | `items[].price` | integer | yes | Its price in sats: what you receive. Above the UTXO's own value, at least 546 and at least the payout output's dust limit. | #### Example request ```bash title="cURL" curl -X POST 'https://raresats.net/api/market/listings/prepare' \ -H 'content-type: application/json' \ -d '{ "seller_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "seller_public_key": "0283dfe85a3151d2517290da461fe2815591ef69f2b18a2ce63f01697a8b313145", "payout_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "items": [ { "outpoint": "8b09d6914ae17431940ce8b6cadae378a5d9b3c90c73c3a5b0b8c8e7edc9e86a:0", "price": 94200 } ] }' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/market/listings/prepare', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ "seller_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "seller_public_key": "0283dfe85a3151d2517290da461fe2815591ef69f2b18a2ce63f01697a8b313145", "payout_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "items": [ { "outpoint": "8b09d6914ae17431940ce8b6cadae378a5d9b3c90c73c3a5b0b8c8e7edc9e86a:0", "price": 94200 } ] }) }); const data = await response.json(); ``` ```python title="Python" import requests data = requests.post("https://raresats.net/api/market/listings/prepare", json={ "seller_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "seller_public_key": "0283dfe85a3151d2517290da461fe2815591ef69f2b18a2ce63f01697a8b313145", "payout_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "items": [{ "outpoint": "8b09d6914ae17431940ce8b6cadae378a5d9b3c90c73c3a5b0b8c8e7edc9e86a:0", "price": 94200 }] }).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `psbt` | string | The unsigned listing PSBT, base64: input i is UTXO i (with `witnessUtxo`, `sighashType` 0x83, `tapInternalKey` or `redeemScript`), output i pays its price to the payout address; version 2, locktime 0, sequence 0xfffffffd. | | `sign` | array of object | The inputs to sign: every input, with `sighash: 131`. | | `sign[].index` | integer | The input to sign. | | `sign[].address` | string | The address whose key signs it. | | `sign[].sighash` | integer | The sighash type to sign with, when it is not the default: 131 (`0x83`, SIGHASH_SINGLE\|ANYONECANPAY) for listings. Absent on purchase inputs: sign with the default (SIGHASH_DEFAULT for Taproot, SIGHASH_ALL for SegWit v0). | | `items` | array of object | What each listed UTXO will show. | | `items[].outpoint` | string | The UTXO. | | `items[].value` | integer | Its value in sats. | | `items[].price` | integer | Its listing price in sats. | | `items[].unit_price` | number | `(price - value) / count`. | | `items[].row` | string | Its own row. | | `items[].count` | integer | Sats of that row it holds. | ```json title="Response" { "psbt": "cHNidP8BAF4CAAAAAWroye3nyLiwpcNzDMmz2aV449rKtugMlDF04UqR1gmLAAAAAAD9////AfhvAQAAAAAAIlEgqC8plE1luGrmteXMdeKU6tbFk5Gh7cXgFuNJjGf8e7sAAAAAAAEBK4gYAAAAAAAAIlEgqC8plE1luGrmteXMdeKU6tbFk5Gh7cXgFuNJjGf8e7sBAwSDAAAAARcgg9/oWjFR0lFykNpGH+KBVZHvafKxiizmPwFpeosxMUUAAA==", "sign": [ { "index": 0, "address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "sighash": 131 } ], "items": [ { "outpoint": "8b09d6914ae17431940ce8b6cadae378a5d9b3c90c73c3a5b0b8c8e7edc9e86a:0", "value": 6280, "price": 94200, "unit_price": 14, "row": "block9", "count": 6280 } ] } ``` #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 400 | `bad_address` | An address is not a mainnet address (or not one at all). | | 400 | `bad_items` | `items` is not a list of 1-50 UTXOs (or the PSBT lists more), or an item's `sats` is not null or a list of sat numbers. | | 400 | `bad_json` | The body is not valid JSON. | | 400 | `bad_outpoint` | Not an outpoint (`txid:vout`). | | 400 | `bad_price` | A price is not a whole number of sats. | | 400 | `bad_public_key` | A public key is not hex, or not a valid secp256k1 key of the right size. | | 400 | `bad_request` | The body is not a JSON object, or the engine refused a malformed request (e.g. a purchase submitted without its signed setup PSBT). | | 400 | `duplicate_outpoint` | The same UTXO twice in one listing. | | 403 | `cross_origin` | A POST from a browser on another site (a foreign `Origin`, or `sec-fetch-site: cross-site`). Server-side clients send no `Origin` and are not affected. | | 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. | | 409 | `pending` | The UTXO's listing is being bought, or an offer of it accepted, right now. | | 409 | `spent` | The UTXO (or a listing's UTXO) is spent or does not exist. | | 409 | `unconfirmed` | The UTXO is not confirmed yet. | | 409 | `unindexed` | The ord indexer does not know the UTXO yet. | | 413 | `too_large` | The body is over 256 KiB. | | 415 | `not_json` | A POST without `content-type: application/json`. | | 422 | `bad_payout` | A listed input is paired with an output that pays no address, or the payout address is the marketplace's fee address. | | 422 | `inscribed` | The UTXO carries an inscription: only rare sats are listed; the wallet tools only send it whole. | | 422 | `key_mismatch` | The public key does not control the address. | | 422 | `legacy_address` | A legacy (P2PKH, `1…`) address: use Taproot or SegWit. | | 422 | `no_row` | The UTXO holds no sat of a market row. | | 422 | `not_owner` | The UTXO does not belong to the seller address, or (wallet tools) to the owner's addresses, or the transfer was made by another wallet. | | 422 | `price_too_high` | The price is above 21,000,000 BTC. | | 422 | `price_too_low` | The price is at most the UTXO's own value, below 546 sats, or below the payout output's dust limit. | | 422 | `pubkey_mismatch` | The trade engine found the public key is not the address's key. | | 422 | `runes` | The UTXO carries runes (the wallet tools only send it whole). | | 422 | `too_many_items` | Too many UTXOs in a listing, or listings in a purchase (`max_bulk`); for a wallet transfer, too many rare sats (and the runs between them) to lay out in one UTXO: choose fewer. | | 422 | `unsupported_address` | An address type the marketplace does not trade (only Taproot, native SegWit and nested SegWit). | | 422 | `unsupported_script` | The trade engine met a script type it does not support. | | 422 | `untracked` | The UTXO holds no rare sat the index tracks (send it whole instead). | | 429 | `rate_limited` | Over 30 POSTs a minute from your IPv4 address or IPv6 /64 (in chat, counted per IPv4 address or IPv6 /64: 30 writes and 20 login nonces a minute, 20 sign-ins and 10 display-name changes an hour; one message every 3 s and 20 a minute per address); wait `retry-after` seconds. With the AI moderation on, at most 6 messages and names a minute are checked per address and per IPv4 address or IPv6 /48. | | 503 | `bitcoind_unavailable` | bitcoind is not available. | | 503 | `engine_unavailable` | The trade engine is not available. | | 503 | `fork_behind` | The rare-sat index has not caught up with bitcoind: try again in a minute. | | 503 | `fork_unavailable` | The rare-sat index is not available. | | 503 | `indexer_behind` | The ord indexer is catching up: try again shortly. | | 503 | `market_unavailable` | The marketplace is closed right now (its backends disagree on the network, or are unreachable). | | 503 | `oracle_unavailable` | The ord oracle is not available. | ```json title="Error (422)" { "error": "8b09d6914ae17431940ce8b6cadae378a5d9b3c90c73c3a5b0b8c8e7edc9e86a:0 carries an inscription: only rare sats are listed here", "code": "inscribed", "outpoint": "8b09d6914ae17431940ce8b6cadae378a5d9b3c90c73c3a5b0b8c8e7edc9e86a:0" } ``` Sign every input listed in `sign` with sighash `0x83` (SIGHASH_SINGLE|ANYONECANPAY), then [submit the listing](https://raresats.net/docs/submit-listing.md). The whole flow, with code: [List a UTXO](https://raresats.net/docs/guide-list.md). - Nothing is stored at this step; preparing twice is harmless. - For SegWit v0 sellers, small previous transactions are included as `nonWitnessUtxo` (some hardware wallets want them). --- Page: https://raresats.net/docs/submit-listing.md # Submit a listing `POST /api/market/listings` Step 3 of listing: the PSBT from `POST /api/market/listings/prepare`, every input signed SIGHASH_SINGLE|ANYONECANPAY (0x83). Everything is checked again against the chain and every signature verified before anything is stored. A new listing of an outpoint already listed replaces the old one (whose signature is deleted). #### Request body `content-type: application/json` | Field | Type | Required | Description | | --- | --- | --- | --- | | `psbt` | string | yes | The listing PSBT, every input signed with SIGHASH_SINGLE\|ANYONECANPAY (0x83). Base64. Partially signed or finalized inputs are both accepted. | #### Example request ```bash title="cURL" curl -X POST 'https://raresats.net/api/market/listings' \ -H 'content-type: application/json' \ -d '{ "psbt": "cHNidP8BAF4CAAAAAWroye3nyLiwpcNzDMmz2aV449rKtugMlDF04UqR1gmLAAAAAAD9////AfhvAQAAAAAAIlEgqC8plE1luGrmteXMdeKU6tbFk5Gh7cXgFuNJjGf8e7sAAAAAAAEBK4gYAAAAAAAAIlEgqC8plE1luGrmteXMdeKU6tbFk5Gh7cXgFuNJjGf8e7sBAwSDAAAAARNBTRvH6V6M7UvHRHSt9rxehNgXWjFx339UPLBrpakZUHtJWYdSEjJcKWGz0mMAzAntUr45lEpONbwi2QRK4vGr6IMBFyCD3+haMVHSUXKQ2kYf4oFVke9p8rGKLOY/AWl6izExRQAA" }' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/market/listings', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ "psbt": "cHNidP8BAF4CAAAAAWroye3nyLiwpcNzDMmz2aV449rKtugMlDF04UqR1gmLAAAAAAD9////AfhvAQAAAAAAIlEgqC8plE1luGrmteXMdeKU6tbFk5Gh7cXgFuNJjGf8e7sAAAAAAAEBK4gYAAAAAAAAIlEgqC8plE1luGrmteXMdeKU6tbFk5Gh7cXgFuNJjGf8e7sBAwSDAAAAARNBTRvH6V6M7UvHRHSt9rxehNgXWjFx339UPLBrpakZUHtJWYdSEjJcKWGz0mMAzAntUr45lEpONbwi2QRK4vGr6IMBFyCD3+haMVHSUXKQ2kYf4oFVke9p8rGKLOY/AWl6izExRQAA" }) }); const data = await response.json(); ``` ```python title="Python" import requests data = requests.post("https://raresats.net/api/market/listings", json={ "psbt": "cHNidP8BAF4CAAAAAWroye3nyLiwpcNzDMmz2aV449rKtugMlDF04UqR1gmLAAAAAAD9////AfhvAQAAAAAAIlEgqC8plE1luGrmteXMdeKU6tbFk5Gh7cXgFuNJjGf8e7sAAAAAAAEBK4gYAAAAAAAAIlEgqC8plE1luGrmteXMdeKU6tbFk5Gh7cXgFuNJjGf8e7sBAwSDAAAAARNBTRvH6V6M7UvHRHSt9rxehNgXWjFx339UPLBrpakZUHtJWYdSEjJcKWGz0mMAzAntUr45lEpONbwi2QRK4vGr6IMBFyCD3+haMVHSUXKQ2kYf4oFVke9p8rGKLOY/AWl6izExRQAA" }).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `listings` | array of [Listing](https://raresats.net/docs/listing-object.md) | The new listings, `active`, in input order. | | `warning` | string | Present when a listing replaced here had a public signature (see `exposed`). | | `exposed` | array of string | Ids of replaced listings whose signature a broadcast purchase made public: their old price can still be taken until the UTXO moves. | ```json title="Response" { "listings": [ { "id": "3324d08569806818", "outpoint": "8b09d6914ae17431940ce8b6cadae378a5d9b3c90c73c3a5b0b8c8e7edc9e86a:0", "value": 6280, "price": 94200, "unit_price": 14, "row": "block9", "count": 6280, "rows": {"block9": 6280, "block9_1st_tx": 6280}, "types": [ { "types": ["block9", "nakamoto", "vintage"], "count": 6280, "sats": [45100023043, 45100023044, 45100023045] } ], "sats": 6280, "ranges": [[45100023043, 45100029322]], "years": {"2009": 6280}, "seller_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "payout_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "status": "active", "created_at": 1790952985, "updated_at": 1790952985, "txid": null, "signature_public": false, "sold_via": null, "offer_id": null, "best_offer": null } ] } ``` #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 400 | `bad_items` | `items` is not a list of 1-50 UTXOs (or the PSBT lists more), or an item's `sats` is not null or a list of sat numbers. | | 400 | `bad_json` | The body is not valid JSON. | | 400 | `bad_price` | A price is not a whole number of sats. | | 400 | `bad_psbt` | Not a base64 PSBT, or one that does not parse. | | 400 | `bad_request` | The body is not a JSON object, or the engine refused a malformed request (e.g. a purchase submitted without its signed setup PSBT). | | 400, 422 | `bad_signature` | 400: `signature` is missing or too long. 422: a signature does not verify (cancel or registration message, listing or purchase input). | | 400 | `duplicate_outpoint` | The same UTXO twice in one listing. | | 403 | `cross_origin` | A POST from a browser on another site (a foreign `Origin`, or `sec-fetch-site: cross-site`). Server-side clients send no `Origin` and are not affected. | | 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. | | 409 | `changed` | The UTXO's value is not what the PSBT says, or (wallet tools) a UTXO, its sats or the transaction being replaced changed since prepare: prepare again. | | 409 | `pending` | The UTXO's listing is being bought, or an offer of it accepted, right now. | | 409 | `spent` | The UTXO (or a listing's UTXO) is spent or does not exist. | | 409 | `unconfirmed` | The UTXO is not confirmed yet. | | 409 | `unindexed` | The ord indexer does not know the UTXO yet. | | 409 | `utxo_mismatch` | The PSBT's amount or script of a UTXO is not the chain's. | | 413 | `too_large` | The body is over 256 KiB. | | 415 | `not_json` | A POST without `content-type: application/json`. | | 422 | `bad_payout` | A listed input is paired with an output that pays no address, or the payout address is the marketplace's fee address. | | 422 | `bad_sequence` | A listed input is not signed with sequence 0xfffffffd. | | 422 | `bad_sighash` | A signature uses the wrong sighash type (listings: 0x83; purchases: DEFAULT or ALL). | | 422 | `bad_structure` | The listing PSBT is not the expected layout (pairing, version 2, locktime 0, one seller, sequences), or a purchase would pay the fee address. | | 422 | `inscribed` | The UTXO carries an inscription: only rare sats are listed; the wallet tools only send it whole. | | 422 | `legacy_address` | A legacy (P2PKH, `1…`) address: use Taproot or SegWit. | | 422 | `missing_signature` | An input that must be signed is not. | | 422 | `no_row` | The UTXO holds no sat of a market row. | | 422 | `not_owner` | The UTXO does not belong to the seller address, or (wallet tools) to the owner's addresses, or the transfer was made by another wallet. | | 422 | `price_too_high` | The price is above 21,000,000 BTC. | | 422 | `price_too_low` | The price is at most the UTXO's own value, below 546 sats, or below the payout output's dust limit. | | 422 | `runes` | The UTXO carries runes (the wallet tools only send it whole). | | 422 | `too_many_items` | Too many UTXOs in a listing, or listings in a purchase (`max_bulk`); for a wallet transfer, too many rare sats (and the runs between them) to lay out in one UTXO: choose fewer. | | 422 | `unsupported_address` | An address type the marketplace does not trade (only Taproot, native SegWit and nested SegWit). | | 422 | `unsupported_script` | The trade engine met a script type it does not support. | | 422 | `untracked` | The UTXO holds no rare sat the index tracks (send it whole instead). | | 429 | `rate_limited` | Over 30 POSTs a minute from your IPv4 address or IPv6 /64 (in chat, counted per IPv4 address or IPv6 /64: 30 writes and 20 login nonces a minute, 20 sign-ins and 10 display-name changes an hour; one message every 3 s and 20 a minute per address); wait `retry-after` seconds. With the AI moderation on, at most 6 messages and names a minute are checked per address and per IPv4 address or IPv6 /48. | | 503 | `bitcoind_unavailable` | bitcoind is not available. | | 503 | `engine_unavailable` | The trade engine is not available. | | 503 | `fork_behind` | The rare-sat index has not caught up with bitcoind: try again in a minute. | | 503 | `fork_unavailable` | The rare-sat index is not available. | | 503 | `indexer_behind` | The ord indexer is catching up: try again shortly. | | 503 | `market_unavailable` | The marketplace is closed right now (its backends disagree on the network, or are unreachable). | | 503 | `oracle_unavailable` | The ord oracle is not available. | ```json title="Error (400)" { "error": "Listing signature (input 0): the signature does not verify", "code": "bad_signature" } ``` - Every rule of [prepare](https://raresats.net/docs/prepare-listing.md) is checked again, and each signature is verified against the chain's amount and script of its UTXO: Schnorr over the BIP341 sighash for Taproot (65 bytes, ending in `0x83`), low-S ECDSA over the BIP143 sighash for SegWit v0 (DER, ending in `0x83`). - Listing a UTXO that is already listed **replaces** the old listing: it becomes `cancelled` and its signature is deleted. That is how a price is changed. - `warning` and `exposed` appear when a replaced listing's signature was already public (see [Cancel listings](https://raresats.net/docs/guide-cancel.md)). --- Page: https://raresats.net/docs/cancel-listings.md # Cancel listings `POST /api/market/listings/cancel` Takes active listings off sale with a BIP-322 signature by the seller address of `raresats.net cancel `. The stored signatures are deleted. A message is usable once and for 10 minutes. #### Request body `content-type: application/json` | Field | Type | Required | Description | | --- | --- | --- | --- | | `ids` | array of string | yes | 1 to 100 listing ids, all listed by `address`. (1-100 items) | | `address` | string | yes | The seller address that listed them. | | `message` | string | yes | Exactly `raresats.net cancel `: the ids comma-separated in the same order as `ids`, and the current time. At most 10 minutes old, at most 2 minutes ahead. | | `signature` | string | yes | A BIP-322 signature of `message` by `address`, base64 (what wallets' "sign message" with BIP-322 returns). | #### Example request ```bash title="cURL" curl -X POST 'https://raresats.net/api/market/listings/cancel' \ -H 'content-type: application/json' \ -d '{ "ids": ["3324d08569806818"], "address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "message": "raresats.net cancel 3324d08569806818 1790949400", "signature": "AUBGQvdc2H+7HTo1njZ1vlDxoX6ncoehAGbaHcAY1FtfSNg7lmo8Ih3WGInhAH/snOUIb8DkwRSQxc2dOu911Z9E" }' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/market/listings/cancel', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ "ids": ["3324d08569806818"], "address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "message": "raresats.net cancel 3324d08569806818 1790949400", "signature": "AUBGQvdc2H+7HTo1njZ1vlDxoX6ncoehAGbaHcAY1FtfSNg7lmo8Ih3WGInhAH/snOUIb8DkwRSQxc2dOu911Z9E" }) }); const data = await response.json(); ``` ```python title="Python" import requests data = requests.post("https://raresats.net/api/market/listings/cancel", json={ "ids": ["3324d08569806818"], "address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "message": "raresats.net cancel 3324d08569806818 1790949400", "signature": "AUBGQvdc2H+7HTo1njZ1vlDxoX6ncoehAGbaHcAY1FtfSNg7lmo8Ih3WGInhAH/snOUIb8DkwRSQxc2dOu911Z9E" }).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `listings` | array of [Listing](https://raresats.net/docs/listing-object.md) | The cancelled listings (`status: "cancelled"`). | | `warning` | string | Present when some of them have a public signature (see `exposed`). | | `exposed` | array of string | Ids of cancelled listings whose signature a broadcast purchase made public: off sale here, but only moving the UTXO makes the cancel certain. | ```json title="Response" { "listings": [ { "id": "3324d08569806818", "outpoint": "8b09d6914ae17431940ce8b6cadae378a5d9b3c90c73c3a5b0b8c8e7edc9e86a:0", "value": 6280, "price": 94200, "unit_price": 14, "row": "block9", "count": 6280, "rows": {"block9": 6280, "block9_1st_tx": 6280}, "types": [ { "types": ["block9", "nakamoto", "vintage"], "count": 6280, "sats": [45100023043, 45100023044, 45100023045] } ], "sats": 6280, "ranges": [[45100023043, 45100029322]], "years": {"2009": 6280}, "seller_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "payout_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "status": "cancelled", "created_at": 1790952985, "updated_at": 1790949401, "txid": null, "signature_public": false, "sold_via": null, "offer_id": null, "best_offer": null } ] } ``` #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 400 | `bad_address` | An address is not a mainnet address (or not one at all). | | 400 | `bad_ids` | `ids` / `listing_ids` is empty, too long, malformed, or lists an id twice. | | 400 | `bad_json` | The body is not valid JSON. | | 400 | `bad_message` | The cancel message is not `raresats.net cancel ` or names other listings than `ids`; or the registration message is not `raresats.net affiliate
` for `address`. | | 400 | `bad_request` | The body is not a JSON object, or the engine refused a malformed request (e.g. a purchase submitted without its signed setup PSBT). | | 400, 422 | `bad_signature` | 400: `signature` is missing or too long. 422: a signature does not verify (cancel or registration message, listing or purchase input). | | 403 | `cross_origin` | A POST from a browser on another site (a foreign `Origin`, or `sec-fetch-site: cross-site`). Server-side clients send no `Origin` and are not affected. | | 404 | `not_found` | No such listing or offer. In chat: no such message. A display name to remove that does not exist. | | 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. | | 409 | `not_active` | A listing (or offer) is not active: being bought (or accepted), sold, cancelled, moved, expired or invalid. Carries `id` and `status`. | | 409 | `replayed` | That cancel or registration message was used already. | | 413 | `too_large` | The body is over 256 KiB. | | 415 | `not_json` | A POST without `content-type: application/json`. | | 422 | `future_message` | The cancel or registration message is dated more than 2 minutes ahead: check the clock. | | 422 | `not_seller` | A listing was not listed by `address`. | | 422 | `stale_message` | The cancel or registration message is more than 10 minutes old: sign a new one. | | 429 | `rate_limited` | Over 30 POSTs a minute from your IPv4 address or IPv6 /64 (in chat, counted per IPv4 address or IPv6 /64: 30 writes and 20 login nonces a minute, 20 sign-ins and 10 display-name changes an hour; one message every 3 s and 20 a minute per address); wait `retry-after` seconds. With the AI moderation on, at most 6 messages and names a minute are checked per address and per IPv4 address or IPv6 /48. | | 503 | `engine_unavailable` | The trade engine is not available. | ```json title="Error (422)" { "error": "the cancel message is more than 10 minutes old: sign a new one", "code": "stale_message" } ``` The message format, the time window, replays and what `exposed` means: [Cancel listings guide](https://raresats.net/docs/guide-cancel.md). --- Page: https://raresats.net/docs/prepare-purchase.md # Prepare a purchase `POST /api/market/purchase/prepare` Step 1 of buying 1-20 listings: checks they are active and unspent, picks plain cardinal UTXOs of your payment address, and returns the PSBTs to sign (a setup PSBT first when you lack padding), the fee breakdown and an expiry. The plan is held 10 minutes. #### Request body `content-type: application/json` | Field | Type | Required | Description | | --- | --- | --- | --- | | `listing_ids` | array of string | yes | 1 to 20 (`max_bulk`) active listings, each once. (1-20 items) | | `buyer` | object | yes | The buyer's wallet: Xverse-style wallets have two addresses, UniSat-style one (use it for both). | | `buyer.payment_address` | string | yes | Pays: padding, funding and change. Taproot, native SegWit or nested SegWit. | | `buyer.payment_public_key` | string | yes | Its public key, hex (Taproot: the internal key, x-only or compressed; SegWit: compressed). | | `buyer.ordinals_address` | string | yes | Receives the bought UTXOs. Taproot, native SegWit or nested SegWit. May equal `payment_address`. | | `buyer.ordinals_public_key` | string \| null | no | Its public key, hex. Optional; checked when given. | | `fee_rate` | number | yes | Network fee rate in sat/vB: at least `minimum` from `GET /api/market/fees` (and at least 1), at most 5000. (max 5000) | | `affiliate` | string \| null | no | Optional: the referring affiliate's address. It wins over the `rs_ref` cookie the site sets from a referral link. Any address of the network is an affiliate, with no registration; an unusable one (another network's, or the fee address) is ignored (`invalid`), never refused. | #### Example request ```bash title="cURL" curl -X POST 'https://raresats.net/api/market/purchase/prepare' \ -H 'content-type: application/json' \ -d '{ "listing_ids": ["1031931af1592e1c"], "buyer": { "payment_address": "37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", "payment_public_key": "039b3b694b8fc5b5e07fb069c783cac754f5d38c3e08bed1960e31fdb1dda35c24", "ordinals_address": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", "ordinals_public_key": "03cc8a4bc64d897bddc5fbc2f670f7a8ba0b386779106cf1223c6fc5d7cd6fc115" }, "fee_rate": 4, "affiliate": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh" }' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/market/purchase/prepare', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ "listing_ids": ["1031931af1592e1c"], "buyer": { "payment_address": "37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", "payment_public_key": "039b3b694b8fc5b5e07fb069c783cac754f5d38c3e08bed1960e31fdb1dda35c24", "ordinals_address": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", "ordinals_public_key": "03cc8a4bc64d897bddc5fbc2f670f7a8ba0b386779106cf1223c6fc5d7cd6fc115" }, "fee_rate": 4, "affiliate": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh" }) }); const data = await response.json(); ``` ```python title="Python" import requests data = requests.post("https://raresats.net/api/market/purchase/prepare", json={ "listing_ids": ["1031931af1592e1c"], "buyer": { "payment_address": "37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", "payment_public_key": "039b3b694b8fc5b5e07fb069c783cac754f5d38c3e08bed1960e31fdb1dda35c24", "ordinals_address": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", "ordinals_public_key": "03cc8a4bc64d897bddc5fbc2f670f7a8ba0b386779106cf1223c6fc5d7cd6fc115" }, "fee_rate": 4, "affiliate": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh" }).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `id` | string | The prepared purchase: submit it with this id. | | `setup` | object | Present only when your payment address lacks padding: a setup transaction that makes it, to sign and submit too (broadcast first). | | `setup.psbt` | string | The unsigned PSBT, base64. The sellers' inputs carry no signature (the server adds them at submit). | | `setup.sign` | array of object | Your inputs: sign exactly these, with your wallet's default sighash. | | `setup.sign[].index` | integer | The input to sign. | | `setup.sign[].address` | string | The address whose key signs it. | | `setup.sign[].sighash` | integer | The sighash type to sign with, when it is not the default: 131 (`0x83`, SIGHASH_SINGLE\|ANYONECANPAY) for listings. Absent on purchase inputs: sign with the default (SIGHASH_DEFAULT for Taproot, SIGHASH_ALL for SegWit v0). | | `purchase` | object | The purchase transaction to sign. | | `purchase.psbt` | string | The unsigned PSBT, base64. The sellers' inputs carry no signature (the server adds them at submit). | | `purchase.sign` | array of object | Your inputs: sign exactly these, with your wallet's default sighash. | | `purchase.sign[].index` | integer | The input to sign. | | `purchase.sign[].address` | string | The address whose key signs it. | | `purchase.sign[].sighash` | integer | The sighash type to sign with, when it is not the default: 131 (`0x83`, SIGHASH_SINGLE\|ANYONECANPAY) for listings. Absent on purchase inputs: sign with the default (SIGHASH_DEFAULT for Taproot, SIGHASH_ALL for SegWit v0). | | `summary` | object | The fee breakdown and what you buy. | | `summary.prices` | integer | Sum of the listing prices. | | `summary.platform_fee` | integer | The platform fee the buyer pays: `max(ceil(prices × fee_bps / 10000), min_fee)` (2.5%, at least 546 sats), the same with or without a referrer. Split between `platform_share` and `affiliate_fee`. | | `summary.platform_share` | integer | The part of `platform_fee` paid to the fee address (all of it without an applied referral). | | `summary.affiliate_fee` | integer | The part paid to the affiliate, in its own output right after the platform's (0 unless `affiliate_status` is `applied`). | | `summary.affiliate_address` | string \| null | The referrer (normalized), or `null` when none was given or it was not a usable address. | | `summary.affiliate_status` | string | `none` (no referrer), `invalid` (not a usable address of the network, or the fee address), `ignored_self` (a self-referral; see `affiliate_reason`), `applied` (paid), `below_minimum` (a share would be under 546 sats: the platform keeps the fee). | | `summary.affiliate_reason` | string \| null | Why an `ignored_self` referral was not paid: `same_address` (the referrer is the buyer's payment or ordinals address), `affiliate_wallet` (the buyer's address belongs to the wallet that registered the affiliate, when it used the optional registration), `linked_funds` (the buyer's coins came from the affiliate's addresses, or the affiliate's from the buyer's), `unchecked` (the funds could not be checked in time: no split), `seller` (the referrer is a seller's address or payout). `null` otherwise. | | `summary.affiliate_share_bps` | integer | The affiliate's share of the fee in force, basis points. | | `summary.network_fee` | integer | Network fee of the purchase plus the setup transaction. | | `summary.purchase_network_fee` | integer | Network fee of the purchase transaction. | | `summary.setup_network_fee` | integer | Network fee of the setup transaction (0 without one). | | `summary.total` | integer | What the purchase costs you: `prices + platform_fee + network_fee`. The padding you spend comes back to you. | | `summary.vsize` | integer | Virtual size of the purchase transaction, vB. | | `summary.setup_vsize` | integer | Virtual size of the setup transaction, vB (0 without one). | | `summary.fee_rate` | number | The fee rate asked for, sat/vB. | | `summary.change` | integer | Change back to your payment address (0: none; a remainder below dust goes to the network fee). | | `summary.padding_value` | integer | Value of each new padding output (600). | | `summary.items` | array of object | The listings bought, in purchase order. | | `summary.items[].id` | string | The listing. | | `summary.items[].outpoint` | string | Its UTXO. | | `summary.items[].value` | integer | The UTXO's value (it lands whole in your ordinals address). | | `summary.items[].price` | integer | The listing price. | | `summary.items[].unit_price` | number | The listing's unit price. | | `summary.items[].row` | string | Its own row. | | `summary.items[].count` | integer | Sats of that row. | | `expires_at` | integer | Unix seconds: the plan is held 10 minutes. Submit before then, or prepare again. | ```json title="Response" { "id": "5c9e5c410688cd85", "purchase": { "psbt": "cHNidP8BAP3lAQIAAAAEV/MhdPD/0sIHo8hX35p9GQud7geCW9jXa3hoAejEjgoAAAAAAP3///+3Sw6TNUSithUu7/zVH0UQDemfGc8HWcjxkuB/9nd1LAAAAAAA/f///6nTU92uYg6gtb+FEVnT5JxNFJ1H13OjJCln4+xg+7YvAAAAAAD9////VHDf42dMx3y1IrYZcBnC/YiD1iNsIqK5yRDtpRMrGUYAAAAAAP3///8JsAQAAAAAAAAXqRQ/tulYEuV7tGkfmkpiiGKmGk92m4ciAgAAAAAAACJRIKYIafDbzx3GWcnOy6+AUBNeqejNxIcFPx3GiAlJ3GhMguwAAAAAAAAWABTAzrzWw9PKjHXcXsYuvlUzDvkQ4vUCAAAAAAAAIlEgrfKcXTOb9Ymo27aSDRsOplytPApoV4riNE46JGW7F/D1AgAAAAAAACJRIKgvKZRNZbhq5rXlzHXilOrWxZORoe3F4BbjSYxn/Hu7WAIAAAAAAAAXqRQ/tulYEuV7tGkfmkpiiGKmGk92m4dYAgAAAAAAABepFD+26VgS5Xu0aR+aSmKIYqYaT3abh3hIAQAAAAAAF6kUP7bpWBLle7RpH5pKYohiphpPdpuHAAAAAAAAAAAOagxyYXJlc2F0cy5uZXQAAAAAAAEBIFgCAAAAAAAAF6kUP7bpWBLle7RpH5pKYohiphpPdpuHAQQWABT5kGeayv4lwnYVNztAvyJEbST/RAABASBYAgAAAAAAABepFD+26VgS5Xu0aR+aSmKIYqYaT3abhwEEFgAU+ZBnmsr+JcJ2FTc7QL8iRG0k/0QAAQEfIgIAAAAAAAAWABTAzrzWw9PKjHXcXsYuvlUzDvkQ4gABASDwSQIAAAAAABepFD+26VgS5Xu0aR+aSmKIYqYaT3abhwEEFgAU+ZBnmsr+JcJ2FTc7QL8iRG0k/0QAAAAAAAAAAAAA", "sign": [ {"index": 0, "address": "37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf"}, {"index": 1, "address": "37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf"}, {"index": 3, "address": "37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf"} ] }, "summary": { "prices": 60546, "platform_fee": 1514, "platform_share": 757, "affiliate_fee": 757, "affiliate_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "affiliate_status": "applied", "affiliate_reason": null, "affiliate_share_bps": 5000, "network_fee": 2652, "purchase_network_fee": 2652, "setup_network_fee": 0, "total": 64712, "vsize": 663, "setup_vsize": 0, "fee_rate": 4, "change": 84088, "padding_value": 600, "items": [ { "id": "1031931af1592e1c", "outpoint": "2fb6fb60ece3672924a373d7479d144d9ce4d3591185bfb5a00e62aedd53d3a9:0", "value": 546, "price": 60546, "unit_price": 60000, "row": "black_rare", "count": 1 } ] }, "expires_at": 1790953585 } ``` #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 400 | `bad_address` | An address is not a mainnet address (or not one at all). | | 400 | `bad_buyer` | `buyer` is not an object. | | 400 | `bad_fee_rate` | `fee_rate` is not a positive number up to 5000. | | 400 | `bad_ids` | `ids` / `listing_ids` is empty, too long, malformed, or lists an id twice. | | 400 | `bad_json` | The body is not valid JSON. | | 400 | `bad_public_key` | A public key is not hex, or not a valid secp256k1 key of the right size. | | 400 | `bad_request` | The body is not a JSON object, or the engine refused a malformed request (e.g. a purchase submitted without its signed setup PSBT). | | 403 | `cross_origin` | A POST from a browser on another site (a foreign `Origin`, or `sec-fetch-site: cross-site`). Server-side clients send no `Origin` and are not affected. | | 404 | `not_found` | No such listing or offer. In chat: no such message. A display name to remove that does not exist. | | 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. | | 409 | `not_active` | A listing (or offer) is not active: being bought (or accepted), sold, cancelled, moved, expired or invalid. Carries `id` and `status`. | | 409 | `spent` | The UTXO (or a listing's UTXO) is spent or does not exist. | | 413 | `too_large` | The body is over 256 KiB. | | 415 | `not_json` | A POST without `content-type: application/json`. | | 422 | `bad_structure` | The listing PSBT is not the expected layout (pairing, version 2, locktime 0, one seller, sequences), or a purchase would pay the fee address. | | 422 | `dust_output` | A bought UTXO is below the dust limit of your ordinals address type: use a Taproot or native SegWit ordinals address. | | 422 | `fee_rate_too_low` | `fee_rate` is below the current minimum (`GET /api/market/fees`). | | 422 | `fee_too_high` | The network fee would exceed the per-transaction cap (200,000 sats): lower the fee rate or move fewer. | | 422 | `insufficient_funds` | The payment address's plain UTXOs cannot pay prices, fee, padding and network fee (or a transfer's fees). Carries `available`, `prices`, `platform_fee`. | | 422 | `key_mismatch` | The public key does not control the address. | | 422 | `legacy_address` | A legacy (P2PKH, `1…`) address: use Taproot or SegWit. | | 422 | `pubkey_mismatch` | The trade engine found the public key is not the address's key. | | 422 | `too_many_items` | Too many UTXOs in a listing, or listings in a purchase (`max_bulk`); for a wallet transfer, too many rare sats (and the runs between them) to lay out in one UTXO: choose fewer. | | 422 | `unsupported_address` | An address type the marketplace does not trade (only Taproot, native SegWit and nested SegWit). | | 422 | `unsupported_script` | The trade engine met a script type it does not support. | | 429 | `rate_limited` | Over 30 POSTs a minute from your IPv4 address or IPv6 /64 (in chat, counted per IPv4 address or IPv6 /64: 30 writes and 20 login nonces a minute, 20 sign-ins and 10 display-name changes an hour; one message every 3 s and 20 a minute per address); wait `retry-after` seconds. With the AI moderation on, at most 6 messages and names a minute are checked per address and per IPv4 address or IPv6 /48. | | 503 | `bitcoind_unavailable` | bitcoind is not available. | | 503 | `busy` | Too many purchases, offers or transfers are being prepared: try again in a minute. Each client (IPv4 address or IPv6 /64) keeps its 10 latest prepared purchases, offers and transfers; an older one answers `unknown_purchase` / `unknown_offer` / `unknown_transfer`. | | 503 | `engine_unavailable` | The trade engine is not available. | | 503 | `fork_unavailable` | The rare-sat index is not available. | | 503 | `market_unavailable` | The marketplace is closed right now (its backends disagree on the network, or are unreachable). | | 503 | `oracle_unavailable` | The ord oracle is not available. | ```json title="Error (409)" { "error": "listing 1031931af1592e1c is pending", "code": "not_active", "id": "1031931af1592e1c", "status": "pending" } ``` Then sign the `setup` PSBT (when present) and the `purchase` PSBT, exactly the inputs in their `sign` lists, and [submit](https://raresats.net/docs/submit-purchase.md) before `expires_at`. The whole flow: [Buy listings](https://raresats.net/docs/guide-buy.md). ## Affiliates `affiliate` names the referrer of the purchase: any address of the network but the fee address, with no registration; it is never paid for a self-referral. Without it, the server uses the `rs_ref` cookie the site sets when a buyer follows a referral link (`?ref=
` or `/r/
`); the body's field wins. The summary says what happened (`affiliate_status`) and how the platform fee is split (`platform_share`, `affiliate_fee`). A referrer never makes prepare fail. See [Affiliates](https://raresats.net/docs/guide-affiliates.md). --- Page: https://raresats.net/docs/submit-purchase.md # Submit a purchase `POST /api/market/purchase/submit` Step 3 of buying: the PSBTs from prepare, your inputs signed. The server checks they are exactly the prepared transactions, verifies your signatures, adds the sellers' signatures, rechecks every rule, then `testmempoolaccept` and broadcasts (setup first). The listings become `pending`. Resubmitting the same purchase is idempotent: use it to settle `broadcast_unknown`. #### Request body `content-type: application/json` | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | The prepared purchase id. | | `setup_psbt` | string \| null | no | The signed setup PSBT, base64: required when prepare returned `setup`, absent otherwise. | | `purchase_psbt` | string | yes | The signed purchase PSBT, base64. Not finalized is fine; the transaction must be exactly the prepared one. | #### Example request ```bash title="cURL" curl -X POST 'https://raresats.net/api/market/purchase/submit' \ -H 'content-type: application/json' \ -d '{ "id": "5c9e5c410688cd85", "purchase_psbt": "cHNidP8BAP3lAQIAAAAEV/MhdPD/0sIHo8hX35p9GQud7geCW9jXa3hoAejEjgoAAAAAAP3///+3Sw6TNUSithUu7/zVH0UQDemfGc8HWcjxkuB/9nd1LAAAAAAA/f///6nTU92uYg6gtb+FEVnT5JxNFJ1H13OjJCln4+xg+7YvAAAAAAD9////VHDf42dMx3y1IrYZcBnC/YiD1iNsIqK5yRDtpRMrGUYAAAAAAP3///8JsAQAAAAAAAAXqRQ/tulYEuV7tGkfmkpiiGKmGk92m4ciAgAAAAAAACJRIKYIafDbzx3GWcnOy6+AUBNeqejNxIcFPx3GiAlJ3GhMguwAAAAAAAAWABTAzrzWw9PKjHXcXsYuvlUzDvkQ4vUCAAAAAAAAIlEgrfKcXTOb9Ymo27aSDRsOplytPApoV4riNE46JGW7F/D1AgAAAAAAACJRIKgvKZRNZbhq5rXlzHXilOrWxZORoe3F4BbjSYxn/Hu7WAIAAAAAAAAXqRQ/tulYEuV7tGkfmkpiiGKmGk92m4dYAgAAAAAAABepFD+26VgS5Xu0aR+aSmKIYqYaT3abh3hIAQAAAAAAF6kUP7bpWBLle7RpH5pKYohiphpPdpuHAAAAAAAAAAAOagxyYXJlc2F0cy5uZXQAAAAAAAEBIFgCAAAAAAAAF6kUP7bpWBLle7RpH5pKYohiphpPdpuHIgIDmztpS4/FteB/sGnHg8rHVPXTjD4IvtGWDjH9sd2jXCRIMEUCIQDqT5JFB8nk2LvYxjpKXk87G12Tn0Jt7fslQf2gSIvJjwIgbg+BAy20UrXd2niDIeZbPLkgyz+NcnSSkChVICw0gV4BAQQWABT5kGeayv4lwnYVNztAvyJEbST/RAABASBYAgAAAAAAABepFD+26VgS5Xu0aR+aSmKIYqYaT3abhyICA5s7aUuPxbXgf7Bpx4PKx1T104w+CL7Rlg4x/bHdo1wkRzBEAiAbu6SfmqbPok5pJyyLQoLTWtu6xHYFUxdkVqMdge1hPwIgXVp1PF1DvIaRLu2QeT0Qv9t+5Dfe2BUaY/Y2QBv58+ABAQQWABT5kGeayv4lwnYVNztAvyJEbST/RAABAR8iAgAAAAAAABYAFMDOvNbD08qMddxexi6+VTMO+RDiAAEBIPBJAgAAAAAAF6kUP7bpWBLle7RpH5pKYohiphpPdpuHIgIDmztpS4/FteB/sGnHg8rHVPXTjD4IvtGWDjH9sd2jXCRHMEQCIC8ykJLr2bz7gpup8hfRujOnTV4UdPVuIsfzFgJGmMuCAiARSDj1rK63S1wPEKMrRPqnZ7u+rJDysVPRn7ym3iNFpQEBBBYAFPmQZ5rK/iXCdhU3O0C/IkRtJP9EAAAAAAAAAAAAAA==" }' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/market/purchase/submit', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ "id": "5c9e5c410688cd85", "purchase_psbt": "cHNidP8BAP3lAQIAAAAEV/MhdPD/0sIHo8hX35p9GQud7geCW9jXa3hoAejEjgoAAAAAAP3///+3Sw6TNUSithUu7/zVH0UQDemfGc8HWcjxkuB/9nd1LAAAAAAA/f///6nTU92uYg6gtb+FEVnT5JxNFJ1H13OjJCln4+xg+7YvAAAAAAD9////VHDf42dMx3y1IrYZcBnC/YiD1iNsIqK5yRDtpRMrGUYAAAAAAP3///8JsAQAAAAAAAAXqRQ/tulYEuV7tGkfmkpiiGKmGk92m4ciAgAAAAAAACJRIKYIafDbzx3GWcnOy6+AUBNeqejNxIcFPx3GiAlJ3GhMguwAAAAAAAAWABTAzrzWw9PKjHXcXsYuvlUzDvkQ4vUCAAAAAAAAIlEgrfKcXTOb9Ymo27aSDRsOplytPApoV4riNE46JGW7F/D1AgAAAAAAACJRIKgvKZRNZbhq5rXlzHXilOrWxZORoe3F4BbjSYxn/Hu7WAIAAAAAAAAXqRQ/tulYEuV7tGkfmkpiiGKmGk92m4dYAgAAAAAAABepFD+26VgS5Xu0aR+aSmKIYqYaT3abh3hIAQAAAAAAF6kUP7bpWBLle7RpH5pKYohiphpPdpuHAAAAAAAAAAAOagxyYXJlc2F0cy5uZXQAAAAAAAEBIFgCAAAAAAAAF6kUP7bpWBLle7RpH5pKYohiphpPdpuHIgIDmztpS4/FteB/sGnHg8rHVPXTjD4IvtGWDjH9sd2jXCRIMEUCIQDqT5JFB8nk2LvYxjpKXk87G12Tn0Jt7fslQf2gSIvJjwIgbg+BAy20UrXd2niDIeZbPLkgyz+NcnSSkChVICw0gV4BAQQWABT5kGeayv4lwnYVNztAvyJEbST/RAABASBYAgAAAAAAABepFD+26VgS5Xu0aR+aSmKIYqYaT3abhyICA5s7aUuPxbXgf7Bpx4PKx1T104w+CL7Rlg4x/bHdo1wkRzBEAiAbu6SfmqbPok5pJyyLQoLTWtu6xHYFUxdkVqMdge1hPwIgXVp1PF1DvIaRLu2QeT0Qv9t+5Dfe2BUaY/Y2QBv58+ABAQQWABT5kGeayv4lwnYVNztAvyJEbST/RAABAR8iAgAAAAAAABYAFMDOvNbD08qMddxexi6+VTMO+RDiAAEBIPBJAgAAAAAAF6kUP7bpWBLle7RpH5pKYohiphpPdpuHIgIDmztpS4/FteB/sGnHg8rHVPXTjD4IvtGWDjH9sd2jXCRHMEQCIC8ykJLr2bz7gpup8hfRujOnTV4UdPVuIsfzFgJGmMuCAiARSDj1rK63S1wPEKMrRPqnZ7u+rJDysVPRn7ym3iNFpQEBBBYAFPmQZ5rK/iXCdhU3O0C/IkRtJP9EAAAAAAAAAAAAAA==" }) }); const data = await response.json(); ``` ```python title="Python" import requests data = requests.post("https://raresats.net/api/market/purchase/submit", json={ "id": "5c9e5c410688cd85", "purchase_psbt": "cHNidP8BAP3lAQIAAAAEV/MhdPD/0sIHo8hX35p9GQud7geCW9jXa3hoAejEjgoAAAAAAP3///+3Sw6TNUSithUu7/zVH0UQDemfGc8HWcjxkuB/9nd1LAAAAAAA/f///6nTU92uYg6gtb+FEVnT5JxNFJ1H13OjJCln4+xg+7YvAAAAAAD9////VHDf42dMx3y1IrYZcBnC/YiD1iNsIqK5yRDtpRMrGUYAAAAAAP3///8JsAQAAAAAAAAXqRQ/tulYEuV7tGkfmkpiiGKmGk92m4ciAgAAAAAAACJRIKYIafDbzx3GWcnOy6+AUBNeqejNxIcFPx3GiAlJ3GhMguwAAAAAAAAWABTAzrzWw9PKjHXcXsYuvlUzDvkQ4vUCAAAAAAAAIlEgrfKcXTOb9Ymo27aSDRsOplytPApoV4riNE46JGW7F/D1AgAAAAAAACJRIKgvKZRNZbhq5rXlzHXilOrWxZORoe3F4BbjSYxn/Hu7WAIAAAAAAAAXqRQ/tulYEuV7tGkfmkpiiGKmGk92m4dYAgAAAAAAABepFD+26VgS5Xu0aR+aSmKIYqYaT3abh3hIAQAAAAAAF6kUP7bpWBLle7RpH5pKYohiphpPdpuHAAAAAAAAAAAOagxyYXJlc2F0cy5uZXQAAAAAAAEBIFgCAAAAAAAAF6kUP7bpWBLle7RpH5pKYohiphpPdpuHIgIDmztpS4/FteB/sGnHg8rHVPXTjD4IvtGWDjH9sd2jXCRIMEUCIQDqT5JFB8nk2LvYxjpKXk87G12Tn0Jt7fslQf2gSIvJjwIgbg+BAy20UrXd2niDIeZbPLkgyz+NcnSSkChVICw0gV4BAQQWABT5kGeayv4lwnYVNztAvyJEbST/RAABASBYAgAAAAAAABepFD+26VgS5Xu0aR+aSmKIYqYaT3abhyICA5s7aUuPxbXgf7Bpx4PKx1T104w+CL7Rlg4x/bHdo1wkRzBEAiAbu6SfmqbPok5pJyyLQoLTWtu6xHYFUxdkVqMdge1hPwIgXVp1PF1DvIaRLu2QeT0Qv9t+5Dfe2BUaY/Y2QBv58+ABAQQWABT5kGeayv4lwnYVNztAvyJEbST/RAABAR8iAgAAAAAAABYAFMDOvNbD08qMddxexi6+VTMO+RDiAAEBIPBJAgAAAAAAF6kUP7bpWBLle7RpH5pKYohiphpPdpuHIgIDmztpS4/FteB/sGnHg8rHVPXTjD4IvtGWDjH9sd2jXCRHMEQCIC8ykJLr2bz7gpup8hfRujOnTV4UdPVuIsfzFgJGmMuCAiARSDj1rK63S1wPEKMrRPqnZ7u+rJDysVPRn7ym3iNFpQEBBBYAFPmQZ5rK/iXCdhU3O0C/IkRtJP9EAAAAAAAAAAAAAA==" }).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `txid` | string | The purchase transaction, accepted by bitcoind and broadcast. | | `setup_txid` | string | The setup transaction (broadcast first), when there was one. | ```json title="Response" { "txid": "6897451c42f02de2594abfdd6a5d4fcf4794a62cbbd9fbec1ec983893ddc5df4" } ``` #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 400 | `bad_id` | `id` is not a prepared purchase or transfer id. | | 400 | `bad_json` | The body is not valid JSON. | | 400 | `bad_psbt` | Not a base64 PSBT, or one that does not parse. | | 400 | `bad_request` | The body is not a JSON object, or the engine refused a malformed request (e.g. a purchase submitted without its signed setup PSBT). | | 400, 422 | `bad_signature` | 400: `signature` is missing or too long. 422: a signature does not verify (cancel or registration message, listing or purchase input). | | 403 | `cross_origin` | A POST from a browser on another site (a foreign `Origin`, or `sec-fetch-site: cross-site`). Server-side clients send no `Origin` and are not affected. | | 404 | `unknown_purchase` | No such prepared purchase (it was dropped: your 11th prepared since, a busy server, or a restart): prepare it again. | | 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. | | 409 | `config_changed` | The marketplace settings changed since this purchase or transfer was prepared (prepare it again), or since this offer was made (it is now `invalid`, reason `config_changed`). | | 409 | `conflict` | A listing or one of your UTXOs was spent meanwhile (lost race), or bitcoind would not replace a transaction already spending it (`insufficient fee`), or a transfer's inputs are spent by another transaction. | | 409 | `expired` | The prepared purchase, offer or transfer expired, or the offer itself has: prepare it again. | | 409 | `listing_changed` | A listing's stored terms no longer match the plan: prepare it again. | | 409 | `not_active` | A listing (or offer) is not active: being bought (or accepted), sold, cancelled, moved, expired or invalid. Carries `id` and `status`. | | 409 | `spent` | The UTXO (or a listing's UTXO) is spent or does not exist. | | 413 | `too_large` | The body is over 256 KiB. | | 415 | `not_json` | A POST without `content-type: application/json`. | | 422 | `bad_sighash` | A signature uses the wrong sighash type (listings: 0x83; purchases: DEFAULT or ALL). | | 422 | `missing_signature` | An input that must be signed is not. | | 422 | `psbt_mismatch` | The signed PSBT is not the prepared transaction. | | 422 | `rejected` | bitcoind refused the transaction (`reason`). | | 429 | `rate_limited` | Over 30 POSTs a minute from your IPv4 address or IPv6 /64 (in chat, counted per IPv4 address or IPv6 /64: 30 writes and 20 login nonces a minute, 20 sign-ins and 10 display-name changes an hour; one message every 3 s and 20 a minute per address); wait `retry-after` seconds. With the AI moderation on, at most 6 messages and names a minute are checked per address and per IPv4 address or IPv6 /48. | | 503 | `bitcoind_unavailable` | bitcoind is not available. | | 503 | `broadcast_unknown` | The broadcast's outcome is unknown: submit the same purchase or transfer again to settle it. Carries `txid`. | | 503 | `engine_unavailable` | The trade engine is not available. | | 503 | `fork_unavailable` | The rare-sat index is not available. | | 503 | `market_unavailable` | The marketplace is closed right now (its backends disagree on the network, or are unreachable). | | 503 | `oracle_unavailable` | The ord oracle is not available. | ```json title="Error (409)" { "error": "the prepared purchase expired: prepare it again", "code": "expired" } ``` ## When the answer is lost A submit that times out, or answers 503 `broadcast_unknown`, may still have gone through. **Submit the same body again**: it is idempotent. If bitcoind has the transaction, the answer is the same `{txid}`; if it was refused, you get that error; while it cannot be known yet, `broadcast_unknown` again. Meanwhile the listings stay reserved (`pending`) for you, and return to sale if the transaction never reaches the mempool. --- Page: https://raresats.net/docs/register-affiliate.md # Register an affiliate `POST /api/market/affiliates/register` Optional, kept for compatibility: any address is an affiliate and is paid without registering. It still verifies as before: a BIP-322 signature by the payout address of `raresats.net affiliate
`, at most 10 minutes old, used once. The registering wallet's addresses are kept with it and only ever narrow what the affiliate is paid: a purchase by any of them is a self-referral (`affiliate_wallet`). Registering again adds the new wallet's addresses. #### Request body `content-type: application/json` | Field | Type | Required | Description | | --- | --- | --- | --- | | `address` | string | yes | The payout address: Taproot, native SegWit or nested SegWit (it must sign BIP-322). | | `message` | string | yes | `raresats.net affiliate
`, at most 10 minutes old (2 minutes ahead at most), usable once. | | `signature` | string | yes | The BIP-322 signature of `message` by `address` (base64), as wallets' signMessage makes it. | | `wallet_addresses` | array of string | no | Optional: the registering wallet's other addresses (payment and ordinals). A purchase by any of them never pays this affiliate. (0-4 items) | #### Example request ```bash title="cURL" curl -X POST 'https://raresats.net/api/market/affiliates/register' \ -H 'content-type: application/json' \ -d '{ "address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "message": "raresats.net affiliate bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh 1790949000", "signature": "AUBGQvdc2H+7HTo1njZ1vlDxoX6ncoehAGbaHcAY1FtfSNg7lmo8Ih3WGInhAH/snOUIb8DkwRSQxc2dOu911Z9E", "wallet_addresses": ["37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf"] }' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/market/affiliates/register', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ "address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "message": "raresats.net affiliate bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh 1790949000", "signature": "AUBGQvdc2H+7HTo1njZ1vlDxoX6ncoehAGbaHcAY1FtfSNg7lmo8Ih3WGInhAH/snOUIb8DkwRSQxc2dOu911Z9E", "wallet_addresses": ["37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf"] }) }); const data = await response.json(); ``` ```python title="Python" import requests data = requests.post("https://raresats.net/api/market/affiliates/register", json={ "address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "message": "raresats.net affiliate bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh 1790949000", "signature": "AUBGQvdc2H+7HTo1njZ1vlDxoX6ncoehAGbaHcAY1FtfSNg7lmo8Ih3WGInhAH/snOUIb8DkwRSQxc2dOu911Z9E", "wallet_addresses": ["37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf"] }).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `affiliate` | object | | | `affiliate.address` | string | The registered payout address, normalized. | | `affiliate.registered_at` | integer | When it first registered (unix seconds). | | `affiliate.wallet_addresses` | array of string | Every address recorded for it: the payout and the registering wallets' addresses. | ```json title="Response" { "affiliate": { "address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "registered_at": 1790949000, "wallet_addresses": [ "37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh" ] } } ``` #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 400 | `bad_address` | An address is not a mainnet address (or not one at all). | | 400 | `bad_json` | The body is not valid JSON. | | 400 | `bad_message` | The cancel message is not `raresats.net cancel ` or names other listings than `ids`; or the registration message is not `raresats.net affiliate
` for `address`. | | 400 | `bad_request` | The body is not a JSON object, or the engine refused a malformed request (e.g. a purchase submitted without its signed setup PSBT). | | 400, 422 | `bad_signature` | 400: `signature` is missing or too long. 422: a signature does not verify (cancel or registration message, listing or purchase input). | | 400 | `bad_wallet_addresses` | `wallet_addresses` is not a list of at most 4 mainnet addresses. | | 403 | `cross_origin` | A POST from a browser on another site (a foreign `Origin`, or `sec-fetch-site: cross-site`). Server-side clients send no `Origin` and are not affected. | | 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. | | 409 | `replayed` | That cancel or registration message was used already. | | 413 | `too_large` | The body is over 256 KiB. | | 415 | `not_json` | A POST without `content-type: application/json`. | | 422 | `future_message` | The cancel or registration message is dated more than 2 minutes ahead: check the clock. | | 422 | `legacy_address` | A legacy (P2PKH, `1…`) address: use Taproot or SegWit. | | 422 | `stale_message` | The cancel or registration message is more than 10 minutes old: sign a new one. | | 422 | `unsupported_address` | An address type the marketplace does not trade (only Taproot, native SegWit and nested SegWit). | | 429 | `rate_limited` | Over 30 POSTs a minute from your IPv4 address or IPv6 /64 (in chat, counted per IPv4 address or IPv6 /64: 30 writes and 20 login nonces a minute, 20 sign-ins and 10 display-name changes an hour; one message every 3 s and 20 a minute per address); wait `retry-after` seconds. With the AI moderation on, at most 6 messages and names a minute are checked per address and per IPv4 address or IPv6 /48. | | 503 | `engine_unavailable` | The trade engine is not available. | ```json title="Error (400)" { "error": "the signature does not verify for that address and message", "code": "bad_signature" } ``` **Optional, kept for compatibility.** Any address is an affiliate without registering: referrals to it are paid whether or not it ever registered. This endpoint still works for clients written when it was required, and it verifies exactly as before; registering changes only one thing, and only in the affiliate's disfavour: the wallet addresses it records count as the affiliate's own, so a purchase by any of them is a self-referral (`affiliate_wallet`) and pays nothing. To register, sign this message with the payout address (BIP-322, as wallets' `signMessage` makes it) and send it within 10 minutes: ```text raresats.net affiliate
``` A signed message is usable once. `wallet_addresses` lists the registering wallet's other addresses (its payment and ordinals addresses). Registering again from another wallet adds that wallet's addresses. The payout address must be Taproot, native SegWit or nested SegWit, which is what BIP-322 signs with. How the programme works: [Affiliates](https://raresats.net/docs/guide-affiliates.md). --- Page: https://raresats.net/docs/offer-object.md # The Offer object An offer is a bid of a fixed price on one UTXO, listed or not. Every offer endpoint returns this object. The bidder's signatures are never part of it. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | The offer id: 16 lowercase hex characters. | | `kind` | string | yes | What the offer is on: `item`, one UTXO (the only kind so far). | | `outpoint` | string | yes | The UTXO bid on, `txid:vout`. | | `value` | integer | yes | The UTXO's own value in sats (it moves to the bidder whole). | | `seller_address` | string | yes | The address that held the UTXO when the offer was made: it receives `price`, and only it can accept. | | `price` | integer | yes | The offer price in sats: exactly what the seller receives (output 1). | | `platform_fee` | integer | yes | The bidder's platform fee: 2.5% of `price`, at least 546 sats. | | `fee_rate` | number | yes | The network fee rate in sat/vB, fixed by the bidder's signature. | | `network_fee` | integer | yes | The network fee in sats. | | `total` | integer | yes | What the bidder pays: `price + platform_fee + network_fee`. | | `vsize` | integer | yes | The transaction's estimated virtual size. | | `unit_price` | number | yes | `(price - value) / count`, to the hundredth of a sat, as listings. | | `row` | string | yes | The listing's own row (collection): the rarest market row holding every tracked sat of the UTXO, else the rarest market row present. Never a label row. | | `count` | integer | yes | How many sats of `row` the UTXO holds (the "×N" of a bulk listing). | | `rows` | map of integer | yes | Every row the UTXO's tracked sats count toward, with their counts (label rows included). | | `types` | array of [TypeGroup](https://raresats.net/docs/listing-object.md) \| null | yes | The UTXO's sats grouped by exact type combination, rarest first. | | `sats` | integer \| null | yes | Tracked sats in the UTXO (all rows). | | `ranges` | array of array of integer \| null | yes | The tracked sats as inclusive `[first, last]` ranges, ascending. | | `years` | map of integer \| null | yes | Tracked sats per mint year (UTC year of the block that mined them). | | `bidder_payment_address` | string | yes | The bidder's payment address: it funds the offer and gets the change. | | `bidder_ordinals_address` | string | yes | Where the bidder receives the UTXO (output 0). | | `status` | string | yes | `active` (can be accepted), `pending` (accepted, in the mempool), `accepted` (confirmed), `cancelled` (by the bidder), `expired`, `invalid` (the UTXO moved or the bidder's funds were spent). | | `created_at` | integer | yes | Unix seconds. | | `updated_at` | integer | yes | Unix seconds of the last status change. | | `expires_at` | integer \| null | yes | Unix seconds after which it can no longer be accepted; `null`: no expiry (until cancelled, accepted or invalid). | | `reason` | string \| null | yes | Why an `invalid` offer is: `utxo_spent` (the UTXO was sold here otherwise: its listing bought, or another offer accepted), `utxo_moved` (spent elsewhere), `funding_spent` (a UTXO funding it was spent), `config_changed` (the marketplace's fee settings changed since it was made). `null` in every other status. | | `txid` | string \| null | yes | The accepting transaction, while `pending` and once `accepted`; otherwise `null`. | | `listing_id` | string \| null | yes | The UTXO's live listing, if it is listed. | | `acceptance_public` | boolean | yes | `true` once an acceptance the server broadcast left the mempool unconfirmed: that fully signed transaction is public and can still confirm, even after a cancel or expiry here (the offer then ends `accepted`), until the bidder spends a funding UTXO or the UTXO moves. Back to `false` once it no longer can. | | `sub` | boolean | no | Only on an offer read for a row (`row=`) that is not its own row but holds every tracked sat of the item (a sub-category or a label row): `true`. | | `in_bundle` | boolean | no | Only on an offer read for a row (`row=`) whose UTXO holds some sats of that row among others (in bundle): `true`. Its unit price is its own `row`'s. | | `contains_count` | integer \| null | no | With `in_bundle`: how many sats of the row asked for the item holds. | ## Example ```json title="Offer" { "id": "dbf866a1e7e209e7", "kind": "item", "outpoint": "24b3eedfdc210f4a5baa96a6c99db5a51d3b1286ab2f58cb0b16fc607e54107f:0", "value": 10000, "seller_address": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", "price": 110000, "platform_fee": 2750, "fee_rate": 3, "network_fee": 963, "total": 113713, "vsize": 321, "unit_price": 100000, "row": "decasat_alpha", "count": 1, "rows": {"uncommon": 1, "alpha_uncommon": 1, "decasat_alpha": 1}, "types": [ { "types": ["uncommon", "decasat_alpha", "alpha"], "count": 1, "sats": [441000000000000] } ], "sats": 1, "ranges": [[441000000000000, 441000000000000]], "years": {"2010": 1}, "bidder_payment_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "bidder_ordinals_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "status": "active", "created_at": 1790967012, "updated_at": 1790967012, "expires_at": 1791571812, "reason": null, "txid": null, "listing_id": "5e1cb930f43354c3", "acceptance_public": false } ``` The bidder offers 110,000 sats for a 10,000-sat UTXO holding one sat of its row: `unit_price` = (110,000 − 10,000) / 1 = 100,000 sats per sat. If the holder accepts, they receive 110,000 sats; the bidder pays 110,000 + 2,750 (2.5%) + the network fee. ## Statuses `active` → `pending` (accepted, in the mempool) → `accepted` (confirmed); or `cancelled` (by the bidder), `expired` (past `expires_at`; never when it is `null`), `invalid`. Leaving `active` or `pending` deletes the bidder's signatures from the server. An `invalid` offer says why in `reason`: | `reason` | | | --- | --- | | `utxo_spent` | The UTXO was sold here otherwise: its listing was bought, or another offer on it accepted. | | `utxo_moved` | The UTXO was spent elsewhere (its holder moved it). | | `funding_spent` | A UTXO funding the offer was spent: by the bidder, or by another of their offers sharing it that was accepted. | | `config_changed` | The marketplace's fee settings changed since the offer was made: the signed fee no longer matches. | ## A public acceptance: `acceptance_public` Once accepted, the transaction carries every signature it needs. If it leaves the mempool unconfirmed, the offer goes back to `active` with **`acceptance_public: true`**: anyone holding that transaction can broadcast it again, as long as everything it spends is unspent. Cancelling or expiry here then ends the offer here but cannot recall it; should it confirm, the offer ends `accepted` all the same. **Spend a funding UTXO to be certain.** `acceptance_public` goes back to `false` once the transaction can no longer confirm. ## The best offer of a listing A [Listing](https://raresats.net/docs/listing-object.md) shows the best active offer on its UTXO as `best_offer`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | The offer id. | | `price` | integer | yes | What the seller would receive, in sats. | | `unit_price` | number | yes | `(price - value) / count` of the UTXO's own row, to the hundredth of a sat. | | `bidder_payment_address` | string | yes | The bidder's payment address. | | `bidder_ordinals_address` | string | yes | Where the bidder receives the UTXO. | | `created_at` | integer | yes | Unix seconds. | | `expires_at` | integer \| null | yes | Unix seconds; `null`: no expiry. | --- Page: https://raresats.net/docs/get-offers.md # List offers `GET /api/market/offers` Offers filtered by UTXO, seller, bidder, row and status, sorted by price, unit price, age or expiry, paged. Without `status`, only active offers. A wallet's "offers made" are `bidder=
`, its "offers received" `seller=
`. #### Query parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `outpoint` | string | no | Only offers on this UTXO (`txid:vout`). | | `seller` | string | no | Only offers to this address (that held the UTXO when the offer was made). | | `bidder` | string | no | Only offers made by this address (the bidder's payment or ordinals address). | | `row` | string | no | Only offers whose own row is this one (as always); with `sub=1` also those on UTXOs the row holds whole (marked `sub`), and with `contains` those on bundles holding some of its sats, apart. | | `sub` | string | no | With `row`: `1` also answers the offers on UTXOs the row holds whole, as a rarer sub-category or a label (marked `sub`). One of: `0`, `1`. Default: `0`. | | `contains` | string | no | With `row`: `1` also answers, apart, the offers on UTXOs holding only some sats of the row among others (in bundle) as `contains` (the first `limit`) and `contains_total`, each marked `in_bundle` with `contains_count`; `only` answers those alone as `offers`/`total`. Their unit price is their own row's, so `sort=unit` orders them by price. One of: `0`, `1`, `only`. Default: `0`. | | `status` | string | no | `all`, or a comma-separated list of `active`, `pending`, `accepted`, `cancelled`, `expired`, `invalid`. Default: `active`. | | `sort` | string | no | `price` (the default), `unit` (unit price), `new` (creation time) or `expiry` (offers with no expiry last). One of: `price`, `unit`, `new`, `expiry`. Default: `price`. | | `order` | string | no | `desc` (the default; `asc` for `expiry`) or `asc`. One of: `asc`, `desc`. | | `limit` | integer | no | Offers per page, 1-200. Default: `50`. (min 1, max 200) | | `offset` | integer | no | Offers to skip. Default: `0`. (min 0) | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/market/offers?outpoint=24b3eedfdc210f4a5baa96a6c99db5a51d3b1286ab2f58cb0b16fc607e54107f:0&seller=bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr&bidder=bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh&row=decasat_alpha&status=active,pending&limit=20' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/market/offers?outpoint=24b3eedfdc210f4a5baa96a6c99db5a51d3b1286ab2f58cb0b16fc607e54107f:0&seller=bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr&bidder=bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh&row=decasat_alpha&status=active,pending&limit=20'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/market/offers", params={"outpoint": "24b3eedfdc210f4a5baa96a6c99db5a51d3b1286ab2f58cb0b16fc607e54107f:0", "seller": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", "bidder": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "row": "decasat_alpha", "status": "active,pending", "limit": 20}).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `offers` | array of [Offer](https://raresats.net/docs/offer-object.md) | One page of offers. | | `total` | integer | All offers matching the query. | ```json title="Response" { "offers": [ { "id": "dbf866a1e7e209e7", "kind": "item", "outpoint": "24b3eedfdc210f4a5baa96a6c99db5a51d3b1286ab2f58cb0b16fc607e54107f:0", "value": 10000, "seller_address": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", "price": 110000, "platform_fee": 2750, "fee_rate": 3, "network_fee": 963, "total": 113713, "vsize": 321, "unit_price": 100000, "row": "decasat_alpha", "count": 1, "rows": {"uncommon": 1, "alpha_uncommon": 1, "decasat_alpha": 1}, "types": [ { "types": ["uncommon", "decasat_alpha", "alpha"], "count": 1, "sats": [441000000000000] } ], "sats": 1, "ranges": [[441000000000000, 441000000000000]], "years": {"2010": 1}, "bidder_payment_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "bidder_ordinals_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "status": "active", "created_at": 1790967012, "updated_at": 1790967012, "expires_at": 1791571812, "reason": null, "txid": null, "listing_id": "5e1cb930f43354c3", "acceptance_public": false }, { "id": "9ab58f471cc625be", "kind": "item", "outpoint": "24b3eedfdc210f4a5baa96a6c99db5a51d3b1286ab2f58cb0b16fc607e54107f:0", "value": 10000, "seller_address": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", "price": 100000, "platform_fee": 2500, "fee_rate": 3, "network_fee": 921, "total": 103421, "vsize": 307, "unit_price": 90000, "row": "decasat_alpha", "count": 1, "rows": {"uncommon": 1, "alpha_uncommon": 1, "decasat_alpha": 1}, "types": [ { "types": ["uncommon", "decasat_alpha", "alpha"], "count": 1, "sats": [441000000000000] } ], "sats": 1, "ranges": [[441000000000000, 441000000000000]], "years": {"2010": 1}, "bidder_payment_address": "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu", "bidder_ordinals_address": "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu", "status": "active", "created_at": 1790967012, "updated_at": 1790967012, "expires_at": null, "reason": null, "txid": null, "listing_id": "5e1cb930f43354c3", "acceptance_public": false } ], "total": 2 } ``` #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 400 | `bad_address` | An address is not a mainnet address (or not one at all). | | 400 | `bad_order` | `order` is not `asc` or `desc`. | | 400 | `bad_outpoint` | Not an outpoint (`txid:vout`). | | 400 | `bad_paging` | `limit` or `offset` out of range. | | 400 | `bad_row` | `row` is not a row id. | | 400 | `bad_sort` | `sort` is not `unit`, `price` or `new`. | | 400 | `bad_status` | `status` is not `all` or a comma-separated list of offer statuses, or (wallet transfers) not `pending` or `all`. | | 404 | `retired_row` | `row` is a row the site retired (`uncommon_2009`, `rare_2009`): its listings are its parent row's. `retired` names the row and the parent and years to ask for. | ```json title="Error (400)" { "error": "status must be all, or a comma-separated list of active, pending, accepted, cancelled, expired, invalid", "code": "bad_status" } ``` A wallet page's two offer tabs are two queries: **offers made** is `?bidder=
&status=all`, **offers received** is `?seller=
&status=all`. A UTXO's offers, best first, are `?outpoint=`. See [The Offer object](https://raresats.net/docs/offer-object.md) and [Make, accept and cancel offers](https://raresats.net/docs/guide-offers.md). --- Page: https://raresats.net/docs/get-offer.md # Get an offer `GET /api/market/offers/{id}` One offer by id, whatever its status. #### Path parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | The offer id (16 hex characters). | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/market/offers/dbf866a1e7e209e7' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/market/offers/dbf866a1e7e209e7'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/market/offers/dbf866a1e7e209e7").json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `id` | string | The offer id: 16 lowercase hex characters. | | `kind` | string | What the offer is on: `item`, one UTXO (the only kind so far). | | `outpoint` | string | The UTXO bid on, `txid:vout`. | | `value` | integer | The UTXO's own value in sats (it moves to the bidder whole). | | `seller_address` | string | The address that held the UTXO when the offer was made: it receives `price`, and only it can accept. | | `price` | integer | The offer price in sats: exactly what the seller receives (output 1). | | `platform_fee` | integer | The bidder's platform fee: 2.5% of `price`, at least 546 sats. | | `fee_rate` | number | The network fee rate in sat/vB, fixed by the bidder's signature. | | `network_fee` | integer | The network fee in sats. | | `total` | integer | What the bidder pays: `price + platform_fee + network_fee`. | | `vsize` | integer | The transaction's estimated virtual size. | | `unit_price` | number | `(price - value) / count`, to the hundredth of a sat, as listings. | | `row` | string | The listing's own row (collection): the rarest market row holding every tracked sat of the UTXO, else the rarest market row present. Never a label row. | | `count` | integer | How many sats of `row` the UTXO holds (the "×N" of a bulk listing). | | `rows` | map of integer | Every row the UTXO's tracked sats count toward, with their counts (label rows included). | | `types` | array of [TypeGroup](https://raresats.net/docs/listing-object.md) \| null | The UTXO's sats grouped by exact type combination, rarest first. | | `sats` | integer \| null | Tracked sats in the UTXO (all rows). | | `ranges` | array of array of integer \| null | The tracked sats as inclusive `[first, last]` ranges, ascending. | | `years` | map of integer \| null | Tracked sats per mint year (UTC year of the block that mined them). | | `bidder_payment_address` | string | The bidder's payment address: it funds the offer and gets the change. | | `bidder_ordinals_address` | string | Where the bidder receives the UTXO (output 0). | | `status` | string | `active` (can be accepted), `pending` (accepted, in the mempool), `accepted` (confirmed), `cancelled` (by the bidder), `expired`, `invalid` (the UTXO moved or the bidder's funds were spent). | | `created_at` | integer | Unix seconds. | | `updated_at` | integer | Unix seconds of the last status change. | | `expires_at` | integer \| null | Unix seconds after which it can no longer be accepted; `null`: no expiry (until cancelled, accepted or invalid). | | `reason` | string \| null | Why an `invalid` offer is: `utxo_spent` (the UTXO was sold here otherwise: its listing bought, or another offer accepted), `utxo_moved` (spent elsewhere), `funding_spent` (a UTXO funding it was spent), `config_changed` (the marketplace's fee settings changed since it was made). `null` in every other status. | | `txid` | string \| null | The accepting transaction, while `pending` and once `accepted`; otherwise `null`. | | `listing_id` | string \| null | The UTXO's live listing, if it is listed. | | `acceptance_public` | boolean | `true` once an acceptance the server broadcast left the mempool unconfirmed: that fully signed transaction is public and can still confirm, even after a cancel or expiry here (the offer then ends `accepted`), until the bidder spends a funding UTXO or the UTXO moves. Back to `false` once it no longer can. | | `sub` | boolean | Only on an offer read for a row (`row=`) that is not its own row but holds every tracked sat of the item (a sub-category or a label row): `true`. | | `in_bundle` | boolean | Only on an offer read for a row (`row=`) whose UTXO holds some sats of that row among others (in bundle): `true`. Its unit price is its own `row`'s. | | `contains_count` | integer \| null | With `in_bundle`: how many sats of the row asked for the item holds. | ```json title="Response" { "id": "dbf866a1e7e209e7", "kind": "item", "outpoint": "24b3eedfdc210f4a5baa96a6c99db5a51d3b1286ab2f58cb0b16fc607e54107f:0", "value": 10000, "seller_address": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", "price": 110000, "platform_fee": 2750, "fee_rate": 3, "network_fee": 963, "total": 113713, "vsize": 321, "unit_price": 100000, "row": "decasat_alpha", "count": 1, "rows": {"uncommon": 1, "alpha_uncommon": 1, "decasat_alpha": 1}, "types": [ { "types": ["uncommon", "decasat_alpha", "alpha"], "count": 1, "sats": [441000000000000] } ], "sats": 1, "ranges": [[441000000000000, 441000000000000]], "years": {"2010": 1}, "bidder_payment_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "bidder_ordinals_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "status": "pending", "created_at": 1790967012, "updated_at": 1790967012, "expires_at": 1791571812, "reason": null, "txid": "332f8cf83155e2532101454afe84ed45aedf9b2f98f8593b82ff38687cfee3be", "listing_id": "5e1cb930f43354c3", "acceptance_public": false } ``` #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 400 | `bad_path` | The path is not valid percent-encoding. | | 404 | `not_found` | No such listing or offer. In chat: no such message. A display name to remove that does not exist. | ```json title="Error (404)" {"error": "no such offer", "code": "not_found"} ``` Use it to follow an offer through its life: `active`, then `pending` with the accepting `txid`, then `accepted`; or `cancelled`, `expired` or `invalid`. See [The Offer object](https://raresats.net/docs/offer-object.md). --- Page: https://raresats.net/docs/get-best-offers.md # Get the best offers `GET /api/market/offers/best` The best active offer (the highest price, the oldest first) of up to 100 outpoints, keyed by outpoint. Outpoints without one are absent. A listing carries its own as `best_offer`. #### Query parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `outpoints` | string | yes | 1-100 outpoints (`txid:vout`), comma-separated. | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/market/offers/best?outpoints=24b3eedfdc210f4a5baa96a6c99db5a51d3b1286ab2f58cb0b16fc607e54107f:0' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/market/offers/best?outpoints=24b3eedfdc210f4a5baa96a6c99db5a51d3b1286ab2f58cb0b16fc607e54107f:0'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/market/offers/best", params={"outpoints": "24b3eedfdc210f4a5baa96a6c99db5a51d3b1286ab2f58cb0b16fc607e54107f:0"}).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `offers` | map of [Offer](https://raresats.net/docs/offer-object.md) | The best active offer of each outpoint asked for that has one, keyed by outpoint. | ```json title="Response" { "offers": { "24b3eedfdc210f4a5baa96a6c99db5a51d3b1286ab2f58cb0b16fc607e54107f:0": { "id": "dbf866a1e7e209e7", "kind": "item", "outpoint": "24b3eedfdc210f4a5baa96a6c99db5a51d3b1286ab2f58cb0b16fc607e54107f:0", "value": 10000, "seller_address": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", "price": 110000, "platform_fee": 2750, "fee_rate": 3, "network_fee": 963, "total": 113713, "vsize": 321, "unit_price": 100000, "row": "decasat_alpha", "count": 1, "rows": {"uncommon": 1, "alpha_uncommon": 1, "decasat_alpha": 1}, "types": [ { "types": ["uncommon", "decasat_alpha", "alpha"], "count": 1, "sats": [441000000000000] } ], "sats": 1, "ranges": [[441000000000000, 441000000000000]], "years": {"2010": 1}, "bidder_payment_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "bidder_ordinals_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "status": "active", "created_at": 1790967012, "updated_at": 1790967012, "expires_at": 1791571812, "reason": null, "txid": null, "listing_id": "5e1cb930f43354c3", "acceptance_public": false } } } ``` #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 400 | `bad_outpoint` | Not an outpoint (`txid:vout`). | | 400 | `bad_outpoints` | `outpoints` lists none, or more than 100. | ```json title="Error (400)" {"error": "outpoints must list 1-100 outpoints", "code": "bad_outpoints"} ``` The best offer is the highest `price`; between equal prices, the oldest. Every [Listing](https://raresats.net/docs/listing-object.md) carries its UTXO's own as `best_offer` (a short form), so a page of listings needs no extra call. --- Page: https://raresats.net/docs/prepare-offer.md # Prepare an offer `POST /api/market/offers/prepare` Step 1 of making an offer on a UTXO, listed or not: checks the UTXO (tracked sats, no inscription or rune, unspent and confirmed, not yours), picks plain cardinal UTXOs of your payment address and returns the offer transaction to sign: the seller's UTXO at input 0 (unsigned), your funding after it; the UTXO's sats to your ordinals address (output 0), `price` to the address that holds it (output 1), the platform fee (output 2), your change, the marker. The plan is held 10 minutes. #### Request body `content-type: application/json` | Field | Type | Required | Description | | --- | --- | --- | --- | | `outpoint` | string | yes | The UTXO to bid on, `txid:vout`: any UTXO holding tracked sats, unspent and confirmed, with no inscription or rune, not your own. Listed or not. | | `price` | integer | yes | What the seller receives, in sats: above the UTXO's value, at least 546. You pay it plus the platform fee and the network fee. | | `bidder` | object | yes | The bidder's wallet: Xverse-style wallets have two addresses, UniSat-style one (use it for both). | | `bidder.payment_address` | string | yes | Funds the offer and gets the change. Taproot, native SegWit or nested SegWit. | | `bidder.payment_public_key` | string | yes | Its public key, hex (Taproot: the internal key, x-only or compressed; SegWit: compressed). | | `bidder.ordinals_address` | string | yes | Receives the UTXO. Taproot, native SegWit or nested SegWit. May equal `payment_address`. | | `bidder.ordinals_public_key` | string | yes | Its public key, hex (Taproot: the internal key, x-only or compressed; SegWit: compressed). Required: the ordinals address can cancel the offer and lists it as made. | | `fee_rate` | number | yes | The network fee rate in sat/vB (see `GET /api/market/fees`), fixed when you sign. (max 5000) | | `expires_in` | integer \| `"never"` \| null | no | How long it can be accepted, in seconds: 86400 (1 day), 259200 (3 days), 604800 (7 days, the default when absent) or 2592000 (30 days); `null` or `"never"` for no expiry. | #### Example request ```bash title="cURL" curl -X POST 'https://raresats.net/api/market/offers/prepare' \ -H 'content-type: application/json' \ -d '{ "outpoint": "24b3eedfdc210f4a5baa96a6c99db5a51d3b1286ab2f58cb0b16fc607e54107f:0", "price": 110000, "bidder": { "payment_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "payment_public_key": "0283dfe85a3151d2517290da461fe2815591ef69f2b18a2ce63f01697a8b313145", "ordinals_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "ordinals_public_key": "0283dfe85a3151d2517290da461fe2815591ef69f2b18a2ce63f01697a8b313145" }, "fee_rate": 3, "expires_in": 604800 }' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/market/offers/prepare', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ "outpoint": "24b3eedfdc210f4a5baa96a6c99db5a51d3b1286ab2f58cb0b16fc607e54107f:0", "price": 110000, "bidder": { "payment_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "payment_public_key": "0283dfe85a3151d2517290da461fe2815591ef69f2b18a2ce63f01697a8b313145", "ordinals_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "ordinals_public_key": "0283dfe85a3151d2517290da461fe2815591ef69f2b18a2ce63f01697a8b313145" }, "fee_rate": 3, "expires_in": 604800 }) }); const data = await response.json(); ``` ```python title="Python" import requests data = requests.post("https://raresats.net/api/market/offers/prepare", json={ "outpoint": "24b3eedfdc210f4a5baa96a6c99db5a51d3b1286ab2f58cb0b16fc607e54107f:0", "price": 110000, "bidder": { "payment_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "payment_public_key": "0283dfe85a3151d2517290da461fe2815591ef69f2b18a2ce63f01697a8b313145", "ordinals_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "ordinals_public_key": "0283dfe85a3151d2517290da461fe2815591ef69f2b18a2ce63f01697a8b313145" }, "fee_rate": 3, "expires_in": 604800 }).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `id` | string | The prepared offer's id: submit with it. | | `psbt` | string | The whole offer transaction, base64. Input 0 is the seller's UTXO, unsigned (the seller signs it to accept). | | `sign` | array of object | Your inputs (1 and up): sign exactly these, with your wallet's default sighash. | | `sign[].index` | integer | The input to sign. | | `sign[].address` | string | The address whose key signs it. | | `sign[].sighash` | integer | The sighash type to sign with, when it is not the default: 131 (`0x83`, SIGHASH_SINGLE\|ANYONECANPAY) for listings. Absent on purchase inputs: sign with the default (SIGHASH_DEFAULT for Taproot, SIGHASH_ALL for SegWit v0). | | `summary` | object | What the offer costs and pays. | | `summary.outpoint` | string | The UTXO bid on. | | `summary.value` | integer | Its value in sats. | | `summary.price` | integer | What the seller receives. | | `summary.platform_fee` | integer | 2.5% of `price`, at least 546 sats. | | `summary.network_fee` | integer | The network fee in sats. | | `summary.total` | integer | What you pay: `price + platform_fee + network_fee`. | | `summary.vsize` | integer | Estimated virtual size. | | `summary.fee_rate` | number | sat/vB. | | `summary.change` | integer | Your change output in sats, 0 when none. | | `summary.unit_price` | number | `(price - value) / count`. | | `summary.row` | string | The UTXO's own row. | | `summary.count` | integer | Sats of `row` in the UTXO. | | `expires_at` | integer \| null | When the offer will expire, unix seconds; `null`: no expiry. | | `submit_by` | integer | Submit before this (unix seconds, 10 minutes from now), or prepare again. | ```json title="Response" { "id": "dbf866a1e7e209e7", "psbt": "cHNidP8BAP0fAQIAAAACfxBUfmD8FgvLWC+rhhI7HaW1ncmmlqpbSg8h3N/usyQAAAAAAP3///9Bihr4H2G1iZuygx3BHzwLSWfyqpoJAv/W0tpPJYLJAQAAAAAA/f///wUQJwAAAAAAACJRIKgvKZRNZbhq5rXlzHXilOrWxZORoe3F4BbjSYxn/Hu7sK0BAAAAAAAiUSCmCGnw288dxlnJzsuvgFATXqnozcSHBT8dxogJSdxoTL4KAAAAAAAAIlEgrfKcXTOb9Ymo27aSDRsOplytPApoV4riNE46JGW7F/AvJxUAAAAAACJRIKgvKZRNZbhq5rXlzHXilOrWxZORoe3F4BbjSYxn/Hu7AAAAAAAAAAAOagxyYXJlc2F0cy5uZXQAAAAAAAEBKxAnAAAAAAAAIlEgpghp8NvPHcZZyc7Lr4BQE16p6M3EhwU/HcaICUncaEwAAQErYOMWAAAAAAAiUSCoLymUTWW4aua15cx14pTq1sWTkaHtxeAW40mMZ/x7uwEXIIPf6FoxUdJRcpDaRh/igVWR72nysYos5j8BaXqLMTFFAAAAAAAA", "sign": [ { "index": 1, "address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh" } ], "summary": { "outpoint": "24b3eedfdc210f4a5baa96a6c99db5a51d3b1286ab2f58cb0b16fc607e54107f:0", "value": 10000, "price": 110000, "platform_fee": 2750, "network_fee": 963, "total": 113713, "vsize": 321, "fee_rate": 3, "change": 1386287, "unit_price": 100000, "row": "decasat_alpha", "count": 1 }, "expires_at": 1791571812, "submit_by": 1790967612 } ``` #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 400 | `bad_address` | An address is not a mainnet address (or not one at all). | | 400 | `bad_bidder` | The offer's `bidder` is missing, not an object, or lacks `ordinals_public_key`. | | 400 | `bad_expiry` | `expires_in` is not 86400, 259200, 604800 or 2592000 (seconds), `null` or `"never"`. | | 400 | `bad_fee_rate` | `fee_rate` is not a positive number up to 5000. | | 400 | `bad_json` | The body is not valid JSON. | | 400 | `bad_outpoint` | Not an outpoint (`txid:vout`). | | 400 | `bad_price` | A price is not a whole number of sats. | | 400 | `bad_public_key` | A public key is not hex, or not a valid secp256k1 key of the right size. | | 400 | `bad_request` | The body is not a JSON object, or the engine refused a malformed request (e.g. a purchase submitted without its signed setup PSBT). | | 403 | `cross_origin` | A POST from a browser on another site (a foreign `Origin`, or `sec-fetch-site: cross-site`). Server-side clients send no `Origin` and are not affected. | | 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. | | 409 | `pending` | The UTXO's listing is being bought, or an offer of it accepted, right now. | | 409 | `spent` | The UTXO (or a listing's UTXO) is spent or does not exist. | | 409 | `unconfirmed` | The UTXO is not confirmed yet. | | 409 | `unindexed` | The ord indexer does not know the UTXO yet. | | 413 | `too_large` | The body is over 256 KiB. | | 415 | `not_json` | A POST without `content-type: application/json`. | | 422 | `bad_structure` | The listing PSBT is not the expected layout (pairing, version 2, locktime 0, one seller, sequences), or a purchase would pay the fee address. | | 422 | `dust_output` | A bought UTXO is below the dust limit of your ordinals address type: use a Taproot or native SegWit ordinals address. | | 422 | `fee_rate_too_low` | `fee_rate` is below the current minimum (`GET /api/market/fees`). | | 422 | `fee_too_high` | The network fee would exceed the per-transaction cap (200,000 sats): lower the fee rate or move fewer. | | 422 | `inscribed` | The UTXO carries an inscription: only rare sats are listed; the wallet tools only send it whole. | | 422 | `insufficient_funds` | The payment address's plain UTXOs cannot pay prices, fee, padding and network fee (or a transfer's fees). Carries `available`, `prices`, `platform_fee`. | | 422 | `key_mismatch` | The public key does not control the address. | | 422 | `legacy_address` | A legacy (P2PKH, `1…`) address: use Taproot or SegWit. | | 422 | `no_row` | The UTXO holds no sat of a market row. | | 422 | `own_utxo` | The UTXO is held by one of the bidder's own addresses. | | 422 | `price_too_high` | The price is above 21,000,000 BTC. | | 422 | `price_too_low` | The price is at most the UTXO's own value, below 546 sats, or below the payout output's dust limit. | | 422 | `pubkey_mismatch` | The trade engine found the public key is not the address's key. | | 422 | `runes` | The UTXO carries runes (the wallet tools only send it whole). | | 422 | `unsupported_address` | An address type the marketplace does not trade (only Taproot, native SegWit and nested SegWit). | | 422 | `unsupported_script` | The trade engine met a script type it does not support. | | 422 | `untracked` | The UTXO holds no rare sat the index tracks (send it whole instead). | | 429 | `rate_limited` | Over 30 POSTs a minute from your IPv4 address or IPv6 /64 (in chat, counted per IPv4 address or IPv6 /64: 30 writes and 20 login nonces a minute, 20 sign-ins and 10 display-name changes an hour; one message every 3 s and 20 a minute per address); wait `retry-after` seconds. With the AI moderation on, at most 6 messages and names a minute are checked per address and per IPv4 address or IPv6 /48. | | 500 | `invariant_violated` | A planned or finalized purchase or transfer broke an invariant (`violations`); nothing was broadcast. | | 503 | `bitcoind_unavailable` | bitcoind is not available. | | 503 | `busy` | Too many purchases, offers or transfers are being prepared: try again in a minute. Each client (IPv4 address or IPv6 /64) keeps its 10 latest prepared purchases, offers and transfers; an older one answers `unknown_purchase` / `unknown_offer` / `unknown_transfer`. | | 503 | `engine_unavailable` | The trade engine is not available. | | 503 | `fork_behind` | The rare-sat index has not caught up with bitcoind: try again in a minute. | | 503 | `fork_unavailable` | The rare-sat index is not available. | | 503 | `indexer_behind` | The ord indexer is catching up: try again shortly. | | 503 | `market_unavailable` | The marketplace is closed right now (its backends disagree on the network, or are unreachable). | | 503 | `oracle_unavailable` | The ord oracle is not available. | ```json title="Error (422)" { "error": "24b3eedfdc210f4a5baa96a6c99db5a51d3b1286ab2f58cb0b16fc607e54107f:0 is held by your own address: make offers on other people's UTXOs", "code": "own_utxo", "outpoint": "24b3eedfdc210f4a5baa96a6c99db5a51d3b1286ab2f58cb0b16fc607e54107f:0" } ``` Then sign the PSBT, exactly the inputs in `sign`, and [submit](https://raresats.net/docs/submit-offer.md) before `submit_by`. The whole flow: [Make, accept and cancel offers](https://raresats.net/docs/guide-offers.md). ## Expiry `expires_in` is 1, 3, 7 or 30 days in seconds (7 days when absent), or `null` / `"never"` for an offer with **no expiry**. The server stops accepting an offer once it expires and deletes your signatures. One with no expiry can be accepted at its price at any time until you cancel it, it is accepted, or it becomes invalid (the UTXO moves or your funding is spent). Its fee rate is fixed now: the seller can speed up a slow confirmation by spending their output 1 (CPFP). --- Page: https://raresats.net/docs/submit-offer.md # Submit an offer `POST /api/market/offers` Step 2 of making an offer: the PSBT from prepare with your inputs signed. The server checks it is exactly the prepared transaction, verifies your signatures (SIGHASH_DEFAULT or ALL), checks the UTXO and your funding are unspent, and stores the offer, `active`. Your signatures stay on the server: no API ever returns them. Resubmitting answers the stored offer. #### Request body `content-type: application/json` | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | The id from prepare. | | `psbt` | string | yes | The PSBT from prepare with your inputs signed (not finalized), base64. | #### Example request ```bash title="cURL" curl -X POST 'https://raresats.net/api/market/offers' \ -H 'content-type: application/json' \ -d '{ "id": "dbf866a1e7e209e7", "psbt": "cHNidP8BAP0fAQIAAAACfxBUfmD8FgvLWC+rhhI7HaW1ncmmlqpbSg8h3N/usyQAAAAAAP3///9Bihr4H2G1iZuygx3BHzwLSWfyqpoJAv/W0tpPJYLJAQAAAAAA/f///wUQJwAAAAAAACJRIKgvKZRNZbhq5rXlzHXilOrWxZORoe3F4BbjSYxn/Hu7sK0BAAAAAAAiUSCmCGnw288dxlnJzsuvgFATXqnozcSHBT8dxogJSdxoTL4KAAAAAAAAIlEgrfKcXTOb9Ymo27aSDRsOplytPApoV4riNE46JGW7F/AvJxUAAAAAACJRIKgvKZRNZbhq5rXlzHXilOrWxZORoe3F4BbjSYxn/Hu7AAAAAAAAAAAOagxyYXJlc2F0cy5uZXQAAAAAAAEBKxAnAAAAAAAAIlEgpghp8NvPHcZZyc7Lr4BQE16p6M3EhwU/HcaICUncaEwAAQErYOMWAAAAAAAiUSCoLymUTWW4aua15cx14pTq1sWTkaHtxeAW40mMZ/x7uwETQJva/GuDEmjt90kgar0keIovQZv62QXPHzpZgAH+Fjr29SIhlsTWs98306xkWYcBMCulKINXRtRZ1fPDLp3C5ScBFyCD3+haMVHSUXKQ2kYf4oFVke9p8rGKLOY/AWl6izExRQAAAAAAAA==" }' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/market/offers', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ "id": "dbf866a1e7e209e7", "psbt": "cHNidP8BAP0fAQIAAAACfxBUfmD8FgvLWC+rhhI7HaW1ncmmlqpbSg8h3N/usyQAAAAAAP3///9Bihr4H2G1iZuygx3BHzwLSWfyqpoJAv/W0tpPJYLJAQAAAAAA/f///wUQJwAAAAAAACJRIKgvKZRNZbhq5rXlzHXilOrWxZORoe3F4BbjSYxn/Hu7sK0BAAAAAAAiUSCmCGnw288dxlnJzsuvgFATXqnozcSHBT8dxogJSdxoTL4KAAAAAAAAIlEgrfKcXTOb9Ymo27aSDRsOplytPApoV4riNE46JGW7F/AvJxUAAAAAACJRIKgvKZRNZbhq5rXlzHXilOrWxZORoe3F4BbjSYxn/Hu7AAAAAAAAAAAOagxyYXJlc2F0cy5uZXQAAAAAAAEBKxAnAAAAAAAAIlEgpghp8NvPHcZZyc7Lr4BQE16p6M3EhwU/HcaICUncaEwAAQErYOMWAAAAAAAiUSCoLymUTWW4aua15cx14pTq1sWTkaHtxeAW40mMZ/x7uwETQJva/GuDEmjt90kgar0keIovQZv62QXPHzpZgAH+Fjr29SIhlsTWs98306xkWYcBMCulKINXRtRZ1fPDLp3C5ScBFyCD3+haMVHSUXKQ2kYf4oFVke9p8rGKLOY/AWl6izExRQAAAAAAAA==" }) }); const data = await response.json(); ``` ```python title="Python" import requests data = requests.post("https://raresats.net/api/market/offers", json={ "id": "dbf866a1e7e209e7", "psbt": "cHNidP8BAP0fAQIAAAACfxBUfmD8FgvLWC+rhhI7HaW1ncmmlqpbSg8h3N/usyQAAAAAAP3///9Bihr4H2G1iZuygx3BHzwLSWfyqpoJAv/W0tpPJYLJAQAAAAAA/f///wUQJwAAAAAAACJRIKgvKZRNZbhq5rXlzHXilOrWxZORoe3F4BbjSYxn/Hu7sK0BAAAAAAAiUSCmCGnw288dxlnJzsuvgFATXqnozcSHBT8dxogJSdxoTL4KAAAAAAAAIlEgrfKcXTOb9Ymo27aSDRsOplytPApoV4riNE46JGW7F/AvJxUAAAAAACJRIKgvKZRNZbhq5rXlzHXilOrWxZORoe3F4BbjSYxn/Hu7AAAAAAAAAAAOagxyYXJlc2F0cy5uZXQAAAAAAAEBKxAnAAAAAAAAIlEgpghp8NvPHcZZyc7Lr4BQE16p6M3EhwU/HcaICUncaEwAAQErYOMWAAAAAAAiUSCoLymUTWW4aua15cx14pTq1sWTkaHtxeAW40mMZ/x7uwETQJva/GuDEmjt90kgar0keIovQZv62QXPHzpZgAH+Fjr29SIhlsTWs98306xkWYcBMCulKINXRtRZ1fPDLp3C5ScBFyCD3+haMVHSUXKQ2kYf4oFVke9p8rGKLOY/AWl6izExRQAAAAAAAA==" }).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `offer` | [Offer](https://raresats.net/docs/offer-object.md) | | ```json title="Response" { "offer": { "id": "dbf866a1e7e209e7", "kind": "item", "outpoint": "24b3eedfdc210f4a5baa96a6c99db5a51d3b1286ab2f58cb0b16fc607e54107f:0", "value": 10000, "seller_address": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", "price": 110000, "platform_fee": 2750, "fee_rate": 3, "network_fee": 963, "total": 113713, "vsize": 321, "unit_price": 100000, "row": "decasat_alpha", "count": 1, "rows": {"uncommon": 1, "alpha_uncommon": 1, "decasat_alpha": 1}, "types": [ { "types": ["uncommon", "decasat_alpha", "alpha"], "count": 1, "sats": [441000000000000] } ], "sats": 1, "ranges": [[441000000000000, 441000000000000]], "years": {"2010": 1}, "bidder_payment_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "bidder_ordinals_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "status": "active", "created_at": 1790967012, "updated_at": 1790967012, "expires_at": 1791571812, "reason": null, "txid": null, "listing_id": "5e1cb930f43354c3", "acceptance_public": false } } ``` #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 400 | `bad_id` | `id` is not a prepared purchase or transfer id. | | 400 | `bad_json` | The body is not valid JSON. | | 400 | `bad_psbt` | Not a base64 PSBT, or one that does not parse. | | 400 | `bad_request` | The body is not a JSON object, or the engine refused a malformed request (e.g. a purchase submitted without its signed setup PSBT). | | 400, 422 | `bad_signature` | 400: `signature` is missing or too long. 422: a signature does not verify (cancel or registration message, listing or purchase input). | | 403 | `cross_origin` | A POST from a browser on another site (a foreign `Origin`, or `sec-fetch-site: cross-site`). Server-side clients send no `Origin` and are not affected. | | 404 | `unknown_offer` | No such prepared offer (it expired, or the server restarted): prepare it again. | | 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. | | 409 | `changed` | The UTXO's value is not what the PSBT says, or (wallet tools) a UTXO, its sats or the transaction being replaced changed since prepare: prepare again. | | 409 | `expired` | The prepared purchase, offer or transfer expired, or the offer itself has: prepare it again. | | 409 | `funding_spent` | A UTXO of the bidder's that funds the offer was spent (or is no longer plain cardinal): the offer is no longer valid. | | 409 | `pending` | The UTXO's listing is being bought, or an offer of it accepted, right now. | | 409 | `spent` | The UTXO (or a listing's UTXO) is spent or does not exist. | | 413 | `too_large` | The body is over 256 KiB. | | 415 | `not_json` | A POST without `content-type: application/json`. | | 422 | `bad_sighash` | A signature uses the wrong sighash type (listings: 0x83; purchases: DEFAULT or ALL). | | 422 | `missing_signature` | An input that must be signed is not. | | 422 | `psbt_mismatch` | The signed PSBT is not the prepared transaction. | | 422 | `unsupported_script` | The trade engine met a script type it does not support. | | 429 | `rate_limited` | Over 30 POSTs a minute from your IPv4 address or IPv6 /64 (in chat, counted per IPv4 address or IPv6 /64: 30 writes and 20 login nonces a minute, 20 sign-ins and 10 display-name changes an hour; one message every 3 s and 20 a minute per address); wait `retry-after` seconds. With the AI moderation on, at most 6 messages and names a minute are checked per address and per IPv4 address or IPv6 /48. | | 500 | `invariant_violated` | A planned or finalized purchase or transfer broke an invariant (`violations`); nothing was broadcast. | | 503 | `bitcoind_unavailable` | bitcoind is not available. | | 503 | `engine_unavailable` | The trade engine is not available. | | 503 | `fork_unavailable` | The rare-sat index is not available. | | 503 | `market_unavailable` | The marketplace is closed right now (its backends disagree on the network, or are unreachable). | | 503 | `oracle_unavailable` | The ord oracle is not available. | ```json title="Error (404)" {"error": "no such prepared offer", "code": "unknown_offer"} ``` From now on the offer is `active`: the UTXO's holder can [accept](https://raresats.net/docs/accept-offer.md) it. Your funding UTXOs stay in your wallet; if you spend any of them, the offer becomes `invalid` (so do other offers you made with the same UTXOs once one is accepted). --- Page: https://raresats.net/docs/cancel-offers.md # Cancel offers `POST /api/market/offers/cancel` Withdraws active offers with a BIP-322 signature of `raresats.net cancel-offer ` by the bidder's payment or ordinals address, at most 10 minutes old, accepted once. The server deletes your signatures. The signed transaction itself has no expiry: to be certain, spend the funding UTXO(s). #### Request body `content-type: application/json` | Field | Type | Required | Description | | --- | --- | --- | --- | | `ids` | array of string | yes | 1 to 100 offer ids, all made by `address`. (1-100 items) | | `address` | string | yes | The bidder's payment or ordinals address. | | `message` | string | yes | Exactly `raresats.net cancel-offer `: the ids comma-separated in the same order as `ids`, and the current time. At most 10 minutes old, at most 2 minutes ahead. | | `signature` | string | yes | A BIP-322 signature of `message` by `address`, base64. | #### Example request ```bash title="cURL" curl -X POST 'https://raresats.net/api/market/offers/cancel' \ -H 'content-type: application/json' \ -d '{ "ids": ["f035c509de153a30"], "address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "message": "raresats.net cancel-offer f035c509de153a30 1790967012", "signature": "AUAbWZZUOrgfeSJtVYKiSdx9j70KWLVcI8nznx1U8hP+zet/ojilZjtbfyFlZc/VGHpLrTT4dDfzEKaf/9dcnn3i" }' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/market/offers/cancel', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ "ids": ["f035c509de153a30"], "address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "message": "raresats.net cancel-offer f035c509de153a30 1790967012", "signature": "AUAbWZZUOrgfeSJtVYKiSdx9j70KWLVcI8nznx1U8hP+zet/ojilZjtbfyFlZc/VGHpLrTT4dDfzEKaf/9dcnn3i" }) }); const data = await response.json(); ``` ```python title="Python" import requests data = requests.post("https://raresats.net/api/market/offers/cancel", json={ "ids": ["f035c509de153a30"], "address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "message": "raresats.net cancel-offer f035c509de153a30 1790967012", "signature": "AUAbWZZUOrgfeSJtVYKiSdx9j70KWLVcI8nznx1U8hP+zet/ojilZjtbfyFlZc/VGHpLrTT4dDfzEKaf/9dcnn3i" }).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `offers` | array of [Offer](https://raresats.net/docs/offer-object.md) | The offers, now `cancelled`. | | `warning` | string | Present when some of them have a public acceptance (see `exposed`). | | `exposed` | array of string | Ids of cancelled offers whose acceptance was broadcast and dropped (`acceptance_public`): that transaction can still confirm until you spend a funding UTXO. | ```json title="Response" { "offers": [ { "id": "f035c509de153a30", "kind": "item", "outpoint": "d184375c0e70392605961f20ae083a53cdac71fc923a9df49a4daf5bbc511b4a:0", "value": 2000, "seller_address": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", "price": 52000, "platform_fee": 1300, "fee_rate": 3, "network_fee": 963, "total": 54263, "vsize": 321, "unit_price": 50000, "row": "uncommon", "count": 1, "rows": {"uncommon": 1}, "types": [ {"types": ["uncommon"], "count": 1, "sats": [1925389375000000]} ], "sats": 1, "ranges": [[1925389375000000, 1925389375000000]], "years": {"2023": 1}, "bidder_payment_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "bidder_ordinals_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "status": "cancelled", "created_at": 1790967012, "updated_at": 1790967012, "expires_at": 1791053412, "reason": null, "txid": null, "listing_id": null, "acceptance_public": false } ] } ``` #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 400 | `bad_address` | An address is not a mainnet address (or not one at all). | | 400 | `bad_ids` | `ids` / `listing_ids` is empty, too long, malformed, or lists an id twice. | | 400 | `bad_json` | The body is not valid JSON. | | 400 | `bad_message` | The cancel message is not `raresats.net cancel ` or names other listings than `ids`; or the registration message is not `raresats.net affiliate
` for `address`. | | 400 | `bad_request` | The body is not a JSON object, or the engine refused a malformed request (e.g. a purchase submitted without its signed setup PSBT). | | 400, 422 | `bad_signature` | 400: `signature` is missing or too long. 422: a signature does not verify (cancel or registration message, listing or purchase input). | | 403 | `cross_origin` | A POST from a browser on another site (a foreign `Origin`, or `sec-fetch-site: cross-site`). Server-side clients send no `Origin` and are not affected. | | 404 | `not_found` | No such listing or offer. In chat: no such message. A display name to remove that does not exist. | | 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. | | 409 | `not_active` | A listing (or offer) is not active: being bought (or accepted), sold, cancelled, moved, expired or invalid. Carries `id` and `status`. | | 409 | `replayed` | That cancel or registration message was used already. | | 413 | `too_large` | The body is over 256 KiB. | | 415 | `not_json` | A POST without `content-type: application/json`. | | 422 | `future_message` | The cancel or registration message is dated more than 2 minutes ahead: check the clock. | | 422 | `not_bidder` | An offer was not made by `address` (neither its payment nor its ordinals address). | | 422 | `stale_message` | The cancel or registration message is more than 10 minutes old: sign a new one. | | 429 | `rate_limited` | Over 30 POSTs a minute from your IPv4 address or IPv6 /64 (in chat, counted per IPv4 address or IPv6 /64: 30 writes and 20 login nonces a minute, 20 sign-ins and 10 display-name changes an hour; one message every 3 s and 20 a minute per address); wait `retry-after` seconds. With the AI moderation on, at most 6 messages and names a minute are checked per address and per IPv4 address or IPv6 /48. | | 503 | `engine_unavailable` | The trade engine is not available. | ```json title="Error (409)" {"error": "that cancel message was used already", "code": "replayed"} ``` The message is `raresats.net cancel-offer ` (`offer_cancel_prefix` in [settings](https://raresats.net/docs/get-config.md)), signed with BIP-322 by the address that made the offers (its payment or its ordinals address), exactly as [cancelling listings](https://raresats.net/docs/guide-cancel.md) works: at most 10 minutes old, accepted once, whatever the signature's encoding. Only `active` offers can be cancelled; one being accepted (`pending`) answers 409 `not_active`. The server deletes your signatures, so no one can complete the trade through it. The signed transaction itself has no expiry, though: **to cancel for certain, spend the funding UTXO(s)**. When an acceptance of an offer was already broadcast and dropped (`acceptance_public: true` on the [Offer](https://raresats.net/docs/offer-object.md)), the answer carries `warning` and `exposed` (the ids concerned): that transaction is public and can still confirm until you spend a funding UTXO. --- Page: https://raresats.net/docs/prepare-accept-offer.md # Prepare to accept an offer `POST /api/market/offers/{id}/accept/prepare` Step 1 of accepting, for the UTXO's current holder only: checks the offer is active and the UTXO unspent at the same address and value, and returns the offer transaction with input 0 (your UTXO) ready for your wallet. It carries no bidder signature. #### Path parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | The offer id (16 hex characters). | #### Request body `content-type: application/json` | Field | Type | Required | Description | | --- | --- | --- | --- | | `seller_address` | string | yes | The address that holds the UTXO (the offer's `seller_address`). | | `seller_public_key` | string | yes | Its public key, hex (Taproot: the internal key, x-only or compressed; SegWit: compressed). | #### Example request ```bash title="cURL" curl -X POST 'https://raresats.net/api/market/offers/dbf866a1e7e209e7/accept/prepare' \ -H 'content-type: application/json' \ -d '{ "seller_address": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", "seller_public_key": "03cc8a4bc64d897bddc5fbc2f670f7a8ba0b386779106cf1223c6fc5d7cd6fc115" }' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/market/offers/dbf866a1e7e209e7/accept/prepare', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ "seller_address": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", "seller_public_key": "03cc8a4bc64d897bddc5fbc2f670f7a8ba0b386779106cf1223c6fc5d7cd6fc115" }) }); const data = await response.json(); ``` ```python title="Python" import requests data = requests.post("https://raresats.net/api/market/offers/dbf866a1e7e209e7/accept/prepare", json={ "seller_address": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", "seller_public_key": "03cc8a4bc64d897bddc5fbc2f670f7a8ba0b386779106cf1223c6fc5d7cd6fc115" }).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `psbt` | string | The offer transaction, base64, with input 0 ready for your wallet. It carries no bidder signature (the server adds them). | | `sign` | array of object | Input 0, your UTXO: sign it with your wallet's default sighash. | | `sign[].index` | integer | The input to sign. | | `sign[].address` | string | The address whose key signs it. | | `sign[].sighash` | integer | The sighash type to sign with, when it is not the default: 131 (`0x83`, SIGHASH_SINGLE\|ANYONECANPAY) for listings. Absent on purchase inputs: sign with the default (SIGHASH_DEFAULT for Taproot, SIGHASH_ALL for SegWit v0). | | `summary` | object | The trade you accept. | | `summary.outpoint` | string | Your UTXO. | | `summary.value` | integer | Its value in sats (it goes to the bidder). | | `summary.price` | integer | What you receive, in sats (output 1, to your address). | | `summary.platform_fee` | integer | The bidder's platform fee. | | `summary.network_fee` | integer | The network fee, paid by the bidder. | | `summary.total` | integer | What the bidder pays. | | `summary.vsize` | integer | Estimated virtual size. | | `summary.fee_rate` | number | sat/vB, fixed by the bidder. If it confirms slowly, you can speed it up by spending output 1 (CPFP). | | `summary.unit_price` | number | `(price - value) / count`. | | `summary.row` | string | The UTXO's own row. | | `summary.count` | integer | Sats of `row` in the UTXO. | | `summary.bidder_ordinals_address` | string | Where the UTXO goes. | | `summary.listing_id` | string \| null | Your live listing of the UTXO, if any: accepting sells it (`sold` once confirmed). | ```json title="Response" { "psbt": "cHNidP8BAP0fAQIAAAACfxBUfmD8FgvLWC+rhhI7HaW1ncmmlqpbSg8h3N/usyQAAAAAAP3///9Bihr4H2G1iZuygx3BHzwLSWfyqpoJAv/W0tpPJYLJAQAAAAAA/f///wUQJwAAAAAAACJRIKgvKZRNZbhq5rXlzHXilOrWxZORoe3F4BbjSYxn/Hu7sK0BAAAAAAAiUSCmCGnw288dxlnJzsuvgFATXqnozcSHBT8dxogJSdxoTL4KAAAAAAAAIlEgrfKcXTOb9Ymo27aSDRsOplytPApoV4riNE46JGW7F/AvJxUAAAAAACJRIKgvKZRNZbhq5rXlzHXilOrWxZORoe3F4BbjSYxn/Hu7AAAAAAAAAAAOagxyYXJlc2F0cy5uZXQAAAAAAAEBKxAnAAAAAAAAIlEgpghp8NvPHcZZyc7Lr4BQE16p6M3EhwU/HcaICUncaEwBFyDMikvGTYl73cX7wvZw96i6CzhneRBs8SI8b8XXzW/BFQABAStg4xYAAAAAACJRIKgvKZRNZbhq5rXlzHXilOrWxZORoe3F4BbjSYxn/Hu7AAAAAAAA", "sign": [ { "index": 0, "address": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr" } ], "summary": { "outpoint": "24b3eedfdc210f4a5baa96a6c99db5a51d3b1286ab2f58cb0b16fc607e54107f:0", "value": 10000, "price": 110000, "platform_fee": 2750, "network_fee": 963, "total": 113713, "vsize": 321, "fee_rate": 3, "unit_price": 100000, "row": "decasat_alpha", "count": 1, "bidder_ordinals_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "listing_id": "5e1cb930f43354c3" } } ``` #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 400 | `bad_address` | An address is not a mainnet address (or not one at all). | | 400 | `bad_json` | The body is not valid JSON. | | 400 | `bad_public_key` | A public key is not hex, or not a valid secp256k1 key of the right size. | | 400 | `bad_request` | The body is not a JSON object, or the engine refused a malformed request (e.g. a purchase submitted without its signed setup PSBT). | | 403 | `cross_origin` | A POST from a browser on another site (a foreign `Origin`, or `sec-fetch-site: cross-site`). Server-side clients send no `Origin` and are not affected. | | 404 | `not_found` | No such listing or offer. In chat: no such message. A display name to remove that does not exist. | | 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. | | 409 | `changed` | The UTXO's value is not what the PSBT says, or (wallet tools) a UTXO, its sats or the transaction being replaced changed since prepare: prepare again. | | 409 | `config_changed` | The marketplace settings changed since this purchase or transfer was prepared (prepare it again), or since this offer was made (it is now `invalid`, reason `config_changed`). | | 409 | `expired` | The prepared purchase, offer or transfer expired, or the offer itself has: prepare it again. | | 409 | `not_active` | A listing (or offer) is not active: being bought (or accepted), sold, cancelled, moved, expired or invalid. Carries `id` and `status`. | | 409 | `spent` | The UTXO (or a listing's UTXO) is spent or does not exist. | | 413 | `too_large` | The body is over 256 KiB. | | 415 | `not_json` | A POST without `content-type: application/json`. | | 422 | `key_mismatch` | The public key does not control the address. | | 422 | `legacy_address` | A legacy (P2PKH, `1…`) address: use Taproot or SegWit. | | 422 | `not_owner` | The UTXO does not belong to the seller address, or (wallet tools) to the owner's addresses, or the transfer was made by another wallet. | | 422 | `pubkey_mismatch` | The trade engine found the public key is not the address's key. | | 422 | `unsupported_address` | An address type the marketplace does not trade (only Taproot, native SegWit and nested SegWit). | | 422 | `unsupported_script` | The trade engine met a script type it does not support. | | 429 | `rate_limited` | Over 30 POSTs a minute from your IPv4 address or IPv6 /64 (in chat, counted per IPv4 address or IPv6 /64: 30 writes and 20 login nonces a minute, 20 sign-ins and 10 display-name changes an hour; one message every 3 s and 20 a minute per address); wait `retry-after` seconds. With the AI moderation on, at most 6 messages and names a minute are checked per address and per IPv4 address or IPv6 /48. | | 500 | `internal` | A server bug. Report it. | | 503 | `bitcoind_unavailable` | bitcoind is not available. | | 503 | `engine_unavailable` | The trade engine is not available. | | 503 | `fork_unavailable` | The rare-sat index is not available. | | 503 | `market_unavailable` | The marketplace is closed right now (its backends disagree on the network, or are unreachable). | | 503 | `oracle_unavailable` | The ord oracle is not available. | ```json title="Error (422)" { "error": "24b3eedfdc210f4a5baa96a6c99db5a51d3b1286ab2f58cb0b16fc607e54107f:0 is not held by bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh: only its holder can accept", "code": "not_owner", "outpoint": "24b3eedfdc210f4a5baa96a6c99db5a51d3b1286ab2f58cb0b16fc607e54107f:0" } ``` Then sign input 0 with your wallet's default sighash and [accept](https://raresats.net/docs/accept-offer.md). You receive `price` at the address that holds the UTXO (output 1); the bidder pays the platform fee and the network fee. --- Page: https://raresats.net/docs/accept-offer.md # Accept an offer `POST /api/market/offers/{id}/accept` Step 2 of accepting: the PSBT from accept/prepare with input 0 signed. The server rechecks everything (the UTXO unspent at the same address and value, the bidder's funding unspent and plain cardinal), verifies your signature (SIGHASH_DEFAULT or ALL), adds the bidder's, finalizes, rechecks every rule, then `testmempoolaccept` and broadcasts. The offer becomes `pending` (and your listing of the UTXO, if any), `accepted` once confirmed; other offers on the UTXO become `invalid`. Resubmitting is idempotent: use it to settle `broadcast_unknown`. #### Path parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | The offer id (16 hex characters). | #### Request body `content-type: application/json` | Field | Type | Required | Description | | --- | --- | --- | --- | | `psbt` | string | yes | The PSBT from accept/prepare with input 0 signed (not finalized), base64. | #### Example request ```bash title="cURL" curl -X POST 'https://raresats.net/api/market/offers/dbf866a1e7e209e7/accept' \ -H 'content-type: application/json' \ -d '{ "psbt": "cHNidP8BAP0fAQIAAAACfxBUfmD8FgvLWC+rhhI7HaW1ncmmlqpbSg8h3N/usyQAAAAAAP3///9Bihr4H2G1iZuygx3BHzwLSWfyqpoJAv/W0tpPJYLJAQAAAAAA/f///wUQJwAAAAAAACJRIKgvKZRNZbhq5rXlzHXilOrWxZORoe3F4BbjSYxn/Hu7sK0BAAAAAAAiUSCmCGnw288dxlnJzsuvgFATXqnozcSHBT8dxogJSdxoTL4KAAAAAAAAIlEgrfKcXTOb9Ymo27aSDRsOplytPApoV4riNE46JGW7F/AvJxUAAAAAACJRIKgvKZRNZbhq5rXlzHXilOrWxZORoe3F4BbjSYxn/Hu7AAAAAAAAAAAOagxyYXJlc2F0cy5uZXQAAAAAAAEBKxAnAAAAAAAAIlEgpghp8NvPHcZZyc7Lr4BQE16p6M3EhwU/HcaICUncaEwBE0AH22HiJwQjhGYmUScuZwJAaaRczyxViuzB4L48meWqATwLvJ3xPAgrv7MqmcxjqeR+Oe+frCEqFAKj0AZy4BF1ARcgzIpLxk2Je93F+8L2cPeougs4Z3kQbPEiPG/F181vwRUAAQErYOMWAAAAAAAiUSCoLymUTWW4aua15cx14pTq1sWTkaHtxeAW40mMZ/x7uwAAAAAAAA==" }' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/market/offers/dbf866a1e7e209e7/accept', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ "psbt": "cHNidP8BAP0fAQIAAAACfxBUfmD8FgvLWC+rhhI7HaW1ncmmlqpbSg8h3N/usyQAAAAAAP3///9Bihr4H2G1iZuygx3BHzwLSWfyqpoJAv/W0tpPJYLJAQAAAAAA/f///wUQJwAAAAAAACJRIKgvKZRNZbhq5rXlzHXilOrWxZORoe3F4BbjSYxn/Hu7sK0BAAAAAAAiUSCmCGnw288dxlnJzsuvgFATXqnozcSHBT8dxogJSdxoTL4KAAAAAAAAIlEgrfKcXTOb9Ymo27aSDRsOplytPApoV4riNE46JGW7F/AvJxUAAAAAACJRIKgvKZRNZbhq5rXlzHXilOrWxZORoe3F4BbjSYxn/Hu7AAAAAAAAAAAOagxyYXJlc2F0cy5uZXQAAAAAAAEBKxAnAAAAAAAAIlEgpghp8NvPHcZZyc7Lr4BQE16p6M3EhwU/HcaICUncaEwBE0AH22HiJwQjhGYmUScuZwJAaaRczyxViuzB4L48meWqATwLvJ3xPAgrv7MqmcxjqeR+Oe+frCEqFAKj0AZy4BF1ARcgzIpLxk2Je93F+8L2cPeougs4Z3kQbPEiPG/F181vwRUAAQErYOMWAAAAAAAiUSCoLymUTWW4aua15cx14pTq1sWTkaHtxeAW40mMZ/x7uwAAAAAAAA==" }) }); const data = await response.json(); ``` ```python title="Python" import requests data = requests.post("https://raresats.net/api/market/offers/dbf866a1e7e209e7/accept", json={ "psbt": "cHNidP8BAP0fAQIAAAACfxBUfmD8FgvLWC+rhhI7HaW1ncmmlqpbSg8h3N/usyQAAAAAAP3///9Bihr4H2G1iZuygx3BHzwLSWfyqpoJAv/W0tpPJYLJAQAAAAAA/f///wUQJwAAAAAAACJRIKgvKZRNZbhq5rXlzHXilOrWxZORoe3F4BbjSYxn/Hu7sK0BAAAAAAAiUSCmCGnw288dxlnJzsuvgFATXqnozcSHBT8dxogJSdxoTL4KAAAAAAAAIlEgrfKcXTOb9Ymo27aSDRsOplytPApoV4riNE46JGW7F/AvJxUAAAAAACJRIKgvKZRNZbhq5rXlzHXilOrWxZORoe3F4BbjSYxn/Hu7AAAAAAAAAAAOagxyYXJlc2F0cy5uZXQAAAAAAAEBKxAnAAAAAAAAIlEgpghp8NvPHcZZyc7Lr4BQE16p6M3EhwU/HcaICUncaEwBE0AH22HiJwQjhGYmUScuZwJAaaRczyxViuzB4L48meWqATwLvJ3xPAgrv7MqmcxjqeR+Oe+frCEqFAKj0AZy4BF1ARcgzIpLxk2Je93F+8L2cPeougs4Z3kQbPEiPG/F181vwRUAAQErYOMWAAAAAAAiUSCoLymUTWW4aua15cx14pTq1sWTkaHtxeAW40mMZ/x7uwAAAAAAAA==" }).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `txid` | string | The broadcast transaction. | ```json title="Response" { "txid": "332f8cf83155e2532101454afe84ed45aedf9b2f98f8593b82ff38687cfee3be" } ``` #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 400 | `bad_json` | The body is not valid JSON. | | 400 | `bad_psbt` | Not a base64 PSBT, or one that does not parse. | | 400 | `bad_request` | The body is not a JSON object, or the engine refused a malformed request (e.g. a purchase submitted without its signed setup PSBT). | | 400, 422 | `bad_signature` | 400: `signature` is missing or too long. 422: a signature does not verify (cancel or registration message, listing or purchase input). | | 403 | `cross_origin` | A POST from a browser on another site (a foreign `Origin`, or `sec-fetch-site: cross-site`). Server-side clients send no `Origin` and are not affected. | | 404 | `not_found` | No such listing or offer. In chat: no such message. A display name to remove that does not exist. | | 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. | | 409 | `changed` | The UTXO's value is not what the PSBT says, or (wallet tools) a UTXO, its sats or the transaction being replaced changed since prepare: prepare again. | | 409 | `config_changed` | The marketplace settings changed since this purchase or transfer was prepared (prepare it again), or since this offer was made (it is now `invalid`, reason `config_changed`). | | 409 | `conflict` | A listing or one of your UTXOs was spent meanwhile (lost race), or bitcoind would not replace a transaction already spending it (`insufficient fee`), or a transfer's inputs are spent by another transaction. | | 409 | `expired` | The prepared purchase, offer or transfer expired, or the offer itself has: prepare it again. | | 409 | `funding_spent` | A UTXO of the bidder's that funds the offer was spent (or is no longer plain cardinal): the offer is no longer valid. | | 409 | `not_active` | A listing (or offer) is not active: being bought (or accepted), sold, cancelled, moved, expired or invalid. Carries `id` and `status`. | | 409 | `offer_changed` | The offer's stored signatures no longer verify: it cannot be accepted. | | 409 | `pending` | The UTXO's listing is being bought, or an offer of it accepted, right now. | | 409 | `spent` | The UTXO (or a listing's UTXO) is spent or does not exist. | | 413 | `too_large` | The body is over 256 KiB. | | 415 | `not_json` | A POST without `content-type: application/json`. | | 422 | `bad_sighash` | A signature uses the wrong sighash type (listings: 0x83; purchases: DEFAULT or ALL). | | 422 | `missing_signature` | An input that must be signed is not. | | 422 | `psbt_mismatch` | The signed PSBT is not the prepared transaction. | | 422 | `rejected` | bitcoind refused the transaction (`reason`). | | 422 | `unsupported_script` | The trade engine met a script type it does not support. | | 429 | `rate_limited` | Over 30 POSTs a minute from your IPv4 address or IPv6 /64 (in chat, counted per IPv4 address or IPv6 /64: 30 writes and 20 login nonces a minute, 20 sign-ins and 10 display-name changes an hour; one message every 3 s and 20 a minute per address); wait `retry-after` seconds. With the AI moderation on, at most 6 messages and names a minute are checked per address and per IPv4 address or IPv6 /48. | | 500 | `internal` | A server bug. Report it. | | 500 | `invariant_violated` | A planned or finalized purchase or transfer broke an invariant (`violations`); nothing was broadcast. | | 503 | `bitcoind_unavailable` | bitcoind is not available. | | 503 | `broadcast_unknown` | The broadcast's outcome is unknown: submit the same purchase or transfer again to settle it. Carries `txid`. | | 503 | `engine_unavailable` | The trade engine is not available. | | 503 | `fork_unavailable` | The rare-sat index is not available. | | 503 | `market_unavailable` | The marketplace is closed right now (its backends disagree on the network, or are unreachable). | | 503 | `oracle_unavailable` | The ord oracle is not available. | ```json title="Error (409)" { "error": "the bidder's UTXO 24b3eedfdc210f4a5baa96a6c99db5a51d3b1286ab2f58cb0b16fc607e54107f:0 is no longer usable (spent (in the mempool or a block)): the offer is no longer valid", "code": "funding_spent", "outpoint": "24b3eedfdc210f4a5baa96a6c99db5a51d3b1286ab2f58cb0b16fc607e54107f:0" } ``` ## When the answer is lost An accept that times out, or answers 503 `broadcast_unknown`, may still have gone through. **Submit the same body again**: it is idempotent. If bitcoind has the transaction, the answer is the same `{txid}`; if it was refused, you get that error; while it cannot be known yet, `broadcast_unknown` again. Meanwhile the offer (and your listing of the UTXO) stays reserved: `pending`. --- Page: https://raresats.net/docs/prepare-transfer.md # Prepare a transfer `POST /api/market/transfer/prepare` Step 1 of every wallet tool: extract rare sats, send UTXOs or only their rare sats, send N sats of a type, chop ranges, consolidate plain bitcoin, pay many recipients, or speed up or cancel a pending transfer built here. The server checks every UTXO (yours, unspent, confirmed; its tracked sats, inscriptions and runes from the rare-sat index and the ord indexer), lays out the outputs, funds the network fee and the flat tools fee only from plain bitcoin of your payment address, checks the unsigned transaction against its invariants (no tracked, inscribed or rune sat is paid as fee, every tracked sat lands in its planned output, …) and returns it to sign with a summary. Nothing is reserved; the plan is held 10 minutes. #### Request body `content-type: application/json` | Field | Type | Required | Description | | --- | --- | --- | --- | | `owner` | object | yes | The connected wallet: Xverse-style wallets have two addresses, UniSat-style one (give it as both). Both keys are checked against their addresses. | | `owner.payment_address` | string | yes | Pays the fees and gets the change and plain slices. Taproot, native SegWit or nested SegWit. | | `owner.payment_public_key` | string | yes | Its public key, hex (Taproot: the internal key, x-only or compressed; SegWit: compressed). | | `owner.ordinals_address` | string | yes | Holds the rare sats; the default destination. May equal `payment_address`. | | `owner.ordinals_public_key` | string | yes | Its public key, hex. | | `kind` | string | yes | What to do. One of: `extract`, `send`, `send_n`, `chop`, `consolidate`, `multisend`, `speedup`, `cancel`. | | `fee_rate` | number | yes | sat/vB, at least `minimum` of `GET /api/market/fees`. (max 5000) | | `items` | array of object | no | `extract`, `send`, `chop`; `consolidate` (2 or more plain UTXOs, or absent: the server picks); optional for `send_n` (limits its pick). (1-50 items) | | `items[].outpoint` | string | yes | `txid:vout`. | | `items[].sats` | array of integer \| null | no | `extract` only: the tracked sats (absolute sat numbers) to extract; `null` or absent: all of them. A sat inside a block 9 range picks the whole range. (0-10000 items) | | `destination` | string | no | Where the moved sats go. Default: your ordinals address (your payment address for `consolidate`). P2TR, P2WPKH, P2SH, P2WSH or P2PKH; never the fee address. | | `plain_to` | string | no | `extract`: where slices holding no tracked sat go (default `payment`). One of: `payment`, `source`. | | `split` | object \| object | no | `extract` only: split each UTXO's sats of one type into chunks instead of separating each rare sat (see [Extract rare sats](https://raresats.net/docs/guide-extract.md#split-into-chunks)). Each chunk is an output of its own to `destination` (a chunk whose sats lie on both sides of a whole range, block 9, is cut around it: the range keeps an output of its own); the other rare sats of the UTXO stay home or ride along with a chunk to your own address, never to plain change. The items take no `sats`. With several items, one that cannot make 2 chunks is skipped (`summary.skipped`); 2,000 sats of the type at most per request, and per UTXO at most 2,000 of them and its other tracked runs and exotic intervals together (`too_many_items`), and the outputs as laid out (chunks too close together share one) must fit in 100 (`too_many_outputs`). | | `split.type` | string | yes | The exact type combination to chunk (`uncommon`, `black+uncommon`): 1 to 8 lowercase types of 1-40 letters, digits or `_`, joined with `+`, as `send_n` matches it (`uncommon` does not take a `black+uncommon` sat); repeats count once. Never a whole range (`block9`, `450x`): `chop` cuts those. | | `split.per_output` | integer | no | Sats of the type per output, in offset order; the last chunk holds the remainder. Exactly one of `per_output` and `chunks`. (min 1, max 2000) | | `split.chunks` | integer | no | How many chunks: near-equal, the first ones one larger. Exactly one of `per_output` and `chunks`. (min 2, max 100) | | `split.trim` | boolean | no | `true`: the plain sats between two chunks go back as plain change (to `plain_to`) when they make an output of their own; smaller gaps ride along with a chunk. `false` (default): each chunk's output runs from its first sat to the next chunk; other rare or exotic sats between two chunks ride along too when `destination` is your own address (a whole range never does: it keeps an output of its own). | | `mode` | string | no | `send`: the UTXOs as they are, or only their rare sats (default `whole`). One of: `whole`, `rare`. | | `consolidate` | boolean | no | `send` `whole`: one output for all the UTXOs instead of one each. | | `type` | string | no | `send_n`: an exact type combination (`uncommon`, `black+uncommon`) or a range type (`block9`, `450x`). | | `count` | integer | no | `send_n`: how many sats. (min 1) | | `chop_size` | integer | no | `chop`: cut at sats divisible by this. (min 330, max 100000000) | | `sends` | array of object | no | `multisend`: the payments. (1-50 items) | | `sends[].address` | string | yes | | | `sends[].value` | integer | yes | Sats. | | `txid` | string | no | `speedup`, `cancel`: the pending wallet transfer to replace. | | `confirm_payment_destination` | boolean | no | Allow sending rare sats, inscriptions or runes to your own payment address (otherwise `payment_destination`). | #### Example request ```bash title="cURL" curl -X POST 'https://raresats.net/api/market/transfer/prepare' \ -H 'content-type: application/json' \ -d '{ "owner": { "payment_address": "37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", "payment_public_key": "039b3b694b8fc5b5e07fb069c783cac754f5d38c3e08bed1960e31fdb1dda35c24", "ordinals_address": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", "ordinals_public_key": "03cc8a4bc64d897bddc5fbc2f670f7a8ba0b386779106cf1223c6fc5d7cd6fc115" }, "kind": "extract", "items": [ { "outpoint": "24b3eedfdc210f4a5baa96a6c99db5a51d3b1286ab2f58cb0b16fc607e54107f:0" } ], "fee_rate": 3 }' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/market/transfer/prepare', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ "owner": { "payment_address": "37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", "payment_public_key": "039b3b694b8fc5b5e07fb069c783cac754f5d38c3e08bed1960e31fdb1dda35c24", "ordinals_address": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", "ordinals_public_key": "03cc8a4bc64d897bddc5fbc2f670f7a8ba0b386779106cf1223c6fc5d7cd6fc115" }, "kind": "extract", "items": [ { "outpoint": "24b3eedfdc210f4a5baa96a6c99db5a51d3b1286ab2f58cb0b16fc607e54107f:0" } ], "fee_rate": 3 }) }); const data = await response.json(); ``` ```python title="Python" import requests data = requests.post("https://raresats.net/api/market/transfer/prepare", json={ "owner": { "payment_address": "37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", "payment_public_key": "039b3b694b8fc5b5e07fb069c783cac754f5d38c3e08bed1960e31fdb1dda35c24", "ordinals_address": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", "ordinals_public_key": "03cc8a4bc64d897bddc5fbc2f670f7a8ba0b386779106cf1223c6fc5d7cd6fc115" }, "kind": "extract", "items": [{ "outpoint": "24b3eedfdc210f4a5baa96a6c99db5a51d3b1286ab2f58cb0b16fc607e54107f:0" }], "fee_rate": 3 }).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `id` | string | The prepared transfer, for submit. | | `psbt` | string | The PSBT to sign, base64. Every input carries `witnessUtxo` (and `tapInternalKey` / `redeemScript` / `nonWitnessUtxo` as its type needs); no `sighashType`. | | `sign` | array of object | Every input, with the address whose key signs it. Sign with the default sighash. | | `sign[].index` | integer | The input to sign. | | `sign[].address` | string | The address whose key signs it. | | `sign[].sighash` | integer | The sighash type to sign with, when it is not the default: 131 (`0x83`, SIGHASH_SINGLE\|ANYONECANPAY) for listings. Absent on purchase inputs: sign with the default (SIGHASH_DEFAULT for Taproot, SIGHASH_ALL for SegWit v0). | | `summary` | object | The plan as people read it: what each input holds, what each output gets and where it goes, the fees, the checks run on the unsigned transaction, and the warnings. | | `summary.kind` | string | The kind. | | `summary.network_fee` | integer | Sats, paid from your plain bitcoin. | | `summary.tools_fee` | integer | The flat raresats.net fee (`tools.fee_sats`; 0 on a speed-up or cancel, which keep the original's fee output). | | `summary.fee_rate` | number | sat/vB. | | `summary.vsize` | integer | Estimated vbytes (an upper bound). | | `summary.change` | integer | Sats back to your payment address as change. | | `summary.total_cost` | integer | `network_fee + tools_fee`. | | `summary.leaves_wallet` | integer | Sats paid to addresses that are not yours, other than the fees. | | `summary.destination` | string \| null | The destination address. | | `summary.external` | boolean | The destination is not one of your addresses. | | `summary.inputs` | array of object | Every input: your UTXOs to move (`source`), plain funding (`funding`), or plain UTXOs (`plain`). | | `summary.inputs[].index` | integer | | | `summary.inputs[].outpoint` | string | | | `summary.inputs[].value` | integer | | | `summary.inputs[].role` | string | One of: `source`, `funding`, `plain`. | | `summary.inputs[].address` | string | | | `summary.inputs[].owner_role` | string | One of: `ordinals`, `payment`. | | `summary.inputs[].items` | array of object | Its tracked sats. | | `summary.inputs[].items[].sat` | integer | The first sat. | | `summary.inputs[].items[].len` | integer | How many sats (1, or a range). | | `summary.inputs[].items[].types` | array of string | Its sat types. | | `summary.inputs[].items[].input` | integer | The input it comes from. | | `summary.inputs[].items[].offset` | integer | Its offset in that input. | | `summary.inputs[].items[].at` | integer \| null | Its offset in this output. | | `summary.inputs[].items[].selected` | boolean | Picked to move (`false`: it stays where it was). | | `summary.inputs[].inscriptions` | integer | How many inscriptions it carries. | | `summary.inputs[].runes` | boolean | | | `summary.outputs` | array of object | Every output, in order. | | `summary.outputs[].index` | integer | | | `summary.outputs[].value` | integer | | | `summary.outputs[].address` | string | | | `summary.outputs[].role` | string | `dest`: moved sats; `return`: back to a source's address; `plain_return`: a plain slice to your payment address; `tools_fee`; `change`. | | `summary.outputs[].to` | string | Whose address it pays. One of: `ordinals`, `payment`, `source`, `external`, `fee`. | | `summary.outputs[].parts` | array of object | The input sats it receives, first in first out: `[from, to)` of input `input`, at offset `at`. | | `summary.outputs[].parts[].input` | integer | | | `summary.outputs[].parts[].from` | integer | | | `summary.outputs[].parts[].to` | integer | | | `summary.outputs[].parts[].at` | integer | | | `summary.outputs[].parts[].kind` | string | | | `summary.outputs[].items` | array of object | The tracked sats it receives. | | `summary.outputs[].items[].sat` | integer | The first sat. | | `summary.outputs[].items[].len` | integer | How many sats (1, or a range). | | `summary.outputs[].items[].types` | array of string | Its sat types. | | `summary.outputs[].items[].input` | integer | The input it comes from. | | `summary.outputs[].items[].offset` | integer | Its offset in that input. | | `summary.outputs[].items[].at` | integer \| null | Its offset in this output. | | `summary.outputs[].items[].selected` | boolean | Picked to move (`false`: it stays where it was). | | `summary.outputs[].rides_along` | array of object | Unpicked or exotic sats that ride along in an output to yourself. | | `summary.outputs[].rides_along[].input` | integer | | | `summary.outputs[].rides_along[].from` | integer | | | `summary.outputs[].rides_along[].to` | integer | | | `summary.outputs[].rides_along[].at` | integer | | | `summary.outputs[].rides_along[].kind` | string | | | `summary.outputs[].kept` | array of object | Sats that are not plain going back home in this output: unpicked tracked sats and exotic ranges (`types`, e.g. vintage). Never shown as plain bitcoin. | | `summary.outputs[].kept[].input` | integer | | | `summary.outputs[].kept[].from` | integer | | | `summary.outputs[].kept[].to` | integer | | | `summary.outputs[].kept[].kind` | string | | | `summary.outputs[].kept[].types` | array of string | | | `summary.outputs[].padding` | integer | Sats other than the picked ones in an extracted output. | | `summary.outputs[].extracted` | boolean \| null | Whether the index will count it extracted ([Extraction](https://raresats.net/types/extraction)); `null` when it holds no tracked sat or the rule is unknown. | | `summary.checks` | array of object | What the server verified on the unsigned transaction ("No rare sat is paid as fee", …). | | `summary.checks[].rule` | string | | | `summary.checks[].ok` | boolean | | | `summary.checks[].label` | string | | | `summary.warnings` | array of object | Things to know before signing: `listed` (carries `listing_id`, `signature_public` and, when that is true, `recommended_fee_rate`: the fast rate), `offers_received`, `spends_offer_funding`, `payment_destination`, `not_taproot`, `external`, `fee_exceeds_freed`, `merged`, `chunk_not_extracted` (a split's chunk output above the extraction budget), `chunk_cut` (a split's chunk cut around a whole range), `not_aligned`. | | `summary.warnings[].code` | string | | | `summary.warnings[].message` | string | | | `summary.warnings[].outpoint` | string | | | `summary.skipped` | array of object | Items left out of a multi-item extract or an automatic send N pick, with the reason (`already_extracted`, `split_chunk`, `inscribed`, `runes`, `unconfirmed`, `pending`, `untracked`, `nothing_to_separate`, `keep_in_destination`, `listed`, `offered`; for a split over several UTXOs `not_enough_sats` and `nothing_to_do`). | | `summary.skipped[].outpoint` | string | | | `summary.skipped[].code` | string | | | `summary.skipped[].message` | string | | | `summary.replaces` | object \| null | A speed-up or cancel: the pending transaction it replaces (`fee` and `fee_rate` null for a void of one no longer in the mempool). | | `summary.replaces.txid` | string | | | `summary.replaces.fee` | integer \| null | | | `summary.replaces.fee_rate` | number \| null | | | `summary.min_fee_rate` | number \| null | A replacement: the lowest fee rate that replaces the pending transaction. | | `summary.layout` | array of object | How each source was cut (layout kinds). | | `summary.layout[].outpoint` | string | | | `summary.layout[].input` | integer | | | `summary.layout[].separated` | boolean | Something was split off. | | `summary.layout[].borrow` | integer | Sats the last output borrowed from the funding. | | `summary.layout[].merged` | array of object | Picked sats sharing one output (too close together, or at the tail). | | `summary.layout[].rides_along` | integer | | | `summary.layout[].chunks` | integer | A split: the chunks planned for this source. | | `summary.layout[].chunk_outputs` | integer | A split: the outputs they make (fewer when chunks sit too close together to separate; a `merged` warning). | | `summary.split` | object | An extract with `split`: the split as planned (the request's, normalized). | | `summary.split.type` | string | | | `summary.split.per_output` | integer \| null | | | `summary.split.chunks` | integer \| null | | | `summary.split.trim` | boolean | | | `expires_at` | integer | Unix seconds: submit before then (10 minutes). | ```json title="Response" { "id": "6baec47455bf8df8", "psbt": "cHNidP8BAP0dAQIAAAACfxBUfmD8FgvLWC+rhhI7HaW1ncmmlqpbSg8h3N/usyQAAAAAAP3///9AKU7jykAIeJ8gRh6iFq7aPfAmRiqhMPIbX7W1YPtYHgAAAAAA/f///wWEAQAAAAAAACJRIKYIafDbzx3GWcnOy6+AUBNeqejNxIcFPx3GiAlJ3GhMSgEAAAAAAAAiUSCmCGnw288dxlnJzsuvgFATXqnozcSHBT8dxogJSdxoTEIkAAAAAAAAF6kUP7bpWBLle7RpH5pKYohiphpPdpuH6AMAAAAAAAAiUSCt8pxdM5v1iajbtpINGw6mXK08CmhXiuI0TjokZbsX8J1ZAAAAAAAAF6kUP7bpWBLle7RpH5pKYohiphpPdpuHAAAAAAABASsQJwAAAAAAACJRIKYIafDbzx3GWcnOy6+AUBNeqejNxIcFPx3GiAlJ3GhMARcgzIpLxk2Je93F+8L2cPeougs4Z3kQbPEiPG/F181vwRUAAQEgqGEAAAAAAAAXqRQ/tulYEuV7tGkfmkpiiGKmGk92m4cBBBYAFPmQZ5rK/iXCdhU3O0C/IkRtJP9EAAAAAAAA", "sign": [ { "index": 0, "address": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr" }, {"index": 1, "address": "37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf"} ], "summary": { "kind": "extract", "network_fee": 1059, "tools_fee": 1000, "fee_rate": 3, "vsize": 353, "change": 22941, "total_cost": 2059, "leaves_wallet": 0, "destination": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", "external": false, "inputs": [ { "index": 0, "outpoint": "24b3eedfdc210f4a5baa96a6c99db5a51d3b1286ab2f58cb0b16fc607e54107f:0", "value": 10000, "role": "source", "address": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", "owner_role": "ordinals", "items": [ { "sat": 441000000000000, "len": 1, "offset": 388, "types": ["uncommon", "alpha", "decasat_alpha"], "selected": true } ], "inscriptions": 0, "runes": false }, { "index": 1, "outpoint": "1e58fb60b5b55f1bf230a12a4626f03ddaae16a21e46209f780840cae34e2940:0", "value": 25000, "role": "funding", "address": "37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", "owner_role": "payment", "items": [], "inscriptions": 0, "runes": false } ], "outputs": [ { "index": 0, "value": 388, "address": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", "role": "return", "to": "source", "parts": [{"input": 0, "from": 0, "to": 388, "at": 0}], "items": [], "rides_along": [], "padding": 0, "extracted": null }, { "index": 1, "value": 330, "address": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", "role": "dest", "to": "ordinals", "parts": [{"input": 0, "from": 388, "to": 718, "at": 0}], "items": [ { "sat": 441000000000000, "len": 1, "types": ["uncommon", "alpha", "decasat_alpha"], "input": 0, "offset": 388, "at": 0 } ], "rides_along": [], "padding": 329, "extracted": true }, { "index": 2, "value": 9282, "address": "37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", "role": "plain_return", "to": "payment", "parts": [ {"input": 0, "from": 718, "to": 10000, "at": 0} ], "items": [], "rides_along": [], "padding": 0, "extracted": null }, { "index": 3, "value": 1000, "address": "bc1p4hefchfnn06cn2xmk6fq6xcw5ew260q2dptc4c35fcazgedmzlcqkxf057", "role": "tools_fee", "to": "fee", "parts": [{"input": 1, "from": 0, "to": 1000, "at": 0}], "items": [], "rides_along": [], "padding": 0, "extracted": null }, { "index": 4, "value": 22941, "address": "37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", "role": "change", "to": "payment", "parts": [ {"input": 1, "from": 1000, "to": 23941, "at": 0} ], "items": [], "rides_along": [], "padding": 0, "extracted": null } ], "checks": [ { "rule": "fifo", "ok": true, "label": "The fee is paid only from your plain bitcoin" }, {"rule": "fifo", "ok": true, "label": "No rare sat is paid as fee"}, { "rule": "fifo", "ok": true, "label": "Every rare sat lands in its planned output" }, { "rule": "fifo", "ok": true, "label": "No inscription or rune leaves its UTXO" }, {"rule": "owner_inputs", "ok": true, "label": "Every input is yours"}, {"rule": "no_op_return", "ok": true, "label": "No OP_RETURN output"}, { "rule": "dust", "ok": true, "label": "Every output is above the dust limit" }, { "rule": "tools_fee", "ok": true, "label": "One flat raresats.net fee output" }, { "rule": "change", "ok": true, "label": "Your change returns to your payment address" }, { "rule": "network_fee", "ok": true, "label": "The network fee matches the fee rate" }, { "rule": "standard", "ok": true, "label": "A standard, replaceable transaction" } ], "warnings": [], "skipped": [], "replaces": null, "min_fee_rate": null, "layout": [ { "outpoint": "24b3eedfdc210f4a5baa96a6c99db5a51d3b1286ab2f58cb0b16fc607e54107f:0", "input": 0, "separated": true, "borrow": 0, "merged": [], "rides_along": 0 } ] }, "expires_at": 1791428836 } ``` #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 400 | `bad_address` | An address is not a mainnet address (or not one at all). | | 400 | `bad_chop_size` | `chop_size` is not a whole number from 330 to 100,000,000. | | 400 | `bad_count` | `count` (send N) is not a whole number of sats of at least 1. | | 400 | `bad_fee_rate` | `fee_rate` is not a positive number up to 5000. | | 400 | `bad_items` | `items` is not a list of 1-50 UTXOs (or the PSBT lists more), or an item's `sats` is not null or a list of sat numbers. | | 400 | `bad_json` | The body is not valid JSON. | | 400 | `bad_kind` | `kind` is not one of the wallet tools (`extract`, `send`, `send_n`, `chop`, `consolidate`, `multisend`, `speedup`, `cancel`), or a send `mode` is unknown. | | 400 | `bad_outpoint` | Not an outpoint (`txid:vout`). | | 400 | `bad_owner` | `owner` must give the payment and ordinals addresses and their public keys. | | 400 | `bad_request` | The body is not a JSON object, or the engine refused a malformed request (e.g. a purchase submitted without its signed setup PSBT). | | 400 | `bad_sends` | `sends` must list 1-50 payments of `{address, value}`, each at least its address type's minimum (330, or 546 for P2SH and P2PKH). | | 400 | `bad_split` | `split` (extract) is not `{type, per_output \| chunks, trim?}`: an exact type combination that is not a whole range, `per_output` 1–2,000 or `chunks` 2–100 (exactly one), `trim` a boolean; or an item names `sats` (a split takes every sat of its type). | | 400 | `bad_type` | `type` (send N) is not a sat type, types joined with `+`, or `block9` / `450x`. | | 400 | `duplicate_outpoint` | The same UTXO twice in one listing. | | 403 | `cross_origin` | A POST from a browser on another site (a foreign `Origin`, or `sec-fetch-site: cross-site`). Server-side clients send no `Origin` and are not affected. | | 404 | `unknown_transfer` | No such transfer: an unknown or forgotten prepared id, or a txid the wallet tools did not build. | | 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. | | 409 | `conflict` | A listing or one of your UTXOs was spent meanwhile (lost race), or bitcoind would not replace a transaction already spending it (`insufficient fee`), or a transfer's inputs are spent by another transaction. | | 409 | `has_descendants` | A transaction spends the transfer's outputs already: it can no longer be replaced. | | 409 | `not_pending` | The transfer to speed up or cancel is not waiting in the mempool any more (confirmed, replaced). | | 409 | `nothing_to_cancel` | A cancel that could save nothing: the transfer was refused (never sent), or it (and, once dropped, every transfer it replaced) only pays your own addresses. | | 409 | `pending` | The UTXO's listing is being bought, or an offer of it accepted, right now. | | 409 | `spent` | The UTXO (or a listing's UTXO) is spent or does not exist. | | 409 | `unconfirmed` | The UTXO is not confirmed yet. | | 409 | `unindexed` | The ord indexer does not know the UTXO yet. | | 413 | `too_large` | The body is over 256 KiB. | | 415 | `not_json` | A POST without `content-type: application/json`. | | 422 | `address_too_large` | An owner address holds more than 5,000 outputs: the wallet tools cannot plan for it. | | 422 | `already_extracted` | The UTXO is extracted already (its rare sats sit in an output of their own). | | 422 | `bad_destination` | The destination (or a multisend recipient) is the marketplace's fee address, or missing. | | 422 | `dust_destination` | A whole UTXO is too small for the destination's address type. | | 422 | `fee_rate_too_low` | `fee_rate` is below the current minimum (`GET /api/market/fees`). | | 422 | `fee_too_high` | The network fee would exceed the per-transaction cap (200,000 sats): lower the fee rate or move fewer. | | 422 | `inscribed` | The UTXO carries an inscription: only rare sats are listed; the wallet tools only send it whole. | | 422 | `inscribed_consolidate` | Inscribed UTXOs are sent whole and 1:1, never merged into one output. | | 422 | `insufficient_funds` | The payment address's plain UTXOs cannot pay prices, fee, padding and network fee (or a transfer's fees). Carries `available`, `prices`, `platform_fee`. | | 422 | `keep_in_destination` | Exotic or unpicked tracked sats sit so close to a picked one that they would have to leave with it: send the whole UTXO instead. | | 422 | `key_mismatch` | The public key does not control the address. | | 422 | `legacy_address` | A legacy (P2PKH, `1…`) address: use Taproot or SegWit. | | 422 | `listed_public_alone` | A UTXO whose listing signature is public must be moved on its own (and fast). | | 422 | `not_enough_sats` | Your UTXOs do not hold `count` sats of that type that can be sent. Carries `available`. | | 422 | `not_owner` | The UTXO does not belong to the seller address, or (wallet tools) to the owner's addresses, or the transfer was made by another wallet. | | 422 | `not_plain` | A UTXO to consolidate is not plain bitcoin (it holds tracked, exotic or inscribed sats, or runes). | | 422 | `nothing_to_do` | Nothing to move: every item was skipped (`skipped`), or a consolidation has fewer than 2 UTXOs, or a chop leaves one piece. | | 422 | `nothing_to_separate` | Extracting would leave the UTXO one output: its rare sats are apart already, or too close to its ends. | | 422 | `payment_destination` | Rare sats, inscriptions or runes would go to your own payment address, which your wallet spends as plain bitcoin: send them to your ordinals address, or say `confirm_payment_destination: true`. | | 422 | `range_too_small` | A range chunk (send N of `block9` or `450x`) must be at least the destination's padding: 330 sats, 546 for P2SH and P2PKH. | | 422 | `replacement_fee_too_low` | The replacement pays too little to replace the pending transaction (BIP 125): raise the fee rate to at least `min_fee_rate`. | | 422 | `runes` | The UTXO carries runes (the wallet tools only send it whole). | | 422 | `runes_multi` | At most one rune UTXO per transfer (except a whole send merged into one output). | | 422 | `split_chunk` | Skip code of an "extract all" over several UTXOs: the UTXO is a chunk a split on this site made (extracting it again would undo the split). Name it alone to extract it. | | 422 | `too_many_items` | Too many UTXOs in a listing, or listings in a purchase (`max_bulk`); for a wallet transfer, too many rare sats (and the runs between them) to lay out in one UTXO: choose fewer. | | 422 | `too_many_outputs` | The transfer would have more than 100 outputs: move fewer UTXOs at once. | | 422 | `tx_too_large` | The transfer would exceed 100,000 vB: move fewer UTXOs at once. | | 422 | `unsupported_address` | An address type the marketplace does not trade (only Taproot, native SegWit and nested SegWit). | | 422 | `untracked` | The UTXO holds no rare sat the index tracks (send it whole instead). | | 429 | `rate_limited` | Over 30 POSTs a minute from your IPv4 address or IPv6 /64 (in chat, counted per IPv4 address or IPv6 /64: 30 writes and 20 login nonces a minute, 20 sign-ins and 10 display-name changes an hour; one message every 3 s and 20 a minute per address); wait `retry-after` seconds. With the AI moderation on, at most 6 messages and names a minute are checked per address and per IPv4 address or IPv6 /48. | | 500 | `invariant_violated` | A planned or finalized purchase or transfer broke an invariant (`violations`); nothing was broadcast. | | 503 | `bitcoind_unavailable` | bitcoind is not available. | | 503 | `busy` | Too many purchases, offers or transfers are being prepared: try again in a minute. Each client (IPv4 address or IPv6 /64) keeps its 10 latest prepared purchases, offers and transfers; an older one answers `unknown_purchase` / `unknown_offer` / `unknown_transfer`. | | 503 | `engine_unavailable` | The trade engine is not available. | | 503 | `fork_behind` | The rare-sat index has not caught up with bitcoind: try again in a minute. | | 503 | `fork_unavailable` | The rare-sat index is not available. | | 503 | `indexer_behind` | The ord indexer is catching up: try again shortly. | | 503 | `market_unavailable` | The marketplace is closed right now (its backends disagree on the network, or are unreachable). | | 503 | `oracle_unavailable` | The ord oracle is not available. | ```json title="Error (422)" { "error": "c3f31009cbbbced4b1a0acb30d924e3e025b10647c43092b68262f4e83125bf6:0 would stay one output: its rare sats are already apart, or too close to its ends", "code": "nothing_to_separate" } ``` Then sign **every** input in `sign` (each with the key of its `address`; your wallet's default sighash) and [submit](https://raresats.net/docs/submit-transfer.md) before `expires_at`. Nothing is reserved meanwhile: if a UTXO moves, submit answers 409 and you prepare again. ## Kinds | `kind` | Body fields | What the transaction does | | --- | --- | --- | | `extract` | `items: [{outpoint, sats?}]`, `destination?`, `plain_to?`, `split?` | Carves the chosen tracked sats (all of them when `sats` is absent) of 1–50 UTXOs into outputs of their own, to `destination` (default: your ordinals address). What you did not pick goes back to the address it came from. With `split: {type, per_output \| chunks, trim?}` it cuts each UTXO's sats of one type into chunks instead, one output per chunk ([Split into chunks](https://raresats.net/docs/guide-extract.md#split-into-chunks)). | | `send` | `items`, `destination`, `mode: "whole"\|"rare"`, `consolidate?` | `whole`: the UTXOs as they are, one output each (or one output for all with `consolidate: true`). `rare`: only the rare sats, laid out as an extraction; the rest comes back. | | `send_n` | `type`, `count`, `destination`, `items?` | N sats of one type: an exact type combination such as `uncommon` or `black+uncommon`, or a range type (`block9`, `450x`) cut as an aligned sub-range. | | `chop` | `items`, `chop_size`, `destination?` | Cuts a UTXO's whole ranges (block 9 style) into `chop_size`-aligned pieces. | | `consolidate` | `items?`, `destination?` | Merges plain UTXOs into one output (default: your payment address). Without `items` the server picks your payment address's plain UTXOs: up to 50, smallest first, never a listed UTXO or your offers' funding, and not the largest when it is worth more than all the others together (merging into it would only cost fees). Fewer than 2 left is `nothing_to_do`. | | `multisend` | `sends: [{address, value}]` | Plain bitcoin to 1–50 recipients, change back to you. | | `speedup` | `txid` | Replaces a pending transfer the site built with the same transaction at a higher fee rate (RBF). | | `cancel` | `txid` | Replaces (or voids) a transfer the site built with one that sends every source UTXO back to where it came from. | Every kind also takes `owner` and `fee_rate`. The guides walk through them: [Extract rare sats](https://raresats.net/docs/guide-extract.md), [Send rare sats and bitcoin](https://raresats.net/docs/guide-send.md), [Speed up or cancel](https://raresats.net/docs/guide-speed-up.md). ## The summary `summary` is the plan drawn for people: each input with its tracked sats (`items`, `selected`), each output with what it holds (`items`, `rides_along`, `padding`) and where it goes (`to`: `ordinals`, `payment`, `source`, `external` or `fee`), the `checks` the server ran on the unsigned transaction, and `warnings` and `skipped` items with their reasons (for example `listed`, `offered`, `inscribed`, `runes`, `already_extracted`, `nothing_to_separate`, or `spent` for a UTXO `send_n` would have picked that is already spent in the mempool). An output that carries home sats that are not plain (unpicked rare sats, exotic ranges such as vintage) lists them in `kept`, with their types. A split also answers `split` (as planned) and, per source in `layout`, `chunks` and `chunk_outputs`. `total_cost` is the network fee plus the tools fee; `leaves_wallet` is what is paid to addresses that are not yours (other than the fees). --- Page: https://raresats.net/docs/submit-transfer.md # Submit a transfer `POST /api/market/transfer/submit` Step 2: the PSBT from prepare with every input signed. The server checks that it is exactly the prepared transaction, verifies every signature, rechecks every UTXO against bitcoind, the rare-sat index and the ord indexer (`changed`, `spent`), runs the invariants with the real size and `testmempoolaccept`, cancels the active listings of the moved UTXOs (their signatures are deleted, so they can never sell) and broadcasts. Idempotent: the same body again answers the same `txid`. #### Request body `content-type: application/json` | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | From prepare. | | `psbt` | string | yes | The PSBT from prepare with every input signed, base64 (finalized or not). | #### Example request ```bash title="cURL" curl -X POST 'https://raresats.net/api/market/transfer/submit' \ -H 'content-type: application/json' \ -d '{ "id": "6baec47455bf8df8", "psbt": "" }' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/market/transfer/submit', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ "id": "6baec47455bf8df8", "psbt": "" }) }); const data = await response.json(); ``` ```python title="Python" import requests data = requests.post("https://raresats.net/api/market/transfer/submit", json={ "id": "6baec47455bf8df8", "psbt": "" }).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `txid` | string | The broadcast transaction. | | `transfer` | object | A wallet transfer: what is on chain or was chosen by its owner. | | `transfer.id` | string | The prepared transfer's id. | | `transfer.txid` | string | Its transaction id (what `/tx/{txid}` shows). | | `transfer.kind` | string | One of: `extract`, `send`, `send_n`, `chop`, `consolidate`, `multisend`, `speedup`, `cancel`. | | `transfer.status` | string | `pending` in the mempool; `confirmed` in a block; `replaced` by a speed-up or cancel built here (`replaced_by`); `conflicted`: a block spent its inputs and it was provably not mined (its own outputs never appeared within six blocks); `dropped`: in no mempool and its inputs unspent (it may still resurface until voided); `unknown`: the broadcast's outcome is not known yet; `rejected`: bitcoind refused it, so it was never sent and cannot resurface. | | `transfer.created_at` | integer | Unix seconds. | | `transfer.updated_at` | integer | Unix seconds. | | `transfer.owner` | object | The owner's addresses (one transfer's read). | | `transfer.owner.payment_address` | string | Paid the fees. | | `transfer.owner.ordinals_address` | string | The ordinals address. | | `transfer.owner_role` | string | The queried address's part in it (a list read). One of: `payment`, `ordinals`, `both`. | | `transfer.destination` | string \| null | Where the moved sats went (`null` for a multisend). | | `transfer.fee_rate` | number | sat/vB. | | `transfer.network_fee` | integer | Sats. | | `transfer.tools_fee` | integer | The flat raresats.net fee it paid, in sats. | | `transfer.vsize` | integer | Its size in vbytes. | | `transfer.replaces` | string \| null | The transfer it replaced (speed-up, cancel). | | `transfer.replaced_by` | string \| null | The transaction that replaced it. | | `transfer.block_height` | integer \| null | The block it confirmed in. | | `transfer.outputs` | array of object | Its outputs. | | `transfer.outputs[].index` | integer | vout. | | `transfer.outputs[].address` | string \| null | Who it pays. | | `transfer.outputs[].value` | integer | Sats. | | `transfer.outputs[].role` | string | `dest`, `return`, `plain_return`, `tools_fee` or `change`. | | `transfer.actions` | object | What its owner can do now. | | `transfer.actions.speedup` | boolean | It is pending: a speed-up can replace it. | | `transfer.actions.cancel` | boolean | It could still confirm (pending, unknown, dropped) and its chain of replacements pays someone else: a cancel (or a void) sends its sources back. It never hides a cancel the prepare would allow; the prepare decides with the mempool (409 nothing_to_cancel). | ```json title="Response" { "txid": "a1edadb9e3b45097d272621ea8adcbee697281bd4d611462db134292d4e1cf9c", "transfer": { "id": "6baec47455bf8df8", "txid": "a1edadb9e3b45097d272621ea8adcbee697281bd4d611462db134292d4e1cf9c", "kind": "extract", "status": "pending", "created_at": 1791428236, "updated_at": 1791428236, "owner": { "payment_address": "37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", "ordinals_address": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr" }, "destination": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", "fee_rate": 3, "network_fee": 1059, "tools_fee": 1000, "vsize": 352, "replaces": null, "replaced_by": null, "block_height": null, "outputs": [ { "index": 0, "address": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", "value": 388, "role": "return" }, { "index": 1, "address": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", "value": 330, "role": "dest" }, { "index": 2, "address": "37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", "value": 9282, "role": "plain_return" }, { "index": 3, "address": "bc1p4hefchfnn06cn2xmk6fq6xcw5ew260q2dptc4c35fcazgedmzlcqkxf057", "value": 1000, "role": "tools_fee" }, { "index": 4, "address": "37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", "value": 22941, "role": "change" } ], "actions": {"speedup": true, "cancel": false} } } ``` #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 400 | `bad_id` | `id` is not a prepared purchase or transfer id. | | 400 | `bad_json` | The body is not valid JSON. | | 400 | `bad_psbt` | Not a base64 PSBT, or one that does not parse. | | 400 | `bad_request` | The body is not a JSON object, or the engine refused a malformed request (e.g. a purchase submitted without its signed setup PSBT). | | 400, 422 | `bad_signature` | 400: `signature` is missing or too long. 422: a signature does not verify (cancel or registration message, listing or purchase input). | | 403 | `cross_origin` | A POST from a browser on another site (a foreign `Origin`, or `sec-fetch-site: cross-site`). Server-side clients send no `Origin` and are not affected. | | 404 | `unknown_transfer` | No such transfer: an unknown or forgotten prepared id, or a txid the wallet tools did not build. | | 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. | | 409 | `changed` | The UTXO's value is not what the PSBT says, or (wallet tools) a UTXO, its sats or the transaction being replaced changed since prepare: prepare again. | | 409 | `config_changed` | The marketplace settings changed since this purchase or transfer was prepared (prepare it again), or since this offer was made (it is now `invalid`, reason `config_changed`). | | 409 | `conflict` | A listing or one of your UTXOs was spent meanwhile (lost race), or bitcoind would not replace a transaction already spending it (`insufficient fee`), or a transfer's inputs are spent by another transaction. | | 409 | `expired` | The prepared purchase, offer or transfer expired, or the offer itself has: prepare it again. | | 409 | `has_descendants` | A transaction spends the transfer's outputs already: it can no longer be replaced. | | 409 | `not_pending` | The transfer to speed up or cancel is not waiting in the mempool any more (confirmed, replaced). | | 409 | `offer_overlap` | Since the prepare, a planned input started funding one of your offers, or a UTXO the server picked got an offer: prepare the transfer again. | | 409 | `pending` | The UTXO's listing is being bought, or an offer of it accepted, right now. | | 409 | `spent` | The UTXO (or a listing's UTXO) is spent or does not exist. | | 413 | `too_large` | The body is over 256 KiB. | | 415 | `not_json` | A POST without `content-type: application/json`. | | 422 | `fee_rate_too_low` | `fee_rate` is below the current minimum (`GET /api/market/fees`). | | 422 | `fee_too_high` | The network fee would exceed the per-transaction cap (200,000 sats): lower the fee rate or move fewer. | | 422 | `missing_signature` | An input that must be signed is not. | | 422 | `psbt_mismatch` | The signed PSBT is not the prepared transaction. | | 422 | `rejected` | bitcoind refused the transaction (`reason`). | | 422 | `replacement_fee_too_low` | The replacement pays too little to replace the pending transaction (BIP 125): raise the fee rate to at least `min_fee_rate`. | | 422 | `unsupported_script` | The trade engine met a script type it does not support. | | 429 | `rate_limited` | Over 30 POSTs a minute from your IPv4 address or IPv6 /64 (in chat, counted per IPv4 address or IPv6 /64: 30 writes and 20 login nonces a minute, 20 sign-ins and 10 display-name changes an hour; one message every 3 s and 20 a minute per address); wait `retry-after` seconds. With the AI moderation on, at most 6 messages and names a minute are checked per address and per IPv4 address or IPv6 /48. | | 500 | `internal` | A server bug. Report it. | | 500 | `invariant_violated` | A planned or finalized purchase or transfer broke an invariant (`violations`); nothing was broadcast. | | 503 | `bitcoind_unavailable` | bitcoind is not available. | | 503 | `broadcast_unknown` | The broadcast's outcome is unknown: submit the same purchase or transfer again to settle it. Carries `txid`. | | 503 | `engine_unavailable` | The trade engine is not available. | | 503 | `fork_behind` | The rare-sat index has not caught up with bitcoind: try again in a minute. | | 503 | `fork_unavailable` | The rare-sat index is not available. | | 503 | `market_unavailable` | The marketplace is closed right now (its backends disagree on the network, or are unreachable). | | 503 | `oracle_unavailable` | The ord oracle is not available. | ```json title="Error (404)" {"error": "no such transfer", "code": "unknown_transfer"} ``` Submitting is **idempotent**: the same `{id, psbt}` again answers the same `txid`. On 503 `broadcast_unknown` (or a lost answer), submit the same body again until it answers `{txid}` or a definite refusal: when bitcoind still does not have the transaction, the server checks it again and sends the same transaction once more (same txid, so nothing is sent twice). Once the transfer has settled, a resubmit answers definitely: `{txid}` when it confirmed, 409 `conflict` (with `txid`, `status` and `replaced_by`) when it was replaced or its inputs were spent by another transaction. Offers are checked again just before the broadcast: a planned input that started funding one of your offers since the prepare (unless you chose it knowing, after the `spends_offer_funding` warning), or a UTXO the server picked that got an offer, answers 409 `offer_overlap`: prepare again. Your listings on the moved UTXOs are cancelled just before the broadcast, so they can never sell after you signed a move of them. Follow the transfer with [Get a wallet transfer](https://raresats.net/docs/get-wallet-transfer.md); speed it up or cancel it with [Speed up or cancel a transfer](https://raresats.net/docs/guide-speed-up.md). --- Page: https://raresats.net/docs/get-wallet-transfers.md # List an address's wallet transfers `GET /api/market/transfers` The transfers built with the wallet tools that an address took part in (as payment or ordinals address), newest first. Each names only that address's part (`owner_role`). #### Query parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `address` | string | yes | A payment or ordinals address. | | `status` | string | no | `pending` (what you still have to watch: in the mempool, not yet known, or dropped in a chain of replacements that pays someone else, which a cancel voids) or `all` (default). One of: `pending`, `all`. | | `limit` | integer | no | 1-100, default 20. (min 1, max 100) | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/market/transfers?address=bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr&status=all&limit=5' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/market/transfers?address=bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr&status=all&limit=5'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/market/transfers", params={"address": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", "status": "all", "limit": 5}).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `transfers` | array of object | Newest first. | | `transfers[].id` | string | The prepared transfer's id. | | `transfers[].txid` | string | Its transaction id (what `/tx/{txid}` shows). | | `transfers[].kind` | string | One of: `extract`, `send`, `send_n`, `chop`, `consolidate`, `multisend`, `speedup`, `cancel`. | | `transfers[].status` | string | `pending` in the mempool; `confirmed` in a block; `replaced` by a speed-up or cancel built here (`replaced_by`); `conflicted`: a block spent its inputs and it was provably not mined (its own outputs never appeared within six blocks); `dropped`: in no mempool and its inputs unspent (it may still resurface until voided); `unknown`: the broadcast's outcome is not known yet; `rejected`: bitcoind refused it, so it was never sent and cannot resurface. | | `transfers[].created_at` | integer | Unix seconds. | | `transfers[].updated_at` | integer | Unix seconds. | | `transfers[].owner` | object | The owner's addresses (one transfer's read). | | `transfers[].owner.payment_address` | string | Paid the fees. | | `transfers[].owner.ordinals_address` | string | The ordinals address. | | `transfers[].owner_role` | string | The queried address's part in it (a list read). One of: `payment`, `ordinals`, `both`. | | `transfers[].destination` | string \| null | Where the moved sats went (`null` for a multisend). | | `transfers[].fee_rate` | number | sat/vB. | | `transfers[].network_fee` | integer | Sats. | | `transfers[].tools_fee` | integer | The flat raresats.net fee it paid, in sats. | | `transfers[].vsize` | integer | Its size in vbytes. | | `transfers[].replaces` | string \| null | The transfer it replaced (speed-up, cancel). | | `transfers[].replaced_by` | string \| null | The transaction that replaced it. | | `transfers[].block_height` | integer \| null | The block it confirmed in. | | `transfers[].outputs` | array of object | Its outputs. | | `transfers[].outputs[].index` | integer | vout. | | `transfers[].outputs[].address` | string \| null | Who it pays. | | `transfers[].outputs[].value` | integer | Sats. | | `transfers[].outputs[].role` | string | `dest`, `return`, `plain_return`, `tools_fee` or `change`. | | `transfers[].actions` | object | What its owner can do now. | | `transfers[].actions.speedup` | boolean | It is pending: a speed-up can replace it. | | `transfers[].actions.cancel` | boolean | It could still confirm (pending, unknown, dropped) and its chain of replacements pays someone else: a cancel (or a void) sends its sources back. It never hides a cancel the prepare would allow; the prepare decides with the mempool (409 nothing_to_cancel). | | `total` | integer | How many transfers match without `limit` (with `status=pending`: everything you still have to watch). | ```json title="Response" { "transfers": [ { "id": "6baec47455bf8df8", "txid": "a1edadb9e3b45097d272621ea8adcbee697281bd4d611462db134292d4e1cf9c", "kind": "extract", "status": "pending", "created_at": 1791428236, "updated_at": 1791428236, "owner_role": "ordinals", "destination": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", "fee_rate": 3, "network_fee": 1059, "tools_fee": 1000, "vsize": 352, "replaces": null, "replaced_by": null, "block_height": null, "outputs": [ { "index": 0, "address": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", "value": 388, "role": "return" }, { "index": 1, "address": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", "value": 330, "role": "dest" }, { "index": 2, "address": "37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", "value": 9282, "role": "plain_return" }, { "index": 3, "address": "bc1p4hefchfnn06cn2xmk6fq6xcw5ew260q2dptc4c35fcazgedmzlcqkxf057", "value": 1000, "role": "tools_fee" }, { "index": 4, "address": "37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", "value": 22941, "role": "change" } ], "actions": {"speedup": true, "cancel": false} } ], "total": 1 } ``` #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 400 | `bad_address` | An address is not a mainnet address (or not one at all). | | 400 | `bad_paging` | `limit` or `offset` out of range. | | 400 | `bad_status` | `status` is not `all` or a comma-separated list of offer statuses, or (wallet transfers) not `pending` or `all`. | | 422 | `legacy_address` | A legacy (P2PKH, `1…`) address: use Taproot or SegWit. | | 422 | `unsupported_address` | An address type the marketplace does not trade (only Taproot, native SegWit and nested SegWit). | | 503 | `market_unavailable` | The marketplace is closed right now (its backends disagree on the network, or are unreachable). | ```json title="Error (400)" {"error": "address must be an address", "code": "bad_address"} ``` These are the transfers built with the wallet tools ([Prepare a transfer](https://raresats.net/docs/prepare-transfer.md)), not the site's feed of notable rare-sat transfers (`GET /api/transfers`, see [Supply, rows and status](https://raresats.net/docs/site-feeds.md)). Each record shows only the queried address's side (`owner_role`). This is not a privacy boundary: a transfer spends from both of the wallet's addresses in one transaction, which links them on chain anyway. `status=pending` lists what you still have to watch: transfers in the mempool or whose broadcast is not known yet, and **dropped** transfers of a replacement chain that pays someone else (the transfer itself, or an original it replaced by a speed-up or cancel). A signed transaction that left the mempool can resurface and confirm until something else spends its inputs; a [cancel](https://raresats.net/docs/guide-speed-up.md) of the listed one spends every input of the chain, so it voids them all. A dropped transfer whose chain pays only your own addresses is not listed: if it resurfaces, the sats still end up in your wallet. `total` counts the matching transfers without `limit`. To follow changes live, listen for the `transfer` event of [Live events](https://raresats.net/docs/live-events.md#transfer-events): it names the addresses whose transfers changed, then read this again. --- Page: https://raresats.net/docs/get-wallet-transfer.md # Get a wallet transfer `GET /api/market/transfers/{txid}` One transfer built with the wallet tools, by its transaction id, whatever its status, with both owner addresses and whether it can be sped up or cancelled. #### Path parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `txid` | string | yes | The transfer's transaction id. | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/market/transfers/a1edadb9e3b45097d272621ea8adcbee697281bd4d611462db134292d4e1cf9c' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/market/transfers/a1edadb9e3b45097d272621ea8adcbee697281bd4d611462db134292d4e1cf9c'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/market/transfers/a1edadb9e3b45097d272621ea8adcbee697281bd4d611462db134292d4e1cf9c").json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `id` | string | The prepared transfer's id. | | `txid` | string | Its transaction id (what `/tx/{txid}` shows). | | `kind` | string | One of: `extract`, `send`, `send_n`, `chop`, `consolidate`, `multisend`, `speedup`, `cancel`. | | `status` | string | `pending` in the mempool; `confirmed` in a block; `replaced` by a speed-up or cancel built here (`replaced_by`); `conflicted`: a block spent its inputs and it was provably not mined (its own outputs never appeared within six blocks); `dropped`: in no mempool and its inputs unspent (it may still resurface until voided); `unknown`: the broadcast's outcome is not known yet; `rejected`: bitcoind refused it, so it was never sent and cannot resurface. | | `created_at` | integer | Unix seconds. | | `updated_at` | integer | Unix seconds. | | `owner` | object | The owner's addresses (one transfer's read). | | `owner.payment_address` | string | Paid the fees. | | `owner.ordinals_address` | string | The ordinals address. | | `owner_role` | string | The queried address's part in it (a list read). One of: `payment`, `ordinals`, `both`. | | `destination` | string \| null | Where the moved sats went (`null` for a multisend). | | `fee_rate` | number | sat/vB. | | `network_fee` | integer | Sats. | | `tools_fee` | integer | The flat raresats.net fee it paid, in sats. | | `vsize` | integer | Its size in vbytes. | | `replaces` | string \| null | The transfer it replaced (speed-up, cancel). | | `replaced_by` | string \| null | The transaction that replaced it. | | `block_height` | integer \| null | The block it confirmed in. | | `outputs` | array of object | Its outputs. | | `outputs[].index` | integer | vout. | | `outputs[].address` | string \| null | Who it pays. | | `outputs[].value` | integer | Sats. | | `outputs[].role` | string | `dest`, `return`, `plain_return`, `tools_fee` or `change`. | | `actions` | object | What its owner can do now. | | `actions.speedup` | boolean | It is pending: a speed-up can replace it. | | `actions.cancel` | boolean | It could still confirm (pending, unknown, dropped) and its chain of replacements pays someone else: a cancel (or a void) sends its sources back. It never hides a cancel the prepare would allow; the prepare decides with the mempool (409 nothing_to_cancel). | ```json title="Response" { "id": "6baec47455bf8df8", "txid": "a1edadb9e3b45097d272621ea8adcbee697281bd4d611462db134292d4e1cf9c", "kind": "extract", "status": "pending", "created_at": 1791428236, "updated_at": 1791428236, "owner": { "payment_address": "37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", "ordinals_address": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr" }, "destination": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", "fee_rate": 3, "network_fee": 1059, "tools_fee": 1000, "vsize": 352, "replaces": null, "replaced_by": null, "block_height": null, "outputs": [ { "index": 0, "address": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", "value": 388, "role": "return" }, { "index": 1, "address": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", "value": 330, "role": "dest" }, { "index": 2, "address": "37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", "value": 9282, "role": "plain_return" }, { "index": 3, "address": "bc1p4hefchfnn06cn2xmk6fq6xcw5ew260q2dptc4c35fcazgedmzlcqkxf057", "value": 1000, "role": "tools_fee" }, { "index": 4, "address": "37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", "value": 22941, "role": "change" } ], "actions": {"speedup": true, "cancel": false} } ``` #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 404 | `unknown_transfer` | No such transfer: an unknown or forgotten prepared id, or a txid the wallet tools did not build. | | 503 | `market_unavailable` | The marketplace is closed right now (its backends disagree on the network, or are unreachable). | ```json title="Error (404)" {"error": "no such transfer", "code": "unknown_transfer"} ``` `status` follows the transaction: `pending` in the mempool, `confirmed` once mined (with `block_height`), `replaced` by a speed-up or cancel (`replaced_by`; followed until a block settles it: if the original itself is mined it turns `confirmed` and its replacement `conflicted`; `block_height` is the block that settled it), `conflicted` when a block spent its inputs and it was provably not mined (none of its own outputs appeared within six blocks; a transfer spent only in the mempool by someone else stays `dropped`, voidable), a transfer is `confirmed` on its own outputs' evidence even when the node's transaction index lags, a `replaced` transfer whose inputs nothing spends any more goes back to `dropped` (it could resurface), `dropped` when it left the mempool unmined (not final: it is checked again every 10 minutes, and turns `pending`, `confirmed`, `replaced` or `conflicted` if it resurfaces), `unknown` while a broadcast's outcome is not known, and `rejected` when bitcoind refused it (never sent: it cannot resurface, and nothing is left to do). `actions.cancel` never hides a cancel the prepare would allow: it is shown for a transfer that could still confirm (pending, unknown, dropped) whose replacement chain pays someone else, and the prepare decides with the mempool ([Speed up or cancel](https://raresats.net/docs/guide-speed-up.md)). `actions` says whether [speed up and cancel](https://raresats.net/docs/guide-speed-up.md) are offered. To follow changes live, listen for the `transfer` event of [Live events](https://raresats.net/docs/live-events.md#transfer-events): it names the addresses whose transfers changed, then read this again. --- Page: https://raresats.net/docs/get-market.md # Market overview `GET /api/market` The homepage's market over a window: totals against the window before, rows by volume, the dearest and the latest sales (each with seller and buyer). Covers sales on every marketplace the index knows. Cacheable 30 s. `pending` lists the latest sales still in the mempool, in no total. #### Query parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `window` | string | no | The window. One of: `24h`, `7d`, `30d`, `90d`, `1y`, `all`. Default: `30d`. | | `inscribed` | string | no | `1` counts inscribed sales too. One of: `0`, `1`. Default: `0`. | | `outliers` | string | no | `1` counts statistical outliers too. One of: `0`, `1`. Default: `0`. | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/market?window=30d' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/market?window=30d'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/market", params={"window": "30d"}).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `available` | boolean | Whether the sales index is available. | | `generation` | integer | Sales generation (changes on a reorg). | | `seq` | integer | The newest sale's sequence number. | | `as_of` | object \| null | The block the sales are as of: `{height, time}`. | | `window` | string | The window. One of: `24h`, `7d`, `30d`, `90d`, `1y`, `all`. | | `inscribed` | boolean | Inscribed sales counted. | | `outliers` | boolean | Outliers counted. | | `since` | integer \| null | Window start, unix seconds (`null` for `all`). | | `until` | integer \| null | Window end, unix seconds. | | `totals` | object | The window's totals. | | `totals.volume` | integer | Sats traded. | | `totals.sales` | integer | Sales. | | `totals.sats` | integer | Sats sold. | | `previous` | object \| null | The window before's totals (`null` for `all`). | | `rows` | array of object | Rows by volume, largest first. | | `rows[].row` | string | Row id. | | `rows[].volume` | integer | Sats traded. | | `rows[].sales` | integer | Sales. | | `rows[].sats` | integer | Sats sold. | | `rows[].median_unit` | number \| null | Median unit price. | | `rows[].last_time` | integer \| null | Last sale, unix seconds. | | `rows[].last_unit` | number \| null | Last unit price. | | `rows[].last_price` | integer \| null | Last price. | | `rows[].prev_volume` | integer \| null | Volume in the window before. | | `rows[].prev_sales` | integer \| null | Sales in the window before. | | `top` | array of object | The window's dearest sales (up to 24). | | `top[].seq` | integer | The sale's sequence number (for `/api/sales/parties` and `/api/sales/{seq}/tx`). | | `top[].t` | integer | Sale time, unix seconds. | | `top[].h` | integer | Block height. | | `top[].row` | string | The sale's own row. | | `top[].u` | number \| null | Unit price: premium per sat of the row. | | `top[].c` | integer | Sats of the row sold. | | `top[].p` | integer \| null | Price in sats. | | `top[].n` | integer | Tracked sats the sale sold. | | `top[].s` | integer \| null | The first special sat sold. | | `top[].sh` | integer \| null | The block that mined it. | | `top[].sd` | integer \| null | The day it was mined (UTC days since 1970). | | `top[].m` | string | Marketplace id (`magisat`, `magic_eden`, `unknown`, …). | | `top[].x` | string \| null | The txid's first 8 and last 4 hex digits. | | `top[].ins` | boolean | An inscription was on a sold sat. | | `top[].out` | boolean | A statistical outlier. | | `top[].secondary` | map of integer | A sale's secondary rows: the market rows of the other sats it sold (not its own row, a row it is a sub-category of, or a label row), each with its sats, primary-first: `{"black_omega": 1}` for a black omega sold with an alpha uncommon; `{}` for none. A sale never counts in its secondary rows' markets (the primary-sat rule, in Concepts). | | `top[].seller` | string \| null | The seller's address. | | `top[].buyer` | string \| null | The buyer's address. | | `latest` | array of object | The latest sales (up to 10). | | `latest[].seq` | integer | The sale's sequence number (for `/api/sales/parties` and `/api/sales/{seq}/tx`). | | `latest[].t` | integer | Sale time, unix seconds. | | `latest[].h` | integer | Block height. | | `latest[].row` | string | The sale's own row. | | `latest[].u` | number \| null | Unit price: premium per sat of the row. | | `latest[].c` | integer | Sats of the row sold. | | `latest[].p` | integer \| null | Price in sats. | | `latest[].n` | integer | Tracked sats the sale sold. | | `latest[].s` | integer \| null | The first special sat sold. | | `latest[].sh` | integer \| null | The block that mined it. | | `latest[].sd` | integer \| null | The day it was mined (UTC days since 1970). | | `latest[].m` | string | Marketplace id (`magisat`, `magic_eden`, `unknown`, …). | | `latest[].x` | string \| null | The txid's first 8 and last 4 hex digits. | | `latest[].ins` | boolean | An inscription was on a sold sat. | | `latest[].out` | boolean | A statistical outlier. | | `latest[].secondary` | map of integer | A sale's secondary rows: the market rows of the other sats it sold (not its own row, a row it is a sub-category of, or a label row), each with its sats, primary-first: `{"black_omega": 1}` for a black omega sold with an alpha uncommon; `{}` for none. A sale never counts in its secondary rows' markets (the primary-sat rule, in Concepts). | | `latest[].seller` | string \| null | The seller's address. | | `latest[].buyer` | string \| null | The buyer's address. | | `error` | string \| null | The sales index's last error, if any. | | `pending` | array of object | The latest sales still in the mempool (at most 10), newest first: beside `latest`, in no total, row or top. | | `pending[].seq` | null | No sequence number until it confirms. | | `pending[].t` | integer \| null | `first_seen`: sorted by time, it leads the confirmed sales. | | `pending[].h` | null | No block yet. | | `pending[].row` | string | The sale's own row. | | `pending[].u` | number \| null | Unit price: premium per sat of the row. | | `pending[].c` | integer | Sats of the row sold. | | `pending[].p` | integer \| null | Price in sats. | | `pending[].n` | integer | Tracked sats the sale sold. | | `pending[].s` | integer \| null | The first special sat sold. | | `pending[].sh` | integer \| null | The block that mined it. | | `pending[].sd` | integer \| null | The day it was mined (UTC days since 1970). | | `pending[].m` | string | Marketplace id (`magisat`, `magic_eden`, `unknown`, …). | | `pending[].x` | string \| null | The txid's first 8 and last 4 hex digits. | | `pending[].ins` | boolean | An inscription was on a sold sat. | | `pending[].out` | boolean | Always false: a pending sale is judged by no median. | | `pending[].secondary` | map of integer | A sale's secondary rows: the market rows of the other sats it sold (not its own row, a row it is a sub-category of, or a label row), each with its sats, primary-first: `{"black_omega": 1}` for a black omega sold with an alpha uncommon; `{}` for none. A sale never counts in its secondary rows' markets (the primary-sat rule, in Concepts). | | `pending[].seller` | string \| null | The seller's address. | | `pending[].buyer` | string \| null | The buyer's address. | | `pending[].key` | string | `txid:vin`: its identity. The confirmed sale has the same txid and vin and replaces it. | | `pending[].pending` | `true` | Always true: the sale is still in the mempool. | | `pending[].first_seen` | integer \| null | When the transaction was first seen in the mempool (bitcoind's entry time), unix seconds. | | `pending[].fee_rate` | number \| null | Its fee rate, sat/vB (one decimal). | | `pending[].txid` | string | The sale transaction, whole. | | `pending[].vin` | integer \| null | The seller's input. | | `pending[].seller_outpoint` | string \| null | The output it sells. | | `pending[].buyer_outpoint` | string \| null | The output the sold sats go to. | | `pending[].rows` | array of string | Its own row, then the rows of which it is a rarer sub-category. | | `pending[].led_rows` | array of string | The rows of its other sats (it is led by `row` there). | | `pending[].labels` | array of string | The label rows it lies in. | | `pending[].kind` | string \| null | The fork's sale kind (`listing`, `offer`, …). | #### Errors `{error}` (a message, no code): | Status | When | | --- | --- | | 400 | An unknown window, or a flag that is not 0 or 1. | Each sale counts once, as its own row, so the rows add up to the totals: a bundle counts under its primary row only, and names the rest of what it sold in `secondary` (`{"black_omega": 1}`). Inscribed sales and statistical outliers are left out unless asked for. `previous` and the rows' `prev_*` fields compare with the window before. `pending` lists the latest sales still in the mempool (at most 10, newest first, marked `pending: true` with `first_seen` and `fee_rate`). They count in no total, row, top list or `latest` until they confirm. --- Page: https://raresats.net/docs/get-sales.md # Sales `GET /api/sales` Every sale of tracked sats the index found, on every marketplace, oldest first, as compact columns (gzip when accepted). Follow new sales with `after=`. Addresses are left out: ask `/api/sales/parties`. Cacheable 30 s. With `years`, `month` or `luck`, only the sales that pass (the filter echoed as `filter`); 501 on a server without the filters, 503 (`retry: true`) while the mint days or the luck are still being read, 429 (`retry: true`, with `Retry-After`) past your network's budget of new filtered answers (a cached one is always served). Sales still in the mempool come apart, in `pending` (newest first, marked `pending: true` with `first_seen` and `fee_rate`); they never enter the columns, stats or markets. #### Query parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `row` | string | no | Only sales of this row. | | `group` | string | no | Only sales of this group's rows (e.g. `rarity`). Ignored with `row`. | | `after` | integer | no | Only sales after this sequence number (an answer's `seq`). A cursor at most 5,000 sales behind the newest is answered as asked; one further back is answered whole (`after: 0` in the answer: replace what you hold rather than append), and one past the newest as the newest. Default: `0`. (min 0) | | `sub` | string | no | With `row`: `1` adds its rarer sub-categories' sales, flagged 4. One of: `0`, `1`. Default: `0`. | | `led` | string | no | With `row` or `group`: `1` adds the sales led by another row that sold sats of these rows (their secondary rows), flagged 8, outside every market and median. One of: `0`, `1`. Default: `0`. | | `years` | string | no | Only the sales of the row's sats mined in these years (comma-separated YYYY; `year` too): a sale passes when a sat of the row (of any row, without `row`) it sold was mined in one of them, as `/api/sales/query` and the collection's Activity match it. The Statistics' and a row detail's sales under a year filter. Years 2009 to this one (400 otherwise). | | `month` | string | no | Only the sales of each row's sats mined in this month (UTC, `YYYY-MM`, by each block's own time), matched on the dashboard: any rare-sat index. | | `blocks` | string | no | Only the sales of each row's sats mined in these blocks: heights `from-to`, inclusive (`1000-5000`), or one block (`9`), up to the tip (400 past it or for a malformed or reversed range), matched on the dashboard: any rare-sat index. | | `luck` | string | no | Only the sales of the row's sats mined in blocks of these lucks: `N` (N or more) or `N-M`, matched as `years` is. With `years`, both. | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/sales?row=uncommon&years=2009&month=2017-12&blocks=1000-5000&luck=10' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/sales?row=uncommon&years=2009&month=2017-12&blocks=1000-5000&luck=10'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/sales", params={"row": "uncommon", "years": "2009", "month": "2017-12", "blocks": "1000-5000", "luck": "10"}).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `available` | boolean | Whether the sales index is available. | | `generation` | integer | Sales generation: a reorg starts a new one; reload from `after=0`. | | `seq` | integer | The newest sale's sequence number: pass it as `after` to get only newer sales. | | `after` | integer | The `after` asked for. | | `count` | integer | Sales in this answer (the length of every column). | | `as_of` | object \| null | `{height, time}` of the sales. | | `rows` | array of string | Row ids, indexed by `r`, `o` and `w`. | | `markets` | array of string | Marketplace ids, indexed by `m`. | | `sub_count` | integer | With one market row: sales of its rarer sub-categories. | | `sub_rows` | map of integer | Those sales by their own row. | | `led_count` | integer | With one market row: sales led by another row that sold sats of it (it is one of their secondary rows). | | `led_rows` | map of integer | Those sales by their own row. | | `t` | array of integer | Sale time, unix seconds, as deltas: the first from 0, each next from the one before. | | `q` | array of integer | Sequence number, as deltas: the first from `after`. | | `u` | array of number \| null | Unit price under the row shown. | | `p` | array of integer \| null | Price in sats. | | `c` | array of integer | Sats of the row sold. | | `r` | array of integer | Index into `rows`: the row the sale is shown as. | | `m` | array of integer | Index into `markets`. | | `f` | array of integer | Flags: 1 inscribed, 2 outlier, 4 a rarer sub-category's sale (with `sub=1`), 8 a sale led by another row, shown under one of its secondary rows (with `led=1`), 16 the sale has secondary rows (an item in `w`). | | `md` | array of number \| null | The row's rolling median unit price at the sale (4 significant digits). | | `o` | array of integer \| null | With `sub=1` or `led=1`: index into `rows` of a sub-category's or a led sale's own row, else `null`. | | `s` | array of integer \| null | The first special sat sold. | | `sh` | array of integer \| null | The block that mined it. | | `sd` | array of integer \| null | The day it was mined (UTC days since 1970). | | `n` | array of integer | Tracked sats sold. | | `x` | array of string \| null | The txid's first 8 and last 4 hex digits (`/api/sales/{seq}/tx` redirects to the full one). | | `w` | array of array of integer | One item per sale flagged 16, in their order: its secondary rows, primary-first, as `[row index, sats, row index, sats, …]` (indexes into `rows`). | | `k` | array of integer | The exact types each sale sold: an index into `kinds`. | | `kinds` | array of array of integer | The kinds of sale `k` names, each `[type set index, sats, type set index, sats, …]` (indexes into `type_sets`): the sats it sold grouped by exact type combination, its own row's combination first, then rarest first. A piece longer than one sat (a block 9 chunk) counts under its first sat's types. `[]` when the fork typed none. | | `type_sets` | array of array of string | The exact type combinations `kinds` names, each in the badges' order (`["uncommon", "alpha"]`). | | `error` | string \| null | The sales index's last error, if any. | | `pending` | array of object | The selection's sales still in the mempool, newest first (the same `row`, `group`, `sub` and `led` rules). Never in the columns, `count`, `seq` or any market: they confirm as new sales. | | `pending[].seq` | null | No sequence number until it confirms. | | `pending[].t` | integer \| null | `first_seen`: sorted by time, it leads the confirmed sales. | | `pending[].h` | null | No block yet. | | `pending[].row` | string | The sale's own row. | | `pending[].u` | number \| null | Unit price: premium per sat of the row. | | `pending[].c` | integer | Sats of the row sold. | | `pending[].p` | integer \| null | Price in sats. | | `pending[].n` | integer | Tracked sats the sale sold. | | `pending[].s` | integer \| null | The first special sat sold. | | `pending[].sh` | integer \| null | The block that mined it. | | `pending[].sd` | integer \| null | The day it was mined (UTC days since 1970). | | `pending[].m` | string | Marketplace id (`magisat`, `magic_eden`, `unknown`, …). | | `pending[].x` | string \| null | The txid's first 8 and last 4 hex digits. | | `pending[].ins` | boolean | An inscription was on a sold sat. | | `pending[].out` | boolean | Always false: a pending sale is judged by no median. | | `pending[].secondary` | map of integer | A sale's secondary rows: the market rows of the other sats it sold (not its own row, a row it is a sub-category of, or a label row), each with its sats, primary-first: `{"black_omega": 1}` for a black omega sold with an alpha uncommon; `{}` for none. A sale never counts in its secondary rows' markets (the primary-sat rule, in Concepts). | | `pending[].seller` | string \| null | The seller's address. | | `pending[].buyer` | string \| null | The buyer's address. | | `pending[].key` | string | `txid:vin`: its identity. The confirmed sale has the same txid and vin and replaces it. | | `pending[].pending` | `true` | Always true: the sale is still in the mempool. | | `pending[].first_seen` | integer \| null | When the transaction was first seen in the mempool (bitcoind's entry time), unix seconds. | | `pending[].fee_rate` | number \| null | Its fee rate, sat/vB (one decimal). | | `pending[].txid` | string | The sale transaction, whole. | | `pending[].vin` | integer \| null | The seller's input. | | `pending[].seller_outpoint` | string \| null | The output it sells. | | `pending[].buyer_outpoint` | string \| null | The output the sold sats go to. | | `pending[].rows` | array of string | Its own row, then the rows of which it is a rarer sub-category. | | `pending[].led_rows` | array of string | The rows of its other sats (it is led by `row` there). | | `pending[].labels` | array of string | The label rows it lies in. | | `pending[].kind` | string \| null | The fork's sale kind (`listing`, `offer`, …). | #### Errors `{error}` (a message, no code): | Status | When | | --- | --- | | 400 | A malformed `after`, `sub`, `years` or `luck`. | | 404 | An unknown row or group. A retired row (`uncommon_2009`, `rare_2009`) answers 404 too, its `retired` field naming the row and years to ask for instead. | | 429 | Too many new filtered answers (each a pass over every sale) from your network (an IPv4 address or IPv6 /64), or from everyone at once: `retry: true`; try again after `Retry-After` seconds. Answers already built are served all the same. | | 501 | `years` or `luck` asked of a server without the sat filters. | | 503 | The mint days or the block luck are still being read (`retry: true`), or the luck is not available. | ## Reading the columns The answer is column-oriented: entry *i* of every column is sale *i*, oldest first. Decode it like this: ```js title="Decode" const data = await (await fetch('https://raresats.net/api/sales?row=uncommon')).json(); let time = 0; let seq = data.after; let w = 0; // the next item of `w` const pairs = list => Object.fromEntries(list.flatMap((v, j) => (j % 2 ? [] : [[data.rows[v], list[j + 1]]]))); const kind = list => list.flatMap((v, j) => (j % 2 ? [] : [{ types: data.type_sets[v], sats: list[j + 1] }])); const sales = data.t.map((dt, i) => { time += dt; seq += data.q[i]; return { seq, time, // unix seconds row: data.rows[data.r[i]], // the row it is shown as unit_price: data.u[i], // premium per sat of that row price: data.p[i], // sats count: data.c[i], // sats of the row sold marketplace: data.markets[data.m[i]], inscribed: (data.f[i] & 1) !== 0, outlier: (data.f[i] & 2) !== 0, sub_category: (data.f[i] & 4) !== 0, led: (data.f[i] & 8) !== 0, // led by another row (with led=1) median: data.md[i], // the row's rolling median at the time first_sat: data.s[i], mined_height: data.sh[i], sats_sold: data.n[i], txid_short: data.x[i], // first 8 + last 4 hex digits // The rows of the other sats it sold, primary-first ({black_omega: 1}). secondary: (data.f[i] & 16) ? pairs(data.w[w++]) : {}, // The sats it sold by exact type combination ([{types: ['uncommon', 'alpha'], sats: 1}]). types: kind(data.kinds[data.k[i]]) }; }); ``` - **Follow new sales:** keep `seq` and ask `?after=` (same `row`/`group`) on the next block; append what comes back. If `generation` changes (a reorg), reload from `after=0`. A cursor more than 5,000 sales behind the newest is answered whole: the answer's `after` is then `0`, and replaces what you hold. One past the newest is answered as the newest (nothing new). - **Secondary rows:** a bundle is shown as its primary row (the [primary-sat rule](https://raresats.net/docs/concepts.md#rows-collections)); a sale flagged 16 sold sats of other rows too, named by its item in `w`. With `led=1`, a row's view also lists the sales led by another row that it is a secondary row of, flagged 8 (with their own row in `o`), never in its median or market. - **Exact types:** a row's sales are not all alike (an alpha uncommon sold with a plain uncommon is an Uncommon sale). `k` names the type combinations each sale sold, its own row's first, through the small `kinds` and `type_sets` tables. - **Addresses** are not in the columns: ask [Sale parties](https://raresats.net/docs/get-sales-parties.md) for the rows you show. - **The full txid** is a redirect away: `/api/sales/{seq}/tx` (to the [transaction page](https://raresats.net/docs/get-tx.md); `{txid, href}` with `accept: application/json`). - A sale's *price* is the marketplace's: on Magisat the listing price; elsewhere the seller's payment plus attributable marketplace fees. Its *unit price* is the premium (price less the sold output's value) per sat of its row. To filter by mint year, mint day, birthday (month-day) or a rarer sub-category, newest first and paged, use [Query sales](https://raresats.net/docs/get-sales-query.md); see [Filtering & birthday sats](https://raresats.net/docs/guide-filters.md). ## Pending sales `pending` lists the selection's sales still in the mempool, newest first, under the same `row`, `group`, `sub` and `led` rules. Each has the [market overview](https://raresats.net/docs/get-market.md)'s sale fields (`row`, `u`, `p`, `n`, `s`, `secondary`, `seller`, `buyer`, …), placed among the rows exactly as a confirmed sale, plus `key` (`txid:vin`), the whole `txid`, `vin`, `first_seen` and `fee_rate`; `seq` and `h` are null. They are never in the columns, `count` or `seq`, and never in a median, volume or stat: once mined, the same sale (same txid and vin) arrives in the columns and the pending one is gone. See [Pending](https://raresats.net/docs/site-feeds.md#pending-the-mempool). --- Page: https://raresats.net/docs/get-sales-query.md # Query sales `GET /api/sales/query` Sales on every marketplace, newest first, filtered by row, rarer sub-category, mint years, a mint month, a mint day or a month-day (birthday sats), and sat luck; with facets. Page with `before` = the previous answer's `next`. Cacheable 30 s. #### Query parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `row` | string | no | Only this row (collection). None: every row. | | `sub` | string | no | A rarer sub-category of `row` (e.g. `pizza_uncommon` under `uncommon`): only items holding sats of it. Implies `subs=1`. | | `subs` | string | no | `1` includes the row's sub-category items (sold or listed under their own rows). One of: `0`, `1`. Default: `0`. | | `led` | string | no | With `row`: `1` adds the sales led by another row that sold sats of it (every other row a sale sold sats of is one of its secondary rows), each with `led: true`. A collection's Activity asks `subs=1&led=1`: every sale that sold a sat of the row. One of: `0`, `1`. Default: `0`. | | `years` | string | no | Mint years, comma-separated (`2009,2010`; up to 40): items holding a sat mined in one of them. Each must be a year sats were mined in, 2009 to this one (400 otherwise); repeats and order do not matter. `year` is accepted too. | | `month` | string | no | A mint month, `YYYY-MM` (UTC, by each block's own time): items holding a sat mined that month. Combines with the other filters. | | `blocks` | string | no | A block range: heights `from-to`, inclusive (`1000-5000`), or one block (`9`): items holding a sat mined in those blocks. Up to the tip (400 past it or for a malformed or reversed range). Combines with the other filters. | | `date` | string | no | A mint day, `YYYY-MM-DD` (UTC), 2009 to this year: items holding a sat mined that day. Exclusive with `md`. | | `md` | string | no | A month-day, `MM-DD`: items holding a sat mined that day of any year (with `years`, of those years). Birthday sats. | | `luck` | string | no | Sat luck, a minimum (`8`: luck 8 or more) or an inclusive range (`8-12`), 0 to 254: items holding a sat of that luck. A sat's luck is its block's: the leading zero bits of the block hash less those of the block's target. Combines with the other filters (the matched sat passes them all). 503 for the few seconds after a first start while the luck of every block is read. | | `limit` | integer | no | Page size, 1-200. Default: `50`. (min 1, max 200) | | `before` | integer | no | A sequence number: only older sales (the previous page's `next`). (min 1) | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/sales/query?row=uncommon&years=2009&month=2017-12&blocks=1000-5000&luck=8&limit=2' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/sales/query?row=uncommon&years=2009&month=2017-12&blocks=1000-5000&luck=8&limit=2'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/sales/query", params={"row": "uncommon", "years": "2009", "month": "2017-12", "blocks": "1000-5000", "luck": "8", "limit": 2}).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `available` | boolean | Whether the sales index is available. | | `generation` | integer | Sales generation (a reorg starts a new one). | | `seq` | integer | The newest sale's sequence number. | | `as_of` | object \| null | `{height, time}` of the sales. | | `row` | string \| null | The row asked for. | | `filter` | object | The filter as the server understood it. | | `filter.years` | array of integer | The years asked for (ascending), `[]` for none. | | `filter.month` | string \| null | The month asked for (`YYYY-MM`), `null` for none. | | `filter.date` | string \| null | The day asked for. | | `filter.md` | string \| null | The month-day asked for. | | `filter.blocks` | object \| null | The blocks asked for: `{from, to}`, heights inclusive (one block: `from` = `to`), `null` for none. | | `filter.blocks.from` | integer | | | `filter.blocks.to` | integer | | | `filter.luck` | object \| null | The luck asked for: `{min, max}` (`max` `null` for a minimum alone), `null` for none. | | `filter.luck.min` | integer | | | `filter.luck.max` | integer \| null | | | `filter.sub` | string \| null | The sub-category asked for. | | `filter.subs` | boolean | Whether sub-category items are included (absent on events and counts). | | `filter.led` | boolean | Whether sales led by another row are included (`led=1`; sales only). | | `total` | integer | Matching sales (all pages). | | `kinds` | object | Of `total`, how the sales are under `row`: `own` (its own sales, the only ones in its stats and line), `sub` (a rarer sub-category's, with `subs=1`) and `led` (led by another row, with `led=1`). Without `row`, every sale is `own`. | | `kinds.own` | integer | | | `kinds.sub` | integer | | | `kinds.led` | integer | | | `facets` | object | Counts for refining the filter. | | `facets.years` | map of integer | Matching items per mint year (each item counted once per year it holds). | | `facets.luck` | map of integer | Luck minimums: `{N: items holding a sat of luck N or more}` from N = 1 up to the highest, counting the filter but its `luck` (what `luck=N` would match). `{}` while the luck table is being read. | | `facets.subs` | array of object | The row's sub-categories, in the index's order (`[]` without a row). | | `facets.subs[].row` | string | A sub-category row. | | `facets.subs[].count` | integer | Matching items holding it. | | `facets.rows` | map of integer | Without `row`: matching items per own row. | | `facets.types` | object | `/api/sales/query` with a `row` only: the row's sales that pass the filter (its sub-categories' sales too, whatever `subs` says; never those another row led) by kind, the set of exact type combinations each sold. A plain uncommon, an alpha uncommon and an alpha uncommon sold with a black omega are three kinds: the rarer ones show what skews the row's prices. | | `facets.types.kinds` | array of object | The 12 commonest kinds, by sales, most first. | | `facets.types.kinds[].types` | array of array of string | The type combinations its sales sold, each in the badges' order, the lead row's first (`[["uncommon", "alpha"], ["black", "omega"]]`). `[]` for sales the fork typed none of. | | `facets.types.kinds[].row` | string | The own row of its first sale counted: a sub-category of `row` (the `sub` that filters to it) or `row` itself. | | `facets.types.kinds[].count` | integer | Its sales. | | `facets.types.kinds[].priced` | integer | Those the median is of: inscribed, outlier and free sales aside. | | `facets.types.kinds[].median` | number \| null | Their median unit price per sat of `row`, to the hundredth of a sat; `null` with none priced. | | `facets.types.other` | object | | | `facets.types.other.kinds` | integer | Kinds not listed. | | `facets.types.other.count` | integer | Their sales. | | `sales` | array of object | One page, newest first. | | `sales[].seq` | integer | The sale's sequence number. | | `sales[].time` | integer | Unix seconds. | | `sales[].height` | integer | Block height. | | `sales[].row` | string | The row it is shown as (its own, or one it is a sub-category of or a secondary row of). | | `sales[].own` | string | Its own row. | | `sales[].sub` | boolean | A sub-category sale shown under `row`. | | `sales[].led` | boolean | A sale led by another row, shown under `row`, one of its secondary rows (with `led=1`). | | `sales[].secondary` | map of integer | A sale's secondary rows: the market rows of the other sats it sold (not its own row, a row it is a sub-category of, or a label row), each with its sats, primary-first: `{"black_omega": 1}` for a black omega sold with an alpha uncommon; `{}` for none. A sale never counts in its secondary rows' markets (the primary-sat rule, in Concepts). | | `sales[].types` | array of [TypeGroup](https://raresats.net/docs/listing-object.md) | The sats it sold grouped by exact type combination: the group of its own row's sat first, then rarest first (an alpha uncommon sold with a black omega: `uncommon, alpha` then `black, omega`). A piece longer than one sat (a block 9 chunk) counts under its first sat's types. `[]` when the fork typed none. | | `sales[].unit` | number \| null | Unit price per sat of `row`. | | `sales[].count` | integer | Sats of `row` sold. | | `sales[].price` | integer \| null | Price in sats. | | `sales[].sats` | integer | Tracked sats sold. | | `sales[].sat` | integer \| null | The first special sat sold. | | `sales[].sat_height` | integer \| null | The block that mined it. | | `sales[].mined` | string \| null | Its mint day, YYYY-MM-DD. | | `sales[].match` | object \| null | The sat that made the item pass a date or luck filter: its number, block, mint day (UTC) and luck. `null` without such a filter. | | `sales[].match.sat` | integer | The sat. | | `sales[].match.height` | integer | The block that mined it. | | `sales[].match.date` | string \| null | Its mint day, YYYY-MM-DD. | | `sales[].match.luck` | integer \| null | Its luck (its block's: the leading zero bits of the block hash less the target's); `null` while the luck table is being read. | | `sales[].market` | string | Marketplace id. | | `sales[].tx` | string \| null | The txid's first 8 and last 4 hex digits (`/api/sales/{seq}/tx` redirects). | | `sales[].inscribed` | boolean | An inscription was on a sold sat. | | `sales[].outlier` | boolean | A statistical outlier. | | `sales[].seller` | string \| null | The seller's address. | | `sales[].buyer` | string \| null | The buyer's address. | | `next` | integer \| null | Pass as `before` for the next page; `null` on the last. | | `ms` | integer | Server time spent, ms. | #### Errors `{error}` (a message, no code): | Status | When | | --- | --- | | 400 | A malformed parameter (e.g. a `date` that is no date, `date` with `md`). | | 404 | An unknown `row` or `sub`. A retired row (`uncommon_2009`, `rare_2009`) answers 404 too, its `retired` field naming the row and years to ask for instead. | | 429 | Too many new filtered answers (each a pass over every sale) from your network (an IPv4 address or IPv6 /64), or from everyone at once: `retry: true`; try again after `Retry-After` seconds. Answers already built are served all the same. | | 503 | Mint dates or sat luck are still being read (`retry: true`): try again in a few seconds. Without `retry`: this server has no sat luck for its chain. | Each sale names its secondary rows (`secondary`: every other row it sold sats of, with the fork's count of each); with `row` and `led=1` the answer also lists the sales led by another row that sold sats of it (`led: true`), and with `subs=1` its rarer sub-categories' (`sub: true`). `kinds` counts the sales by which of these they are; only `own` sales are in the row's stats. A collection's Activity asks `subs=1&led=1`: every sale that sold a sat of the row. Each sale also names the exact types of the sats it sold (`types`, grouped by type combination, its own row's sat first), so an alpha uncommon or a pizza uncommon among a row's sales is told from a plain one. With a `row`, `facets.types` breaks the row's sales down by kind (the set of type combinations each sold) with each kind's sales and median unit price: the rarer kinds are what lift a row's prices. A kind's `row` is the sub-category (`sub`) that filters to it. How the filters combine, and examples: [Filtering & birthday sats](https://raresats.net/docs/guide-filters.md). For every sale of a row as compact columns, unfiltered, use [Sales](https://raresats.net/docs/get-sales.md). --- Page: https://raresats.net/docs/get-sales-parties.md # Sale parties `GET /api/sales/parties` The seller's and buyer's addresses of up to 500 sales by sequence number (`null`: a script with no address). Cacheable 30 s. #### Query parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `seqs` | string | yes | 1-500 sequence numbers, comma-separated. | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/sales/parties?seqs=1,2' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/sales/parties?seqs=1,2'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/sales/parties", params={"seqs": "1,2"}).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `generation` | integer | Sales generation the sequence numbers belong to. | | `parties` | map of object | Keyed by sequence number. Unknown sequence numbers are absent. | | `parties..seller` | string \| null | An address, or `null` (a script with no address). | | `parties..buyer` | string \| null | An address, or `null` (a script with no address). | ```json title="Response" { "generation": 1, "parties": { "1": { "seller": "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu", "buyer": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr" }, "2": { "seller": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "buyer": null } } } ``` #### Errors `{error}` (a message, no code): | Status | When | | --- | --- | | 400 | Missing, too many or malformed sequence numbers. | Sequence numbers belong to a `generation` of the sales; after a reorg (a new generation) ask again. --- Page: https://raresats.net/docs/get-sales-stats.md # Sales stats ## Every row's market `GET /api/sales/stats` Per row: the last unit price, the 30-day median unit price, volume and sales, and all-time sales. With `years`, `month` or `luck`, every row's market over only the sales of its sats of those years, that month or those lucks (matched as `/api/sales` matches them; the sales' outlier verdicts are their row's), and `filter` echoed: the Statistics' sales columns under a year, month or luck filter, and a collection's head under its Activity filter. Confirmed sales only: pending (mempool) sales never count. #### Query parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `years` | string | no | Only the sales of each row's sats mined in these years (comma-separated YYYY, 2009 to this year; `year` too). | | `month` | string | no | Only the sales of each row's sats mined in this month (UTC, `YYYY-MM`, by each block's own time), matched on the dashboard: any rare-sat index. | | `blocks` | string | no | Only the sales of each row's sats mined in these blocks: heights `from-to`, inclusive (`1000-5000`), or one block (`9`), up to the tip (400 past it or for a malformed or reversed range), matched on the dashboard: any rare-sat index. | | `luck` | string | no | Only the sales of each row's sats mined in blocks of these lucks: `N` (N or more) or `N-M`. | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/sales/stats?years=2009&month=2017-12&blocks=1000-5000&luck=10' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/sales/stats?years=2009&month=2017-12&blocks=1000-5000&luck=10'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/sales/stats", params={"years": "2009", "month": "2017-12", "blocks": "1000-5000", "luck": "10"}).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `available` | boolean | Whether the sales index is available. | | `generation` | integer | Sales generation. | | `seq` | integer | The newest sale's sequence number. | | `count` | integer | Sales held. | | `as_of` | object \| null | `{height, time}`. | | `first_time` | integer \| null | The first sale, unix seconds. | | `stats` | map of object | Per row. | | `stats..last_unit_price` | number \| null | The last sale's unit price (outliers excluded). | | `stats..last_time` | integer \| null | Its time. | | `stats..median_30d` | number \| null | 30-day median unit price. | | `stats..volume_30d` | integer | 30-day volume in sats. | | `stats..volume_all` | integer | All-time volume in sats (inscribed sales aside). | | `stats..sales_30d` | integer | 30-day sales. | | `stats..sales` | integer | Sales in all. | #### Errors `{error}` (a message, no code): | Status | When | | --- | --- | | 400 | A malformed `years`, `month` or `luck`. | | 429 | Too many new filtered answers (each a pass over every sale) from your network (an IPv4 address or IPv6 /64), or from everyone at once: `retry: true`; try again after `Retry-After` seconds. Answers already built are served all the same. | | 501 | `years` or `luck` asked of a server without the sat filters. | | 503 | The mint days or the block luck are still being read (`retry: true`), or the luck is not available. | With `?years=2009`, `?month=2017-12`, `?blocks=1000-5000` or `?luck=10` (or several), every row's market is over only the sales of its own sats mined in those years, in that month, in those blocks (heights, inclusive), or in blocks of those lucks: a sale counts under its row when a sat of that row it sold passes, as the collection's Activity filters it (`/api/sales/query?row=…&years=…`). The sales keep their row's outlier verdicts. ## A sale's transaction `GET /api/sales/{seq}/tx` A 302 redirect to the sale's transaction page, `/tx/{txid}` (the compact sales carry only part of the txid). With `accept: application/json`, `{txid, href}` instead. #### Path parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `seq` | integer | yes | The sale's sequence number. (min 0) | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/sales/1/tx' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/sales/1/tx'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/sales/1/tx").json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `txid` | string | | | `href` | string | `/tx/{txid}`. | ```json title="Response" { "txid": "d78f3f29369db39032219ae561732dda47b54b6f6d4d93d72a22c257c3946ac2", "href": "/tx/d78f3f29369db39032219ae561732dda47b54b6f6d4d93d72a22c257c3946ac2" } ``` #### Response `302` Redirect; `location` is `/tx/{txid}`. #### Errors `{error}` (a message, no code): | Status | When | | --- | --- | | 404 | No such sale. | To turn sats into dollars at the time of a sale, use the daily BTC/USD history of [Prices](https://raresats.net/docs/get-prices.md). Stats are of confirmed sales only: a sale still in the mempool (`pending` in [Sales](https://raresats.net/docs/get-sales.md)) never counts. --- Page: https://raresats.net/docs/get-sat.md # Sat `GET /api/sat/{n}` One sat: the block that mined it, its types and rows, inscriptions, where it is now, every sale of it (newest first) and the marketplace's live listing of its UTXO. Cacheable 15 s. `pending` says where a transaction still in the mempool takes it; its pending sales lead `sales`, marked `pending`. #### Path parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `n` | integer | yes | The sat number, a decimal integer below 2,099,999,997,690,000. (min 0) | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/sat/45100023043' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/sat/45100023043'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/sat/45100023043").json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `sat` | integer | The sat number. | | `as_of` | object \| null | `{height, time}` of the index. | | `explorer` | string \| null | Block explorer base URL (`https://mempool.space`). | | `height` | integer | The block that mined it. | | `block_time` | integer \| null | That block's time, unix seconds. | | `luck` | integer \| null | Its luck, its block's: how many more leading zero bits the block hash has than its difficulty target required (every sat of a block shares it). `null` while the luck table is being read. | | `tracked` | boolean | Whether the rare-sat index tracks it (location and sales are known only then). | | `types` | array of string | Its sat types. | | `rows` | array of string | Its rows. | | `inscribed` | boolean | Whether it carries inscriptions. | | `inscriptions` | integer | How many. | | `location` | object \| null | Where it is now: `{kind: unspent\|burned\|lost\|overwritten, satpoint, outpoint, offset, output}`. `output` is the UTXO holding it (`outpoint`, `value`, `address`, `quantity`, `groups`, `ranges`, `rows` (the fork's sats per row), …), `null` for a lost sat. Its `output` carries `spent_by_pending` (a `PendingSpend`) while a pending transaction spends it. | | `sales` | array of object | Every sale of it, newest first. | | `sales[].seq` | integer \| null | The sales store's sequence number, else `null`. | | `sales[].txid` | string | The sale transaction. | | `sales[].vin` | integer \| null | The seller input. | | `sales[].height` | integer \| null | Block height (null while pending). | | `sales[].time` | integer | Unix seconds. | | `sales[].marketplace` | string | Marketplace id. | | `sales[].price` | integer \| null | Price in sats. | | `sales[].row` | string \| null | The sale's own row. | | `sales[].unit_price` | number \| null | Unit price per sat of that row. | | `sales[].count` | integer \| null | Sats of that row sold. | | `sales[].sats_sold` | integer \| null | Tracked sats the sale sold. | | `sales[].seller` | object \| null | `{address, outpoint}`. | | `sales[].buyer` | object \| null | `{address, outpoint}`. | | `sales[].inscribed` | boolean | An inscription was on a sold sat. | | `sales[].outlier` | boolean \| null | A statistical outlier (`null` when unknown). | | `sales[].secondary` | map of integer | A sale's secondary rows: the market rows of the other sats it sold (not its own row, a row it is a sub-category of, or a label row), each with its sats, primary-first: `{"black_omega": 1}` for a black omega sold with an alpha uncommon; `{}` for none. A sale never counts in its secondary rows' markets (the primary-sat rule, in Concepts). | | `sales[].as` | array of string | The sat's rows (`rows`, in its order) the sale sold sats of: why a sale another row led is listed on this sat (a Block 9 chunk on its palindrome's page names `block9`, `block9_palindrome`, `nakamoto_palindrome`). | | `sales[].pending` | boolean | Still in the mempool: listed first, with `first_seen` and `fee_rate`; `time` is `first_seen`. | | `sales[].first_seen` | integer \| null | When the transaction was first seen in the mempool (bitcoind's entry time), unix seconds. | | `sales[].fee_rate` | number \| null | Its fee rate, sat/vB (one decimal). | | `listing` | [Listing](https://raresats.net/docs/listing-object.md) \| null | The marketplace's live (active or pending) listing of its UTXO, else `null`. | | `pending` | object \| null | Where transactions still in the mempool take it (the end of a chain of them), else null: `{txid, first_seen, fee_rate, kind: unspent\|burned\|fee, satpoint, outpoint, offset, output, url}`; `output` is the pending output (a `PendingOutput`), null when it goes to fees. `location` stays the confirmed one, its output carrying `spent_by_pending`. Present only while the index serves its mempool overlay. | #### Errors `{error}` (a message, no code): | Status | When | | --- | --- | | 400 | Not a sat number. | | 503 | The index is unavailable or busy. | - A sat the index does not track still gets its types and rows, with no location or sales. - `luck` is its block's: how many more leading zero bits the block hash has than its difficulty target required ([Sat luck](https://raresats.net/docs/guide-filters.md#sat-luck)). - `listing` is the marketplace's live listing of the UTXO the sat is in (a [Listing](https://raresats.net/docs/listing-object.md)), or `null`. - Each sale in `sales` carries the Sales tab's `seq`, row, unit price and count when the sales index has it, and its `secondary` rows: the other sats sold with it (a black omega sold with this alpha uncommon). - `pending` says where a transaction still in the mempool takes the sat (`{txid, first_seen, fee_rate, kind, satpoint, outpoint, offset, output}`; `kind` is `fee` when it goes to the miner), else `null`. `location` stays the confirmed one; its output carries `spent_by_pending`. The sat's pending sales lead `sales`, with `pending: true`, `first_seen` and `fee_rate` and no `height`. --- Page: https://raresats.net/docs/get-utxo.md # UTXO `GET /api/utxo/{outpoint}` One output and every rare sat in it: the sats raresats.net tracks, each with its offset in the output; the rare and exotic sats it does not track, found in the output's sat ranges; inscriptions and runes; for a spent output, where it was spent and where its tracked sats are now; its sales and the marketplace's live listing of it. Cacheable 15 s. A spend still in the mempool is one `spent_in` with `pending: true` (the index's word preferred to bitcoind's); an output a pending transaction makes has `pending`. #### Path parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `outpoint` | string | yes | The output as `txid:vout`. | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/utxo/f1c500f81c331d31415902755278e2a599311d8121ac9582bc63ad981efa48fb:1' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/utxo/f1c500f81c331d31415902755278e2a599311d8121ac9582bc63ad981efa48fb:1'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/utxo/f1c500f81c331d31415902755278e2a599311d8121ac9582bc63ad981efa48fb:1").json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `outpoint` | string | `txid:vout`. | | `txid` | string | | | `vout` | integer | | | `status` | string | `unknown` when this server has no way to tell (no bitcoind or ord index configured, or both unreachable). One of: `unspent`, `spent`, `unknown`. | | `spent` | boolean \| null | Whether it is spent (a spend in the mempool counts), `null` when unknown. | | `value` | integer \| null | Its value in sats. | | `address` | string \| null | Its address (`null` for a script without one). | | `script` | string \| null | Its scriptPubKey, hex. | | `kind` | string \| null | `spendable` or `unspendable`. | | `created_height` | integer \| null | The block that created it. | | `created_time` | integer \| null | That block's time, unix seconds. | | `confirmations` | integer \| null | Confirmations of the transaction that created it. | | `extracted` | boolean \| null | The index's extracted verdict (rare sats isolated in a small UTXO), when it tracks the output. | | `as_of` | object \| null | `{height, time}` of the index. | | `chain` | string \| null | The chain (`mainnet`). | | `explorer` | string \| null | Block explorer base URL. | | `sources` | object | `{fork, oracle, bitcoind, history}`, each `ok`, `off` (not configured) or `unavailable`: what this answer could ask. | | `contents` | string | `complete`: every sat range was typed; `tracked`: the tracked sats only; `recovered`: a spent output's tracked sats, from the moves the index keeps; `unknown`: nothing could be recovered. | | `tracked` | object \| null | | | `other` | object \| null | `null` without the ord index, or for a spent output. | | `inscriptions` | array of object \| null | `{id, sat, offset}`, `sat` and `offset` where known (`id` null when only the tracked sat is known to be inscribed). | | `inscriptions_total` | integer | | | `runes` | array of object \| null | `{name, amount, divisibility, symbol}`; `amount` a decimal string. | | `spent_in` | object \| null | `{txid, height, time, pending, source}`: the spending transaction (`pending` while in the mempool, then with `first_seen`, `fee_rate`, `vin`, `sale`); `source` says how it was found: `fork_mempool` (the index's mempool overlay, preferred), `mempool` (bitcoind's), `sale`, `moves` or `history`. One spender, whichever said so. | | `now` | array of object \| null | A spent output: where its tracked sats are now, up to 12: `{sat, len, types, kind, outpoint, offset, address, created_height, created_time}`. | | `now_total` | integer | | | `sales` | array of object | Sales out of the output (`direction: sold`) and into it (`bought`), newest first. | | `sales[].seq` | integer \| null | The sales store's sequence number, else `null`. | | `sales[].txid` | string | The sale transaction. | | `sales[].vin` | integer \| null | The seller input. | | `sales[].height` | integer \| null | Block height (null while pending). | | `sales[].time` | integer | Unix seconds. | | `sales[].marketplace` | string | Marketplace id. | | `sales[].price` | integer \| null | Price in sats. | | `sales[].row` | string \| null | The sale's own row. | | `sales[].unit_price` | number \| null | Unit price per sat of that row. | | `sales[].count` | integer \| null | Sats of that row sold. | | `sales[].sats_sold` | integer \| null | Tracked sats the sale sold. | | `sales[].seller` | object \| null | `{address, outpoint}`. | | `sales[].buyer` | object \| null | `{address, outpoint}`. | | `sales[].inscribed` | boolean | An inscription was on a sold sat. | | `sales[].outlier` | boolean \| null | A statistical outlier (`null` when unknown). | | `sales[].secondary` | map of integer | A sale's secondary rows: the market rows of the other sats it sold (not its own row, a row it is a sub-category of, or a label row), each with its sats, primary-first: `{"black_omega": 1}` for a black omega sold with an alpha uncommon; `{}` for none. A sale never counts in its secondary rows' markets (the primary-sat rule, in Concepts). | | `sales[].as` | array of string | The sat's rows (`rows`, in its order) the sale sold sats of: why a sale another row led is listed on this sat (a Block 9 chunk on its palindrome's page names `block9`, `block9_palindrome`, `nakamoto_palindrome`). | | `sales[].pending` | boolean | Still in the mempool: listed first, with `first_seen` and `fee_rate`; `time` is `first_seen`. | | `sales[].first_seen` | integer \| null | When the transaction was first seen in the mempool (bitcoind's entry time), unix seconds. | | `sales[].fee_rate` | number \| null | Its fee rate, sat/vB (one decimal). | | `notes` | array of string | What could not be shown, and why. | | `listing` | [Listing](https://raresats.net/docs/listing-object.md) \| null | The marketplace's live listing of the output, else `null`. | | `pending` | object \| null | An output a transaction still in the mempool makes: `{txid, first_seen, fee_rate}`. Absent otherwise. | #### Errors `{error}` (a message, no code): | Status | When | | --- | --- | | 400 | Not an outpoint. | | 404 | No such output: its transaction is unknown, or has no output of that index. | | 503 | The index is unavailable or busy. | - `tracked` lists the sats raresats.net tracks, each sat (or run of a whole range such as block 9) with its `offset` in the output, so a wallet can split them out, and its `luck` (its block's: [Sat luck](https://raresats.net/docs/guide-filters.md#sat-luck)). - `other` lists the rare and exotic sats raresats.net does not track (vintage, pizza, Nakamoto, block 78/286/666, jpeg, hitman, Silk Road, plain alphas and omegas, palindromes, …), grouped by exact type combination with counts and a few sample sats. It needs the ord index; a huge output is scanned only up to a budget (`truncated`). - A spent output has `spent_in` (the spending transaction, found in the mempool, a sale, the block moves the index keeps or the holder's history) and `now`: where each of its tracked sats is today. What a spent output held is recovered from the block moves the index keeps (the last 4,032 blocks); older ones answer `contents: "unknown"`, and `notes` says so. - `sources` and `notes` say what the answer could not ask: a server without bitcoind or the ord index shows only the tracked sats, and may answer `status: "unknown"` for an output it tracks nothing in. - `sales` are the sales out of and into the output, each with its own `row` and its `secondary` rows (the other sats sold with it). - `listing` is the marketplace's live listing of the output (a [Listing](https://raresats.net/docs/listing-object.md)), or `null`. - A spend still in the mempool is one `spent_in` with `pending: true`: the index's mempool overlay names it (`source: "fork_mempool"`, with `first_seen`, `fee_rate`, `vin` and `sale`), else bitcoind's mempool (`source: "mempool"`). An output a pending transaction makes has `pending: {txid, first_seen, fee_rate}`. --- Page: https://raresats.net/docs/get-tx.md # Transaction `GET /api/tx/{txid}` One transaction, confirmed or in the mempool, as the site's transaction page shows it: status (block, time, confirmations; or first seen and fee rate), fee, sizes, every input and output (address, value, the tracked rare sats each held), the flow of every tracked rare sat from the input that carried it to the output (or fee) it landed in, its sales and the Transfers and Extractions feeds' notable verdicts. A transaction confirmed before the index's moves retention shows only the tracked sats still in its outputs (`sat_flow: outputs`). Cacheable 15 s (a minute once 6 blocks deep). #### Path parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `txid` | string | yes | The transaction id, 64 hex digits. | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/tx/d78f3f29369db39032219ae561732dda47b54b6f6d4d93d72a22c257c3946ac2' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/tx/d78f3f29369db39032219ae561732dda47b54b6f6d4d93d72a22c257c3946ac2'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/tx/d78f3f29369db39032219ae561732dda47b54b6f6d4d93d72a22c257c3946ac2").json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `txid` | string | | | `status` | string | `pending`: in the mempool; `replaced`, `conflicted`, `evicted`: it left the mempool unconfirmed and bitcoind no longer knows it (nothing else is known then). One of: `confirmed`, `pending`, `replaced`, `conflicted`, `evicted`, `unknown`. | | `height` | integer \| null | The confirming block. | | `hash` | string \| null | | | `time` | integer \| null | That block's time, unix seconds. | | `confirmations` | integer \| null | As of the index's tip. | | `tx_index` | integer \| null | Its position in the block. | | `indexed` | boolean | The index holds its block. | | `first_seen` | integer \| null | Pending: when bitcoind first saw it, unix seconds. | | `removed` | object \| null | The index's record of its leaving the mempool (`status`, `replaced_by`, `at`). | | `version` | integer \| null | Its version. | | `locktime` | integer \| null | Its locktime. | | `size` | integer \| null | Bytes. | | `vsize` | integer \| null | Virtual bytes. | | `weight` | integer \| null | Weight units. | | `coinbase` | boolean | | | `input_value` | integer \| null | Sats; `null` for a coinbase or an input of unknown value. | | `output_value` | integer \| null | Sats. | | `fee` | integer \| null | Sats. | | `fee_rate` | number \| null | sat/vB, one decimal. | | `sat_flow` | string | `complete`: every tracked sat it moved is in `flow`; `outputs`: confirmed before the index's moves retention (`moves_from`), so only the tracked sats still in its unspent outputs are known; `unknown`: its block is not indexed yet. | | `tracked` | boolean | It moved (or minted) tracked sats. | | `marker` | boolean | It carries the marketplace's OP_RETURN marker. | | `raresats` | object \| null | A raresats.net sale: `{kind, count}`, `kind` `purchase` or `offer`. | | `kinds` | array of string | Its address-history kinds: `mined`, `sale`, `extraction`, `burn`, `consolidation`, `transfer`. | | `inputs` | array of object | | | `inputs[].vin` | integer | | | `inputs[].outpoint` | string \| null | The spent output, `txid:vout`; `null` for a coinbase. | | `inputs[].value` | integer \| null | The spent output's value in sats; `null` for a coinbase or when unknown. | | `inputs[].address` | string \| null | | | `inputs[].script_pubkey` | string \| null | The spent output's script, hex. | | `inputs[].sequence` | integer | | | `inputs[].created_height` | integer \| null | The block that created the spent output, when known. | | `inputs[].sighash` | integer \| null | Its first signature's sighash flag (131 is SINGLE\|ANYONECANPAY, a listing's). | | `inputs[].inscriptions` | integer | Inscription envelopes it reveals. | | `inputs[].tracked` | boolean | It spent a UTXO holding tracked sats. | | `inputs[].extracted` | boolean | That UTXO was extracted. | | `inputs[].pieces` | array of object | | | `inputs[].pieces[].offset` | integer | Its offset in the output (or the fee). | | `inputs[].pieces[].start` | integer | Its first sat. | | `inputs[].pieces[].len` | integer | How many sats from `start` on. | | `inputs[].pieces[].types` | array of string | | | `inputs[].pieces[].height` | integer \| null | The block that mined it. | | `inputs[].pieces[].date` | string \| null | That block's UTC day, `YYYY-MM-DD` (its birthday page). | | `inputs[].pieces[].luck` | integer \| null | That block's luck. | | `inputs[].inscribed_sats` | array of integer | | | `outputs` | array of object | | | `outputs[].vout` | integer | | | `outputs[].value` | integer | Sats. | | `outputs[].address` | string \| null | | | `outputs[].script_pubkey` | string | | | `outputs[].kind` | string | One of: `spendable`, `unspendable`. | | `outputs[].tracked` | boolean | | | `outputs[].extracted` | boolean | | | `outputs[].pieces` | array of object | | | `outputs[].pieces[].offset` | integer | Its offset in the output (or the fee). | | `outputs[].pieces[].start` | integer | Its first sat. | | `outputs[].pieces[].len` | integer | How many sats from `start` on. | | `outputs[].pieces[].types` | array of string | | | `outputs[].pieces[].height` | integer \| null | The block that mined it. | | `outputs[].pieces[].date` | string \| null | That block's UTC day, `YYYY-MM-DD` (its birthday page). | | `outputs[].pieces[].luck` | integer \| null | That block's luck. | | `outputs[].inscribed_sats` | array of integer | | | `outputs[].marker` | boolean | The marketplace's OP_RETURN marker. | | `outputs[].runestone` | boolean | An OP_RETURN OP_13 runestone. | | `outputs[].spent_by_pending` | object | The pending transaction spending it (tracked outputs only). | | `flow` | array of object | | | `flow[].source` | string | A spent UTXO (`vin`), newly mined sats (a coinbase's subsidy) or the block's fees (a coinbase). One of: `input`, `subsidy`, `fees`. | | `flow[].vin` | integer \| null | The input it came from (`source: input`). | | `flow[].input_offset` | integer \| null | Its offset in the spent output (or in the coinbase's input stream). | | `flow[].dest` | string | An output (`vout`), the fee (paid to the miner) or `lost` (a coinbase that did not claim it). One of: `output`, `fee`, `lost`. | | `flow[].vout` | integer \| null | The output it went to (`dest: output`). | | `flow[].offset` | integer \| null | Its offset in that output (or in the fee). | | `flow[].start` | integer | | | `flow[].len` | integer | | | `flow[].types` | array of string | | | `flow[].height` | integer \| null | The block that mined it. | | `flow[].date` | string \| null | That block's UTC day. | | `flow[].luck` | integer \| null | That block's luck. | | `fee_pieces` | array of object | | | `fee_pieces[].offset` | integer | Its offset in the output (or the fee). | | `fee_pieces[].start` | integer | Its first sat. | | `fee_pieces[].len` | integer | How many sats from `start` on. | | `fee_pieces[].types` | array of string | | | `fee_pieces[].height` | integer \| null | The block that mined it. | | `fee_pieces[].date` | string \| null | That block's UTC day, `YYYY-MM-DD` (its birthday page). | | `fee_pieces[].luck` | integer \| null | That block's luck. | | `mined_pieces` | array of object | | | `mined_pieces[].offset` | integer | Its offset in the output (or the fee). | | `mined_pieces[].start` | integer | Its first sat. | | `mined_pieces[].len` | integer | How many sats from `start` on. | | `mined_pieces[].types` | array of string | | | `mined_pieces[].height` | integer \| null | The block that mined it. | | `mined_pieces[].date` | string \| null | That block's UTC day, `YYYY-MM-DD` (its birthday page). | | `mined_pieces[].luck` | integer \| null | That block's luck. | | `lost_pieces` | array of object | | | `lost_pieces[].offset` | integer | Its offset in the output (or the fee). | | `lost_pieces[].start` | integer | Its first sat. | | `lost_pieces[].len` | integer | How many sats from `start` on. | | `lost_pieces[].types` | array of string | | | `lost_pieces[].height` | integer \| null | The block that mined it. | | `lost_pieces[].date` | string \| null | That block's UTC day, `YYYY-MM-DD` (its birthday page). | | `lost_pieces[].luck` | integer \| null | That block's luck. | | `rows` | object | Tracked sats it moved, per row. | | `sales` | array of object | Its sales, each with `pending`. | | `sales[].seq` | integer \| null | The sales store's sequence number, else `null`. | | `sales[].txid` | string | The sale transaction. | | `sales[].vin` | integer \| null | The seller input. | | `sales[].height` | integer \| null | Block height (null while pending). | | `sales[].time` | integer | Unix seconds. | | `sales[].marketplace` | string | Marketplace id. | | `sales[].price` | integer \| null | Price in sats. | | `sales[].row` | string \| null | The sale's own row. | | `sales[].unit_price` | number \| null | Unit price per sat of that row. | | `sales[].count` | integer \| null | Sats of that row sold. | | `sales[].sats_sold` | integer \| null | Tracked sats the sale sold. | | `sales[].seller` | object \| null | `{address, outpoint}`. | | `sales[].buyer` | object \| null | `{address, outpoint}`. | | `sales[].inscribed` | boolean | An inscription was on a sold sat. | | `sales[].outlier` | boolean \| null | A statistical outlier (`null` when unknown). | | `sales[].secondary` | map of integer | A sale's secondary rows: the market rows of the other sats it sold (not its own row, a row it is a sub-category of, or a label row), each with its sats, primary-first: `{"black_omega": 1}` for a black omega sold with an alpha uncommon; `{}` for none. A sale never counts in its secondary rows' markets (the primary-sat rule, in Concepts). | | `sales[].as` | array of string | The sat's rows (`rows`, in its order) the sale sold sats of: why a sale another row led is listed on this sat (a Block 9 chunk on its palindrome's page names `block9`, `block9_palindrome`, `nakamoto_palindrome`). | | `sales[].pending` | boolean | Still in the mempool: listed first, with `first_seen` and `fee_rate`; `time` is `first_seen`. | | `sales[].first_seen` | integer \| null | When the transaction was first seen in the mempool (bitcoind's entry time), unix seconds. | | `sales[].fee_rate` | number \| null | Its fee rate, sat/vB (one decimal). | | `transfer` | object \| null | Its notable transfer (the Transfers feed's event, with `reasons`), else `null`. | | `extractions` | array of object | Its notable extractions (the Extractions feed's events). | | `entries` | array of object | One address-history entry per party it moved tracked sats for. | | `depends` | array of string | Pending parents whose tracked outputs it spends. | | `moves_from` | integer \| null | The lowest block whose full sat flow the index keeps. | | `as_of` | object \| null | `{height, time}` of the index. | | `explorer` | string \| null | Block explorer base URL, for a secondary link. | ```json title="Response" { "txid": "d78f3f29369db39032219ae561732dda47b54b6f6d4d93d72a22c257c3946ac2", "status": "confirmed", "height": 969717, "hash": "00000000000000000001ffc80f14c6109df0c60ae45957f0f22e736914cfb276", "time": 1791024538, "confirmations": 11, "tx_index": 1408, "indexed": true, "first_seen": null, "removed": null, "version": 2, "locktime": 969671, "size": 11675, "vsize": 11675, "weight": 46700, "coinbase": false, "input_value": 3649149319, "output_value": 3649128183, "fee": 21136, "fee_rate": 1.8, "sat_flow": "complete", "tracked": true, "marker": false, "raresats": null, "kinds": ["transfer"], "inputs": [ { "vin": 0, "outpoint": "00d22e4e66b6e4de2e3f0fb3cb146df30c8d9b40845b103d5574741c1c7c778a:0", "value": 50000000, "address": "12DeZJEgWyd2ffYU6KekZdevGLqnkpnUU9", "script_pubkey": "76a9140d5c77dc25efd254a51e23ba422c4e6e8e2d25f588ac", "sequence": 4294967293, "created_height": 302976, "sighash": 1, "inscriptions": 0, "tracked": false, "extracted": false, "pieces": [], "inscribed_sats": [] }, { "vin": 2, "outpoint": "14833143ef7ebe658760f03f7533b50b9ebaa1ad87d85c597aaaa6ffe4b0b767:0", "value": 210587000, "address": "15vSCMDKkR3s7rDLNMnTrL7ivg8bqyRTSs", "script_pubkey": "76a91435fb94d60678093dce315bbd73dd4084582c93cb88ac", "sequence": 4294967293, "created_height": 709400, "sighash": 1, "inscriptions": 0, "tracked": true, "extracted": false, "pieces": [ { "offset": 97510060, "start": 1282092500000000, "len": 1, "types": ["uncommon", "alpha"], "height": 302837, "date": "2014-05-27", "luck": 1 } ], "inscribed_sats": [] } ], "outputs": [ { "vout": 0, "value": 3649128183, "address": "3EBzpPwBe8TBeuVzTGmKuJADQUniLKHSwy", "script_pubkey": "a914891ca628ea69a42f89db374f34c7ff4eff6fce9487", "kind": "spendable", "tracked": true, "extracted": false, "pieces": [ { "offset": 159380060, "start": 1282092500000000, "len": 1, "types": ["uncommon", "alpha"], "height": 302837, "date": "2014-05-27", "luck": 1 } ], "inscribed_sats": [], "marker": false, "runestone": false } ], "flow": [ { "source": "input", "vin": 2, "input_offset": 97510060, "dest": "output", "vout": 0, "offset": 159380060, "start": 1282092500000000, "len": 1, "types": ["uncommon", "alpha"], "height": 302837, "date": "2014-05-27", "luck": 1 } ], "fee_pieces": [], "mined_pieces": [], "lost_pieces": [], "rows": {"uncommon": 8, "black": 3, "alpha_uncommon": 8, "black_omega": 3}, "sales": [], "transfer": { "id": "transfer:969717:d78f3f29369db39032219ae561732dda47b54b6f6d4d93d72a22c257c3946ac2", "kind": "transfer", "height": 969717, "time": 1791024538, "block_hash": "00000000000000000001ffc80f14c6109df0c60ae45957f0f22e736914cfb276", "txid": "d78f3f29369db39032219ae561732dda47b54b6f6d4d93d72a22c257c3946ac2", "tx_index": 1408, "rows": {"uncommon": 8, "black": 3, "alpha_uncommon": 8, "black_omega": 3}, "moved": [ {"row": "uncommon", "label": "Uncommon", "count": 8}, {"row": "black", "label": "Black", "count": 3}, {"row": "alpha_uncommon", "label": "Alpha uncommon", "count": 8}, {"row": "black_omega", "label": "Black omega", "count": 3} ], "from": [ { "outpoint": "14833143ef7ebe658760f03f7533b50b9ebaa1ad87d85c597aaaa6ffe4b0b767:0", "address": "15vSCMDKkR3s7rDLNMnTrL7ivg8bqyRTSs", "label": "15vSCMDKkR3s7rDLNMnTrL7ivg8bqyRTSs", "value": 210587000, "sats": 1, "created_height": 709400, "created_time": 1636736136, "extracted": false } ], "from_count": 8, "to": [ { "vout": 0, "address": "3EBzpPwBe8TBeuVzTGmKuJADQUniLKHSwy", "label": "3EBzpPwBe8TBeuVzTGmKuJADQUniLKHSwy", "value": 3649128183, "sats": 11, "kind": "spendable", "extracted": false } ], "to_count": 1, "fee_sats": 0, "dormancy": { "seconds": 301043862, "outpoint": "f836de28ecaed8eea81acee25931c47e8dc136d2969d21f2ec81ac32375cdd04:1", "created_height": 458063, "created_time": 1489980676 }, "sats": [{"sat": 472424999999999, "height": 94484}], "sats_total": 11, "reasons": [ { "code": "dormant", "category": "dormant", "seconds": 301043862, "label": "Dormant 9.5 years" } ] }, "extractions": [], "entries": [ { "txid": "d78f3f29369db39032219ae561732dda47b54b6f6d4d93d72a22c257c3946ac2", "height": 969717, "time": 1791024538, "tx_index": 1408, "direction": "out", "kind": "transfer", "rows_in": {}, "rows_out": {"uncommon": 1, "alpha_uncommon": 1}, "sats_in": 0, "sats_out": 1, "value_in": 0, "value_out": 61700000, "outputs_in": 0, "inputs_out": 1, "extracted_in": false, "extracted_out": false, "extracted_sats": 0, "isolated": 0, "burned": 0, "roles": [], "counterparty": "3EBzpPwBe8TBeuVzTGmKuJADQUniLKHSwy", "counterparty_script_pubkey": "a914891ca628ea69a42f89db374f34c7ff4eff6fce9487", "counterparties": 1, "lead_sat": {"sat": 1739532500000000, "types": ["uncommon", "alpha"]}, "sales": [], "address": "1Hjv1dXPWvcZ2E7un5wv5tkCJ2oa9r4rj", "script_pubkey": "76a914032a7a69b345c6770a95be057d54af7c384c34b288ac" } ], "depends": [], "moves_from": 965696, "as_of": {"height": 969727, "time": 1791028507}, "explorer": "https://mempool.space" } ``` #### Errors `{error}` (a message, no code): | Status | When | | --- | --- | | 400 | Not a transaction id. | | 404 | No such transaction: neither the blockchain nor the mempool holds it. | | 503 | The index is unavailable, busy, or does not serve transactions yet. | - The site's own view of a transaction (the page at `/tx/{txid}`): what a block explorer shows, plus what the transaction did to the rare sats raresats.net tracks. - `flow` lists every run of tracked sats from the input that carried it (`vin`, its offset in the spent output) to the output it landed in (`vout`, its offset there), or the fee, under ordinal theory's first-in-first-out rule. Each run has its mint `date` (a [birthday page](https://raresats.net/docs/get-birthday.md)) and `luck` (its block's: [Sat luck](https://raresats.net/docs/guide-filters.md#sat-luck)). A coinbase's runs come from the block `subsidy` or the block's `fees`. - `inputs` and `outputs` hold every input and output, each with the tracked sats it held (`pieces`), `extracted`, its inscribed tracked sats; an input's revealed inscriptions and its signature's sighash (131, SINGLE|ANYONECANPAY, is a marketplace listing's); an output's OP_RETURN `marker` (a raresats.net sale) or `runestone`. - `sat_flow` says how much is known: `complete`; `outputs` for a transaction confirmed before the index's moves retention (`moves_from`, about four weeks), when only the tracked sats still in its unspent outputs are known; `unknown` while its block is being indexed. - `sales` are its sales (marketplace, price, unit price, seller and buyer); `raresats` is set for a raresats.net purchase or accepted offer. `transfer` and `extractions` are the Transfers and Extractions feeds' notable verdicts, with their `reasons`. - A pending transaction has `status: pending`, `first_seen` and `fee_rate`; one that left the mempool unconfirmed, and that bitcoind no longer knows, has `status` `replaced`, `conflicted` or `evicted` and `removed` (with `replaced_by`), and nothing else. - `GET /api/sales/{seq}/tx` redirects to `/tx/{txid}`; with `accept: application/json` it answers `{txid, href}`. --- Page: https://raresats.net/docs/get-address.md # Address `GET /api/address/{address}` An address's UTXOs that hold tracked sats, as its address page lists them: totals, the types it holds, and one page of UTXOs. Cacheable 30 s. A UTXO a pending transaction spends carries `spent_by_pending`; `pending_outputs` lists the outputs pending transactions send it. #### Path parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `address` | string | yes | A mainnet address. | #### Query parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `offset` | integer | no | UTXOs to skip. Default: `0`. (min 0) | | `limit` | integer | no | Page size, at most 5,000. Default: `500`. (min 1, max 5000) | | `sort` | string | no | A column, `-` first for descending: `rare`, `range`, `year`, `quantity`, `utxo`, `size`, `received`. Default: `range`. | | `types` | string | no | Comma-separated sat types; a UTXO must hold every one. | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/address/bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/address/bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/address/bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr").json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `address` | string | The address. | | `script_pubkey` | string | Its script, hex. | | `chain` | string | Network. | | `explorer` | string \| null | Block explorer base URL. | | `as_of` | object \| null | `{height, hash, time}` of the index. | | `totals` | object | | | `totals.utxos` | integer | UTXOs with tracked sats. | | `totals.sats` | integer | Tracked sats. | | `totals.value` | integer | Their value in sats. | | `rows` | map of integer | Row counts over every UTXO. | | `types` | array of object | Every type the address holds. | | `types[].type` | string | Sat type. | | `types[].utxos` | integer | UTXOs holding it. | | `types[].ranges` | integer | Contiguous runs. | | `types[].sats` | integer | Sats. | | `page` | object | | | `page.offset` | integer | Offset. | | `page.limit` | integer | Limit. | | `page.total` | integer | UTXOs matching `types`. | | `page.sort` | string | Sort. | | `page.types` | array of string | Type filter. | | `outputs` | array of object | One page of UTXOs. | | `outputs[].outpoint` | string | `txid:vout`. | | `outputs[].txid` | string | Transaction. | | `outputs[].vout` | integer | Output index. | | `outputs[].value` | integer \| null | Value in sats. | | `outputs[].kind` | string | `spendable` and so on. | | `outputs[].created_height` | integer \| null | The block that created it. | | `outputs[].created_time` | integer \| null | Its time. | | `outputs[].extracted` | boolean | Extracted (isolated rare sats). | | `outputs[].inscribed_sats` | array of integer | Inscribed tracked sats. | | `outputs[].quantity` | integer | Tracked sats. | | `outputs[].first` | integer \| null | Lowest tracked sat. | | `outputs[].last` | integer \| null | Highest tracked sat. | | `outputs[].ranges` | array of array of integer | Inclusive ranges. | | `outputs[].groups` | array of [TypeGroup](https://raresats.net/docs/listing-object.md) | Sats by exact type combination, rarest first. | | `outputs[].years` | map of integer | Sats per mint year. | | `outputs[].year` | integer \| null | The year holding the most sats. | | `outputs[].rows` | map of integer | Row counts. | | `outputs[].spent_by_pending` | object | A transaction still in the mempool that spends an output (`spent_by_pending`). Absent when nothing pending spends it. | | `outputs[].spent_by_pending.txid` | string | The spending transaction. | | `outputs[].spent_by_pending.vin` | integer \| null | Its input that spends the output. | | `outputs[].spent_by_pending.first_seen` | integer \| null | When the transaction was first seen in the mempool (bitcoind's entry time), unix seconds. | | `outputs[].spent_by_pending.fee_rate` | number \| null | Its fee rate, sat/vB (one decimal). | | `outputs[].spent_by_pending.sale` | boolean | Whether the spend sells this output (a pending sale with it as the seller's input). | | `outputs[].spent_by_pending.replaceable` | boolean | Always true (full RBF): the spend may still be replaced. | | `outputs[].split_counts` | map of integer | The sats an extract's `split` may cut, per exact type combination (types sorted, joined with `+`): the row's sats as `groups` counts them, without the tracked pieces that touch block 9 or 450x (a split leaves those whole). | | `pending_outputs` | array of object | Outputs pending transactions send it, newest first (rare sats being received); only while the index serves its mempool overlay. A UTXO being sent away carries `spent_by_pending`. `totals`, `rows` and `types` stay confirmed-only. | | `pending_outputs[].outpoint` | string | `txid:vout`. | | `pending_outputs[].txid` | string | Transaction. | | `pending_outputs[].vout` | integer | Output index. | | `pending_outputs[].value` | integer \| null | Value in sats. | | `pending_outputs[].kind` | string | `spendable` and so on. | | `pending_outputs[].created_height` | integer \| null | The block that created it. | | `pending_outputs[].created_time` | integer \| null | Its time. | | `pending_outputs[].extracted` | boolean | Extracted (isolated rare sats). | | `pending_outputs[].inscribed_sats` | array of integer | Inscribed tracked sats. | | `pending_outputs[].quantity` | integer | Tracked sats. | | `pending_outputs[].first` | integer \| null | Lowest tracked sat. | | `pending_outputs[].last` | integer \| null | Highest tracked sat. | | `pending_outputs[].ranges` | array of array of integer | Inclusive ranges. | | `pending_outputs[].groups` | array of [TypeGroup](https://raresats.net/docs/listing-object.md) | Sats by exact type combination, rarest first. | | `pending_outputs[].years` | map of integer | Sats per mint year. | | `pending_outputs[].year` | integer \| null | The year holding the most sats. | | `pending_outputs[].rows` | map of integer | Row counts. | | `pending_outputs[].pending` | `true` | | | `pending_outputs[].first_seen` | integer \| null | When the transaction was first seen in the mempool (bitcoind's entry time), unix seconds. | | `pending_outputs[].fee_rate` | number \| null | Its fee rate, sat/vB (one decimal). | | `pending_outputs[].address` | string \| null | Who receives it. | | `pending_outputs[].spent_by_pending` | object | A transaction still in the mempool that spends an output (`spent_by_pending`). Absent when nothing pending spends it. | | `pending_outputs[].spent_by_pending.txid` | string | The spending transaction. | | `pending_outputs[].spent_by_pending.vin` | integer \| null | Its input that spends the output. | | `pending_outputs[].spent_by_pending.first_seen` | integer \| null | When the transaction was first seen in the mempool (bitcoind's entry time), unix seconds. | | `pending_outputs[].spent_by_pending.fee_rate` | number \| null | Its fee rate, sat/vB (one decimal). | | `pending_outputs[].spent_by_pending.sale` | boolean | Whether the spend sells this output (a pending sale with it as the seller's input). | | `pending_outputs[].spent_by_pending.replaceable` | boolean | Always true (full RBF): the spend may still be replaced. | | `pending_outputs[].split_counts` | map of integer | The sats an extract's `split` may cut, per exact type combination (types sorted, joined with `+`): the row's sats as `groups` counts them, without the tracked pieces that touch block 9 or 450x (a split leaves those whole). | #### Errors `{error}` (a message, no code): | Status | When | | --- | --- | | 400 | Not an address, or a malformed parameter. | | 503 | The index is unavailable or busy. | A page holds 500 UTXOs by default (`limit` up to 5,000); `page.total` says how many match. `sort` and `types` sort and filter on the server. To see which of the address's UTXOs are listed, pass their outpoints to [the batch lookup](https://raresats.net/docs/get-listing-by-outpoint.md). While the index watches the mempool, a UTXO a pending transaction is spending carries `spent_by_pending` (`{txid, vin, first_seen, fee_rate, sale, replaceable}`: rare sats being sent), and `pending_outputs` lists the outputs pending transactions send the address (rare sats being received), described as UTXOs with `pending: true`. `totals`, `rows` and `types` stay confirmed-only. --- Page: https://raresats.net/docs/get-address-activity.md # Address activity `GET /api/address/{address}/activity` What an address bought and sold (every marketplace), listed and cancelled on raresats.net, extracted, mined and moved, newest first, with filter facets and totals. Transfers and extractions come from the index's per-address history (from the ordinals era) where it has one, read as far as the pages asked for need it; else from the moves it keeps (about 4 weeks); before either, the address's held UTXOs. `coverage` says which and how far; `summary.partial` says when the totals cover only the history read so far. Pages follow `next` (pass it as `before`), which does not drift when newer activity arrives. Cacheable 15 s. The first page (no `before`, no `to`) leads with the address's pending items (`pending: true`, `source: mempool`), outside `limit`, `total` and the facets; `pending=0` leaves them out. A sale here the index shows pending is listed once, as the index's. #### Path parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `address` | string | yes | A mainnet address. | #### Query parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `types` | string | no | Comma-separated activity types: `bought`, `sold`, `listed`, `cancelled`, `extraction`, `mined`, `transfer_in`, `transfer_out`, `consolidation`. | | `row` | string | no | Only activity of this row (collection) id. | | `marketplace` | string | no | Only sales and listings of this marketplace id (`magisat`, `magic_eden`, `raresats`, …). | | `year` | integer | no | Only activity of this UTC year. (min 2009) | | `from` | string | no | Only activity on or after this UTC day, `YYYY-MM-DD`. | | `to` | string | no | Only activity on or before this UTC day, `YYYY-MM-DD`. | | `limit` | integer | no | Page size, at most 200. Default: `50`. (min 1, max 200) | | `before` | string | no | The previous page's `next`. | | `pending` | string | no | `0` leaves the pending (mempool) items out. One of: `0`, `1`. Default: `1`. | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/address/bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr/activity?types=bought,sold' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/address/bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr/activity?types=bought,sold'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/address/bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr/activity", params={"types": "bought,sold"}).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `address` | string | The address. | | `as_of` | object \| null | `{height, time}` of the index. | | `explorer` | string \| null | Block explorer base URL. | | `coverage` | object | How complete each source is. | | `coverage.sales` | string | Sales: every one, from the sales index. One of: `complete`, `loading`, `unavailable`. | | `coverage.transfers` | string | `since block N`: transfers and extractions from block N on (the history's start, or the retained moves'), and before it only the held UTXOs; `complete` (a history from genesis, read whole); `holdings` (only the held UTXOs); or `unavailable`. | | `coverage.transfers_since` | integer \| null | N of `since block N`; null when `complete`. | | `coverage.source` | string \| null | Where transfers come from. One of: `fork_history`, `moves_window`, `holdings`, `null`. | | `coverage.history_from` | object | Where the per-address history begins (the ordinals era, the fork's `address_history_from_height`): `{height, time}`. | | `coverage.history_from.height` | integer | | | `coverage.history_from.time` | integer \| null | | | `coverage.history_complete` | boolean | Whether the whole history has been read (the fork has no more pages). Until then the totals and facets cover the history read so far, and pages read further as they go. | | `coverage.loaded_since` | integer \| null | While the history is read in part: the oldest block read so far. | | `coverage.loaded_since_time` | integer \| null | Its time, unix seconds. | | `coverage.market` | string | raresats.net listings. One of: `complete`, `off`, `unavailable`. | | `coverage.error` | string | Set when the index could not be read: transfers may be missing. | | `summary` | object | Totals over all of the address's activity known (no filter): sales and listings whole, the history as far as read. | | `summary.bought` | object | | | `summary.bought.count` | integer | | | `summary.bought.volume` | integer | Sats. | | `summary.sold` | object | | | `summary.sold.count` | integer | | | `summary.sold.volume` | integer | Sats. | | `summary.extractions` | integer | | | `summary.listed` | integer | | | `summary.transfers` | integer | Transfers, consolidations and mined. | | `summary.first_time` | integer \| null | Unix seconds of the first activity shown. | | `summary.last_time` | integer \| null | Unix seconds of the last. | | `summary.partial` | boolean | True while the history is read in part: the extraction and transfer counts and the first activity cover the history read so far. | | `facets` | object | Counts for the filters, each under every other filter set, over the activity known (the history as far as read). | | `facets.types` | map of integer | By type. | | `facets.rows` | map of integer | By row id. | | `facets.years` | map of integer | By UTC year. | | `facets.marketplaces` | map of integer | By marketplace id. | | `rows` | map of object | The rows named on the page and in the facets: label and types. | | `rows..label` | string | | | `rows..all_of` | array of string | | | `query` | object | The filters applied. | | `total` | integer | Events matching the filters among those known (the history as far as read; see `summary.partial`). | | `items` | array of object | One page, newest first. | | `items[].id` | string | Stable id (the cursor names it). | | `items[].type` | string | What happened, from the address's side. One of: `bought`, `sold`, `listed`, `cancelled`, `extraction`, `mined`, `transfer_in`, `transfer_out`, `consolidation`. | | `items[].time` | integer | Unix seconds: the block's time, or the marketplace event's. | | `items[].height` | integer \| null | Block height; null for an event off the chain (a listing). | | `items[].tx_index` | integer \| null | Place in the block (null while pending). | | `items[].txid` | string \| null | Transaction id; null for a listing. | | `items[].tx_url` | string \| null | The transaction on the block explorer. | | `items[].sat` | object \| null | The lead sat (the rarest moved, the first sold). | | `items[].sat.sat` | integer | | | `items[].sat.types` | array of string | | | `items[].count` | integer \| null | Tracked sats involved. | | `items[].row` | string \| null | The main row id (its rarest). | | `items[].rows` | array of string | Every row id the event counts toward (the category filter). | | `items[].price` | integer \| null | Sale or listing price, sats. | | `items[].unit_price` | number \| null | Price per sat of `row`, over the sold output's own value. | | `items[].marketplace` | string \| null | Marketplace id, for sales and listings. | | `items[].inscribed` | boolean \| null | A sale of an inscribed sat. | | `items[].outlier` | boolean \| null | A sale far from its row's median. | | `items[].secondary` | map of integer | A sale's secondary rows: the market rows of the other sats it sold (not its own row, a row it is a sub-category of, or a label row), each with its sats, primary-first: `{"black_omega": 1}` for a black omega sold with an alpha uncommon; `{}` for none. A sale never counts in its secondary rows' markets (the primary-sat rule, in Concepts). | | `items[].value` | integer \| null | Sats in the UTXOs received or sent, for a transfer. | | `items[].extracted` | boolean \| null | The rare sats arrived in (or left) an extracted UTXO. | | `items[].counterparty` | string \| null | The other side's main address: the seller of a purchase, the buyer of a sale, the sender or recipient of a transfer. | | `items[].counterparties` | integer \| null | How many addresses were on the other side. | | `items[].source` | string | Where it comes from: the sales index, the marketplace, the per-address history, the retained moves, a held UTXO's creation, or the index's mempool overlay (pending). One of: `sales`, `market`, `history`, `moves`, `holdings`, `mempool`. | | `items[].seq` | integer | The sale's sequence number (sales only). | | `items[].outpoint` | string \| null | The listed UTXO (listings only). | | `items[].burn` | boolean | A `transfer_out` to an unspendable output. | | `items[].pending` | boolean | Not yet in a block: a pending transaction (`source: mempool`, with `first_seen` and `fee_rate`, no `height`), or a sale bought or sold on raresats.net the index has not read yet (`source: market`). Only the first page lists the former, first. | | `items[].first_seen` | integer \| null | When the transaction was first seen in the mempool (bitcoind's entry time), unix seconds. | | `items[].fee_rate` | number \| null | Its fee rate, sat/vB (one decimal). | | `next` | string \| null | The cursor of the next page (pass as `before`), or null at the end. While the history is read in part, a page may hold fewer events than `limit`, even none, with a `next` that reads further. | | `pending_total` | integer | Pending items leading `items` (first page only; outside `total`, the facets and the summary). | #### Errors `{error}` (a message, no code): | Status | When | | --- | --- | | 400 | Not an address, or a malformed parameter or cursor. | | 404 | A retired row (`uncommon_2009`, `rare_2009`): its `retired` field names the row and years to ask for instead. | | 503 | Too many lookups at once. | The address page's Activity tab reads this route. One list, newest first, merges: | Type | From | Complete | | --- | --- | --- | | `bought`, `sold` | the sales index (every marketplace) | yes | | `listed`, `cancelled` | raresats.net | yes | | `extraction`, `mined`, `transfer_in`, `transfer_out`, `consolidation` | the index's per-address history, else the moves it keeps | see `coverage` | A transaction that is one of the address's sales is shown once, as the sale; a sale of a bundle names its `secondary` rows (the other sats sold with its `row`). A raresats.net purchase shows as `pending` until its block is read. **Pending.** The first page (no `before`, no `to`) leads with the address's transactions still in the mempool: sales it is selling or buying, transfers in and out, extractions. Each has `pending: true`, `source: "mempool"`, `first_seen`, `fee_rate`, and no `height`. They match the type, row and date filters but sit outside `limit`, `total`, the facets and `summary` (`pending_total` counts them); once mined they come back as ordinary items. A raresats.net purchase the index already shows pending is listed once, as the index's item. `pending=0` leaves them out. See [Pending](https://raresats.net/docs/site-feeds.md#pending-the-mempool). `coverage.transfers` is `since block N`: transfers and extractions are whole from block N, where the index's per-address history starts (the ordinals era, `coverage.history_from`). Before block N, only the UTXOs the address still holds are listed, as `transfer_in` items with `source: "holdings"`. Until the index serves the history, transfers cover the moves it keeps, about 4 weeks. A busy address has tens of thousands of entries, so the history is read lazily: each page reads as much of it as it needs, newest first. Until all of it has been read, `coverage.history_complete` is false, `coverage.loaded_since` says how far back it goes so far, and `summary.partial` is true. In that case the extraction and transfer counts, the facets and `total` cover the history read so far. Sales and listings are always whole. Type, row and date filters are passed down to the index, so a filtered page skips what cannot match. `facets` count each filter's values under the other filters, so a chip can say what choosing it would show. `summary` totals everything known, whatever the filters. To page, pass `next` as `before` with the same filters. The cursor names the last event shown, so newer activity arriving at the top shifts nothing. While the history is still being read, a page may hold fewer events than `limit`, even none, with a `next` that reads further. Keep following `next` until it is null. ```sh curl 'https://raresats.net/api/address/bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr/activity?types=bought,sold&limit=20' ``` --- Page: https://raresats.net/docs/get-birthday.md # Birthday sats What was mined on a day: per row counts for the site's birthday page, and the mint-day table behind every date filter. ## Counts per day `GET /api/birthday/counts` Per row, how many tracked sats were mined on a UTC day (`date`), or on that month-day of every year (`md`, optionally only `years`), that the index locates. Cacheable 30 s; a cold day can take a few seconds. #### Query parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `date` | string | no | A day, `YYYY-MM-DD`. One of `date` and `md` is required. | | `md` | string | no | A month-day, `MM-DD`: that day of every year. | | `years` | string | no | With `md`: only these years, comma-separated. | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/birthday/counts?date=2009-01-09' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/birthday/counts?date=2009-01-09'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/birthday/counts", params={"date": "2009-01-09"}).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `filter` | object | The filter as the server understood it. | | `filter.years` | array of integer | The years asked for (ascending), `[]` for none. | | `filter.month` | string \| null | The month asked for (`YYYY-MM`), `null` for none. | | `filter.date` | string \| null | The day asked for. | | `filter.md` | string \| null | The month-day asked for. | | `filter.blocks` | object \| null | The blocks asked for: `{from, to}`, heights inclusive (one block: `from` = `to`), `null` for none. | | `filter.blocks.from` | integer | | | `filter.blocks.to` | integer | | | `filter.luck` | object \| null | The luck asked for: `{min, max}` (`max` `null` for a minimum alone), `null` for none. | | `filter.luck.min` | integer | | | `filter.luck.max` | integer \| null | | | `filter.sub` | string \| null | The sub-category asked for. | | `filter.subs` | boolean | Whether sub-category items are included (absent on events and counts). | | `filter.led` | boolean | Whether sales led by another row are included (`led=1`; sales only). | | `days` | array of object | Each day counted (one for `date`; that day of every year for `md`). | | `days[].date` | string | The day, YYYY-MM-DD. | | `days[].from` | integer | Its first block. | | `days[].to` | integer | Its last block. | | `days[].rows` | map of integer | Tracked sats mined that day the index locates, per row (rows with none absent). | | `rows` | map of integer | The days' counts summed per row. | | `ms` | integer | Server time spent, ms. | ```json title="Response" { "filter": {"years": [], "date": "2009-01-09", "md": null, "luck": null, "sub": null}, "days": [ { "date": "2009-01-09", "from": 470, "to": 559, "rows": {"vintage_alpha": 3, "vintage_omega": 1} } ], "rows": {"vintage_alpha": 3, "vintage_omega": 1}, "ms": 749 } ``` #### Errors `{error}` (a message, no code): | Status | When | | --- | --- | | 400 | Neither `date` nor `md`, or a malformed one. | | 503 | Mint dates are still being read (`retry: true`), or the index is unavailable. | ## The mint-day table `GET /api/days` Which UTC day mined each block, compactly: what the site uses to date sats in the browser. A sat's mint day is the day of its block. Cacheable 30 s. #### Example request ```bash title="cURL" curl 'https://raresats.net/api/days' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/days'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/days").json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `ready` | boolean | Whether the table is read (date filters answer 503 until then). | | `version` | integer | Format version. | | `genesis` | string \| null | The genesis block hash. | | `first_day` | integer | The first UTC day (days since 1970) of the table. | | `height` | integer | The last height covered. | | `hash` | string \| null | Its hash. | | `starts` | array of integer | Each day's first height, as deltas: `starts[0]` is the first day's, then each the difference from the day before. | | `exceptions` | array of integer | Blocks whose time is out of order, as flat pairs `[height, day, …]`: the day each belongs to. | | `tail` | array of integer | Recent, not yet final headers as flat pairs `[height, time, …]`. | | `build_ms` | integer \| null | How long the last build took. | #### Errors None of its own. Decoding it: day `first_day + i` (UTC days since 1970) starts at height `starts[0] + … + starts[i]`; a block listed in `exceptions` belongs to the day given there instead (its timestamp is out of order). --- Page: https://raresats.net/docs/get-prices.md # Prices `GET /api/prices` The current BTC/USD price and one price per UTC day since 2010-07-19, for converting sats to dollars at the time of a sale. Cacheable 5 minutes (1 minute while nothing is available). Never an error: unavailable parts are `null`. #### Example request ```bash title="cURL" curl 'https://raresats.net/api/prices' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/prices'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/prices").json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `current` | object \| null | The BTC price now: `usd` (dollars) and `time` (unix seconds of the quote). `null` while the source is down (after an hour of last good values). | | `current.usd` | number | Dollars per bitcoin. | | `current.time` | integer | Unix seconds. | | `history` | object \| null | One price a UTC day: `usd[i]` is the day starting `start + i × step`. `null` while unavailable. | | `history.start` | integer | Unix seconds of the first day (2010-07-19). | | `history.step` | `86400` | Seconds per entry: one day. | | `history.usd` | array of number | Daily dollars per bitcoin (the mean of the day's quotes; 4 significant digits under $100, whole dollars above). | ```json title="Response" { "current": {"usd": 86772, "time": 1790945710}, "history": {"start": 1279497600, "step": 86400, "usd": [0.0858, 0.0808, 0.0747]} } ``` #### Errors None of its own. To value a sale in dollars at the time it happened: `usd[Math.floor((time - start) / 86400)] × price / 100000000`, with `time` the sale's unix seconds and `price` in sats. --- Page: https://raresats.net/docs/chat-sessions.md # Chat sessions Reading chat is public. Posting needs a **session**, opened by one BIP-322 signature: the walkthrough is in the [Chat & profiles guide](https://raresats.net/docs/guide-chat.md). ## Get a login nonce `GET /api/session/nonce` A nonce for an address to sign in with: usable once, for 10 minutes, by that address only. Same-origin, and limited to 20 a minute per IPv4 address or IPv6 /64. #### Query parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `address` | string | yes | The address that will sign. | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/session/nonce?address=bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/session/nonce?address=bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/session/nonce", params={"address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh"}).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `address` | string | The address, normalized. | | `nonce` | string | 32 hex characters, usable once. | | `expires_at` | integer | When the nonce expires, unix seconds (10 minutes). | | `message` | string | The message to sign, with `` for the current time. | ```json title="Response" { "address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "nonce": "9f2c41d07a5be3816c0d4f27e9a1b358", "expires_at": 1790950000, "message": "raresats.net login bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh 9f2c41d07a5be3816c0d4f27e9a1b358 " } ``` #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 400 | `bad_address` | An address is not a mainnet address (or not one at all). | | 403 | `cross_origin` | A POST from a browser on another site (a foreign `Origin`, or `sec-fetch-site: cross-site`). Server-side clients send no `Origin` and are not affected. | | 404 | `chat_disabled` | Chat and display names are off on this server. | | 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. | | 429 | `rate_limited` | Over 30 POSTs a minute from your IPv4 address or IPv6 /64 (in chat, counted per IPv4 address or IPv6 /64: 30 writes and 20 login nonces a minute, 20 sign-ins and 10 display-name changes an hour; one message every 3 s and 20 a minute per address); wait `retry-after` seconds. With the AI moderation on, at most 6 messages and names a minute are checked per address and per IPv4 address or IPv6 /48. | ## Sign in to chat `POST /api/session` Opens a chat session from a BIP-322 signature of `raresats.net login
`, the nonce from [Get a login nonce](https://raresats.net/docs/chat-sessions.md#get-a-login-nonce). Sets the session cookie `__Host-rs_chat` (HttpOnly, Secure, SameSite=Lax, Path=/, 30 days). The nonce and the message are each usable once; the message at most 10 minutes old. At most 20 sign-ins an hour per IPv4 address or IPv6 /64. #### Request body `content-type: application/json` | Field | Type | Required | Description | | --- | --- | --- | --- | | `address` | string | yes | The address signing in (ordinals or payment). | | `message` | string | yes | `raresats.net login
`, at most 10 minutes old. | | `signature` | string | yes | BIP-322 signature of `message` by `address` (base64; simple or full). | #### Example request ```bash title="cURL" curl -X POST 'https://raresats.net/api/session' \ -H 'content-type: application/json' \ -d '{ "address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "message": "raresats.net login bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh 9f2c41d07a5be3816c0d4f27e9a1b358 1790949400", "signature": "AUBGQvdc2H+7HTo1njZ1vlDxoX6ncoehAGbaHcAY1FtfSNg7lmo8Ih3WGInhAH/snOUIb8DkwRSQxc2dOu911Z9E" }' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/session', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ "address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "message": "raresats.net login bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh 9f2c41d07a5be3816c0d4f27e9a1b358 1790949400", "signature": "AUBGQvdc2H+7HTo1njZ1vlDxoX6ncoehAGbaHcAY1FtfSNg7lmo8Ih3WGInhAH/snOUIb8DkwRSQxc2dOu911Z9E" }) }); const data = await response.json(); ``` ```python title="Python" import requests data = requests.post("https://raresats.net/api/session", json={ "address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "message": "raresats.net login bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh 9f2c41d07a5be3816c0d4f27e9a1b358 1790949400", "signature": "AUBGQvdc2H+7HTo1njZ1vlDxoX6ncoehAGbaHcAY1FtfSNg7lmo8Ih3WGInhAH/snOUIb8DkwRSQxc2dOu911Z9E" }).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `enabled` | boolean | Chat and display names are on (always true: off, the route is a 404). | | `max_length` | integer | The longest message, in characters. | | `address` | string \| null | The signed-in address. | | `admin` | boolean | The address is a chat admin. | | `display_name` | string \| null | Its display name. | | `expires_at` | integer \| null | When the session ends, unix seconds. | | `banned` | boolean | The address may not post: banned, or muted until `banned_until`. | | `server_time` | integer | The server's clock, unix seconds: date signed messages by it. | | `banned_until` | integer \| null | When a mute ends, unix seconds (the AI moderation mutes an address for an hour after 3 refusals in 10 minutes); null when not muted or banned for good. | | `moderated` | boolean | Messages and display names are checked by the AI moderation before they are posted. | #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 400 | `bad_address` | An address is not a mainnet address (or not one at all). | | 400 | `bad_json` | The body is not valid JSON. | | 400 | `bad_message` | The cancel message is not `raresats.net cancel ` or names other listings than `ids`; or the registration message is not `raresats.net affiliate
` for `address`. | | 400 | `bad_request` | The body is not a JSON object, or the engine refused a malformed request (e.g. a purchase submitted without its signed setup PSBT). | | 400, 422 | `bad_signature` | 400: `signature` is missing or too long. 422: a signature does not verify (cancel or registration message, listing or purchase input). | | 403 | `cross_origin` | A POST from a browser on another site (a foreign `Origin`, or `sec-fetch-site: cross-site`). Server-side clients send no `Origin` and are not affected. | | 404 | `chat_disabled` | Chat and display names are off on this server. | | 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. | | 409 | `replayed` | That cancel or registration message was used already. | | 413 | `too_large` | The body is over 256 KiB. | | 415 | `not_json` | A POST without `content-type: application/json`. | | 422 | `bad_nonce` | The login nonce was not handed out for this address, has expired (10 minutes) or was used: ask for a new one. | | 422 | `future_message` | The cancel or registration message is dated more than 2 minutes ahead: check the clock. | | 422 | `stale_message` | The cancel or registration message is more than 10 minutes old: sign a new one. | | 429 | `rate_limited` | Over 30 POSTs a minute from your IPv4 address or IPv6 /64 (in chat, counted per IPv4 address or IPv6 /64: 30 writes and 20 login nonces a minute, 20 sign-ins and 10 display-name changes an hour; one message every 3 s and 20 a minute per address); wait `retry-after` seconds. With the AI moderation on, at most 6 messages and names a minute are checked per address and per IPv4 address or IPv6 /48. | ```json title="Error (422)" { "error": "that nonce was not handed out for this address, has expired or was used: ask for a new one", "code": "bad_nonce" } ``` ## Get the session `GET /api/session` The session of the request's `__Host-rs_chat` cookie: the signed-in address, its name and whether it is an admin or banned; `address` null when signed out. A browser's page uses it to know whether chat is on (off: 404 `chat_disabled`). #### Example request ```bash title="cURL" curl 'https://raresats.net/api/session' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/session'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/session").json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `enabled` | boolean | Chat and display names are on (always true: off, the route is a 404). | | `max_length` | integer | The longest message, in characters. | | `address` | string \| null | The signed-in address. | | `admin` | boolean | The address is a chat admin. | | `display_name` | string \| null | Its display name. | | `expires_at` | integer \| null | When the session ends, unix seconds. | | `banned` | boolean | The address may not post: banned, or muted until `banned_until`. | | `server_time` | integer | The server's clock, unix seconds: date signed messages by it. | | `banned_until` | integer \| null | When a mute ends, unix seconds (the AI moderation mutes an address for an hour after 3 refusals in 10 minutes); null when not muted or banned for good. | | `moderated` | boolean | Messages and display names are checked by the AI moderation before they are posted. | #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 404 | `chat_disabled` | Chat and display names are off on this server. | | 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. | ## Sign out `DELETE /api/session` Ends the cookie's session and clears the cookie. Answers the signed-out session. #### Example request ```bash title="cURL" curl -X DELETE 'https://raresats.net/api/session' -H 'cookie: __Host-rs_chat=' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/session', { method: 'DELETE', credentials: 'include' }); const data = await response.json(); ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `enabled` | boolean | Chat and display names are on (always true: off, the route is a 404). | | `max_length` | integer | The longest message, in characters. | | `address` | string \| null | The signed-in address. | | `admin` | boolean | The address is a chat admin. | | `display_name` | string \| null | Its display name. | | `expires_at` | integer \| null | When the session ends, unix seconds. | | `banned` | boolean | The address may not post: banned, or muted until `banned_until`. | | `server_time` | integer | The server's clock, unix seconds: date signed messages by it. | | `banned_until` | integer \| null | When a mute ends, unix seconds (the AI moderation mutes an address for an hour after 3 refusals in 10 minutes); null when not muted or banned for good. | | `moderated` | boolean | Messages and display names are checked by the AI moderation before they are posted. | #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 403 | `cross_origin` | A POST from a browser on another site (a foreign `Origin`, or `sec-fetch-site: cross-site`). Server-side clients send no `Origin` and are not affected. | | 404 | `chat_disabled` | Chat and display names are off on this server. | | 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. | | 429 | `rate_limited` | Over 30 POSTs a minute from your IPv4 address or IPv6 /64 (in chat, counted per IPv4 address or IPv6 /64: 30 writes and 20 login nonces a minute, 20 sign-ins and 10 display-name changes an hour; one message every 3 s and 20 a minute per address); wait `retry-after` seconds. With the AI moderation on, at most 6 messages and names a minute are checked per address and per IPv4 address or IPv6 /48. | --- Page: https://raresats.net/docs/chat-messages.md # Chat messages Messages are filed under **topics** (just General for now), each with threads one level deep: a reply to a reply joins its root's thread, in its topic. Without a topic, a request is General's. Messages and replies arrive live as `chat` events on [the event stream](https://raresats.net/docs/site-feeds.md#live-events), each naming its topic. Where the AI moderation is on, every message is checked before it is posted: [Moderation](https://raresats.net/docs/guide-chat.md#moderation). ## List topics `GET /api/chat/topics` The live topics, in their order, each with its newest message's id and time: a page keeps the last id it read per topic and shows the others as unread. New and archived topics come as `chat` events `{type: "topic", topic, archived}`. #### Example request ```bash title="cURL" curl 'https://raresats.net/api/chat/topics' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/chat/topics'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/chat/topics").json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `topics` | array of object | | | `topics[].id` | string | 1-32 of a-z, 0-9 and inner hyphens. | | `topics[].name` | string | 1-40 characters, plain text. | | `topics[].description` | string | At most 140 characters, plain text; may be empty. | | `topics[].position` | integer | Its place in the list. | | `topics[].created_at` | integer | Unix seconds. | | `topics[].last_message_id` | integer \| null | The newest live message's id (a reply counts); null when it has none. A page that keeps the last id it read per topic shows a topic as unread when this is larger. | | `topics[].last_activity_at` | integer \| null | That message's time, unix seconds; null when it has none. | ```json title="Response" { "topics": [ { "id": "general", "name": "General", "description": "Anything rare-sat: say gm, ask, share a find.", "position": 0, "created_at": 1790900000, "last_message_id": 1044, "last_activity_at": 1790949460 }, { "id": "market", "name": "Market & prices", "description": "Listings, offers, floors and what things sell for.", "position": 1, "created_at": 1790900000, "last_message_id": 1042, "last_activity_at": 1790949400 } ] } ``` #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 404 | `chat_disabled` | Chat and display names are off on this server. | | 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. | ## Create a topic (admin) `POST /api/chat/topics` An admin session creates a topic, last in the list. Only admins may: topic names are not run through the AI moderation (an admin's text never is), but follow a message's text rules (NFC, no control, zero-width or bidi characters) and its link rule. An id is never reused, an archived topic's included, except a retired one's (a topic the defaults once seeded and no longer name): it comes back as the new topic, its messages with it. At most 50 topics. Pages are told with a `chat` event `{type: "topic", topic, archived: false}`. Logged. #### Request body `content-type: application/json` | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | 1-32 of a-z, 0-9 and inner hyphens; not `main`. | | `name` | string | yes | 1-40 characters. | | `description` | string \| null | no | At most 140 characters. | #### Example request ```bash title="cURL" curl -X POST 'https://raresats.net/api/chat/topics' \ -H 'content-type: application/json' \ -d '{ "id": "runes", "name": "Runes", "description": "Runes on rare sats, and what they do to a sat'\''s value." }' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/chat/topics', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ "id": "runes", "name": "Runes", "description": "Runes on rare sats, and what they do to a sat's value." }) }); const data = await response.json(); ``` ```python title="Python" import requests data = requests.post("https://raresats.net/api/chat/topics", json={ "id": "runes", "name": "Runes", "description": "Runes on rare sats, and what they do to a sat's value." }).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `topic` | object | A chat topic (a channel). Its id is its slug: the page is `/chat/`. | | `topic.id` | string | 1-32 of a-z, 0-9 and inner hyphens. | | `topic.name` | string | 1-40 characters, plain text. | | `topic.description` | string | At most 140 characters, plain text; may be empty. | | `topic.position` | integer | Its place in the list. | | `topic.created_at` | integer | Unix seconds. | | `topic.last_message_id` | integer \| null | The newest live message's id (a reply counts); null when it has none. A page that keeps the last id it read per topic shows a topic as unread when this is larger. | | `topic.last_activity_at` | integer \| null | That message's time, unix seconds; null when it has none. | #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 400 | `bad_json` | The body is not valid JSON. | | 400 | `bad_request` | The body is not a JSON object, or the engine refused a malformed request (e.g. a purchase submitted without its signed setup PSBT). | | 400 | `bad_topic` | A topic id that is not one: 1-32 of a-z, 0-9 and inner hyphens (and, to create one, not `main`). On a reply: a `topic` other than its root's. | | 401 | `no_session` | No chat session: sign in (`POST /api/session`) and send its cookie. | | 403 | `cross_origin` | A POST from a browser on another site (a foreign `Origin`, or `sec-fetch-site: cross-site`). Server-side clients send no `Origin` and are not affected. | | 403 | `not_admin` | Only a chat admin (`SUPPLY_CHAT_ADMINS`) may do that. | | 404 | `chat_disabled` | Chat and display names are off on this server. | | 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. | | 409 | `topic_exists` | A topic (or an archived one) has that id already. | | 413 | `too_large` | The body is over 256 KiB. | | 415 | `not_json` | A POST without `content-type: application/json`. | | 422 | `bad_topic_text` | A topic's name must be 1-40 characters, its description at most 140. | | 422 | `suspicious_link` | A link in the message names a host that is not plain ASCII: look-alike letters from other scripts, punycode (`xn--`), or `user@host`. Write the link with its real host name. | | 422 | `too_many_topics` | At most 50 chat topics: archive one first. | | 429 | `rate_limited` | Over 30 POSTs a minute from your IPv4 address or IPv6 /64 (in chat, counted per IPv4 address or IPv6 /64: 30 writes and 20 login nonces a minute, 20 sign-ins and 10 display-name changes an hour; one message every 3 s and 20 a minute per address); wait `retry-after` seconds. With the AI moderation on, at most 6 messages and names a minute are checked per address and per IPv4 address or IPv6 /48. | ## Archive a topic (admin) `DELETE /api/chat/topics/{id}` An admin session archives a topic: it leaves the list and takes no new messages or replies (404 `unknown_topic`); its messages stay in the store. General cannot be archived. Pages are told with a `chat` event `{type: "topic", topic, archived: true}`. Logged. #### Path parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | The topic's id. | #### Example request ```bash title="cURL" curl -X DELETE 'https://raresats.net/api/chat/topics/off-topic' -H 'cookie: __Host-rs_chat=' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/chat/topics/off-topic', { method: 'DELETE', credentials: 'include' }); const data = await response.json(); ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `topic` | object | A chat topic (a channel). Its id is its slug: the page is `/chat/`. | | `topic.id` | string | 1-32 of a-z, 0-9 and inner hyphens. | | `topic.name` | string | 1-40 characters, plain text. | | `topic.description` | string | At most 140 characters, plain text; may be empty. | | `topic.position` | integer | Its place in the list. | | `topic.created_at` | integer | Unix seconds. | | `topic.last_message_id` | integer \| null | The newest live message's id (a reply counts); null when it has none. A page that keeps the last id it read per topic shows a topic as unread when this is larger. | | `topic.last_activity_at` | integer \| null | That message's time, unix seconds; null when it has none. | | `archived` | `true` | | #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 400 | `bad_path` | The path is not valid percent-encoding. | | 400 | `bad_topic` | A topic id that is not one: 1-32 of a-z, 0-9 and inner hyphens (and, to create one, not `main`). On a reply: a `topic` other than its root's. | | 401 | `no_session` | No chat session: sign in (`POST /api/session`) and send its cookie. | | 403 | `cross_origin` | A POST from a browser on another site (a foreign `Origin`, or `sec-fetch-site: cross-site`). Server-side clients send no `Origin` and are not affected. | | 403 | `not_admin` | Only a chat admin (`SUPPLY_CHAT_ADMINS`) may do that. | | 404 | `chat_disabled` | Chat and display names are off on this server. | | 404 | `unknown_topic` | No such chat topic, or an archived one (which takes no new messages). | | 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. | | 422 | `not_archivable` | General cannot be archived. | | 429 | `rate_limited` | Over 30 POSTs a minute from your IPv4 address or IPv6 /64 (in chat, counted per IPv4 address or IPv6 /64: 30 writes and 20 login nonces a minute, 20 sign-ins and 10 display-name changes an hour; one message every 3 s and 20 a minute per address); wait `retry-after` seconds. With the AI moderation on, at most 6 messages and names a minute are checked per address and per IPv4 address or IPv6 /48. | ## List messages `GET /api/chat/messages` A topic's messages (the threads' roots), newest first, with each author's current display name and each thread's reply count and last reply time. Without `topic`, General's (`main`, the one room before topics, names General too). Replies are on their thread: [List a thread](https://raresats.net/docs/chat-messages.md#list-a-thread). Page back with `before`. Messages are kept 30 days, at most 10,000 a topic. #### Query parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `topic` | string | no | A topic id ([List topics](https://raresats.net/docs/chat-messages.md#list-topics)); default `general`. Default: `general`. | | `before` | integer | no | A message id: only older ones (the previous page's `next`). (min 1) | | `limit` | integer | no | Messages per page, 1-100. Default: `50`. (min 1, max 100) | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/chat/messages?topic=market&limit=50' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/chat/messages?topic=market&limit=50'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/chat/messages", params={"topic": "market", "limit": 50}).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `topic` | string | The topic. | | `room` | string | Deprecated: the same as `topic`. | | `messages` | array of object | The topic's threads' roots, newest first (replies are on their thread). | | `messages[].id` | integer | Increasing; the paging cursor. | | `messages[].topic` | string | The topic it is in (a reply: its root's). | | `messages[].room` | string | Deprecated: the same as `topic` (the one room before topics was `main`, now `general`). | | `messages[].address` | string | The author. | | `messages[].display_name` | string \| null | The author's display name now. | | `messages[].admin` | boolean | The author is a chat admin. | | `messages[].text` | string | Plain text (NFC, no control, zero-width or bidi characters, one line, 1-500 characters). Show it as text, never as HTML. | | `messages[].created_at` | integer | Unix seconds. | | `messages[].reply_to` | integer \| null | The thread's root, on a reply; null on a root (a message of the topic). | | `messages[].reply_count` | integer | On a root: its live replies (0 on a reply). | | `messages[].last_reply_at` | integer \| null | On a root: its newest reply's time, unix seconds; null without replies. | | `messages[].quote` | object \| null | On a reply: the message it answered (the root, or a reply in the thread), while it is live; text only, cut at 140 characters. Null on a root. | | `messages[].quote.id` | integer | | | `messages[].quote.address` | string | | | `messages[].quote.display_name` | string \| null | | | `messages[].quote.text` | string | | | `next` | integer \| null | Pass as `before` for the older page; null at the start. | ```json title="Response" { "topic": "market", "room": "market", "messages": [ { "id": 1042, "topic": "market", "room": "market", "address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "display_name": "Rare Hunter", "admin": false, "text": "gm, any block 9 for sale?", "created_at": 1790949400, "reply_to": null, "reply_count": 2, "last_reply_at": 1790949460, "quote": null } ], "next": 1042 } ``` #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 400 | `bad_paging` | `limit` or `offset` out of range. | | 400 | `bad_topic` | A topic id that is not one: 1-32 of a-z, 0-9 and inner hyphens (and, to create one, not `main`). On a reply: a `topic` other than its root's. | | 404 | `chat_disabled` | Chat and display names are off on this server. | | 404 | `unknown_topic` | No such chat topic, or an archived one (which takes no new messages). | | 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. | ## List a thread `GET /api/chat/messages/{id}/replies` A thread: its root (with its reply count) and its replies, newest first, each with its author's current display name and the message it answered (`quote`). Page back with `before`. The id of a reply names its thread too. The answer names the thread's `topic`: a link to a message (`/chat/?m=`) finds its page and thread from it. #### Path parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `id` | integer | yes | The thread's root (or one of its replies). | #### Query parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `before` | integer | no | A reply id: only older ones (the previous page's `next`). (min 1) | | `limit` | integer | no | Replies per page, 1-100. Default: `50`. (min 1, max 100) | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/chat/messages/1042/replies?limit=50' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/chat/messages/1042/replies?limit=50'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/chat/messages/1042/replies", params={"limit": 50}).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `topic` | string | The thread's topic (its root's): a link to a reply finds its page. | | `room` | string | Deprecated: the same as `topic`. | | `root` | object | A message, or a reply in a thread (one level: a reply to a reply joins its root's thread). | | `root.id` | integer | Increasing; the paging cursor. | | `root.topic` | string | The topic it is in (a reply: its root's). | | `root.room` | string | Deprecated: the same as `topic` (the one room before topics was `main`, now `general`). | | `root.address` | string | The author. | | `root.display_name` | string \| null | The author's display name now. | | `root.admin` | boolean | The author is a chat admin. | | `root.text` | string | Plain text (NFC, no control, zero-width or bidi characters, one line, 1-500 characters). Show it as text, never as HTML. | | `root.created_at` | integer | Unix seconds. | | `root.reply_to` | integer \| null | The thread's root, on a reply; null on a root (a message of the topic). | | `root.reply_count` | integer | On a root: its live replies (0 on a reply). | | `root.last_reply_at` | integer \| null | On a root: its newest reply's time, unix seconds; null without replies. | | `root.quote` | object \| null | On a reply: the message it answered (the root, or a reply in the thread), while it is live; text only, cut at 140 characters. Null on a root. | | `root.quote.id` | integer | | | `root.quote.address` | string | | | `root.quote.display_name` | string \| null | | | `root.quote.text` | string | | | `messages` | array of object | The replies, newest first. | | `messages[].id` | integer | Increasing; the paging cursor. | | `messages[].topic` | string | The topic it is in (a reply: its root's). | | `messages[].room` | string | Deprecated: the same as `topic` (the one room before topics was `main`, now `general`). | | `messages[].address` | string | The author. | | `messages[].display_name` | string \| null | The author's display name now. | | `messages[].admin` | boolean | The author is a chat admin. | | `messages[].text` | string | Plain text (NFC, no control, zero-width or bidi characters, one line, 1-500 characters). Show it as text, never as HTML. | | `messages[].created_at` | integer | Unix seconds. | | `messages[].reply_to` | integer \| null | The thread's root, on a reply; null on a root (a message of the topic). | | `messages[].reply_count` | integer | On a root: its live replies (0 on a reply). | | `messages[].last_reply_at` | integer \| null | On a root: its newest reply's time, unix seconds; null without replies. | | `messages[].quote` | object \| null | On a reply: the message it answered (the root, or a reply in the thread), while it is live; text only, cut at 140 characters. Null on a root. | | `messages[].quote.id` | integer | | | `messages[].quote.address` | string | | | `messages[].quote.display_name` | string \| null | | | `messages[].quote.text` | string | | | `next` | integer \| null | Pass as `before` for older replies; null at the start. | ```json title="Response" { "topic": "market", "room": "market", "root": { "id": 1042, "topic": "market", "room": "market", "address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "display_name": "Rare Hunter", "admin": false, "text": "gm, any block 9 for sale?", "created_at": 1790949400, "reply_to": null, "reply_count": 2, "last_reply_at": 1790949460, "quote": null }, "messages": [ { "id": 1044, "topic": "market", "room": "market", "address": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", "display_name": null, "admin": false, "text": "I have two, 0.0021 each", "created_at": 1790949460, "reply_to": 1042, "reply_count": 0, "last_reply_at": null, "quote": { "id": 1042, "address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "display_name": "Rare Hunter", "text": "gm, any block 9 for sale?" } } ], "next": null } ``` #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 400 | `bad_paging` | `limit` or `offset` out of range. | | 400 | `bad_path` | The path is not valid percent-encoding. | | 404 | `chat_disabled` | Chat and display names are off on this server. | | 404 | `not_found` | No such listing or offer. In chat: no such message. A display name to remove that does not exist. | | 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. | ## Post a message `POST /api/chat/messages` Posts as the session's address (the `__Host-rs_chat` cookie of [Sign in](https://raresats.net/docs/chat-sessions.md#sign-in-to-chat)), in a topic (`topic`, default `general`) or, with `reply_to`, as a reply in its root's topic: one level of threads, so a reply to a reply joins its root's thread (and quotes the reply it answered). Plain text: normalized to NFC, control, zero-width and bidi characters dropped, line breaks made spaces; 1-500 characters. One message every 3 s and 20 a minute per address; the same text twice within 2 minutes is refused; the whole chat takes at most 5 messages a second (429 `room_busy`). A link must name a plain ASCII host (422 `suspicious_link`). Then, where the AI moderation is on (the session's `moderated`), the text is checked before it is posted: refused, 422 `moderated` with its `category`; when it cannot be checked, 503 `moderation_unavailable` and nothing is posted (try again); 3 refusals in 10 minutes mute the address for an hour, and 6 from one IPv4 address or IPv6 /48 within an hour pause that network (403 `muted`); at most 6 texts a minute are checked per address and per network (429 `rate_limited`). Delivered live as a `chat` event on `/api/events`, which names the topic; a reply's answer and event carry its root's new `thread` counts. #### Request body `content-type: application/json` | Field | Type | Required | Description | | --- | --- | --- | --- | | `text` | string | yes | The message. | | `topic` | string \| null | no | The topic's id; absent or null: `general`. With `reply_to` it may be left out; given, it must be the root's topic. | | `reply_to` | integer \| null | no | Reply to this message (a root, or a reply: the reply joins its root's thread, in its topic). Absent or null: a new message in the topic. | #### Example request ```bash title="cURL" curl -X POST 'https://raresats.net/api/chat/messages' \ -H 'content-type: application/json' \ -d '{"text": "I have two, 0.0021 each", "topic": "market", "reply_to": 1042}' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/chat/messages', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({"text": "I have two, 0.0021 each", "topic": "market", "reply_to": 1042}) }); const data = await response.json(); ``` ```python title="Python" import requests data = requests.post("https://raresats.net/api/chat/messages", json={ "text": "I have two, 0.0021 each", "topic": "market", "reply_to": 1042 }).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `message` | object | A message, or a reply in a thread (one level: a reply to a reply joins its root's thread). | | `message.id` | integer | Increasing; the paging cursor. | | `message.topic` | string | The topic it is in (a reply: its root's). | | `message.room` | string | Deprecated: the same as `topic` (the one room before topics was `main`, now `general`). | | `message.address` | string | The author. | | `message.display_name` | string \| null | The author's display name now. | | `message.admin` | boolean | The author is a chat admin. | | `message.text` | string | Plain text (NFC, no control, zero-width or bidi characters, one line, 1-500 characters). Show it as text, never as HTML. | | `message.created_at` | integer | Unix seconds. | | `message.reply_to` | integer \| null | The thread's root, on a reply; null on a root (a message of the topic). | | `message.reply_count` | integer | On a root: its live replies (0 on a reply). | | `message.last_reply_at` | integer \| null | On a root: its newest reply's time, unix seconds; null without replies. | | `message.quote` | object \| null | On a reply: the message it answered (the root, or a reply in the thread), while it is live; text only, cut at 140 characters. Null on a root. | | `message.quote.id` | integer | | | `message.quote.address` | string | | | `message.quote.display_name` | string \| null | | | `message.quote.text` | string | | | `thread` | object | On a reply: its root's thread now. | | `thread.id` | integer | The root. | | `thread.reply_count` | integer | | | `thread.last_reply_at` | integer \| null | | #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 400 | `bad_json` | The body is not valid JSON. | | 400 | `bad_request` | The body is not a JSON object, or the engine refused a malformed request (e.g. a purchase submitted without its signed setup PSBT). | | 400 | `bad_topic` | A topic id that is not one: 1-32 of a-z, 0-9 and inner hyphens (and, to create one, not `main`). On a reply: a `topic` other than its root's. | | 401 | `no_session` | No chat session: sign in (`POST /api/session`) and send its cookie. | | 403 | `banned` | The address is banned from chat: it may not post or set a display name. | | 403 | `cross_origin` | A POST from a browser on another site (a foreign `Origin`, or `sec-fetch-site: cross-site`). Server-side clients send no `Origin` and are not affected. | | 403 | `muted` | The address is muted (by the AI moderation after repeated refusals: 3 in 10 minutes), or its network (an IPv4 address or IPv6 /48) is paused after 6 refusals within an hour, until `until`, unix seconds: it may not post or set a display name until then. | | 404 | `chat_disabled` | Chat and display names are off on this server. | | 404 | `not_found` | No such listing or offer. In chat: no such message. A display name to remove that does not exist. | | 404 | `unknown_topic` | No such chat topic, or an archived one (which takes no new messages). | | 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. | | 409 | `duplicate` | The same message (ignoring case) from the same address within 2 minutes. | | 413 | `too_large` | The body is over 256 KiB. | | 415 | `not_json` | A POST without `content-type: application/json`. | | 422 | `empty_text` | Nothing is left of the message once control, zero-width and bidi characters are dropped and spaces collapsed. | | 422 | `moderated` | The AI moderation refused the chat message or display name; `category` says why (the message is not posted, the name not taken). Three refusals in 10 minutes mute the address for an hour (`until`). | | 422 | `suspicious_link` | A link in the message names a host that is not plain ASCII: look-alike letters from other scripts, punycode (`xn--`), or `user@host`. Write the link with its real host name. | | 422 | `text_too_long` | A chat message is at most 500 characters. | | 429 | `chat_busy` | Too many chat messages and names are being checked across the site right now (over the global cap of model calls a minute); wait `retry-after` seconds. | | 429 | `rate_limited` | Over 30 POSTs a minute from your IPv4 address or IPv6 /64 (in chat, counted per IPv4 address or IPv6 /64: 30 writes and 20 login nonces a minute, 20 sign-ins and 10 display-name changes an hour; one message every 3 s and 20 a minute per address); wait `retry-after` seconds. With the AI moderation on, at most 6 messages and names a minute are checked per address and per IPv4 address or IPv6 /48. | | 429 | `room_busy` | Over 5 chat messages a second across the whole chat; wait `retry-after` seconds. | | 503 | `moderation_unavailable` | The AI moderation could not check the text right now (timeout or model error): nothing was posted. Try again in a moment. | ```json title="Error (503)" { "error": "Couldn't check your message right now: try again in a moment", "code": "moderation_unavailable" } ``` ## Delete a message (admin) `DELETE /api/chat/messages/{id}` An admin session deletes a message. Deleting a thread's root deletes its replies too (`deleted` lists them all). Pages are told with a `chat` event `{type: "delete", ids, threads, topics}`. Logged. #### Path parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `id` | integer | yes | The message id. | #### Example request ```bash title="cURL" curl -X DELETE 'https://raresats.net/api/chat/messages/1042' -H 'cookie: __Host-rs_chat=' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/chat/messages/1042', { method: 'DELETE', credentials: 'include' }); const data = await response.json(); ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `deleted` | array of integer | | #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 400 | `bad_path` | The path is not valid percent-encoding. | | 401 | `no_session` | No chat session: sign in (`POST /api/session`) and send its cookie. | | 403 | `cross_origin` | A POST from a browser on another site (a foreign `Origin`, or `sec-fetch-site: cross-site`). Server-side clients send no `Origin` and are not affected. | | 403 | `not_admin` | Only a chat admin (`SUPPLY_CHAT_ADMINS`) may do that. | | 404 | `chat_disabled` | Chat and display names are off on this server. | | 404 | `not_found` | No such listing or offer. In chat: no such message. A display name to remove that does not exist. | | 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. | | 429 | `rate_limited` | Over 30 POSTs a minute from your IPv4 address or IPv6 /64 (in chat, counted per IPv4 address or IPv6 /64: 30 writes and 20 login nonces a minute, 20 sign-ins and 10 display-name changes an hour; one message every 3 s and 20 a minute per address); wait `retry-after` seconds. With the AI moderation on, at most 6 messages and names a minute are checked per address and per IPv4 address or IPv6 /48. | ## List bans (admin) `GET /api/chat/bans` The banned addresses, newest first. An admin session only. #### Example request ```bash title="cURL" curl 'https://raresats.net/api/chat/bans' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/chat/bans'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/chat/bans").json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `bans` | array of object | | | `bans[].address` | string | | | `bans[].reason` | string \| null | | | `bans[].banned_by` | string | The admin's address, or `moderation` for an automatic mute. | | `bans[].created_at` | integer | | | `bans[].expires_at` | integer \| null | When a mute ends, unix seconds; null: banned until unbanned. | #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 401 | `no_session` | No chat session: sign in (`POST /api/session`) and send its cookie. | | 403 | `not_admin` | Only a chat admin (`SUPPLY_CHAT_ADMINS`) may do that. | | 404 | `chat_disabled` | Chat and display names are off on this server. | | 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. | ## Ban or unban (admin) `POST /api/chat/bans` An admin session bans (`banned: true`) or unbans an address. A banned address cannot post or set a display name, and a ban removes its display name for good. `delete_recent` also deletes its messages of the last 24 hours. Logged. #### Request body `content-type: application/json` | Field | Type | Required | Description | | --- | --- | --- | --- | | `address` | string | yes | | | `banned` | boolean | yes | | | `reason` | string \| null | no | At most 200 characters. | | `delete_recent` | boolean | no | Also delete its messages of the last 24 hours. | #### Example request ```bash title="cURL" curl -X POST 'https://raresats.net/api/chat/bans' \ -H 'content-type: application/json' \ -d '{ "address": "bc1qm34lsc65zpw79lxes69zkqmk6ee3ewf0j77s3h", "banned": true, "reason": "spam", "delete_recent": true }' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/chat/bans', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ "address": "bc1qm34lsc65zpw79lxes69zkqmk6ee3ewf0j77s3h", "banned": true, "reason": "spam", "delete_recent": true }) }); const data = await response.json(); ``` ```python title="Python" import requests data = requests.post("https://raresats.net/api/chat/bans", json={ "address": "bc1qm34lsc65zpw79lxes69zkqmk6ee3ewf0j77s3h", "banned": True, "reason": "spam", "delete_recent": True }).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `address` | string | | | `banned` | boolean | | | `ban` | object \| null | | | `deleted` | array of integer | The messages deleted with it. | #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 400 | `bad_address` | An address is not a mainnet address (or not one at all). | | 400 | `bad_json` | The body is not valid JSON. | | 400 | `bad_request` | The body is not a JSON object, or the engine refused a malformed request (e.g. a purchase submitted without its signed setup PSBT). | | 401 | `no_session` | No chat session: sign in (`POST /api/session`) and send its cookie. | | 403 | `cross_origin` | A POST from a browser on another site (a foreign `Origin`, or `sec-fetch-site: cross-site`). Server-side clients send no `Origin` and are not affected. | | 403 | `not_admin` | Only a chat admin (`SUPPLY_CHAT_ADMINS`) may do that. | | 404 | `chat_disabled` | Chat and display names are off on this server. | | 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. | | 413 | `too_large` | The body is over 256 KiB. | | 415 | `not_json` | A POST without `content-type: application/json`. | | 422 | `not_bannable` | An admin cannot be banned. | | 429 | `rate_limited` | Over 30 POSTs a minute from your IPv4 address or IPv6 /64 (in chat, counted per IPv4 address or IPv6 /64: 30 writes and 20 login nonces a minute, 20 sign-ins and 10 display-name changes an hour; one message every 3 s and 20 a minute per address); wait `retry-after` seconds. With the AI moderation on, at most 6 messages and names a minute are checked per address and per IPv4 address or IPv6 /48. | --- Page: https://raresats.net/docs/chat-profiles.md # Display names An address may take a display name, which the site shows wherever it shows that address (the address stays on hover, and links still go to the address page). The rules and the message to sign: [Chat & profiles guide](https://raresats.net/docs/guide-chat.md#display-names). ## Set a display name `POST /api/profile` Sets (or, with an empty name, clears) an address's display name. **Every change needs its own BIP-322 signature** by the address of `raresats.net profile
display_name= `, at most 10 minutes old and usable once; a chat session is not enough. 3-24 characters of letters, digits, spaces and `_ . -`; unique; no address-like or reserved names; at most one change in 24 hours. A banned address may not set one; the hourly cap is 10 changes per IPv4 address or IPv6 /64. Where the AI moderation is on, a new name is checked last, before it is taken (names show site-wide): refused, 422 `moderated`; not checked, 503 `moderation_unavailable` (the signed message is not used up: send it again). #### Request body `content-type: application/json` | Field | Type | Required | Description | | --- | --- | --- | --- | | `address` | string | yes | The address whose name it is. | | `display_name` | string \| null | yes | The new name, trimmed with spaces collapsed; empty or null clears it. | | `message` | string | yes | `raresats.net profile
display_name= `, the name exactly as set, at most 10 minutes old. | | `signature` | string | yes | BIP-322 signature of `message` by `address`. | #### Example request ```bash title="cURL" curl -X POST 'https://raresats.net/api/profile' \ -H 'content-type: application/json' \ -d '{ "address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "display_name": "Rare Hunter", "message": "raresats.net profile bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh display_name=Rare Hunter 1790949400", "signature": "AUBGQvdc2H+7HTo1njZ1vlDxoX6ncoehAGbaHcAY1FtfSNg7lmo8Ih3WGInhAH/snOUIb8DkwRSQxc2dOu911Z9E" }' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/profile', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ "address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "display_name": "Rare Hunter", "message": "raresats.net profile bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh display_name=Rare Hunter 1790949400", "signature": "AUBGQvdc2H+7HTo1njZ1vlDxoX6ncoehAGbaHcAY1FtfSNg7lmo8Ih3WGInhAH/snOUIb8DkwRSQxc2dOu911Z9E" }) }); const data = await response.json(); ``` ```python title="Python" import requests data = requests.post("https://raresats.net/api/profile", json={ "address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "display_name": "Rare Hunter", "message": "raresats.net profile bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh display_name=Rare Hunter 1790949400", "signature": "AUBGQvdc2H+7HTo1njZ1vlDxoX6ncoehAGbaHcAY1FtfSNg7lmo8Ih3WGInhAH/snOUIb8DkwRSQxc2dOu911Z9E" }).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `profile` | object | | | `profile.address` | string | | | `profile.display_name` | string \| null | The display name, null when it has none. | | `profile.admin` | boolean | The address is a chat admin. | | `profile.changed_at` | integer \| null | When the name last changed, unix seconds. | | `profile.next_change_at` | integer \| null | When it may change again, unix seconds. | | `profile.server_time` | integer | The server's clock, unix seconds: date the profile message by it. | #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 400 | `bad_address` | An address is not a mainnet address (or not one at all). | | 400 | `bad_json` | The body is not valid JSON. | | 400 | `bad_message` | The cancel message is not `raresats.net cancel ` or names other listings than `ids`; or the registration message is not `raresats.net affiliate
` for `address`. | | 400 | `bad_request` | The body is not a JSON object, or the engine refused a malformed request (e.g. a purchase submitted without its signed setup PSBT). | | 400, 422 | `bad_signature` | 400: `signature` is missing or too long. 422: a signature does not verify (cancel or registration message, listing or purchase input). | | 403 | `banned` | The address is banned from chat: it may not post or set a display name. | | 403 | `cross_origin` | A POST from a browser on another site (a foreign `Origin`, or `sec-fetch-site: cross-site`). Server-side clients send no `Origin` and are not affected. | | 403 | `muted` | The address is muted (by the AI moderation after repeated refusals: 3 in 10 minutes), or its network (an IPv4 address or IPv6 /48) is paused after 6 refusals within an hour, until `until`, unix seconds: it may not post or set a display name until then. | | 404 | `chat_disabled` | Chat and display names are off on this server. | | 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. | | 409 | `name_taken` | Another address has that display name (compared without case, separators or look-alike digits), gave it up less than 7 days ago, or it was removed by a moderator and is never given out again. | | 409 | `replayed` | That cancel or registration message was used already. | | 409 | `unchanged` | That is already the display name. | | 413 | `too_large` | The body is over 256 KiB. | | 415 | `not_json` | A POST without `content-type: application/json`. | | 422 | `future_message` | The cancel or registration message is dated more than 2 minutes ahead: check the clock. | | 422 | `moderated` | The AI moderation refused the chat message or display name; `category` says why (the message is not posted, the name not taken). Three refusals in 10 minutes mute the address for an hour (`until`). | | 422 | `name_address` | A display name must not look like an address (`bc1…`, base58) or a hash. | | 422 | `name_chars` | A display name uses only letters, digits, spaces and `_ . -`, and starts and ends with a letter or digit. | | 422 | `name_length` | A display name is 3 to 24 characters. | | 422 | `name_reserved` | The display name is reserved (raresats, admin, moderator, support, official, satoshi and the like). | | 422 | `stale_message` | The cancel or registration message is more than 10 minutes old: sign a new one. | | 429 | `chat_busy` | Too many chat messages and names are being checked across the site right now (over the global cap of model calls a minute); wait `retry-after` seconds. | | 429 | `name_change_too_soon` | A display name changes at most once in 24 hours; `next_change_at` says when. | | 429 | `rate_limited` | Over 30 POSTs a minute from your IPv4 address or IPv6 /64 (in chat, counted per IPv4 address or IPv6 /64: 30 writes and 20 login nonces a minute, 20 sign-ins and 10 display-name changes an hour; one message every 3 s and 20 a minute per address); wait `retry-after` seconds. With the AI moderation on, at most 6 messages and names a minute are checked per address and per IPv4 address or IPv6 /48. | | 503 | `moderation_unavailable` | The AI moderation could not check the text right now (timeout or model error): nothing was posted. Try again in a moment. | ```json title="Error (503)" { "error": "Couldn't check your display name right now: try again in a moment", "code": "moderation_unavailable" } ``` ## Get a profile `GET /api/profile/{address}` An address's display name (null when it has none), whether it is a chat admin, and when its name may change next. #### Path parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `address` | string | yes | A mainnet address. | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/profile/bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/profile/bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/profile/bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh").json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `address` | string | | | `display_name` | string \| null | The display name, null when it has none. | | `admin` | boolean | The address is a chat admin. | | `changed_at` | integer \| null | When the name last changed, unix seconds. | | `next_change_at` | integer \| null | When it may change again, unix seconds. | | `server_time` | integer | The server's clock, unix seconds: date the profile message by it. | #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 400 | `bad_address` | An address is not a mainnet address (or not one at all). | | 400 | `bad_path` | The path is not valid percent-encoding. | | 404 | `chat_disabled` | Chat and display names are off on this server. | | 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. | ## Get display names `GET /api/profiles` The display names of up to 200 addresses at once; addresses without one (or that are no address) are left out. Cached 15 s. #### Query parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `addresses` | string | yes | Comma-separated addresses, at most 200. | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/profiles?addresses=bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh,bc1qm34lsc65zpw79lxes69zkqmk6ee3ewf0j77s3h' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/profiles?addresses=bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh,bc1qm34lsc65zpw79lxes69zkqmk6ee3ewf0j77s3h'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/profiles", params={"addresses": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh,bc1qm34lsc65zpw79lxes69zkqmk6ee3ewf0j77s3h"}).json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `profiles` | map of string | address → display name, for the addresses that have one. | ```json title="Response" { "profiles": { "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh": "Rare Hunter" } } ``` #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 400 | `bad_addresses` | `addresses` must list 1 to 200 addresses. | | 404 | `chat_disabled` | Chat and display names are off on this server. | | 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. | ## Remove a display name (admin) `DELETE /api/profile/{address}` An admin session removes an address's display name. The name is never given out again, to anyone. Pages are told with a `profile` event. Logged. #### Path parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `address` | string | yes | A mainnet address. | #### Example request ```bash title="cURL" curl -X DELETE 'https://raresats.net/api/profile/bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh' -H 'cookie: __Host-rs_chat=' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/profile/bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh', { method: 'DELETE', credentials: 'include' }); const data = await response.json(); ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `profile` | object | | | `profile.address` | string | | | `profile.display_name` | string \| null | The display name, null when it has none. | | `profile.admin` | boolean | The address is a chat admin. | | `profile.changed_at` | integer \| null | When the name last changed, unix seconds. | | `profile.next_change_at` | integer \| null | When it may change again, unix seconds. | | `profile.server_time` | integer | The server's clock, unix seconds: date the profile message by it. | | `removed` | string | The name removed. | #### Errors `{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code. | Status | Code | Meaning | | --- | --- | --- | | 400 | `bad_address` | An address is not a mainnet address (or not one at all). | | 400 | `bad_path` | The path is not valid percent-encoding. | | 401 | `no_session` | No chat session: sign in (`POST /api/session`) and send its cookie. | | 403 | `cross_origin` | A POST from a browser on another site (a foreign `Origin`, or `sec-fetch-site: cross-site`). Server-side clients send no `Origin` and are not affected. | | 403 | `not_admin` | Only a chat admin (`SUPPLY_CHAT_ADMINS`) may do that. | | 404 | `chat_disabled` | Chat and display names are off on this server. | | 404 | `not_found` | No such listing or offer. In chat: no such message. A display name to remove that does not exist. | | 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. | | 429 | `rate_limited` | Over 30 POSTs a minute from your IPv4 address or IPv6 /64 (in chat, counted per IPv4 address or IPv6 /64: 30 writes and 20 login nonces a minute, 20 sign-ins and 10 display-name changes an hour; one message every 3 s and 20 a minute per address); wait `retry-after` seconds. With the AI moderation on, at most 6 messages and names a minute are checked per address and per IPv4 address or IPv6 /48. | --- Page: https://raresats.net/docs/site-feeds.md # Supply, rows and status The dashboard's own feeds: what its Statistics, Transfers and Extractions views show. Briefly documented; their shapes follow the rare-sat index and may grow. ## Supply `GET /api/supply` The latest supply of every row (`supply`: `{height, hash, time, rows}`, each row with `total`, `stats` and `active_non_extracted`), the row definitions (`rows`) and their groups (`groups`). A row the site shows as a sub-category of another although its types do not say so names that row in `sub_of`: the milestone alphas (`kilosat_alpha`, `decasat_alpha`, `centasat_alpha`, `megasat_alpha`), labelled Kilosat, Decasat, Centasat and Megasat uncommon, are sub-categories of `uncommon`, and the milestone omegas (`kilosat_omega`, `decasat_omega`, `centasat_omega`, `megasat_omega`), labelled Kilosat, Decasat, Centasat and Megasat black, of `black`. With `years`, every row's supply over the sats mined in those years (rows with none have zeros), and `years` echoed; with `luck`, over the sats mined in blocks of those lucks, and `luck` echoed (`{min, max}`, `max` null for "or more"); with `month`, over the sats mined in that month, and `month` echoed. #### Query parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `years` | string | no | Only the sats mined in these years: comma-separated years (YYYY) blocks were mined in, at most 64 (`year` is accepted too). Counters are the years' sums, `total` and `mined` their sats mined so far, `holders` and `extracted_holders` the addresses holding sats of any of them (exact). Needs a rare-sat index with a year breakdown (`fork.status.year_stats` in `/api/status`), else 501. | | `luck` | string | no | Only the sats mined in blocks of these lucks: `N` (luck N or more) or `N-M`, from 0 to the index's highest luck (31, which also counts any luckier block). A block's luck is the leading zero bits of its hash beyond its target's. Counters are the lucks' sums, `holders` and `extracted_holders` the addresses holding sats of any of them (exact). Not with `years` (400): the supply is broken down by one or the other. Needs a rare-sat index with a luck breakdown (`fork.status.luck_stats` in `/api/status`), else 501. | | `month` | string | no | Only the sats mined in this month (UTC, `YYYY-MM`), one a block was mined in (`months` is accepted too). Counters, `holders` and `extracted_holders` are the month's own (exact). Not with `years` or `luck` (400): the supply is broken down by one of them. Needs a rare-sat index with a month breakdown (`fork.status.month_stats` in `/api/status`, index schema 7), else 501. | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/supply?years=2009,2010&luck=10&month=2017-12' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/supply?years=2009,2010&luck=10&month=2017-12'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/supply", params={"years": "2009,2010", "luck": "10", "month": "2017-12"}).json() ``` #### Response `200` A JSON object. #### Errors `{error}` (a message, no code): | Status | When | | --- | --- | | 400 | Malformed years, a year no block was mined in, a malformed luck or one past the index's highest, a malformed month or one no block was mined in, or more than one of years, a luck and a month. | | 501 | The rare-sat index has no year (luck, month) breakdown (`years`, `luck` or `month` asked of an older index). | | 502 | The index answered with an error. | | 503 | The index is unavailable. | ### By mint year Every number of the supply can be narrowed to the sats mined in some years: `?years=2009` on `/api/supply` and `/api/rows/{id}`, or several years (`?years=2009,2010`). The counters of a set of years add up, but for the holders, which count each address once however many of the years it holds sats of. `?years=2009` gives the same numbers the 2009 rows did: `uncommon_2009` and `rare_2009` are retired, and `/api/rows/uncommon?years=2009` is what `/api/rows/uncommon_2009` was (the latter now answers 404 with a `retired` field naming the row and years to ask for, as does every route taking `row=`; `/collection/uncommon_2009` redirects to `/collection/uncommon?year=2009`). Every year's numbers at once: `GET /api/supply/years` Every row's supply split by the year its sats were mined: `{height, hash, time, years: [{year, from_height, to_height}], rows: [{id, years: {"2009": {total, stats, active_non_extracted}}}]}`, a row listing only the years it has sats of. What the year chips count; cached per block. Needs a rare-sat index with a year breakdown (`fork.status.year_stats` in `/api/status`), else 501. #### Example request ```bash title="cURL" curl 'https://raresats.net/api/supply/years' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/supply/years'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/supply/years").json() ``` #### Response `200` A JSON object. #### Errors `{error}` (a message, no code): | Status | When | | --- | --- | | 501 | The rare-sat index has no year breakdown. | | 502 | The index answered with an error. | | 503 | The index is unavailable. | ### By block luck The same numbers can be narrowed to the sats mined in lucky blocks instead: `?luck=10` (blocks of luck 10 or more) or `?luck=10-12` on `/api/supply` and `/api/rows/{id}`. A block's luck is the leading zero bits of its hash beyond its target's: most blocks are luck 0 to 3, about one in eight hundred is 10 or more. The holders count each address once, as for years; a luck lists no top holders. Years and a luck do not combine in the supply (a 400): the index breaks it down by one or the other. Every luck's numbers at once, with the blocks of each luck: `GET /api/supply/lucks` Every row's supply split by the luck of the blocks its sats were mined in: `{height, hash, time, lucks: [{luck, blocks}], rows: [{id, lucks: {"12": {total, stats, active_non_extracted}}}]}`, a row listing only the lucks it has sats of (`lucks`: the blocks of each luck). What the luck selects count; cached per block. Needs a rare-sat index with a luck breakdown (`fork.status.luck_stats` in `/api/status`), else 501. #### Example request ```bash title="cURL" curl 'https://raresats.net/api/supply/lucks' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/supply/lucks'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/supply/lucks").json() ``` #### Response `200` A JSON object. #### Errors `{error}` (a message, no code): | Status | When | | --- | --- | | 501 | The rare-sat index has no luck breakdown. | | 502 | The index answered with an error. | | 503 | The index is unavailable. | ### By mint month Or to the sats mined in one month (UTC): `?month=2017-12` on `/api/supply` and `/api/rows/{id}`. A month is one at a time, with its own holders (exact) and top holders; it does not combine with years or a luck (a 400). A month is counted as its year is, by the running latest block time, so a year's months add up to it; five blocks of mainnet (272,376 to 272,380) whose own time is 30 November 2013 count in December 2013, where the chain's time already was. Every month's numbers at once, compactly (an array of counters per month, in the order `fields` names them): `GET /api/supply/months` Every row's supply split by the month (UTC) its sats were mined in, compactly: `{height, hash, time, months: [{month, from_height, to_height}], fields: ["mined", "located", "burned", "lost", "overwritten", "extracted", "holders", "extracted_holders", "inscribed"], rows: [{id, months: {"2017-12": [counts in the order of fields]}}]}`, a row listing only the months it has sats of. What a collection's month select counts; cached per block. Needs a rare-sat index with a month breakdown (`fork.status.month_stats` in `/api/status`), else 501. #### Example request ```bash title="cURL" curl 'https://raresats.net/api/supply/months' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/supply/months'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/supply/months").json() ``` #### Response `200` A JSON object. #### Errors `{error}` (a message, no code): | Status | When | | --- | --- | | 501 | The rare-sat index has no month breakdown. | | 502 | The index answered with an error. | | 503 | The index is unavailable. | The sales beside these numbers (the Statistics' Last, 30-day median and volume, and a row's sales chart) follow the same filter: `/api/sales/stats?years=2009` and `/api/sales?row=alpha_uncommon&years=2009` count only the sales of the row's sats mined in 2009, the very sales the collection's Activity lists with `?year=2009` (see [Sales stats](https://raresats.net/docs/get-sales-stats.md)). So does a month (`?month=2017-12`), on any index: the sales are matched on this side, by each block's own day. ## Sat type explainers The text of the site's `/types` pages: what each sat type, category and concept is, with its categories' live numbers. `GET /api/types` Every explainer page of `/types` (one per sat type, category and concept), by section: `sections` (`[{id, label, pages: [{slug, title, summary, href}]}]`), and which page explains each row (`rows`: `{rowId: slug}`) and each sat type (`badges`: `{type: slug}`). The text comes from the site's own pages; the page is `/types/{slug}`. #### Example request ```bash title="cURL" curl 'https://raresats.net/api/types' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/types'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/types").json() ``` #### Response `200` A JSON object. ```json title="Response" { "sections": [ { "id": "rarity", "label": "Rarity tiers", "pages": [ { "slug": "uncommon", "title": "Uncommon", "summary": "The first sat of a block: the lowest rarity tier above common in ordinal theory, one per block bar the few whose first sat ranks higher.", "href": "/types/uncommon" } ] } ], "rows": {"uncommon": "uncommon"}, "badges": {"uncommon": "uncommon"} } ``` #### Errors None of its own. `GET /api/types/{slug}` One explainer: `slug`, `title`, `summary`, `kind` and `kind_label` (its section), `badges` (the sat types it explains), `body` (its text as a tree of blocks: `{t: 'h3'|'h4', id, c}`, `{t: 'p', c}`, `{t: 'ul'|'ol', items: [c]}`, where `c` lists inlines: a string, `{t: 'a', href, c, ext?}`, `{t: 'b'|'i', c}`, `{t: 'code', text}` or `{t: 'n', row, field}`, a live number found in `numbers[row][field]`), `rows` (the categories it explains, each with `total`, `mined`, `extracted`, `holders`, `inscribed` and `market`: `last_unit_price`, `median_30d`, `sales`, and while the marketplace is on `floor_unit` and `listed`), `also_in` (other categories holding its types), `related` pages and `as_of` (the block the numbers are of). #### Path parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `slug` | string | yes | The page's slug, as `/api/types` lists them. | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/types/uncommon' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/types/uncommon'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/types/uncommon").json() ``` #### Response `200` A JSON object. #### Errors `{error}` (a message, no code): | Status | When | | --- | --- | | 404 | No such page. | ## A row `GET /api/rows/{id}` One row: `row` (its supply stats, extracted and non-extracted top holders, label, group and definition) and `history` (extracted and located counts over time). With `years`, over the sats mined in those years (`years` echoed): one year lists its own top holders (at most 100 per list), a set of years none (`null`), and the history's holder counts are `null` for a set. With `luck`, over the sats mined in blocks of those lucks (`luck` echoed): no top holders (`null`), and the history's holder counts are `null` but for a single luck or every luck. With `month`, over the sats mined in that month (`month` echoed): its own top holders (at most 100 per list) and holder counts in its history. #### Path parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | The row id. | #### Query parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `range` | string | no | History range. One of: `all`, `4y`, `1y`, `90d`, `30d`. Default: `all`. | | `top` | integer | no | Holders per list, 1-1000. Default: `100`. (min 1, max 1000) | | `years` | string | no | Only the sats mined in these years: comma-separated years (YYYY) blocks were mined in, at most 64 (`year` is accepted too). Counters are the years' sums, `total` and `mined` their sats mined so far, `holders` and `extracted_holders` the addresses holding sats of any of them (exact). Needs a rare-sat index with a year breakdown (`fork.status.year_stats` in `/api/status`), else 501. | | `luck` | string | no | Only the sats mined in blocks of these lucks: `N` (luck N or more) or `N-M`, from 0 to the index's highest luck (31, which also counts any luckier block). A block's luck is the leading zero bits of its hash beyond its target's. Counters are the lucks' sums, `holders` and `extracted_holders` the addresses holding sats of any of them (exact). Not with `years` (400): the supply is broken down by one or the other. Needs a rare-sat index with a luck breakdown (`fork.status.luck_stats` in `/api/status`), else 501. | | `month` | string | no | Only the sats mined in this month (UTC, `YYYY-MM`), one a block was mined in. Not with `years` or `luck` (400). Needs a rare-sat index with a month breakdown (`fork.status.month_stats`), else 501. | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/rows/uncommon?years=2009&luck=10&month=2017-12' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/rows/uncommon?years=2009&luck=10&month=2017-12'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/rows/uncommon", params={"years": "2009", "luck": "10", "month": "2017-12"}).json() ``` #### Response `200` A JSON object. #### Errors `{error}` (a message, no code): | Status | When | | --- | --- | | 400 | A malformed parameter, or both years and a luck, a malformed month or one no block was mined in, or more than one of years, a luck and a month. | | 404 | No such row. A retired row (`uncommon_2009`, `rare_2009`) answers 404 too, its `retired` field naming the row and years to ask for instead. | | 501 | The rare-sat index has no year (luck, month) breakdown. | | 503 | The index is unavailable. | ## Notable transfers The first page of both feeds (no `before`, `offset` 0) also has `pending`: the notable events of transactions still in the mempool, judged by the same rules as if mined in the next block, outside `total` ([Pending](#pending-the-mempool)). `GET /api/transfers` Notable transfers of rare sats, newest first: `{total, offset, items}`. Page with `before` (an event id) or `offset`. The first page (no `before`, `offset` 0) also has `pending`: the notable transfers of transactions still in the mempool, newest first, outside `total`. #### Query parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `before` | string | no | An event id: the events after it. | | `offset` | integer | no | Events to skip. Default: `0`. (min 0) | | `limit` | integer | no | Events, 1-1000. Default: `200`. (min 1, max 1000) | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/transfers' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/transfers'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/transfers").json() ``` #### Response `200` A JSON object. #### Errors `{error}` (a message, no code): | Status | When | | --- | --- | | 400 | A malformed parameter. | | 404 | `before` is no longer held: reload. | ## Notable extractions `GET /api/extractions` Notable extractions (rare sats newly isolated), newest first, paged like transfers. The first page also has `pending`, as transfers. #### Query parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `before` | string | no | An event id: the events after it. | | `offset` | integer | no | Events to skip. Default: `0`. (min 0) | | `limit` | integer | no | Events, 1-1000. Default: `200`. (min 1, max 1000) | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/extractions' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/extractions'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/extractions").json() ``` #### Response `200` A JSON object. #### Errors `{error}` (a message, no code): | Status | When | | --- | --- | | 400 | A malformed parameter. | | 404 | `before` is no longer held: reload. | ## Pending (the mempool) `GET /api/pending` Every pending sale, notable transfer and extraction of transactions still in the mempool, the live listings being spent, and the transactions that left the mempool lately, from the index's mempool overlay (polled every 5 s). Pending items are marked `pending: true` with `first_seen` and `fee_rate`, and count in no stat. A pending sale is `txid:vin`, a move its `txid`: the confirmed record with the same key replaces it. The SSE `mempool` event says when to read it again. `available` is false against an index without the overlay. #### Example request ```bash title="cURL" curl 'https://raresats.net/api/pending' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/pending'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/pending").json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `available` | boolean | Whether the index serves the mempool overlay and it has been read. | | `seq` | integer \| null | The overlay's change counter: it moves with every change. | | `height` | integer \| null | The index tip the overlay is consistent with; pending items are judged as of the next block. | | `txs` | integer | Pending transactions that move tracked sats. | | `sales` | array of object | Pending sales, newest first. | | `sales[].seq` | null | No sequence number until it confirms. | | `sales[].t` | integer \| null | `first_seen`: sorted by time, it leads the confirmed sales. | | `sales[].h` | null | No block yet. | | `sales[].row` | string | The sale's own row. | | `sales[].u` | number \| null | Unit price: premium per sat of the row. | | `sales[].c` | integer | Sats of the row sold. | | `sales[].p` | integer \| null | Price in sats. | | `sales[].n` | integer | Tracked sats the sale sold. | | `sales[].s` | integer \| null | The first special sat sold. | | `sales[].sh` | integer \| null | The block that mined it. | | `sales[].sd` | integer \| null | The day it was mined (UTC days since 1970). | | `sales[].m` | string | Marketplace id (`magisat`, `magic_eden`, `unknown`, …). | | `sales[].x` | string \| null | The txid's first 8 and last 4 hex digits. | | `sales[].ins` | boolean | An inscription was on a sold sat. | | `sales[].out` | boolean | Always false: a pending sale is judged by no median. | | `sales[].secondary` | map of integer | A sale's secondary rows: the market rows of the other sats it sold (not its own row, a row it is a sub-category of, or a label row), each with its sats, primary-first: `{"black_omega": 1}` for a black omega sold with an alpha uncommon; `{}` for none. A sale never counts in its secondary rows' markets (the primary-sat rule, in Concepts). | | `sales[].seller` | string \| null | The seller's address. | | `sales[].buyer` | string \| null | The buyer's address. | | `sales[].key` | string | `txid:vin`: its identity. The confirmed sale has the same txid and vin and replaces it. | | `sales[].pending` | `true` | Always true: the sale is still in the mempool. | | `sales[].first_seen` | integer \| null | When the transaction was first seen in the mempool (bitcoind's entry time), unix seconds. | | `sales[].fee_rate` | number \| null | Its fee rate, sat/vB (one decimal). | | `sales[].txid` | string | The sale transaction, whole. | | `sales[].vin` | integer \| null | The seller's input. | | `sales[].seller_outpoint` | string \| null | The output it sells. | | `sales[].buyer_outpoint` | string \| null | The output the sold sats go to. | | `sales[].rows` | array of string | Its own row, then the rows of which it is a rarer sub-category. | | `sales[].led_rows` | array of string | The rows of its other sats (it is led by `row` there). | | `sales[].labels` | array of string | The label rows it lies in. | | `sales[].kind` | string \| null | The fork's sale kind (`listing`, `offer`, …). | | `transfers` | array of object | Pending notable transfers, newest first. | | `transfers[].id` | string | `pending:transfer:` or `pending:extraction:`. | | `transfers[].kind` | string | One of: `transfer`, `extraction`. | | `transfers[].txid` | string | | | `transfers[].pending` | `true` | | | `transfers[].first_seen` | integer \| null | When the transaction was first seen in the mempool (bitcoind's entry time), unix seconds. | | `transfers[].fee_rate` | number \| null | Its fee rate, sat/vB (one decimal). | | `transfers[].height` | null | | | `transfers[].time` | integer \| null | `first_seen`. | | `transfers[].reasons` | array of object | Why it is notable. | | `extractions` | array of object | Pending notable extractions, newest first. | | `extractions[].id` | string | `pending:transfer:` or `pending:extraction:`. | | `extractions[].kind` | string | One of: `transfer`, `extraction`. | | `extractions[].txid` | string | | | `extractions[].pending` | `true` | | | `extractions[].first_seen` | integer \| null | When the transaction was first seen in the mempool (bitcoind's entry time), unix seconds. | | `extractions[].fee_rate` | number \| null | Its fee rate, sat/vB (one decimal). | | `extractions[].height` | null | | | `extractions[].time` | integer \| null | `first_seen`. | | `extractions[].reasons` | array of object | Why it is notable. | | `listings` | map of `PendingSpend` | Live marketplace listings whose UTXO a pending transaction spends, by outpoint: they show as pending. | | `removed` | array of object | Transactions that left the mempool lately (the live event: those since the last one). | | `removed[].txid` | string | | | `removed[].status` | string | `confirmed`: in a block (its confirmed items replace the pending ones); `replaced` (RBF, `replaced_by` set), `conflicted` (a block spent an input otherwise) or `evicted`: its pending items are dropped. | | `removed[].height` | integer \| null | The block, when confirmed. | | `removed[].replaced_by` | string \| null | The replacement, when replaced. | | `removed[].at` | integer \| null | When it left, unix seconds. | #### Errors None of its own. The index watches the mempool (its overlay, polled every 5 s and right after each block): a transaction still waiting there that moves tracked sats is shown wherever confirmed activity is, marked `pending: true` with `first_seen` (unix seconds) and `fee_rate` (sat/vB). The site shows it with a **Pending** chip. - **Identity.** A pending sale is `txid:vin` (its `key`), a pending transfer or extraction its `txid` (`id: pending::`). When the transaction confirms, the confirmed record with the same key replaces the pending one at once; nothing is listed twice. - **Leaving.** `removed` names the transactions that left the mempool: `confirmed` (now in a block), `replaced` (RBF; `replaced_by`), `conflicted` (a block spent an input otherwise) or `evicted`. Their pending items are gone from the lists. - **Never counted.** Pending sales are in no stat, median, volume, floor, leaderboard or supply until they confirm. - **Where else.** `pending` in [Sales](https://raresats.net/docs/get-sales.md) and the [market overview](https://raresats.net/docs/get-market.md); the first page of `/api/transfers` and `/api/extractions`; the first page of [Address activity](https://raresats.net/docs/get-address-activity.md); `spent_by_pending` and `pending_outputs` on [Address](https://raresats.net/docs/get-address.md); `pending` on [Sat](https://raresats.net/docs/get-sat.md); `spent_in` and `pending` on [UTXO](https://raresats.net/docs/get-utxo.md); `spent_by_pending` on the listing of a sat or UTXO page. - **Live.** The SSE stream sends `mempool` whenever what is pending changes: an invalidation, `{version, keys: {sales, transfers, extractions, rows, addresses, outpoints}, counts}`, naming what changed (the rows, addresses and outpoints of the transactions added, removed or changed), never the items. Read this route, or the answer you show, again when a key you care about is set. - Against an index without the mempool overlay, `available` is false and every list is empty. ## Everything at once `GET /api/state` Everything the site shows at once: `thresholds`, `limits`, `fork`, `feed`, `rows`, `groups`, `supply`, `sales`, and the newest `transfers` and `extractions`. `pending` is everything pending in the mempool (`/api/pending`). #### Query parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `limit` | integer | no | Events per list, 1-1000. Default: `500`. (min 1, max 1000) | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/state' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/state'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/state").json() ``` #### Response `200` A JSON object. #### Errors `{error}` (a message, no code): | Status | When | | --- | --- | | 400 | A malformed limit. | ## Live events Every change the site shows (blocks, sales, listings, offers, floors, activity, prices, chat) comes down one Server-Sent Events stream: see [Live events](https://raresats.net/docs/live-events.md). ## Status `GET /api/status` The rare-sat index's reachability, mode and progress (`fork`) and the dashboard's tip (`feed`). #### Example request ```bash title="cURL" curl 'https://raresats.net/api/status' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/status'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/status").json() ``` #### Response `200` A JSON object. #### Errors None of its own. ## Health `GET /healthz` `{ok, fork, mode, height, tip}`: `fork` is `up`, `stale` or `down`. #### Example request ```bash title="cURL" curl 'https://raresats.net/healthz' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/healthz'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/healthz").json() ``` #### Response `200` | Field | Type | Description | | --- | --- | --- | | `ok` | boolean | Always true when the dashboard answers. | | `fork` | string | The index. One of: `up`, `stale`, `down`. | | `mode` | string \| null | The index's mode. | | `height` | integer \| null | The index's height. | | `tip` | integer \| null | The dashboard's tip. | ```json title="Response" {"ok": true, "fork": "up", "mode": "live", "height": 969001, "tip": 969001} ``` #### Errors None of its own. --- Page: https://raresats.net/docs/live-events.md # Live events Everything the site shows changes live. One Server-Sent Events stream, `GET /api/events`, carries: - each new block; - the sales; - every listing and offer change; - the floors; - the marketplace's activity; - affiliate stats; - the wallet transfers the site broadcast; - the BTC/USD price; - the chat. A page opens one stream and updates in place. A script can do the same with any SSE client. `GET /api/events` Server-Sent Events: a `snapshot` (the state) on connect, then `status`, `backfill`, `block`, `supply`, `reorg`, `snapshot` and `sales` events, `mempool` (an invalidation whenever what is pending changes: `{version, keys: {sales, transfers, extractions, rows, addresses, outpoints}, counts: {sales, transfers, extractions}}`; read `/api/pending`, or the answer you show, again when a key you care about is set), the chat's `chat` (`{type: "message", topic, message, thread?}`, a reply with its root's new counts in `thread`, `{type: "delete", ids, threads, topics}`, `threads` the roots whose replies went, or `{type: "topic", topic, archived}`) and `profile` (`{address, display_name}`) events while chat is on, the marketplace's `listing`, `offer`, `market`, `activity`, `affiliate` and `transfer` (a wallet transfer broadcast here changed status: `{version, count, keys: {addresses}, changes}`) events while it is on, and a `ping` every 15 s. Limited to 2,048 open streams, 8 from one IPv4 address or IPv6 /64 and 32 from one IPv6 /48. When every place is taken, a newcomer takes the place of the oldest stream of the network holding the most (at least two more than the newcomer's): that stream gets `retry: 60000` and an `evicted` event (`{reason, retry_ms}`), then ends; reconnect after `retry_ms` with `last_event_id`. #### Query parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `limit` | integer | no | Events per list in the snapshot, 1-1000. Default: `500`. (min 1, max 1000) | | `last_event_id` | integer | no | Resume after this event id (what `Last-Event-ID` says; for a client that reopens the stream itself). Unknown or too old: a `snapshot` instead. (min 0) | #### Example request ```bash title="cURL" curl 'https://raresats.net/api/events' ``` ```js title="JavaScript" const response = await fetch('https://raresats.net/api/events'); const data = await response.json(); ``` ```python title="Python" import requests data = requests.get("https://raresats.net/api/events").json() ``` #### Response `200` An event stream. Content type: `text/event-stream`. #### Errors `{error}` (a message, no code): | Status | When | | --- | --- | | 503 | Too many open streams from your network (8 from an IPv4 address or IPv6 /64, 32 from an IPv6 /48), or every place taken and no network holding more than yours. | ## Frames Each frame has three fields, and `data` is one line of JSON: ``` id: 1790950000000042 event: listing data: {"version":7,"changes":[…],"listings":[…]} ``` - **Ids.** Ids increase strictly, across server restarts too. `ping`, `hello` and the `retry:` line carry none, so `Last-Event-ID` always names a real event. - **Ping.** A `ping` (`{"time": }`) comes every 15 s. A stream silent for longer than about 45 s is dead: open it again. - **Hello.** `hello` is the first frame of every connection: `{"boot": , "resumed": , "replayed": , "last_id": }`. ## Resuming Send the id of the last event you handled, either in the `Last-Event-ID` header (browsers do this on their own reconnects) or as `?last_event_id=`. - **Replayed.** If the server still holds every event after it, it sends them in order (`hello.resumed: true`) and then goes on live. No `snapshot` is sent. - **Not replayed.** If the server no longer holds them, it sends a `snapshot` (`hello.resumed: false`). The id may be from before a restart, or too old for the server's buffer of the last 2,000 events or 4 MiB. Treat this as a gap: read again whatever you show. A snapshot frame's id is the newest event's id when it was sent, so resuming from it replays only what came after. - **Taken down.** The replay never brings back what a chat admin removed. A deleted message's frame is dropped (and a quote of it emptied); a removed or changed display name drops the address's earlier `profile` frames and is written into its earlier messages. The removals themselves (`chat` `delete`, `profile`) are replayed in their place. ## Limits A server holds at most 2,048 streams: 8 from one IPv4 address or IPv6 /64, and 32 from one IPv6 /48. Over your network's share, the stream is refused with a 503. When every place is taken, a newcomer takes the place of the oldest stream of the network holding the most (if it holds at least two more than the newcomer's). That stream gets `retry: 60000` and an `evicted` event, `{"reason", "retry_ms"}`, then ends: reconnect after `retry_ms`, resuming from your last id. ## Event types | Event | When | Data | |---|---|---| | `hello` | first frame of a connection | `{boot, resumed, replayed, last_id}` | | `snapshot` | on connect when not resumed; after the server rebuilds its window | what `GET /api/state` answers | | `status` | the index's reachability, mode or tip changed | what `GET /api/status` answers | | `backfill` | the server reads past blocks | `{done, total, …}` | | `block` | new blocks read | `{tip, status, supply, transfers, extractions}`: the new notable transfers and extractions only | | `supply` | the supply was read at a new tip | `{supply, status}` | | `reorg` | blocks were replaced | `{from_height, common_height, removed: {transfers, extractions}, tip}`: the ids of the items dropped | | `sales` | the sales changed | `{available, generation, seq, count, as_of, first_time, stats}`. Read the new sales with `GET /api/sales?after=` while `generation` is the one you hold; on a new `generation` read them all again | | `listing` | listings changed state | see [Listing events](#listing-events) | | `offer` | offers changed state | see [Offer events](#offer-events) | | `market` | a row's floor or listed count changed | `{version, floors: {: {floor_unit, listed, listed_sats, contains?, contains_min_price?} \| null}}`: only the rows that changed, as [floors](https://raresats.net/docs/get-floors.md) gives them; `null` means nothing is listed there now, nor in a bundle | | `activity` | new marketplace activity | `{version, events: [ActivityEvent] \| null, keys: {rows, addresses}}`: `events` as `GET /api/market/activity` lists them, oldest first; `null` over 25 | | `affiliate` | an affiliate registered (optional), or a purchase it referred was made or settled | `{version, keys: {addresses}}`: read `GET /api/market/affiliates/{address}` again | | `transfer` | a wallet transfer the site broadcast went pending, confirmed, was replaced (sped up, cancelled), conflicted, dropped or was refused (rejected) | see [Transfer events](#transfer-events) | | `price` | the BTC/USD price changed (read every 5 minutes while a page is open) | `{current: {usd, time}}` | | `chat` | a message or reply was posted, or messages were deleted | `{type: "message", message, thread?}` or `{type: "delete", ids, threads}` | | `profile` | a display name changed | `{address, display_name}` | | `ping` | every 15 s | `{time}` | | `evicted` | the stream is ended to make room (see [Limits](#limits)) | `{reason, retry_ms}` | ## Listing events ``` {"version": 7, "count": 1, "keys": {"rows": [rows], "sellers": [addresses]}, "changes": [{"id", "outpoint", "status", "row", "under": [rows], "seller_address", "price", "unit_price", "updated_at"}] | null, "listings": [Listing] | null} ``` - **What a change says.** Each change gives the listing's new `status`: `active`, `pending`, `sold`, `cancelled` or `moved`. - `under` lists every row the listing is shown under: its own, the rarer sub-categories', the label rows' and those it holds some sats of in a bundle. Use it to tell whether a list you show is affected. - **Full objects.** `listings` holds the full [Listing objects](https://raresats.net/docs/listing-object.md), exactly as `GET /api/market/listings/{id}` answers. - **Large bursts.** A block that settles many listings, or a large batch listed, can change many at once. One event keeps its size down in two steps: - **Over 25 listings:** `listings` is `null`, and the changes are all still there. - **Over 100:** `changes` is `null` too, and `keys` (every row and seller touched) and `count` remain. In either case, read again once the lists you show that the keys name. - **Transitions.** Every transition is a change, whichever route, block or mempool check made it: - **Listed or repriced.** A listing is created, or a new one replaces the old one: the old id is `cancelled` and the new one `active`. - **Cancelled.** - **Bought.** The listing goes `pending` with its `txid`, then `sold` once confirmed. If the purchase leaves the mempool unconfirmed it goes back to `active`. - **Moved** (`moved`). Its UTXO was spent without paying the listing. - **Signature public.** The listing's signature became public. - **Placed under a new row.** ## Offer events ``` {"version": 8, "count": 1, "keys": {"rows": [rows], "addresses": [sellers and bidders]}, "changes": [{"id", "outpoint", "status", "row", "seller_address", "bidder_payment_address", "bidder_ordinals_address", "price", "updated_at"}] | null, "offers": [Offer] | null, "best": {"": Offer | null} | null} ``` - **Statuses.** Each change gives the offer's new `status`: `active`, `pending`, `accepted`, `cancelled`, `expired` or `invalid`. The full [Offer](https://raresats.net/docs/offer-object.md) gives an invalid offer's `reason`. - **`best`.** `best` holds the best active offer of each outpoint the changes touched, as `GET /api/market/offers/best` answers it, or `null` when none is left. It is what a listing's `best_offer` shows. - **Large bursts.** As for listings: over 25 changes `offers` is `null`, and over 100 `changes` is `null` too. `best` is left out (`null`) above 200 outpoints. An accepted offer also moves the UTXO's listing: a `listing` event says it is `pending`, then `sold` with `sold_via: "offer"`. ## Transfer events ``` {"version": 9, "count": 1, "keys": {"addresses": [addresses]}, "changes": [{"id", "txid", "kind", "status", "updated_at", "addresses": [addresses]}] | null} ``` - **When.** A transfer built on the site (extract, send, consolidate, speed up, cancel, …) is broadcast (`pending`), and then settles: `confirmed` in a block, `replaced` by its speed-up or cancel, `conflicted` (spent by another transaction), `dropped` (left the mempool), `unknown` (the broadcast's answer was lost) or `rejected` (bitcoind refused it: never sent). - **`addresses`.** Every address whose page shows it: the owner's payment and ordinals addresses and every address it pays, the marketplace's fee address left out. Read `GET /api/market/transfers?address=` again for an address you show. - **Large bursts.** Over 100 changes, `changes` is `null` and `keys` remains. Transactions that move tracked sats also show in the `mempool` event while pending; this one covers every transfer the site broadcast, plain bitcoin too. ## What never goes down the stream Events carry only what the GET endpoints already answer, from the same public forms. They never include: - a signature; - a PSBT; - an offer's plan; - a script; - a purchase's reservation. ## Bursts and load - **Coalescing.** The server gathers a market burst for 250 ms and sends one event of each type. - **Reading again.** A client should do the same: wait a moment after an event and read once what it shows. Never send one request per change. - **Hidden pages.** A hidden page may skip its reads and read once when it shows again.