Buy a shop item
Buy an item from the game's shop with the connected player's coins.
POST /v1/player/purchase · for player session
Use it when the player presses Buy. The coins are deducted, the purchase is made, and the item is delivered or awaits claiming, in one step. This is the only game loop request that takes an idempotency key, and the key is required. Send the player session as the bearer token. The request needs the game loop. For the three conditions (game loop enabled for the game, member consent, server membership) and the error each returns, see What has to be true first.
Body parameters
Prop
Type
Returns
Prop
Type
delivery contains:
Prop
Type
spending contains:
Prop
Type
These are the values of status:
status | What it means |
|---|---|
claimable | Bought; the player must claim it with Claim an item before it can be delivered |
pending | Awaiting fulfilment. waitingFor identifies who must act |
delivered | The item has been fulfilled. It may still be active or used |
refunded | The coins were refunded |
rejected | The community's team rejected the purchase without a refund. A rejection with a refund reads refunded |
expired | An item with a time limit that has run out |
failed | It could not be delivered |
Treat the list as open: a new value can appear.
waitingFor | Who must act before a pending purchase completes |
|---|---|
team | The community's team must fulfil or approve it |
studio | The game's own server responded that it will finish later |
delivery | CEEJAY is delivering it and retries if delivery fails |
link | The player must first link an account on a platform |
null | No one needs to act |
A purchase awaiting the community's team reads status: "pending" and waitingFor: "team". An item delivered by the game's own server can read pending with waitingFor: "studio" until that server finishes the delivery: see Deliver shop items from your own platform. A delivery that fails is retried, and the member is refunded if it keeps failing: the balance then reflects the refund, and the locker shows refunded. CEEJAY does not send the member a direct message.
Example
curl -X POST https://ceejay.communitystudios.xyz/api/v1/player/purchase \
-H "Authorization: Bearer $CEEJAY_PLAYER_SESSION" \
-H "Content-Type: application/json" \
-d '{
"itemId": 7,
"idempotencyKey": "buy-2026-10-08-gold-skin-1",
"expectedPrice": 500
}'The TypeScript example uses connect.hub.buy from the Connect client in the TypeScript SDK. The player must be connected first: the client manages and refreshes the player session. It returns the data of the response below. It generates an idempotencyKey when you pass none, and sends every attempt with the same one: see Retrying safely.
{
"success": true,
"data": {
"purchaseId": 4242,
"itemId": 7,
"itemName": "Gold skin",
"pricePaid": 500,
"balance": 950,
"status": "pending",
"waitingFor": "studio",
"claimNeeded": false,
"delivery": {
"state": "pending",
"message": null
},
"duplicate": false,
"spending": {
"dailyCap": 5000,
"spentToday": 500,
"remainingToday": 4500,
"resetsAt": "2026-10-09T00:00:00.000Z"
}
}
}{
"success": true,
"data": {
"purchaseId": 4242,
"itemId": 7,
"itemName": "Gold skin",
"pricePaid": 500,
"balance": 950,
"status": "delivered",
"waitingFor": null,
"claimNeeded": false,
"delivery": {
"state": "delivered",
"message": null
},
"duplicate": false,
"spending": {
"dailyCap": 5000,
"spentToday": 500,
"remainingToday": 4500,
"resetsAt": "2026-10-09T00:00:00.000Z"
}
}
}{
"success": true,
"data": {
"purchaseId": 4242,
"itemId": 7,
"itemName": "Gold skin",
"pricePaid": 500,
"balance": 950,
"status": "pending",
"waitingFor": "studio",
"claimNeeded": false,
"delivery": {
"state": "pending",
"message": null
},
"duplicate": true,
"spending": {
"dailyCap": 5000,
"spentToday": 500,
"remainingToday": 4500,
"resetsAt": "2026-10-09T00:00:00.000Z"
}
}
}What a purchase needs
In addition to what every game loop request needs, a purchase spends the member's coins, so it also requires:
- The economy enabled. Otherwise
409with the codeeconomy_disabled. - Proof, when the game asks for it. A device that connected without proof returns
403with the codeverified_proof_required. Reading, quests and the daily reward need none. - The device's spending hold has ended. A new connection or a new device is held before it can spend:
409with the codespend_on_hold,holdUntilandretryAfter. The time that counts isspending.holdUntilin Get the balance. - Within the daily spending limit. Each player can spend a set number of coins a day through the game: beyond it,
409with the codedaily_spend_cap_reached, withcap,spentToday,price,resetsAtandretryAfter. An item priced above the limit can never be bought through the game. - An item on the game's allow list. An item that is not on the list, is disabled or does not exist returns
404with the codeitem_not_found, and the three cases cannot be distinguished.
The game loop explains each rule.
The price is exact
Send the price the player saw as expectedPrice. When the price has changed, nothing is bought and the response is 409 with the code price_changed and the current price: show the shop again. A member with an insufficient balance gets insufficient_balance with price and balance.
{
"error": "The price of this item has changed. Read the shop again",
"code": "price_changed",
"requestId": "req_5f0c2a9b7d3e41f8a6b0c1d2",
"price": 600,
"expectedPrice": 500
}An item that is not on sale, that the member cannot buy, that is sold out or whose purchase limit the member has reached returns purchase_refused, with a reason and a message the game can show:
reason | What it means |
|---|---|
not_on_sale | The item is not on sale right now |
not_eligible | The member does not meet a condition. missingPlatform names the account the member must link in Discord, when that is the condition |
unavailable | The item cannot be bought now |
out_of_stock | The item is sold out |
limit_reached | The member has bought the maximum allowed. availableAt gives when they can buy again, if known |
{
"error": "This device cannot spend yet. New connections and devices cannot buy until the hold ends",
"code": "spend_on_hold",
"requestId": "req_5f0c2a9b7d3e41f8a6b0c1d2",
"holdUntil": "2026-10-09T18:06:40.000Z",
"retryAfter": 3600
}Retrying safely
Generate a key when the player presses Buy, store it, and retry the same request with the same key after a lost response, a 429, a 503 or a 504. Nothing is bought twice: the response is the original response with duplicate: true and the header Idempotent-Replayed: true. A game in a browser cannot read that header, so read duplicate.
| The response | What it means | What to do |
|---|---|---|
409 idempotency_key_reused | The key was used for another item or other values. Nothing was bought | Use a new key for each new purchase |
409 idempotency_in_progress | The first request with this key is still being processed, for up to a minute | Wait retryAfter seconds and retry with the same key |
409 idempotency_outcome_unknown | Two concurrent requests used the same key, and CEEJAY cannot tell whether the first took effect | Check the locker before buying again with a new key |
503 idempotency_unavailable | This instance does not support idempotency keys yet. Nothing was bought | Retrying does not change this response |
When the first request never finished, because it timed out or its response was lost, a retry within the first minute returns idempotency_in_progress. After that minute the request is processed, and CEEJAY finds the first purchase by its key instead of buying again. On a 504, retry the same request with the same key. A key whose request was rejected can be reused once the cause is fixed.
A key is scoped to the member, the game and the connection: a player who moves to another player of the game, or connects again, starts a new set of keys, and another member's key is never treated as this one. Keys are retained for 30 days. Two purchases of the same item in a row need two keys.
Limits
20 writes a minute for one player session, and 60 buys and claims together an hour for one member in one game. The 20 writes are shared by Start, Verify and Answer a quest task, Buy a shop item, Claim an item and Check in. The three X-RateLimit headers report that count. Every request made with a player session also counts toward 140 a minute. See Rate limits. Over a limit the response is 429 with the code rate_limited, retryAfter and a Retry-After header. A retry served from a key counts toward neither limit.
Errors
| Status | code | error | When |
|---|---|---|---|
400 | bad_request | The request body must be a JSON object | The body is JSON but not an object: null, text, a number or a list. A body that is not JSON at all returns bad_request with the message The request body must be JSON |
400 | bad_request | itemId must be a whole number from 1 to 2147483647 | itemId is missing, is not a whole number, or is outside 1 to 2,147,483,647 |
400 | bad_request | idempotencyKey must be a string of 1-128 characters | idempotencyKey is missing, is not text, or is not 1 to 128 characters |
400 | bad_request | expectedPrice must be a whole number from 0 to 2147483647 | expectedPrice is missing, is not a number, or is outside 0 to 2,147,483,647 |
400 | bad_request | This item asks a question. Answer it first | The item asks a question and no answer was sent. The body includes question |
400 | bad_request | answer cannot be longer than 200 characters | answer is longer than 200 characters |
401 | connection_ended | This connection has ended | The connection or this device was ended since the player session was made. Discard the stored tokens and show the player as disconnected. See Renew a player session for the reasons |
403 | verified_proof_required | This requires a verified player. This device connected without verification | This device connected without proof, and the game only lets proven players spend |
404 | item_not_found | Shop item not found | The item does not exist, is disabled, is not a type the game may sell, or is not on the game's allow list |
409 | hub_not_enabled | This game does not show the community's quests, shop and rank | The game loop is disabled for the game. Do not show the community's quests, shop or rank in the game |
409 | sharing_needed | The member has not consented to sharing their data with this game | The member has not consented to share their quests, rewards, shop and rank with the game, or must consent again. The body includes manageUrl: send the player there |
409 | membership_required | The member has to be in the server to use this | The Discord account is not in the server. The body includes membership (pending, left or not_member) and, for left and not_member, joinUrl, the URL to send the player to |
409 | spend_on_hold | This device cannot spend yet. New connections and devices cannot buy until the hold ends | A new connection or device is still on hold. The body includes holdUntil and retryAfter |
409 | daily_spend_cap_reached | The daily spending limit for this member in this game has been reached | The price would exceed the daily spending limit. The body includes cap, spentToday, price, resetsAt and retryAfter |
409 | price_changed | The price of this item has changed. Read the shop again | The item costs something else now. The body includes price and expectedPrice |
409 | insufficient_balance | Insufficient balance | The member's balance is too low. The body includes price and balance |
409 | purchase_refused | This item cannot be bought right now | The item cannot be bought now. The body includes reason, as in the table above, and message |
409 | economy_disabled | The community has switched its economy off | The community has disabled its economy |
409 | idempotency_key_reused | This idempotencyKey was already used for a different request | The key was used for another item or other values |
409 | idempotency_in_progress | The first request with this idempotencyKey is still running. Try again in a few seconds. | The first request with this key is still being processed. The body includes retryAfter |
409 | idempotency_outcome_unknown | An earlier request with this idempotencyKey may or may not have been applied. It was not sent again. Use a new key to send it again. | An earlier request with this key may or may not have been applied. It was not repeated |
413 | body_too_large | The request body is larger than 16 KB | The body is larger than 16 KB. Nothing was changed |
503 | bot_unavailable | This could not be done right now and nothing was changed. Try again in a few seconds | CEEJAY's bot could not be reached or was too busy, and nothing was changed. The body includes retryAfter, in seconds: retry after that many seconds |
503 | idempotency_unavailable | idempotencyKey cannot be used on this community yet, so nothing was changed | This instance does not support idempotency keys yet. Nothing was bought |
504 | bot_timeout | The answer did not arrive in time, and the request may or may not have been done. Send the same request again: it is safe to repeat | The request timed out, so it is not known whether the purchase was made. Retry the same request with the same key: it is safe to repeat |
Every error body also includes requestId, and a 400 with the code bad_request identifies the invalid field in field. Match on code; message text can change.
The errors every request of a game shares (the key, the state of the game, the origin, 405, 429, 500) are on the Errors page, and the errors specific to the game loop are under Codes of the game loop.

