# 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](https://pay.mmokick.com/developers/guides/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:

```json
{
  "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.

```json
{
  "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`:

```json
{
  "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](https://pay.mmokick.com/developers/guides/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.

```bash
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:

```json
{
  "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:

```bash
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`):

```json
{ "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](https://pay.mmokick.com/developers/guides/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):

```bash
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](https://pay.mmokick.com/developers/guides/webhooks) — the signature verifier you reuse for our fulfilment calls, plus the order events.
- [Top-up products](https://pay.mmokick.com/developers/guides/topup-products) — the validate → execute contract for player-account delivery.
- [Contract reference](https://pay.mmokick.com/developers/reference/contract) — the OpenAPI spec for the endpoints you implement.
