You hold prize inventory with CardOS — a card set aside for first place, a shelf of pre-opened prizes for everyone who paid to enter. A send delivers one of those to a wallet you name. One call, and the prize is the winner's.
A send puts the token in the winner's wallet and stops. It starts no shipment, collects no address, and asks them for nothing. What they do next is theirs: keep it, sell it back, or redeem it for the physical item weeks later. That is the whole design — an award ceremony should not be a checkout.
POST /api/v1/sends, scope sends:create, Idempotency-Key required.
POST /api/v1/sends
X-API-Key: rip_v1_…
Idempotency-Key: 6f1c… # replays return the ORIGINAL send
{
"wallet_address": "0x…", # the winner
"items": [
{ "unique_id": "CARD-RIP0001" }, # a card, a slab, or a sealed product
{ "container_token_id": "777" } # a sealed product by its on-chain id
],
"reference": "chinoize-106:1st", # your handle, echoed back everywhere
"buyback_eligible": true
}
→ 202 { "send_id": 500, "status": "in_progress",
"items": [ { "unique_id": "CARD-RIP0001", "asset_type": "CARD",
"action": "TRANSFER", "status": "PENDING", … } ],
"skipped": [] } One id per item. unique_id is the CardOS asset id
(CARD-…, GRADED-…, SEALED-…) and works for all three kinds. container_token_id exists because a wallet holding a sealed product shows you its
token id, not its unique id. Supplying both is 400 invalid_item.
action is decided, not requested. An asset CardOS holds off-chain is minted straight to the winner; one already minted and held by the CardOS server wallet is transferred. There is no third option, and you do not choose.
An asset that cannot be sent comes back in skipped[] with a reason, and the rest of
the send proceeds:
| reason | What happened |
|---|---|
burned | The token has already been burned — it is gone. |
listed | It is listed on the marketplace. Delist it, then send. |
already_queued | It is already PENDING or SENDING in another send. |
not_held_by_server_wallet | Minted, but held by somebody else. CardOS can only send what it holds. |
not_offchain | An unminted asset that is not in a mintable state (mid-intake, mid-grade). |
container_not_minted | A sealed product with no on-chain token yet. Containers are transfer-only. |
not_sealed | A container that has been queued to be opened. |
tied_to_packet | An unminted card that still belongs to an unopened packet. |
missing_grade_proof | An unminted slab with no grade proof to mint from. |
If every item is skipped the call is 409 no_sendable_items and your Idempotency-Key is not burned — fix the list and retry with the same key.
A container is a sealed product as an on-chain token: a booster box, ETB, tin or bundle. It is not a card, and it never becomes one until somebody opens it. Two rules follow:
container_not_minted in skipped[], never quietly minted.buyback_eligible records unsupported_asset_type for it. The winner can still redeem it, or trade it.buyback_eligibleA prize that changed hands cannot be sold back, unless you ask. The sell-back
contract allows a sale only when the on-chain window names the seller — and for a transferred
prize that window still names whoever the card was first distributed to. Setting buyback_eligible: true asks CardOS to re-open that window in the winner's name once each item lands.
| Item | Result |
|---|---|
| Card or graded slab with a price group | Window re-opened in the winner’s name. buyback_registered_at is set. |
| Card with no CardPriceOracle group | buyback_error: no_oracle_group — the payout could not be priced, so no window is opened rather than one that cannot pay. |
| Sealed product (container) | buyback_error: unsupported_asset_type — a container has no sell-back window at all; its buyback rail is a marketplace offer. |
| Registration reverted on chain | buyback_error carries the revert. The prize is still delivered; only the window is missing. |
This is deliberately not a database flag. Eligibility is enforced on chain, so recording it only
in our mirror would make POST /mystery/sellback/quote answer "eligible"
for a sale the contract then reverts. If buyback_registered_at is null on an item,
the winner cannot sell that item back — and the honest thing is to say so in your UI.
Delivery is on-chain and asynchronous, so the 202 is not the outcome. Subscribe to send.fulfilled and send.failed (registration, signing and retries are in
the webhooks guide), or poll GET /api/v1/sends/:send_id.
// send.fulfilled — every item landed
{ "event": "send.fulfilled", "id": "send.fulfilled:500", "data": {
"send_id": 500, "status": "complete", "wallet_address": "0x…",
"reference": "chinoize-106:1st", "sent_count": 1, "failed_count": 0,
"items": [ { "unique_id": "SEALED-RIP32A9…", "asset_type": "CONTAINER",
"status": "SENT", "token_id": "777", "tx_hash": "0x…",
"buyback_error": "unsupported_asset_type" } ] } }
// send.failed — nothing landed, OR only some of it did
{ "event": "send.failed", "id": "send.failed:501", "data": {
"send_id": 501, "status": "complete_with_errors",
"sent_count": 1, "failed_count": 1,
"items": [ { "unique_id": "CARD-RIP0001", "status": "SENT", … },
{ "unique_id": "CARD-RIP0002", "status": "FAILED",
"error": "unexpected owner 0x…" } ] } } A partial send is send.failed, not send.fulfilled. "Some
of the prize arrived" is not a success, and a webhook that says otherwise is one your support
queue pays for.
GET /api/v1/wallets/:address/holdings is the
read behind a "you hold this prize: redeem it, or keep it" screen. It walks the wallet, not your purchases — which is why a prize you sent appears there at all, having
no purchase behind it. Each row carries redeem_status (REDEEMABLE · REDEEMING · REDEEMED · NOT_REDEEMABLE), the live redemption if one of yours is running, and buyback — { eligible, expires_at } — for cards and slabs.
A container redeems through the ordinary quote → prepare → submit flow, with three differences worth knowing before you build against it:
unique_id (SEALED-…). A container's
token id shares a number space with packets — two different contracts — so a bare token_id can come back 409 ambiguous_token. A unique_id never can, and it resolves the item_type for you.REDEEM on the SealedProduct contract, so unsigned.calls[0].to is that contract, not Card. The approve + payShipping pair, the expiry, the idempotency, the redemption.updated webhooks and the tracking are all identical.One extra eligibility rule: the container must still be SEALED. One already queued to
be opened is 409 not_burnable — it is about to become packets, and you cannot ship
both.
Idempotency-Key, the winner's wallet_address and the items → 202, every item PENDING.buyback_eligible was set, re-opens the sell-back window in the winner's name.send.fulfilled — or send.failed, and
reads items[] to see which ones landed.