Declared stock
Declared stock is for suppliers who keep keys on their own systems. You declare a stock count and implement a small contract: when a buyer pays, we call your endpoints to reserve, order, and fetch the deliverables. Keys stay on your systems until they sell — that is the point.
You host these endpoints; we call them, each call HMAC-signed with your offer's
cksec_ secret. This guide shows the four required endpoints, the exact response
semantics, and the SLA numbers we enforce. It ends at the contract tester, which
machine-checks your implementation before a single real order routes to you.
How it works
The flow is the industry-convergent reservation → order → items pattern:
buyer starts checkout
→ we POST {base}/reservations (hold stock, TTL 15 min)
you: 201
→ we send the order.created webhook (to your webhook endpoints)
[payment AUTHORIZED — funds held, not yet captured]
→ we POST {base}/orders (idempotent on our order ULID)
you: 201 accepted
→ we GET {base}/orders/{id}/items (poll until ready, ≤ 10 min)
you: 200 ready + items
[payment CAPTURED — only now is the buyer charged]
→ we send the order.paid webhook (your items were already provisioned)
→ we encrypt and deliver to the buyer; ledger `sale` entry (pending 7 days)
The refund path calls DELETE {base}/orders/{id}/items to return stock. There is also a
GET {base}/healthcheck liveness probe. Because we authorize → provision → capture, a
declared-stock supplier sees POST /orders and the item fetch before order.paid
arrives, and the buyer is charged only for items you deliver.
If the buyer never pays, the reservation TTLs out, the order is swept to expired, and you
receive a terminal order.expired webhook (order status expired, items released) — the
counterpart to the order.created you already recorded. Release the held reservation on it
so your ERP can close the record. See the Events table in
Webhooks for the full event list.
base is the fulfilment.base_url you set on the offer (see step 3 below); all paths
are appended to it.
The endpoints you implement
| Method | Path (relative to base_url) |
Purpose | Required |
|---|---|---|---|
| POST | /reservations |
Hold stock for a checkout, TTL 15 min | yes |
| DELETE | /reservations/{reservation_id} |
Early release (checkout abandoned) | optional — TTL expiry is enough |
| POST | /orders |
Convert reservation to a sale (idempotent on our order ULID) | yes |
| GET | /orders/{order_id}/items |
Hand over the deliverables | yes |
| DELETE | /orders/{order_id}/items |
Return to stock on refund or cancellation | optional — return 405 if unsupported |
| GET | /healthcheck |
Liveness probe | yes |
Response semantics
The status codes are the contract. Return exactly these.
POST /reservations
We call this when a buyer starts paying. Request body:
{
"reservation_id": "01JZY2G7Q3W5E7R9T1Y3A5S7DF",
"offer_id": "01JZWX5N3PQRS7TV9WXY2Z4A6B",
"external_ref": "SKU-STARFORGE-GLOBAL",
"product_id": "01SANDBX00000000000000PD01",
"quantity": 1,
"buyer_country": "FR",
"expires_at": "2026-07-12T14:17:58Z"
}
| Status | Body | Meaning |
|---|---|---|
201 |
{"reservation_id": "01JZY2G7Q3W5E7R9T1Y3A5S7DF", "expires_at": "2026-07-12T14:17:58Z"} |
Stock held until expires_at |
200 |
same body | Idempotent replay — you already hold this reservation_id |
409 |
{"code": "out_of_stock"} |
Cannot hold; we route to the next supplier (counts against health) |
409 |
{"code": "region_blocked"} |
You refuse this buyer_country (rare — we pre-filter by catalog country lists) |
Anything else (timeout, 5xx, other 4xx) is a reservation failure: health penalty, next supplier.
POST /orders
Sent when payment is authorized. Idempotent on order_id — this is the core rule.
{
"order_id": "01JZY2H8K4M6N8P0Q2R4S6T8V0",
"reservation_id": "01JZY2G7Q3W5E7R9T1Y3A5S7DF",
"offer_id": "01JZWX5N3PQRS7TV9WXY2Z4A6B",
"external_ref": "SKU-STARFORGE-GLOBAL",
"quantity": 1,
"unit_price": { "amount": 1999, "currency": "EUR" },
"buyer_country": "FR"
}
| Status | Body | Meaning |
|---|---|---|
201 |
{"order_id": "01JZY2H8K4M6N8P0Q2R4S6T8V0", "status": "accepted"} |
Sale recorded; we start polling for items |
409 |
the exact same body as your original response | Duplicate order_id (our retry) — treat as success; never create a second sale |
410 |
{"code": "reservation_expired"} |
The reservation TTL'd out; we try one fresh reservation + order cycle, then fail over |
202 |
{"order_id": "…", "status": "pending_stock"} |
Accepted but stock is momentarily unavailable — the 10-minute allocation window still applies |
The 409 rule is the one to get right: on a duplicate order_id, return the same
response body you returned the first time. Store your response keyed by order_id and
replay it. Never create a second sale for the same order.
GET /orders/{order_id}/items
We poll on a schedule (see below). Respond 200:
{
"order_id": "01JZY2H8K4M6N8P0Q2R4S6T8V0",
"status": "ready",
"items": [
{ "kind": "text", "value": "AAAA-BBBB-CCCC-DDDD" }
]
}
Or, while still allocating: {"order_id": "…", "status": "pending", "items": []}. The
items[] count must equal the ordered quantity. The kind set is closed:
kind |
Fields | Limits |
|---|---|---|
text |
value |
1–1,024 chars |
file |
file_name, content_type (application/pdf | image/png | image/jpeg), content_base64 |
≤ 200 KB decoded |
Once you return status: "ready", items are delivered — return the same payload on
every re-poll, holding the original values.
DELETE /orders/{order_id}/items
Sent on a refund before the buyer reveals the key, or on an allocation-window
cancellation. Respond 200 {"order_id": "…", "returned": 1} — the keys are yours to
resell. Respond 405 if you do not support return-to-stock; we proceed regardless.
Once you receive this DELETE, withhold the keys — this order is closed and must not be delivered.
GET /healthcheck
Respond 200 {"status": "ok"} within 5 seconds, no side effects. We still sign this
request; you may skip verification on the healthcheck only.
Verify our signatures
Every call we make to you is HMAC-signed with your offer's cksec_ secret, using the
exact same scheme as webhooks. Our requests carry X-CK-Signature, X-CK-Timestamp,
X-CK-Offer-Id, and User-Agent: cheap-keys-fulfilment/1.0. Treat any unsigned lookalike
request as an attack and reject it.
The verification is identical to the webhook verifier: compute
HMAC-SHA256(secret, "{timestamp}.{raw body}") over the raw request bytes and compare in
constant time. Reuse the one canonical snippet and its Node and Python siblings in the
Webhooks guide, and verify your implementation against
the published test vectors there before going live.
SLA numbers and health
The numbers below are enforced and gate your offer's health score. Published math is the
moderation system, and routing exclusion is the sole penalty — the only cost of an
unfulfilled sale.
| Step | Timeout per attempt | Attempts | Hard deadline |
|---|---|---|---|
POST /reservations |
10 s | 2 (retry after 2 s) | buyer is waiting at checkout |
| Reservation TTL | — | — | 15 min — honor expires_at |
POST /orders |
10 s | 3, 5 s apart | starts at payment authorization |
Items ready (GET …/items) |
10 s per poll | poll at +0 s, +5 s, +15 s, +30 s, then every 60 s | 10-minute allocation window from the first POST /orders |
DELETE /orders/{id}/items |
10 s | 3 over 1 h | best-effort |
GET /healthcheck |
5 s | 1 | probed every 5 min while active; every 10 min in cooldown |
Aim to have items ready within 60 seconds of POST /orders — that earns bonus
health. Miss the 10-minute window and we cancel your item (rerouting or refunding the
buyer), send DELETE /orders/{id}/items, and you take a cooldown.
Offer health is an integer 0–100 per offer, starting at 100; routing requires ≥ 70.
Fast, successful deliveries raise it; timeouts, 5xx, and missed windows lower it. Sustained
failure puts the offer into a 2-hour cooldown (excluded from routing, but still active);
during cooldown we probe your /healthcheck every 10 minutes. An offer continuously in
cooldown for 24 hours is auto-deactivated with an offer.deactivated webhook
(reason health_exhausted). Reactivation is self-service: fix your endpoint, then
POST /offers/{id}/activate — the inline healthcheck must pass, and health restarts at 70.
Create a declared-stock offer
Set stock_mode: "declared" and a fulfilment.base_url. The signing secret is returned
once — save it.
curl -X POST "https://sandbox-pay.mmokick.com/api/v1/offers" \
-H "Authorization: Bearer $CK_TEST_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 01JZWDECLARED0000000OFFER01" \
-d '{
"product_id": "01SANDBX00000000000000PD01",
"stock_mode": "declared",
"price": { "amount": 1999, "currency": "EUR" },
"external_ref": "SKU-STARFORGE-GLOBAL",
"fulfilment": { "base_url": "https://api.your-app.example/ck" },
"declared_stock": 250
}'
201 — the fulfilment.secret is your cksec_ signing secret, shown only here:
{
"id": "01JZWX5N3PQRS7TV9WXY2Z4A6B",
"status": "draft",
"stock_mode": "declared",
"price": { "amount": 1999, "currency": "EUR" },
"stock": { "mode": "declared", "declared": 250, "available": null, "reserved": null, "delivered": 0 },
"fulfilment": {
"base_url": "https://api.your-app.example/ck",
"secret": "cksec_7pQ2wE9rT4yU1iA6sD3fG8hJ5kZ0xC7vB2nM4q",
"secret_set": true,
"last_healthcheck_at": null,
"last_healthcheck_ok": null
},
"health": { "score": 100, "routing_eligible": false, "cooldown_until": null }
}
Run a healthcheck against your endpoint before activating:
curl -X POST "https://sandbox-pay.mmokick.com/api/v1/offers/01JZWX5N3PQRS7TV9WXY2Z4A6B/fulfilment/check" \
-H "Authorization: Bearer $CK_TEST_TOKEN" \
-H "Idempotency-Key: 01JZWDECLARED000000CHECK001"
200 when your endpoint answered (the HTTP 200 means the check ran; look at status):
{ "status": "ok", "http_status": 200, "latency_ms": 142, "checked_at": "2026-07-12T09:10:00Z" }
Activating a declared offer runs a live healthcheck inline; if it fails you get 422 /
fulfilment_unreachable with the probe result in detail, and nothing changes. Then use
POST /sandbox/orders (scenario paid) to drive a real order through your endpoints with
real signatures — see Your first sale in 15 minutes
for the sandbox-order call.
Update declared stock
Declared stock is a routing signal; the reservation call is the truth. Keep the count fresh with the fast lane (300/min, no fees):
curl -X PUT "https://sandbox-pay.mmokick.com/api/v1/offers/01JZWX5N3PQRS7TV9WXY2Z4A6B/stock" \
-H "Authorization: Bearer $CK_TEST_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "declared_stock": 175 }'
200 → { "offer_id": "01JZWX5N3PQRS7TV9WXY2Z4A6B", "declared_stock": 175, "updated_at": "…" }.
Declaring 0 makes the offer unroutable while keeping it active. Running this on an
uploaded offer returns 409 / stock_mode_mismatch.
Reference implementation
A complete, single-file, framework-free PHP reference implementation — routing, signature verification, SQLite-backed reservations and orders, and idempotent order handling — is available to read and download:
https://pay.mmokick.com/developers/examples/declared-stock-supplier.php
It is roughly 150 lines and implements every endpoint above. Read it end to end, then adapt it to your stack. It is the same implementation our contract tester certifies against.
Certify with the contract tester
Before you go live, point our contract tester at your staging endpoint and get a machine-checked, per-step verdict. It runs the real signed calls — healthcheck, a deliberately bad signature (which you must reject), create a reservation, create an order, replay the order (idempotency), fetch items, and an unknown reservation — and validates every response against the contract schema.
The tester lives in the supplier portal at Tools → Contract test
(/supplier/tools/contract-test). Choose declared_stock, enter your endpoint base URL
and the offer secret, and run it. Fix anything that fails; the report shows each step's
status, latency, and any schema violations verbatim.
Go live only after this passes.
Next steps
- Webhooks — the signature verifier you reuse for our fulfilment calls, plus the order events.
- Top-up products — the validate → execute contract for player-account delivery.
- Contract reference — the OpenAPI spec for the endpoints you implement.