TANITARDevelopers
GuidesShop

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

StatusMeaning
settledPaid and delivered in the purchase transaction (award and code lines)
awaiting-fulfillmentPaid; a single manual line your team still has to deliver
in-progressYour team has claimed the manual order; the player can no longer cancel it
fulfilledYour team recorded delivery of the manual order
rejectedRefused at purchase; nothing was charged. rejection.reasonCode says why
cancelledThe player cancelled their own unclaimed manual order and was refunded
reversedYour 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:

FieldMeaning
orderId, playerIdThe order and the player who placed it
purchaseKindcart (checkout of the player's cart) or direct (one listing bought on its own)
status, revisionThe state above and a counter incremented on every change
lines[]Per line: listingKey, listingRevision, quantity, fulfillmentKind, awardKey, basePrice, effectivePrice, discount, codeCount, awardId
totalWhat the order charged, per currency
rejection{ reasonCode, reason } for a rejected order, otherwise null
placedAt, settledAtTimestamps
cartId, ledgerOperationKeyThe cart it came from and the ledger operation that took the currency
fulfillmentFor manual orders: claimedBy, claimedAt, fulfilledBy, fulfilledAt, note
reversalFor 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.

  1. 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 with 409 SHOP_ORDER_TRANSITION_CONFLICT, whose message names the state the order is actually in.

  2. Deliver the item in your own system.

  3. 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 fulfilled and shop.order.fulfilled is published. Only an in-progress order 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"
}
  • reason is required, 1 to 1000 characters. A missing or whitespace-only reason is refused with 422 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. disclosureId identifies it, so you can cite it in the support conversation.
  • It is a POST so the code never appears in a URL, and the response carries Cache-Control: no-store. It does not use an Idempotency-Key: every call is a separate, separately audited disclosure.
  • The response covers every code line of the order. An order answers 404 SHOP_CODE_UNAVAILABLE when it is not a code purchase, 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.

RouteWhat it does
GET /player/v1/shop/listingsThe published catalog, with prices, active discount, stock and availability
GET /player/v1/shop/listings/:listingKeyOne published listing
GET /player/v1/shop/cartThe player's one open cart
PUT /player/v1/shop/cart/lines/:listingKeyAdd 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/:listingKeyRemove a line
DELETE /player/v1/shop/cartEmpty the cart
POST /player/v1/shop/checkoutBuy the whole cart, all or nothing
POST /player/v1/shop/purchasesBuy one listing on its own (listingKey, quantity): the only way to buy a manual listing
GET /player/v1/shop/ordersThe player's own orders
GET /player/v1/shop/orders/:orderIdOne of their orders
POST /player/v1/shop/orders/:orderId/codesRe-read the codes of their own order
POST /player/v1/shop/orders/:orderId/cancelCancel 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:

StatuserrorCodeCause
422SHOP_IDEMPOTENCY_KEY_REQUIREDNo Idempotency-Key header
409SHOP_IDEMPOTENCY_KEY_REUSEDThe key was already used for a different purchase
422SHOP_ORDER_INVALIDMalformed purchase request
422SHOP_CART_EMPTYCheckout of an empty cart
409SHOP_CART_CONFLICTThe cart changed or was checked out concurrently
422SHOP_MANUAL_NOT_CARTABLEA 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.

errorCodeCause
SHOP_INSUFFICIENT_FUNDSThe player's balance does not cover the price
SHOP_SOLD_OUTNo stock left
SHOP_LIMIT_REACHEDThe per-player limit is used up for this window
SHOP_LIMIT_UNAVAILABLEThe limit could not be checked; the purchase is refused rather than allowed
SHOP_PRICE_CHANGEDThe server price differs from expectedTotal
SHOP_LISTING_UNPUBLISHED, SHOP_LISTING_RETIREDThe listing is not on sale
SHOP_CURRENCY_UNPUBLISHED, SHOP_CURRENCY_SCOPE_UNSUPPORTEDThe price currency cannot be charged
SHOP_AWARD_DEFINITION_UNPUBLISHEDThe award the listing delivers is not published
SHOP_AWARD_ALREADY_HELDThe player already holds the award
SHOP_AWARD_QUANTITY_UNSUPPORTEDMore than one unit of an award listing
SHOP_PRICE_LEGS_UNSUPPORTED, SHOP_AMOUNT_UNSUPPORTEDThe 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

StatuserrorCodeCause
404SHOP_ORDER_NOT_FOUNDNo order with that id
409SHOP_ORDER_TRANSITION_CONFLICTThe order is not in the state the action needs (for example, already claimed by someone else, or cancelled by the player)
404SHOP_CODE_UNAVAILABLEThe order holds no code to disclose

On this page