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
| From | Action | To | Notes |
|---|---|---|---|
| None | POST /v1/shop/listings | draft | Players cannot see drafts. Nothing is published. |
draft | PATCH /v1/shop/listings/:listingKey | draft | Any authored field may change. |
draft | POST /v1/shop/listings/:listingKey/publish | published | Checks that the currency and any award definition are published. Emits shop.listing.published. |
published | PATCH /v1/shop/listings/:listingKey | published | Re-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. |
published | POST /v1/shop/listings/:listingKey/retire | retired | Terminal. 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" }
}'| Field | Required | Rules |
|---|---|---|
listingKey | Yes | Lowercase slug (gold-frame), at most 64 characters, unique in your tenant. It is the listing's identity and cannot be changed. |
title | Yes | At most 160 characters, not blank. |
description | No | At most 2000 characters. |
category | No | Free text up to 64 characters; used to filter the catalog. |
media | No | An image URL, at most 2048 characters. |
groupKey, groupOrder | No | Present related listings together. groupKey is a lowercase slug; groupOrder is an integer ≥ 0 and requires a groupKey. |
price | Yes | Exactly one leg: currencyKey and amount. See Prices. |
discount | No | See Discounts. |
fulfillment | Yes | kind is award, code or manual. An award listing names its awardKey; the others must not. |
stock | Yes | policy is unlimited, counted or code-pool. See Stock. |
limit | No | See 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
codelisting must usecode-pool, and only acodelisting may; - a
countedlisting must carryremaining, and the other policies must not; - an
awardlisting must name anawardKey;codeandmanuallistings 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:
| Field | Meaning |
|---|---|
base | The price you authored |
effective | What a player pays at the moment of the read |
discount | What 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"
}
}| Field | Rules |
|---|---|
kind | percentage or absolute |
percentage | For percentage only: 0 to 100, at most four decimals, as a string |
absolute | For absolute only: one { currencyKey, amount } for each priced currency, and no other |
startsAt | Required, ISO 8601 |
endsAt | Optional, 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
| Status | errorCode | Cause |
|---|---|---|
422 | SHOP_LISTING_INVALID | Malformed 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 |
422 | SHOP_PRICE_LEGS_UNSUPPORTED | The price does not have exactly one leg |
422 | SHOP_FULFILLMENT_STOCK_INVALID | Fulfilment kind, stock policy and awardKey do not fit together |
422 | SHOP_DISCOUNT_INVALID | The discount window or amounts are invalid |
422 | SHOP_CURRENCY_UNPUBLISHED | Publishing onto a currency that is not published |
422 | SHOP_AWARD_DEFINITION_UNPUBLISHED | Publishing an award listing whose definition is not published |
404 | SHOP_LISTING_UNAVAILABLE | No listing with that key |
409 | SHOP_LISTING_EXISTS | The listingKey is already used |
409 | SHOP_LISTING_TRANSITION_INVALID | Publish of a non-draft, retire of a non-published listing, or edit of a retired one |
409 | SHOP_LISTING_REVISION_CONFLICT | A concurrent write changed the listing |
409 | SHOP_STOCK_CONFLICT | The edit changes a counted listing's count, and a sale moved it since the stock.expectedRemaining you sent |