TANITARDevelopers
GuidesShop

Listings

Create, edit, publish and retire shop listings priced in your own currencies, with discounts and per-player limits.

A listing is one item in your shop. You create it as a draft, publish it when it is ready, edit it while it is live, and retire it when you stop selling it. All catalog routes need an API key with shop:read (reads) or shop:mutate (writes); every write requires an Idempotency-Key.

Lifecycle

FromActionToNotes
NonePOST /v1/shop/listingsdraftPlayers cannot see drafts. Nothing is published.
draftPATCH /v1/shop/listings/:listingKeydraftAny authored field may change.
draftPOST /v1/shop/listings/:listingKey/publishpublishedChecks that the currency and any award definition are published. Emits shop.listing.published.
publishedPATCH /v1/shop/listings/:listingKeypublishedRe-runs the publish checks and emits shop.listing.published with the new revision. A failing check refuses the edit; it never demotes the listing to draft.
publishedPOST /v1/shop/listings/:listingKey/retireretiredTerminal. Emits shop.listing.retired.

Only a draft can be published and only a published listing can be retired; any other transition is refused with 409 SHOP_LISTING_TRANSITION_INVALID. A retired listing cannot be edited or restocked. Orders already placed against a retired listing are unaffected and remain readable.

Every change increments the listing's revision. If two writes race on the same listing, the loser is refused with 409 SHOP_LISTING_REVISION_CONFLICT: read the listing again and retry.

Create a listing

curl -X POST "$REWARD_API_URL/v1/shop/listings" \
  -H "Authorization: Bearer $TANITAR_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "listingKey": "gold-frame",
    "title": "Gold profile frame",
    "description": "A gold frame for your profile picture.",
    "category": "cosmetics",
    "media": "https://cdn.example.com/shop/gold-frame.png",
    "price": [{ "currencyKey": "coins", "amount": "500" }],
    "fulfillment": { "kind": "award", "awardKey": "gold-frame" },
    "stock": { "policy": "counted", "remaining": 1000 },
    "limit": { "maxPerPlayer": 1, "window": "total" }
  }'
FieldRequiredRules
listingKeyYesLowercase slug (gold-frame), at most 64 characters, unique in your tenant. It is the listing's identity and cannot be changed.
titleYesAt most 160 characters, not blank.
descriptionNoAt most 2000 characters.
categoryNoFree text up to 64 characters; used to filter the catalog.
mediaNoAn image URL, at most 2048 characters.
groupKey, groupOrderNoPresent related listings together. groupKey is a lowercase slug; groupOrder is an integer ≥ 0 and requires a groupKey.
priceYesExactly one leg: currencyKey and amount. See Prices.
discountNoSee Discounts.
fulfillmentYeskind is award, code or manual. An award listing names its awardKey; the others must not.
stockYespolicy is unlimited, counted or code-pool. See Stock.
limitNoSee Per-player limits.

The response is the listing as your integration sees it: the authored fields plus status, revision, retiredReason, the computed price legs (base, effective, discount), the current stock.remaining and availability (available or sold-out).

A listing whose fulfilment kind and stock policy do not fit together is refused with 422 SHOP_FULFILLMENT_STOCK_INVALID:

  • a code listing must use code-pool, and only a code listing may;
  • a counted listing must carry remaining, and the other policies must not;
  • an award listing must name an awardKey; code and manual listings must not.

Prices

A price is a list with exactly one leg: one of your currencies and a whole amount in that currency's smallest unit, written as a decimal string ("500"). A second leg is refused with 422 SHOP_PRICE_LEGS_UNSUPPORTED.

The currency must be one you have published (see Currencies). You can create a draft priced in a currency that is not published yet; publishing it, or editing a published listing onto it, is refused with 422 SHOP_CURRENCY_UNPUBLISHED. For an award listing, the award definition must also be published, or the publication is refused with 422 SHOP_AWARD_DEFINITION_UNPUBLISHED.

Every read reports each price leg three ways:

FieldMeaning
baseThe price you authored
effectiveWhat a player pays at the moment of the read
discountWhat the active discount takes off; "0" when none is active

Discounts

A listing carries at most one discount, active over a time window:

{
  "discount": {
    "kind": "percentage",
    "percentage": "12.5",
    "startsAt": "2026-11-27T00:00:00Z",
    "endsAt": "2026-11-30T00:00:00Z"
  }
}
FieldRules
kindpercentage or absolute
percentageFor percentage only: 0 to 100, at most four decimals, as a string
absoluteFor absolute only: one { currencyKey, amount } for each priced currency, and no other
startsAtRequired, ISO 8601
endsAtOptional, ISO 8601, after startsAt; absent means open-ended

The window includes startsAt and excludes endsAt, so two back-to-back sales never overlap. The discount amount is rounded down, so a rounded price never gives away more than you authored: 1999 at 12.5% takes off 249 and costs 1750. Every read, event, cart and order computes the same effective price for the same instant. An invalid discount is refused with 422 SHOP_DISCOUNT_INVALID.

To schedule a sale on a live listing, PATCH the discount onto it ahead of time; the edit emits shop.listing.published with the new revision so your storefront can show the upcoming sale.

Per-player limits

{ "limit": { "maxPerPlayer": 3, "window": "week" } }

maxPerPlayer is an integer ≥ 1. window is day (the UTC day), week (the ISO week, in UTC) or total (for ever). A purchase that would exceed the limit is refused with SHOP_LIMIT_REACHED. A reversed or cancelled order gives its quantity back to the player's limit.

Edit a listing

PATCH /v1/shop/listings/:listingKey accepts any of the fields above except listingKey. A field you omit keeps its value; an optional field you send as null is cleared.

An edit that sends stock for a counted listing that stays counted also carries stock.expectedRemaining, the count you last read, so a sale made while you edit is never undone. See Counted stock.

curl -X PATCH "$REWARD_API_URL/v1/shop/listings/gold-frame" \
  -H "Authorization: Bearer $TANITAR_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "price": [{ "currencyKey": "coins", "amount": "450" }], "discount": null }'

Publish and retire

curl -X POST "$REWARD_API_URL/v1/shop/listings/gold-frame/publish" \
  -H "Authorization: Bearer $TANITAR_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"

Retiring takes an optional reason (at most 500 characters), shown in your own reads only:

curl -X POST "$REWARD_API_URL/v1/shop/listings/gold-frame/retire" \
  -H "Authorization: Bearer $TANITAR_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "reason": "End of season" }'

Read the catalog

GET /v1/shop/listings returns every listing in any state, ordered by listingKey, up to 200. It takes no pagination parameters. Filter with the optional query parameters status (draft, published, retired), category and groupKey:

curl "$REWARD_API_URL/v1/shop/listings?status=published&category=cosmetics" \
  -H "Authorization: Bearer $TANITAR_API_KEY"

GET /v1/shop/listings/:listingKey returns one listing, or 404 SHOP_LISTING_UNAVAILABLE.

What your players see

Players read the catalog through GET /player/v1/shop/listings and GET /player/v1/shop/listings/:listingKey. They see published listings only, without status or the retirement reason. A draft or retired key reads as 404 SHOP_LISTING_UNAVAILABLE, while a published listing with nothing left is shown with availability: "sold-out". A catalog read never contains a code.

Refusals

StatuserrorCodeCause
422SHOP_LISTING_INVALIDMalformed key, missing title, fulfilment or stock, bad currency key or amount, group order without group key, or stock.expectedRemaining missing from (or sent on) an edit where it does not belong
422SHOP_PRICE_LEGS_UNSUPPORTEDThe price does not have exactly one leg
422SHOP_FULFILLMENT_STOCK_INVALIDFulfilment kind, stock policy and awardKey do not fit together
422SHOP_DISCOUNT_INVALIDThe discount window or amounts are invalid
422SHOP_CURRENCY_UNPUBLISHEDPublishing onto a currency that is not published
422SHOP_AWARD_DEFINITION_UNPUBLISHEDPublishing an award listing whose definition is not published
404SHOP_LISTING_UNAVAILABLENo listing with that key
409SHOP_LISTING_EXISTSThe listingKey is already used
409SHOP_LISTING_TRANSITION_INVALIDPublish of a non-draft, retire of a non-published listing, or edit of a retired one
409SHOP_LISTING_REVISION_CONFLICTA concurrent write changed the listing
409SHOP_STOCK_CONFLICTThe edit changes a counted listing's count, and a sale moved it since the stock.expectedRemaining you sent

On this page