Deliver shop items from your own platform
How a shop item that is Delivered by your API works: the signed request, the responses your server can return, the callback, refunds and retries.
Use this when the item a member buys is fulfilled in your game or platform: a skin, in-game currency, a perk. The item is set to Delivered by your API. When a member buys it, CEEJAY deducts the coins and sends a signed request to your server. Your server unlocks the item and responds.
How it works
- A member buys the item in Discord or in the Community Hub. The coins are deducted.
- CEEJAY sends a signed
POSTto the item's endpoint URL. - Your response decides what happens to the purchase:
- Delivered. The purchase is marked delivered.
- Pending. The purchase shows Being delivered until you call back. List shop purchases lists the purchases awaiting your confirmation.
- Refused. The member is refunded.
- No response, or busy. CEEJAY retries, then refunds.
This is separate from webhooks. The request, its signature and its retries are the ones described here, and they go to the item's endpoint URL, not to a webhook.
Set up the item
Open Commerce → Shop and press New item. Under Games & integrations, choose Delivered by your API.
Fill in the fields:
| Field | What to put |
|---|---|
| Endpoint URL | An https:// URL on a public host, up to 500 characters. Redirects are not followed, so give the final URL. |
| Secret | A long random string you choose, up to 500 characters. CEEJAY stores it encrypted and never shows it again: the field says Saved, and typing a new one replaces it. Your server uses it to check a request came from CEEJAY. |
| Item key (optional) | Up to 64 letters, numbers, dots, dashes, colons or underscores, such as skin_gold. It is sent with every delivery so your server knows what to unlock. |
Save the item, then press Send a test delivery. The button only works once the item is saved. It shows how your server responded, and how long it took.
The Rewards Engine can use the same type: a reward set to Delivered by your API sends the same request each time a member earns it, with event set to reward.earned. A reward has no price, so retries and refunds work differently for it.
If the endpoint URL is not a public https:// URL, or the item has no secret, the delivery fails immediately and the member is refunded.
The request
POST /ceejay/deliver HTTP/1.1
Content-Type: application/json
Accept: application/json
User-Agent: CEEJAY-Shop/1
X-CEEJAY-Delivery: shop_4242
X-CEEJAY-Attempt: 1
X-CEEJAY-Timestamp: 1791021600
X-CEEJAY-Signature-V2: 7c1e0f9a4b...
X-CEEJAY-Signature: t=1791021600,v1=5f2b1c...| Header | Meaning |
|---|---|
X-CEEJAY-Delivery | The delivery ID: the same value as delivery_id in the body, and the same on every retry of the purchase |
X-CEEJAY-Attempt | 1 for the first attempt, then 2, 3 and so on. A test is attempt 1 |
X-CEEJAY-Timestamp | The Unix time, in seconds, at which this attempt was signed. A retry carries a new one |
X-CEEJAY-Signature-V2 | The signature of this attempt: 64 lower-case hexadecimal characters. See Verify the signature |
X-CEEJAY-Signature | The legacy signature, still sent on every delivery. Its t is the same time as X-CEEJAY-Timestamp. See The legacy signature |
{
"event": "shop.purchase",
"delivery_id": "shop_4242",
"purchase_id": 4242,
"item": { "id": 7, "name": "Gold skin", "item_key": "skin_gold" },
"member": {
"discord_id": "409876543210987654",
"username": "nova",
"display_name": "Nova",
"connections": {
"steam": "76561198012345678",
"xbox": null,
"playstation": null,
"epic": null,
"riot": null,
"roblox": null,
"battlenet": null,
"github": null,
"twitch": null
}
},
"price_paid": 500,
"currency": "Coins",
"server_id": "721104938271003842",
"created_at": "2026-10-03T10:00:00.000Z"
}| Field | Meaning |
|---|---|
event | shop.purchase for a purchase, reward.earned for a reward a member earned, shop.test for a test delivery |
delivery_id | shop_ and the purchase number. The same on every retry of the same purchase: de-duplicate on it. A test starts with test_ |
purchase_id | The purchase number, a whole number. You send it back in the callback. A test has an ID starting with test_ |
item | id and name of the shop item, and its item_key, or null when you set none |
member | discord_id, username and display_name (either can be null), and connections |
member.connections | The member's linked account ID for each of steam, xbox, playstation, epic, riot, roblox, battlenet, github and twitch, or null when they have not linked that platform |
player | Only for a purchase made in a game's shop: game, the game's slug, player_id, the game's own ID for the player, and proof, basic or verified. See A purchase made in a game |
price_paid | What the member paid. 0 for a reward and for a test |
currency | The name of your community's currency, such as Coins |
server_id | Your Discord server ID |
created_at | When the purchase was made |
reward | Only when event is reward.earned: id, source_kind, source_id and source_label, which describe what the member earned it for |
Ignore fields you do not know: more can be added.
A purchase made in a game
When a member buys an item in a game's shop (see The game loop), the body includes player, so your server knows which player to deliver the item to:
{
"player": {
"game": "rugby_manager",
"player_id": "p_19",
"proof": "verified"
}
}| Field | Meaning |
|---|---|
player.game | The game's slug, as in the game of Get a player, or null |
player.player_id | The game's own ID for the player, as it was connected, always text |
player.proof | The connection's verification level when the member bought: basic or verified, or null when that is unknown |
player is read from the purchase, never from the connection as it is now, so a player who moves to another ID between the purchase and a retry does not redirect the item. A studio that only trusts verified players checks proof and responds failed for a basic player: the member is refunded.
A purchase made in Discord, a reward a member earned and a test delivery have no player. A player erased with Delete a player has none on a retry. The signature covers the whole body, so a receiver that ignores the field is unaffected. List shop purchases returns the same values as player, with playerId.
Verify the signature
Every delivery is signed with the item's secret, used as the text you typed. X-CEEJAY-Signature-V2 is the HMAC-SHA256, written as 64 lower-case hexadecimal characters, of this text:
v2.<X-CEEJAY-Timestamp>.<X-CEEJAY-Delivery>.<X-CEEJAY-Attempt>.<raw request body>It is the same signature webhooks carry, keyed with the item's secret in place of a webhook's. To check a delivery:
- Check that the four headers are there and have their forms: 64 lower-case hexadecimal characters, an ID of letters, digits,
_and-, and two numbers written in digits with no zeros in front. - Refuse a delivery whose
X-CEEJAY-Timestampis more than 300 seconds from your own clock, either way. Write the test as "refuse unless it is inside the tolerance", so a clock or a setting that is not a number refuses every delivery. - Compute the signature over the raw body, before you parse it, and compare in constant time. Every retry is signed afresh, with a new time and the next attempt number.
- Keep each
X-CEEJAY-Deliverywith itsX-CEEJAY-Attemptfor five minutes, and ignore one you have already handled. Inside the 300 seconds a captured delivery can be replayed: nothing in it can be altered, but the same request can be sent again. De-duplicating ondelivery_id, as the handler below does, covers it.
import crypto from 'node:crypto'
const TOLERANCE_SECONDS = 300
// headers: the request headers, with lower-case names (as Node and Express give them)
function verifyDelivery(rawBody, headers, secret) {
const signature = headers['x-ceejay-signature-v2'] ?? ''
const deliveryId = headers['x-ceejay-delivery'] ?? ''
const attempt = headers['x-ceejay-attempt'] ?? ''
const timestamp = headers['x-ceejay-timestamp'] ?? ''
if (
!/^[0-9a-f]{64}$/.test(signature) || !/^[A-Za-z0-9_-]{1,80}$/.test(deliveryId) ||
!/^[1-9][0-9]{0,8}$/.test(attempt) || !/^[1-9][0-9]{0,11}$/.test(timestamp)
) return false
// Refuse unless the time is inside the tolerance
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp))
if (!(age <= TOLERANCE_SECONDS)) return false
const expected = crypto.createHmac('sha256', secret)
.update(`v2.${timestamp}.${deliveryId}.${attempt}.`)
.update(rawBody)
.digest('hex')
return crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'))
}To try your own code, sign this by hand: the secret a secret a studio chose, with spaces, the timestamp 1791364462, the delivery ID shop_4411, attempt 3 and the body below give the signature ff86b6a1bbb52f6ff00aa87abdf80d98dce52128298edeec0d31f1fec33b69b5.
{"event":"shop.purchase","delivery_id":"shop_4411"}The legacy signature
Before version 1.0.0 a delivery carried one signature header, and it is still sent on every delivery, unchanged, for as long as version 1 exists. X-CEEJAY-Signature is t= and a Unix time in seconds, a comma, then v1= and the hex HMAC-SHA256 of "<t>.<raw body>", keyed with the item's secret. t is the same time as X-CEEJAY-Timestamp. It does not cover the delivery ID or the attempt number. Code that checks it keeps working. Check X-CEEJAY-Signature-V2 in new code, and update existing code when you can: nothing has to be set in CEEJAY to switch.
import crypto from 'node:crypto'
function verifyOlderDelivery(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(
String(header ?? '').split(',').map((part) => part.trim().split('=')),
)
const t = Number(parts.t)
if (!Number.isInteger(t) || !parts.v1) return false
if (!(Math.abs(Date.now() / 1000 - t) <= toleranceSeconds)) return false
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest()
const given = /^[0-9a-f]+$/i.test(parts.v1) ? Buffer.from(parts.v1, 'hex') : Buffer.alloc(0)
return given.length === expected.length && crypto.timingSafeEqual(given, expected)
}A handler
A handler around verifyDelivery, which responds to a test, ignores a delivery it has already handled and responds first:
import express from 'express'
const app = express()
const handled = new Set() // use your database in production
app.post('/ceejay/deliver', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyDelivery(req.body, req.headers, process.env.CEEJAY_SHOP_SECRET)) {
return res.status(401).end()
}
const delivery = JSON.parse(req.body.toString('utf8'))
if (delivery.event === 'shop.test') return res.status(204).end()
if (!handled.has(delivery.delivery_id)) {
handled.add(delivery.delivery_id)
// Unlock delivery.item.item_key for delivery.member.discord_id in your game.
}
return res.json({ status: 'delivered' })
})A wrong secret refunds the member
Any 4xx response except 408 and 429 counts as a refusal, and a refusal refunds the member immediately. A request your server rejects with 401 because the secret does not match is refused in the same way. After you change a secret, press Send a test delivery before the next real purchase.
Your response
You have 5 seconds. CEEJAY reads at most 64 KB of the response.
| You respond with | Meaning | What CEEJAY does |
|---|---|---|
200, 201 or 204 with an empty body, or any 2xx with { "status": "delivered" } | Delivered | Marks the purchase delivered |
202, or a 2xx with { "status": "pending" } | Pending: you confirm by callback | The purchase shows Being delivered and awaits your callback |
A 2xx with { "status": "failed", "message": "…" } | Delivery failed | Refunds the member |
3xx | A redirect. Redirects are not followed | Refunds the member |
4xx except 408 and 429 | Refused | Refunds the member |
408, 429, 5xx, no response within 5 seconds, or no connection | Temporary failure | Retries |
A 2xx response whose body is not JSON, or is JSON without a status, counts as delivered. The status values are not case sensitive. When a failure comes with a message, the first 200 characters of it appear in the delivery log.
Respond delivered only once the item is unlocked. If unlocking takes longer than a few seconds, respond 202 and call back.
Calling back
When you responded pending, complete the purchase with Complete a shop delivery:
curl -X POST https://ceejay.communitystudios.xyz/api/v1/shop/purchases/4242/complete \
-H "Authorization: Bearer $CEEJAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "status": "delivered" }'- The key needs the Confirm shop deliveries scope (
shop:fulfil). Create one in Settings → Developers → API Keys. deliveredmarks the purchase delivered.failedrefunds the member and shows yourmessage(up to 500 characters) to your team, as in{ "status": "failed", "message": "Player has no account on this realm" }.- Sending the same response again is safe: the response includes
alreadyDone. You can also send anidempotencyKeywith the call. - If your server lost a delivery, or was down, List shop purchases lists every purchase awaiting your confirmation, with its
purchaseId. - Once a purchase is delivered you cannot mark it failed, and once it is refunded you cannot mark it delivered. Either returns
409. - You have 24 hours. A purchase still pending then is refunded, and your team is notified.
Retries and refunds
When CEEJAY cannot get a final response (408, 429, 5xx, no response or no connection), the purchase moves to Failed delivery and CEEJAY retries about every hour, with the same delivery_id. After 3 attempts in total, the first one included, the member is refunded.
A retry is the same request again with a new signature time and the next X-CEEJAY-Attempt. Treat it like the first: if you have already delivered, respond delivered.
You can respond pending on any attempt, a retry included. The purchase then shows Being delivered, the retries stop, and CEEJAY awaits your callback for up to 24 hours. A pending response does not count as one of the 3 attempts, and the attempts that failed before it are no longer counted against the purchase. CEEJAY sends no further request after a pending response, so complete the purchase with Complete a shop delivery. List shop purchases shows such a purchase as processing, and one that is being retried as retrying.
A refund returns the full price. An item with limited stock goes back in stock.
A reward that a member earned, rather than bought, has no price, so nothing is refunded. A refusal marks the reward failed. When your server gives no final response, CEEJAY retries after 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and then every day, for as long as the Keep trying a failed delivery for setting in the Rewards Engine settings says (1, 3 or 7 days, and 3 by default). After that the reward is marked failed.
Testing
Send a test delivery sends the same signed request with event set to shop.test, a delivery_id and purchase_id that start with test_, the person who pressed the button as member, and price_paid of 0. Respond to it with any 2xx and unlock nothing. The builder then shows Your endpoint answered 200 in 123 ms, or why it failed.
What your team sees
- Commerce → Shop → Redemptions lists the purchase under Being delivered while it awaits your callback, and under Failed delivery after CEEJAY could not get a response. Failed deliveries have Retry, which resends the request now and counts as one of the 3 attempts, and Refund & reject. When your server responds to a Retry with pending, the purchase moves to Being delivered and nothing is counted as failed. A purchase that is Being delivered has neither: it ends when you call back, or after 24 hours.
- Each such purchase has a Delivery log with every attempt: the time, the status your server returned (
202 · waiting for your API,503 · will retry,403 · refused, orNo answer), the attempt number, how long it took and the error. - Notifications tell the team when a first attempt gets no final response and CEEJAY will retry (A shop purchase could not be delivered), when you report a failure (Your API reported a failed shop delivery) and when 24 hours pass with no callback (A shop purchase was never confirmed). Each links to Redemptions. A refusal refunds the member without a notification: the purchase is under Done, with its Delivery log.
Related
- Complete a shop delivery
- List shop purchases
- The shop and economy in the guide
- The TypeScript SDK checks the signature of a delivery with
verifyDelivery.

