Prize sends

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.

The prize is held, not fulfilled

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.

Sending

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.

Nothing is dropped silently

An asset that cannot be sent comes back in skipped[] with a reason, and the rest of the send proceeds:

reasonWhat happened
burnedThe token has already been burned — it is gone.
listedIt is listed on the marketplace. Delist it, then send.
already_queuedIt is already PENDING or SENDING in another send.
not_held_by_server_walletMinted, but held by somebody else. CardOS can only send what it holds.
not_offchainAn unminted asset that is not in a mintable state (mid-intake, mid-grade).
container_not_mintedA sealed product with no on-chain token yet. Containers are transfer-only.
not_sealedA container that has been queued to be opened.
tied_to_packetAn unminted card that still belongs to an unopened packet.
missing_grade_proofAn 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.

Sealed products

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:

  • Containers are transfer-only. Minting one needs a registered on-chain product type and a warehouse serial — an intake operation, not a send. A container with no token yet is container_not_minted in skipped[], never quietly minted.
  • A container has no sell-back window. buyback_eligible records unsupported_asset_type for it. The winner can still redeem it, or trade it.

Sell-back: buyback_eligible

A 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.

ItemResult
Card or graded slab with a price groupWindow re-opened in the winner’s name. buyback_registered_at is set.
Card with no CardPriceOracle groupbuyback_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 chainbuyback_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.

Webhooks

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.

What the winner sees

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.

Redeeming a sealed product

A container redeems through the ordinary quotepreparesubmit flow, with three differences worth knowing before you build against it:

  1. Identify it by 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.
  2. The parcel is sized as sealed product, not from a card count: one container quotes a 14×10×10 in, ~3 lb box. A booster box is not "one card", and quoting it as one would buy a label the warehouse cannot use.
  3. The burn is 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.

The whole thing, end to end

  1. Partner: POST /api/v1/sends with an Idempotency-Key, the winner's wallet_address and the items → 202, every item PENDING.
  2. rip.fun: mints or transfers each item on chain and, when buyback_eligible was set, re-opens the sell-back window in the winner's name.
  3. Partner: takes send.fulfilled — or send.failed, and reads items[] to see which ones landed.
  4. End user: holds the prize. GET /wallets/:address/holdings says whether it can be redeemed now and whether it can be sold back.
  5. End user (later, optional): redeems it — quote → prepare → sign the burn + shipping payment → submit — and the warehouse ships it.