Orders and fulfilment
Read shop orders, fulfil manual orders, disclose a delivered code under audit, and understand what your players do on the player plane.
An order is created each time one of your players buys something. Orders are placed on the player plane. Your integration reads them, fulfils the ones that need your team and, when necessary, discloses a delivered code or reverses an order.
Order states
| Status | Meaning |
|---|---|
settled | Paid and delivered in the purchase transaction (award and code lines) |
awaiting-fulfillment | Paid; a single manual line your team still has to deliver |
in-progress | Your team has claimed the manual order; the player can no longer cancel it |
fulfilled | Your team recorded delivery of the manual order |
rejected | Refused at purchase; nothing was charged. rejection.reasonCode says why |
cancelled | The player cancelled their own unclaimed manual order and was refunded |
reversed | Your team reversed the order and the player was refunded |
flowchart LR
purchase([Purchase]) --> settled
purchase --> awaiting[awaiting-fulfillment]
purchase --> rejected
awaiting -->|claim| inprogress[in-progress]
inprogress -->|fulfillment| fulfilled
awaiting -->|player cancels| cancelled
settled -->|reversal| reversed
awaiting -->|reversal| reversed
inprogress -->|reversal| reversed
fulfilled -->|reversal| reversed
Read orders
Both routes need shop:read.
GET /v1/shop/orders returns the 100 most recent orders, newest first. It takes no pagination
parameters. Filter with the optional query parameters status, listingKey and playerId:
curl "$REWARD_API_URL/v1/shop/orders?status=awaiting-fulfillment" \
-H "Authorization: Bearer $TANITAR_API_KEY"GET /v1/shop/orders/:orderId returns one order, or 404 SHOP_ORDER_NOT_FOUND.
An order contains:
| Field | Meaning |
|---|---|
orderId, playerId | The order and the player who placed it |
purchaseKind | cart (checkout of the player's cart) or direct (one listing bought on its own) |
status, revision | The state above and a counter incremented on every change |
lines[] | Per line: listingKey, listingRevision, quantity, fulfillmentKind, awardKey, basePrice, effectivePrice, discount, codeCount, awardId |
total | What the order charged, per currency |
rejection | { reasonCode, reason } for a rejected order, otherwise null |
placedAt, settledAt | Timestamps |
cartId, ledgerOperationKey | The cart it came from and the ledger operation that took the currency |
fulfillment | For manual orders: claimedBy, claimedAt, fulfilledBy, fulfilledAt, note |
reversal | For cancelled or reversed orders; see Reversal |
The price on each line is the price actually charged, pinned at purchase; later edits to the listing do not change it. An order never contains a code.
Fulfil a manual order
A manual listing is delivered outside Tanitar: a physical item, a service, a credit in another
system. Its order waits in awaiting-fulfillment until your team takes it on. Both steps need
shop:fulfill and an Idempotency-Key. The actor recorded on the order is the API key (or
administrator) that made the call; there is no field to name someone else.
-
Claim the order so nobody else works on it, and so the player can no longer cancel it:
curl -X POST "$REWARD_API_URL/v1/shop/orders/ord_123/claim" \ -H "Authorization: Bearer $TANITAR_API_KEY" \ -H "Idempotency-Key: $(uuidgen)"The order moves to
in-progress. A claim by the holder is a replay and returns the order; a claim by anyone else, or a claim that loses a race with the player's cancellation, is refused with409 SHOP_ORDER_TRANSITION_CONFLICT, whose message names the state the order is actually in. -
Deliver the item in your own system.
-
Record fulfilment, with an optional note (at most 1000 characters) the player can see:
curl -X POST "$REWARD_API_URL/v1/shop/orders/ord_123/fulfillment" \ -H "Authorization: Bearer $TANITAR_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "note": "Shipped with tracking number 1Z999AA10123456784" }'The order moves to
fulfilledandshop.order.fulfilledis published. Only anin-progressorder can be fulfilled. The claim is kept, so if one person claimed and another fulfilled, the order records both.
To automate manual fulfilment, subscribe a webhook endpoint to shop.order.settled, pick out orders
whose status is awaiting-fulfillment, and drive the two calls above from your fulfilment system.
Code disclosure
A player can always re-read the codes they bought. Your support team may also need to see one when a
player reports that it does not work. This break-glass action has its
own scope, shop:disclose-code, which no other scope implies.
curl -X POST "$REWARD_API_URL/v1/shop/orders/ord_456/code-disclosures" \
-H "Authorization: Bearer $TANITAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "reason": "Support ticket 88213: player reports the code was already redeemed" }'{
"disclosureId": "…",
"orderId": "ord_456",
"lines": [
{ "listingKey": "steam-key-10", "codes": [{ "codeId": "code_…", "code": "AAAA-BBBB-CCCC" }] }
],
"disclosedAt": "2026-10-01T09:30:00.000Z"
}reasonis required, 1 to 1000 characters. A missing or whitespace-only reason is refused with422 SHOP_REASON_REQUIRED, and nothing is disclosed or audited.- An audit record naming the caller, the order and the reason is committed before any code is
decrypted.
disclosureIdidentifies it, so you can cite it in the support conversation. - It is a
POSTso the code never appears in a URL, and the response carriesCache-Control: no-store. It does not use anIdempotency-Key: every call is a separate, separately audited disclosure. - The response covers every
codeline of the order. An order answers404 SHOP_CODE_UNAVAILABLEwhen it is not acodepurchase, is not settled or was already undone.
Treat the response as a secret: do not log it or store it.
What your players do
Players shop through the player plane, authenticated by their player context (see Player identity). Your Player client calls these routes with the short-lived Player token; you do not call them with an API key. Every route takes the player from the context, so no parameter can name another player, and another player's order reads as not found.
| Route | What it does |
|---|---|
GET /player/v1/shop/listings | The published catalog, with prices, active discount, stock and availability |
GET /player/v1/shop/listings/:listingKey | One published listing |
GET /player/v1/shop/cart | The player's one open cart |
PUT /player/v1/shop/cart/lines/:listingKey | Add a line or set its quantity (1 to 100). The quantity is set, not added, so a retried tap never doubles it |
DELETE /player/v1/shop/cart/lines/:listingKey | Remove a line |
DELETE /player/v1/shop/cart | Empty the cart |
POST /player/v1/shop/checkout | Buy the whole cart, all or nothing |
POST /player/v1/shop/purchases | Buy one listing on its own (listingKey, quantity): the only way to buy a manual listing |
GET /player/v1/shop/orders | The player's own orders |
GET /player/v1/shop/orders/:orderId | One of their orders |
POST /player/v1/shop/orders/:orderId/codes | Re-read the codes of their own order |
POST /player/v1/shop/orders/:orderId/cancel | Cancel their own manual order until your team claims it |
Checkout and direct purchase require an Idempotency-Key header, and may carry the total the player
expects to pay (expectedTotal, one { currencyKey, amount } per currency). The player's view of an
order omits playerId, cartId, the ledger key, and who on your team claimed, fulfilled or reversed
it.
A cart holds award and code lines only; a manual listing is refused at add-to-cart with
SHOP_MANUAL_NOT_CARTABLE. An award listing is bought one unit at a time, because a player holds an
award once (SHOP_AWARD_QUANTITY_UNSUPPORTED).
Purchase refusals
A refusal caused by the request itself writes nothing:
| Status | errorCode | Cause |
|---|---|---|
422 | SHOP_IDEMPOTENCY_KEY_REQUIRED | No Idempotency-Key header |
409 | SHOP_IDEMPOTENCY_KEY_REUSED | The key was already used for a different purchase |
422 | SHOP_ORDER_INVALID | Malformed purchase request |
422 | SHOP_CART_EMPTY | Checkout of an empty cart |
409 | SHOP_CART_CONFLICT | The cart changed or was checked out concurrently |
422 | SHOP_MANUAL_NOT_CARTABLE | A manual listing in the cart |
A refusal caused by the catalog, the stock, the limit or the wallet is a settled rejection: it is
answered 422 with the errorCode below and the orderId of the rejected order it created, and it
is published as shop.order.settled with the same code as rejection.reasonCode.
errorCode | Cause |
|---|---|
SHOP_INSUFFICIENT_FUNDS | The player's balance does not cover the price |
SHOP_SOLD_OUT | No stock left |
SHOP_LIMIT_REACHED | The per-player limit is used up for this window |
SHOP_LIMIT_UNAVAILABLE | The limit could not be checked; the purchase is refused rather than allowed |
SHOP_PRICE_CHANGED | The server price differs from expectedTotal |
SHOP_LISTING_UNPUBLISHED, SHOP_LISTING_RETIRED | The listing is not on sale |
SHOP_CURRENCY_UNPUBLISHED, SHOP_CURRENCY_SCOPE_UNSUPPORTED | The price currency cannot be charged |
SHOP_AWARD_DEFINITION_UNPUBLISHED | The award the listing delivers is not published |
SHOP_AWARD_ALREADY_HELD | The player already holds the award |
SHOP_AWARD_QUANTITY_UNSUPPORTED | More than one unit of an award listing |
SHOP_PRICE_LEGS_UNSUPPORTED, SHOP_AMOUNT_UNSUPPORTED | The price cannot be charged as authored |
Award and grant refusals may also be carried through under their own AWARD_… or GRANT_… codes.
Refusals on your routes
| Status | errorCode | Cause |
|---|---|---|
404 | SHOP_ORDER_NOT_FOUND | No order with that id |
409 | SHOP_ORDER_TRANSITION_CONFLICT | The order is not in the state the action needs (for example, already claimed by someone else, or cancelled by the player) |
404 | SHOP_CODE_UNAVAILABLE | The order holds no code to disclose |