cheapkeys

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.