Store and in-app purchases
This page describes the store showcase the player sees, the flow of buying with game money and the verification of in-app purchases (IAP) made with real money. Read it if you want to build a showcase, sell products and complete payment safely on the server.
What it does
The store module (store) ties three things together:
- Showcase (page) and product (listing): The catalog an operator sets up in the panel and that is shown to the player.
- Payment: A product is sold either with game money (
currency: through a merchant, money in exchange for an item) or with real money (iap: an App Store or Google Play receipt). - Reservation: A purchase first reserves an entitlement (stock and the per-player limit), then completes the payment in one transaction. Payment and delivery either both happen or neither does.
If the module is disabled, the endpoints return 404 with {"code":"module_disabled","module":"store"}. Enable the module on the Game settings › Modules page.
Where it lives in the panel
| What you want to do | Panel page |
|---|---|
| Create showcases and products; set price, stock, visibility window and per-player limit | Store, Product |
| Define the currency for a price | Currencies |
| Define the item to be sold | Content |
In the panel, IAP products and receipts are only listed. For attaching an IAP product to the store and for the platform credentials (App Store / Google Play), see Limits and known limitations.
Note
Panel help pages are not translated yet; the panel links above point to the Turkish source.
Data model (in plain words)
- Showcase (page):
key(lowercase letters, digits,_,-; at most 64),title,sort_orderand visibility. Showcase fields such as the description, the banner image, the product title, the badge and the image are entered in the panel; the server-key REST draft does not carry them. - Product (listing): Attached to a showcase. The payment form is
currency(merchant + item definition + currency +price_amount) oriap(an IAP product ID).limit_per_player(at least 1) is the lifetime per-player quantity limit. - Visibility:
active, a date window (starts_at/ends_at, Unix ms) and an optional CELvisibility_rule(a player-specific condition; compiled at registration, at most 8192 bytes). The player showcase is recomputed on every read: the condition, the time and the remaining limit are evaluated against the state at that moment. - Catalog version (
version): Every change made to a showcase increases the catalog version; on management commandsIf-Matchis this version. - Product version: The
versionfield of the product the player sees; it is sent back asexpected_versionon purchase. - Reservation: Its status is
reserved,processing,completed,cancelled.reservedandprocessingdo not count as payment. - Price: An integer in the smallest unit; it comes with
display_decimals(0–4) in the showcase. Build the display with integer/string operations; do not convert it to a decimal number.
Calls your game makes
There are two credential types:
- Player session (cookie or session token +
x-crx-env: <environment uuid>): reads the showcase and buys on its own behalf. With a cookie session, mutations require the CSRF double-submit (x-csrf-token) and theOrigincheck; see Sessions and CSRF. - Server key (
Authorization: Bearer crx_live_<prefix>_<secret>): catalog management, player preview, reservation and payment completion.
| Method and path | Credential | What it does |
|---|---|---|
GET /v1/store/pages[?page=<key>&at=now] | player session | The showcases the player can see; page narrows to one showcase |
POST /v1/player/store/listings/{listing_id}/buy | player session | The player buys on their own behalf |
GET /v1/store/catalog | server key | The raw management catalog (visibility is not filtered) |
GET /v1/store/preview/{player_id}[?at=now] | server key | Previews the showcase a player would see |
POST /v1/store/pages, PUT/DELETE /v1/store/pages/{id}, POST /v1/store/pages/reorder | server key | Showcase management |
POST /v1/store/pages/{page_id}/listings, PUT/DELETE /v1/store/listings/{id}, POST …/listings/reorder | server key | Product management |
POST /v1/store/listings/{listing_id}/reserve | server key | Reserves an entitlement for a player (not a payment) |
POST /v1/store/reservations/{id}/checkout/currency | server key | Completes payment with game money |
POST /v1/store/reservations/{id}/checkout/iap | server key | Completes payment with a verified IAP |
POST /v1/store/reservations/{id}/cancel | server key | Cancels a reserved reservation (not a refund) |
POST /v1/iap/verify | API key | Verifies an IAP receipt and grants the reward |
POST /v1/webhooks/iap/{platform}/{environment_id} | no key (signature) | Receives the platform’s refund notification |
Error envelopes: errors from the management and reservation endpoints are text/plain; the player purchase endpoint returns JSON with a typed code (below). General list: Errors.
Reading the showcase
GET /v1/store/pages?page=garage
x-crx-env: 0192f000-0000-7000-8000-00000000e001
{
"version": 12,
"pages": [
{
"id": "…", "key": "garage", "title": "Garage",
"listings": [
{
"id": "0192f111-…", "version": 3,
"title": "Fast Car", "badge": "sale",
"price": { "currency_key": "gold", "currency_name": "Gold", "amount": 1250, "display_decimals": 0, "icon_url": null },
"item": { "key": "fast_car", "name": "Fast Car" },
"remaining": 40
}
]
}
]
}
The response carries Cache-Control: private, no-store. A page key that is not visible or unknown returns 404. The player identity, the time and the CEL result are not taken from the client; adding player_id or any other field to the query returns 400.
A player’s purchase
POST /v1/player/store/listings/0192f111-…/buy
x-crx-env: 0192f000-0000-7000-8000-00000000e001
x-csrf-token: <csrf>
Idempotency-Key: buy-player-1234-0001
Content-Type: application/json
{ "quantity": 1, "expected_version": 3 }
{
"reservation_id": "…",
"payment_reference": "…",
"item_instance_ids": ["…"],
"balance_after": 750,
"replayed": false
}
The body contains only quantity (positive) and expected_version; a player ID, a price or a payment claim is not accepted, and an unknown field returns 400. The identity comes from the session. Reservation and charging are in a single transaction. Idempotency-Key is required: the same key and the same body repeat the durable result with replayed: true and make no second charge; the same key with a different body is 422. An IAP product cannot be bought on this endpoint (422).
Rejected purchases carry a typed JSON code, {"code": "...", "message": "..."}:
code | HTTP | Meaning |
|---|---|---|
not_visible | 404 | The product is not visible to this player or does not exist |
listing_changed | 409 | The product changed while you were looking (expected_version is stale); refresh the showcase |
out_of_stock | 422 | Out of stock |
limit_reached | 422 | The per-player limit is full |
insufficient_funds | 422 | The balance is insufficient |
inventory_full | 422 | The inventory is full; the operation is rolled back completely |
Other errors (401, 403 CSRF/Origin, 400, 429 budget, 500) follow the general rules.
Server-side flow: reservation and payment
In cases where the game server manages the payment (for example, when your server receives the real-money receipt), use two steps:
POST /v1/store/listings/{listing_id}/reserve
Authorization: Bearer crx_live_<prefix>_<secret>
Idempotency-Key: reserve-player-1234-0001
If-Match: 12
Content-Type: application/json
{ "player_id": "0192f0f2-…", "quantity": 1 }
The response has the form { catalog_version, entity_id, event_id, event, reservation, replayed }; reservation.status is reserved. Then:
POST /v1/store/reservations/{id}/checkout/currency
Authorization: Bearer crx_live_<prefix>_<secret>
Content-Type: application/json
{ "player_id": "0192f0f2-…" }
{ "reservation_id": "…", "payment_reference": "…", "kind": "currency", "replayed": false }
Idempotency-Key and If-Match are not sent on the checkout endpoints (400 if sent); the durable reservation ID is the anchor of replay safety. If there is an error when paying with a currency, the operation is rolled back and the reservation stays reserved; you can retry or cancel. If the outcome of an IAP payment is uncertain, the reservation becomes processing and the player limit is preserved; retry with the same credential. In an IAP payment quantity is 1.
Idempotency-Key is required on reserve and the catalog management commands; If-Match is the catalog version and is optional (a mismatch is 409). A failed command does not burn the key.
IAP verification
POST /v1/iap/verify
Authorization: Bearer crx_live_<prefix>_<secret>
Idempotency-Key: iap-player-1234-tx-abc
Content-Type: application/json
{ "platform": "app_store", "product_sku": "gems_500", "credential": "<transactionId>", "player_id": "0192f0f2-…" }
{ "receipt_id": "…", "status": "granted", "product_sku": "gems_500", "transaction_id": "…", "grants": [ … ], "replayed": false }
platform:app_storeorgoogle_play.credential: thetransactionIdfor the App Store, thepurchaseTokenfor Google Play. The server queries the platform itself; the client is not trusted.- Verifying the same transaction twice does not grant a second reward (
200,replayed: true). statuscan beverified,granted,revokedorfailed; the happy path isgranted.- Errors: a forged receipt
403; the product is not in the catalog or the transaction is not recognized on the platform404; aproduct_skumismatch, a platform that is not configured or a supply cap overrun422; another request in flight with the same key409. - Refund notification: App Store (ASN V2) and Google Play (RTDN) notifications arrive at
POST /v1/webhooks/iap/{platform}/{environment_id}; the endpoint is keyless and authentication is the signature. On a refund of a known receipt the reward is taken back with reversing entries and the receipt becomesrevoked; cases that can only be partially reversed are markedflagged(the default policy; it does not touch the consumed part). Non-refund notifications and unknown transactions get200and are ignored.
Versioning and replay safety
- Player purchase:
expected_version(the product version) +Idempotency-Key. - Catalog management and reservation:
Idempotency-Keyrequired,If-Match= the catalog version. - Payment completion: the durable reservation ID; no header.
- General rules: Idempotency and concurrency.
Events and webhooks
Store events can be delivered as webhooks: store_page_created, store_page_updated, store_page_deleted, store_pages_reordered, store_listing_created, store_listing_updated, store_listing_deleted, store_listings_reordered, store_purchase_initiated, store_payment_started, store_purchase_completed, store_reservation_cancelled. The envelope contains event_id, environment_id, type, seq, now_ms, actor_api_key_id and data. Registration and verification: Webhooks.
Limits and known limitations
- A showcase key is
^[a-z0-9_-]{1,64}$; at most 2000 entries in a reorder request. - An IAP product is not supported on the player purchase endpoint; IAP products are bought with
checkout/iaporiap/verify. - IAP product registration: In the panel, IAP products and receipts are read-only, and there is no public HTTP endpoint yet to save a product. For now, adding an IAP product is done through the Open Play operator.
- Platform credentials: The App Store and Google Play verification credentials are defined by the operator at deployment. If they are not defined,
iap/verifyandcheckout/iapreturn422. - Read the showcase when the player opens or refreshes it; changes are recomputed at read time.
Common errors
| Symptom | Cause | Fix |
|---|---|---|
409 listing_changed | The product version you read in the showcase went stale | Re-read the showcase and try with the new version |
404 not_visible | The product is not visible to the player (window, condition, inactive) | Refresh the showcase; check the condition in the panel |
422 insufficient_funds | The balance is insufficient | Show the balance; give money through a reward or IAP |
400 (unknown field) | An extra field was added to the purchase body | Send only quantity and expected_version |
400 (checkout) | Idempotency-Key/If-Match was sent | Remove these headers on the checkout endpoints |
403 (purchase) | The CSRF pair or the Origin was rejected | Refresh the CSRF token; see Sessions and CSRF |
403 (iap/verify) | A forged receipt or one with an invalid signature | Get the receipt again from the platform |
422 (iap) | The platform is not configured or product_sku does not match | Contact the operator; check the sku |
Working example
An example that reads the showcase and buys the first product is on the Economy page; it shows a separate message per error code (storeBuyErrorCode). The TypeScript SDK provides the player.store.catalog() and player.store.buy() helpers; if Idempotency-Key is not given, it generates one per call, and after a network interruption you must supply the same key yourself.