# Authentication Source: https://pay.mmokick.com/developers/getting-started/authentication.md Authenticate every request with a supplier-scoped bearer token. One authentication scheme covers everything — send an `Authorization` header: ```bash curl "https://sandbox-pay.mmokick.com/api/v1/whoami" \ -H "Authorization: Bearer $CK_TEST_TOKEN" \ -H "Accept: application/json" ``` Expected `200` response: ```json { "token": { "id": "01JZV9AB2CD4EF6GH8JK0MN2PQ", "name": "repricer-bot", "mode": "test", "scopes": ["offers:read", "offers:write", "pricing:read"], "created_at": "2026-06-02T09:14:00Z", "last_used_at": "2026-07-11T13:59:41Z" }, "supplier": { "id": "01JZV8K2E4Q6S8A0C2E4G6J8KM", "name": "Keyforge Ltd", "status": "active" }, "environment": "sandbox", "rate_limits": { "reads": 600, "writes": 120, "sync": 300, "bulk": 12, "simulate": 120, "test": 6 } } ``` `GET /whoami` is your integration smoke test: it confirms the token is valid, tells you which scopes it carries, and reports the environment it belongs to. Use it first in any new integration. ## Get a token Create tokens yourself in the supplier portal, under **Settings → API tokens → Create** (route `supplier.developers.tokens`). Two-factor authentication is required to create a token. A token is **shown once** at creation and hashed at rest; if you lose it, revoke it and create another. - Sandbox access is instant and auto-approved: submitting the supplier application in the portal approves your supplier into the sandbox on the spot, so you can create a `ck_test_` token in the portal (env toggle = Sandbox) — or use the published seeded token — and call the sandbox base URL within minutes. One portal manages both your Live and Sandbox tokens, so the same **Settings → API tokens** flow works for either. See [Environments](https://pay.mmokick.com/developers/getting-started/environments). - You may hold up to **25 tokens per environment**. Tokens stay valid until you revoke them, and you can revoke any token instantly in the portal. `last_used_at` is tracked and displayed so you can spot and retire stale tokens. Export your token once and reuse it across every example in these guides: ```bash export CK_TEST_TOKEN="ck_test_..." ``` ## The header ``` Authorization: Bearer ``` Tokens carry a prefix that names the environment: | Prefix | Environment | Base URL | |---|---|---| | `ck_test_` | Sandbox | `https://sandbox-pay.mmokick.com/api/v1` | | `ck_live_` | Production | `https://pay.mmokick.com/api/v1` | A token is the prefix plus 40 random base62 characters, for example `ck_live_1u2P9zXqK7mA4dT8rW3yB6nC0eF5gH2jL9sV4xQ7`. Use tokens for server-to-server calls: the API sends no CORS headers, so browser use is unsupported by design. Keep tokens out of client-side code, URLs, and logs. Match the token prefix to the host. Using a `ck_test_` token against production (or a `ck_live_` token against sandbox) returns `401` with code `wrong_environment` — the request always fails loudly rather than falling back silently. ## Scopes Scopes are assigned at token creation; the default is all scopes. A token missing the scope an endpoint requires gets `403` with code `insufficient_scope`. | Scope | Grants | |---|---| | `catalog:read` | Products, categories | | `pricing:read` | Fee schedule, pricing simulation | | `offers:read` / `offers:write` | Offers read / create, update, activate, price, stock | | `inventory:read` / `inventory:write` | Inventory list / upload, invalidate | | `orders:read` | Orders read model | | `balance:read` | Balance and ledger | | `webhooks:manage` | Webhook endpoints, deliveries log, test-fire, resend | Create a token with only the scopes an integration needs. A repricer bot, for example, needs `offers:write` and `pricing:read` and nothing else. ## Failure responses Every authentication and authorization failure is an RFC 9457 problem+json body with a stable machine `code` you switch on. Codes stay stable; `title` and `detail` text may change. **`401` — the token is the problem.** You are not (yet) authenticated: ```json { "type": "https://pay.mmokick.com/developers/errors#invalid_token", "title": "Invalid token", "status": 401, "code": "invalid_token", "detail": "The bearer token is unknown.", "trace_id": "req_9f3b2c61a8d44e0f" } ``` The `401` codes and what to do: | `code` | Meaning | What to do | |---|---|---| | `unauthenticated` | The `Authorization` header is missing or malformed | Send `Authorization: Bearer ` | | `invalid_token` | The token is unknown | Check the token; create a new one in the portal | | `token_revoked` | The token was revoked in the portal | Issue a new token | | `wrong_environment` | A `ck_test_` token was used on production, or `ck_live_` on sandbox | Use the host that matches the prefix | **`403` — the token is valid, but not allowed here.** Either it lacks a scope or the supplier account is suspended: ```json { "type": "https://pay.mmokick.com/developers/errors#insufficient_scope", "title": "Insufficient scope", "status": 403, "code": "insufficient_scope", "detail": "This token is missing the required scope: offers:write.", "trace_id": "req_1a2b3c4d5e6f7081" } ``` | `code` | Meaning | What to do | |---|---|---| | `insufficient_scope` | The token lacks a required scope | Issue a token that carries the scope | | `supplier_suspended` | The supplier account is suspended by staff | Contact support; `GET /whoami` still works | A request for another supplier's resource returns `404` with code `not_found` (rather than `403`), keeping the existence of resources you cannot access private. ## Token hygiene - **Rotate** by creating a new token and revoking the old one — create-then-revoke is the rotation for API tokens. - **Revoke immediately** if a token is exposed. Revocation takes effect at once; the next call with a revoked token gets `401` / `token_revoked`. - Tokens stay out of webhook payloads and every response body. If you see a token in a payload, it did not come from us. - Quote the `X-Request-Id` header (echoed in error bodies as `trace_id`) in any support request — it lets us find the exact call in our logs. ## Next steps - [Environments](https://pay.mmokick.com/developers/getting-started/environments) — base URLs, the sandbox, and the magic test values. - [Your first API call in 5 minutes](https://pay.mmokick.com/developers/getting-started/first-call) — from token to a live response. - [Errors](https://pay.mmokick.com/developers/errors) — the full machine error-code catalog. --- # Environments Source: https://pay.mmokick.com/developers/getting-started/environments.md You build against the **sandbox** and go live on **production**. Both run the same code, the same rate limits, and the same signatures — the sandbox is the full platform running on a separate database with deterministic fixtures. ```bash # Sandbox — test tokens, deterministic fixtures, no real money: curl "https://sandbox-pay.mmokick.com/api/v1/whoami" -H "Authorization: Bearer $CK_TEST_TOKEN" # Production — live tokens, real orders and payouts: curl "https://pay.mmokick.com/api/v1/whoami" -H "Authorization: Bearer $CK_LIVE_TOKEN" ``` | | Production | Sandbox | |---|---|---| | Base URL | `https://pay.mmokick.com/api/v1` | `https://sandbox-pay.mmokick.com/api/v1` | | Tokens | `ck_live_*` | `ck_test_*` | | Getting access | KYC-vetted onboarding (24–72 h) | **Instant** — submitting your supplier application approves you into the sandbox on submit; create a `ck_test_` token in the portal and call the sandbox base URL right away | | Data | Real | Fixtures re-asserted nightly; **no destructive resets** — your suppliers, tokens, and offers persist; orders and logs are pruned after 30 days | | Payments | Real providers | Stripe/PayPal test mode | | Settlement clock | Funds clear 7 days after delivery | Funds clear **10 minutes** after delivery, so you can test balance flows in one session | Tokens are prefix-locked to their environment. A `ck_test_` token on production (or a `ck_live_` token on sandbox) returns `401` / `wrong_environment` and stops there. Match the token prefix to the host every time. ## Start in the sandbox Sandbox access is instant and auto-approved. When you submit the production supplier application in the portal, your supplier is mirrored into sandbox the moment you submit, so you can create a `ck_test_` token and reach a delivered test order in minutes while production vetting runs in the background. There is a published demo supplier and token already seeded in the sandbox, so you can run read calls even before creating your own token: | Fixture | Value | |---|---| | Demo supplier ("Acme Digital GmbH") | `01SANDBX00000000000000SP01` | | Published sandbox token | `ck_test_sandbox000000000000000000000000000000001` | Use your own token for anything you write, so your offers and webhook endpoints stay scoped to you. ## Deterministic fixtures The sandbox ships fixed products, offers, and keys with hard-coded ULIDs you can embed in your tests. A nightly upsert re-asserts them in place and **preserves every ID**, so a suite that hard-codes these IDs keeps working. | Fixture | ID | |---|---| | Product — `game_key` ("Cyber Odyssey — PC (Steam)", region `global`) | `01SANDBX00000000000000PD01` | | Product — `gift_card` ("Steam Gift Card 20 EUR", region `europe`) | `01SANDBX00000000000000PD02` | | Product — `topup` ("MMO Kick Gold — 1000 G", `form_fields`: `user_id` regex `^[0-9]{6}$`, `server_id` select) | `01SANDBX00000000000000PD03` | | Deterministic uploaded keys | `CKTEST-OK-00001` … `CKTEST-OK-00100` | The `CKTEST-OK-*` keys are safe, deterministic values you upload as inventory in the sandbox — see [Your first sale in 15 minutes](https://pay.mmokick.com/developers/guides/first-sale-in-15-minutes). ## Magic top-up test values For `topup` products, four magic `user_id` values drive the top-up contract down a fixed path, so you can prove each branch without a real player account. Send them as `form_fields.user_id` to `POST /sandbox/orders` (or your own `validate`/`execute` endpoints when the sandbox calls them): | `user_id` | Behavior | |---|---| | `100001` | Validates and executes cleanly (happy path) | | `999999` | Fails validation with code `invalid_user_id` | | `888888` | Validates, then fails execute with status `failed` and code `topup_failed` | | `777777` | Times out once, then succeeds on retry — the idempotency demo | These are the same values used throughout the [Top-up products](https://pay.mmokick.com/developers/guides/topup-products) guide. ## Sandbox parity The sandbox is the same code as production. Specifically: - **Rate limits are identical** to production, so your load assumptions transfer directly. See [Rate limits](https://pay.mmokick.com/developers/rate-limits). - **Signatures are identical** — the HMAC scheme and the published test vectors are the same in both environments. See [Webhooks](https://pay.mmokick.com/developers/guides/webhooks). - Webhook and fulfilment targets may use any HTTPS port in sandbox. Point them at a public HTTPS address — the sandbox reaches public hosts and rejects private and link-local IPs — so use a tunnel for local development. If the dedicated sandbox host is ever unavailable, the same API is reachable under a `/sandbox` path prefix on the production origin. Every example in these guides renders the correct sandbox base URL for you — copy and paste it as written. ## Next steps - [Your first API call in 5 minutes](https://pay.mmokick.com/developers/getting-started/first-call) — make a real sandbox call now. - [Your first sale in 15 minutes](https://pay.mmokick.com/developers/guides/first-sale-in-15-minutes) — end to end, including a signed webhook. - [Authentication](https://pay.mmokick.com/developers/getting-started/authentication) — tokens, scopes, and failure responses. --- # Your first API call in 5 minutes Source: https://pay.mmokick.com/developers/getting-started/first-call.md All you need is a shell with `curl`. In five minutes you will create a `ck_test_` token in the supplier portal and get a real, authenticated response from the sandbox. ## 1. Create a sandbox token Open the supplier portal at `https://pay.mmokick.com/supplier` → **Developers → API tokens**, set the Live | Sandbox toggle to **Sandbox**, and create a `ck_test_` token. Sandbox access is instant: submitting your supplier application approves you into the sandbox on the spot, so you can mint a token and make your first call within minutes — production vetting runs in the background. Token creation is protected by 2FA, so enrol an authenticator app once in the portal (register → verify email → set up TOTP); see [Authentication](https://pay.mmokick.com/developers/getting-started/authentication) for the one-time setup. To skip ahead for read calls, use the published seeded sandbox token `ck_test_sandbox000000000000000000000000000000001`: ```bash # https://pay.mmokick.com/supplier → Developers → API tokens → env: Sandbox → Create export CK_TEST_TOKEN="ck_test_..." ``` The token is shown once. Export it now; every example in these guides reuses `$CK_TEST_TOKEN`. ## 2. Confirm the token with /whoami ```bash curl "https://sandbox-pay.mmokick.com/api/v1/whoami" \ -H "Authorization: Bearer $CK_TEST_TOKEN" \ -H "Accept: application/json" ``` Expected `200`: ```json { "token": { "id": "01JZV9AB2CD4EF6GH8JK0MN2PQ", "name": "getting-started", "mode": "test", "scopes": ["catalog:read", "offers:read", "offers:write", "inventory:write", "webhooks:manage"], "created_at": "2026-07-12T09:00:00Z", "last_used_at": "2026-07-12T09:00:05Z" }, "supplier": { "id": "01JZV8K2E4Q6S8A0C2E4G6J8KM", "name": "Your Company", "status": "active" }, "environment": "sandbox", "rate_limits": { "reads": 600, "writes": 120, "sync": 300, "bulk": 12, "simulate": 120, "test": 6 } } ``` If you get `401` / `wrong_environment`, you are using a `ck_live_` token against the sandbox host — create a `ck_test_` token instead. See [Authentication](https://pay.mmokick.com/developers/getting-started/authentication). ## 3. List the catalog Suppliers attach offers to platform-owned canonical products. List the first few: ```bash curl "https://sandbox-pay.mmokick.com/api/v1/products?limit=3" \ -H "Authorization: Bearer $CK_TEST_TOKEN" \ -H "Accept: application/json" ``` Expected `200` — a cursor-paginated list. `next_cursor` is the opaque cursor for the next page, or `null` on the last page: ```json { "data": [ { "id": "01SANDBX00000000000000PD01", "slug": "cyber-odyssey-pc-steam", "name": "Cyber Odyssey — PC (Steam)", "type": "game_key", "platform": "steam", "region": "global", "allowed_countries": null, "blocked_countries": null, "category_id": "01JZT2A1B2C3D4E5F6G7H8J9K0", "attributes": { "steam_app_id": "1289310", "edition": "standard" }, "form_fields": [], "status": "sellable", "market": { "buyer_price": { "amount": 2049, "currency": "EUR" }, "active_offers": 2, "your_offer_selected": false }, "created_at": "2026-03-01T10:00:00Z", "updated_at": "2026-07-01T08:30:00Z" } ], "next_cursor": "eyJrIjoiMDFTQU5EQlgiLCJkIjoiZGVzYyJ9" } ``` To fetch the next page, pass the cursor back: `?limit=3&cursor=eyJrIjoiMDFTQU5EQlgi...`. Money is always an object of integer minor units plus an ISO 4217 code: `{"amount": 2049, "currency": "EUR"}` means €20.49. The Supplier API is EUR-only in v1. ## Response headers to know Every response — success or error — carries: ``` X-RateLimit-Limit: 600 X-RateLimit-Remaining: 597 X-RateLimit-Reset: 1783519482 # unix seconds when the window resets X-Request-Id: req_9f3b2c61a8d44e0f # quote this in support requests ``` `X-Request-Id` is echoed in every error body as `trace_id`. On a `429` response you also get `Retry-After` in seconds — honor it. See [Rate limits](https://pay.mmokick.com/developers/rate-limits) for the numbers. ## Next steps - [Your first sale in 15 minutes](https://pay.mmokick.com/developers/guides/first-sale-in-15-minutes) — create an offer, upload a key, and receive a signed webhook. - [Environments](https://pay.mmokick.com/developers/getting-started/environments) — sandbox fixtures and magic test values. - [API reference](https://pay.mmokick.com/developers/reference) — every endpoint, with a try-it console. --- # Your first sale in 15 minutes Source: https://pay.mmokick.com/developers/guides/first-sale-in-15-minutes.md You will take an uploaded-stock offer from nothing to a completed sandbox sale — offer created, key uploaded, order driven through the real pipeline, and a signed `order.paid` webhook received and verified. Every step is a runnable `curl` against the sandbox with the exact response you should see. Each step carries a cumulative time budget. You need a shell with `curl` and a sandbox token exported as `$CK_TEST_TOKEN`. To get a token, do [Your first API call in 5 minutes](https://pay.mmokick.com/developers/getting-started/first-call) first — it takes 30 seconds. ## 1. Get a token · 0:30 ```bash export CK_TEST_TOKEN="ck_test_..." ``` Confirm it works: ```bash curl "https://sandbox-pay.mmokick.com/api/v1/whoami" -H "Authorization: Bearer $CK_TEST_TOKEN" ``` A `200` with your supplier and `"environment": "sandbox"` means you are ready. See [Authentication](https://pay.mmokick.com/developers/getting-started/authentication) if you get a `401`. ## 2. Pick a product · 2:00 Offers attach to platform-owned products. Use the seeded sandbox game key: ```bash curl "https://sandbox-pay.mmokick.com/api/v1/products?type=game_key&limit=1" \ -H "Authorization: Bearer $CK_TEST_TOKEN" \ -H "Accept: application/json" ``` `200`: ```json { "data": [ { "id": "01SANDBX00000000000000PD01", "name": "Cyber Odyssey — PC (Steam)", "type": "game_key", "platform": "steam", "region": "global", "status": "sellable", "market": { "buyer_price": { "amount": 2049, "currency": "EUR" }, "active_offers": 2, "your_offer_selected": false } } ], "next_cursor": null } ``` You will use product `01SANDBX00000000000000PD01`. Only `sellable` products accept new offers; a `not_sellable` product returns `422` / `product_not_sellable` at offer creation. ## 3. Create an offer · 4:00 Create an **uploaded**-stock offer: you hold the keys with us and we deliver them. Price is in **minor units EUR** — `1999` = €19.99. This is your first mutating call, so send an `Idempotency-Key`. If the network drops and you retry with the same key and body, you get the original response back (with header `Idempotent-Replay: true`) and exactly one offer is created. Use a fresh UUID or ULID per logical operation: ```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: 01JZWFIRSTSALE0000000OFFER1" \ -d '{ "product_id": "01SANDBX00000000000000PD01", "stock_mode": "uploaded", "price": { "amount": 1999, "currency": "EUR" }, "external_ref": "SKU-FIRST-SALE", "low_stock_threshold": 5 }' ``` `201` — the offer is created in `draft`. For an `uploaded` offer you omit `fulfilment` and `declared_stock` (sending them returns `422`): ```json { "id": "01JZWX5N3PQRS7TV9WXY2Z4A6B", "product_id": "01SANDBX00000000000000PD01", "external_ref": "SKU-FIRST-SALE", "status": "draft", "stock_mode": "uploaded", "price": { "amount": 1999, "currency": "EUR" }, "delivery_sla_minutes": 0, "delivery_time": "instant", "low_stock_threshold": 5, "stock": { "mode": "uploaded", "declared": null, "available": 0, "reserved": 0, "delivered": 0 }, "health": { "score": 100, "routing_eligible": false, "cooldown_until": null }, "created_at": "2026-07-12T09:03:00Z", "updated_at": "2026-07-12T09:03:00Z" } ``` Note the response echoes your `external_ref` — map it to your own SKU system; it appears on every order and webhook. If you already have an offer for this product you get `409` / `offer_exists`; `PATCH` the existing offer instead. ## 4. Upload a key · 6:00 Add one text key to the offer's inventory. Inventory upload is idempotent too — send an `Idempotency-Key` here so a network retry inserts the key exactly once: ```bash curl -X POST "https://sandbox-pay.mmokick.com/api/v1/offers/01JZWX5N3PQRS7TV9WXY2Z4A6B/inventory" \ -H "Authorization: Bearer $CK_TEST_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 01JZWFIRSTSALE00000INVENTORY" \ -d '{ "mode": "strict", "items": [ { "kind": "text", "value": "CKTEST-OK-00001" } ] }' ``` `201`: ```json { "accepted": 1, "rejected": [], "item_ids": ["01JZWY6P2R4T6V8X0Z2B4D6F8H"], "stock_available": 1 } ``` In `strict` mode (the default) any invalid item rejects the whole batch with `422` / `inventory_rejected` and stores nothing; use `mode: partial` to store the valid items and get the rest reported. Your key is now `available`. Activate the offer so it can be routed: ```bash curl -X POST "https://sandbox-pay.mmokick.com/api/v1/offers/01JZWX5N3PQRS7TV9WXY2Z4A6B/activate" \ -H "Authorization: Bearer $CK_TEST_TOKEN" \ -H "Idempotency-Key: 01JZWFIRSTSALE0000000ACTIV8" ``` `200` returns the offer with `"status": "active"`. Activating an uploaded offer with zero stock succeeds but stays unroutable until stock arrives — you just added a key, so you are routable now. ## 5. Register a webhook endpoint · 8:00 Tell us where to push order events. The secret in the response is shown once — save it. ```bash curl -X POST "https://sandbox-pay.mmokick.com/api/v1/webhooks" \ -H "Authorization: Bearer $CK_TEST_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 01JZWFIRSTSALE00000WEBHOOK1" \ -d '{ "url": "https://your-app.example/hooks/cheap-keys", "events": ["order.paid", "order.refunded"], "description": "First sale intake" }' ``` `201` — the only time the secret appears: ```json { "id": "01JZW0DC8FGH2JK4MN6PQ8RS0T", "url": "https://your-app.example/hooks/cheap-keys", "events": ["order.paid", "order.refunded"], "description": "First sale intake", "status": "active", "secret": "whsec_4d9c2f81a7e35b60d1c8f42e9a0b7365", "consecutive_failures": 0, "disabled_at": null, "created_at": "2026-07-12T09:05:00Z" } ``` > **No public URL handy?** Use any HTTPS tunnel (the sandbox allows any HTTPS port), or > point at a request bin so you can inspect the exact signed request. Point at a public > HTTPS host; private and link-local IPs are rejected. Save the `secret` now — you need it > to verify the signature in step 7, and it is shown this one time only. ## 6. Trigger a sandbox test order · 10:00 `POST /sandbox/orders` is a sandbox-only endpoint (it returns `404` / `not_found` on production) that runs the **full real pipeline** against your offer: order → key allocation → delivery → webhooks. The default `scenario` is `paid`. ```bash curl -X POST "https://sandbox-pay.mmokick.com/api/v1/sandbox/orders" \ -H "Authorization: Bearer $CK_TEST_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 01JZWFIRSTSALE000000ORDER01" \ -d '{ "offer_id": "01JZWX5N3PQRS7TV9WXY2Z4A6B", "quantity": 1, "buyer_country": "FR", "scenario": "paid" }' ``` `201` returns the order object (your items only): ```json { "id": "01JZY2H8K4M6N8P0Q2R4S6T8V0", "number": "CK-01JZY2H8K4M6N8P0Q2R4S6T8V0", "status": "completed", "buyer_country": "FR", "currency": "EUR", "created_at": "2026-07-12T09:07:00Z", "paid_at": "2026-07-12T09:07:01Z", "completed_at": "2026-07-12T09:07:02Z", "items": [ { "id": "01JZY2H8KFAB3CD5EF7GH9JK1M", "product_id": "01SANDBX00000000000000PD01", "offer_id": "01JZWX5N3PQRS7TV9WXY2Z4A6B", "external_ref": "SKU-FIRST-SALE", "quantity": 1, "unit_price": { "amount": 1999, "currency": "EUR" }, "gross": { "amount": 1999, "currency": "EUR" }, "commission": { "amount": 170, "currency": "EUR" }, "net": { "amount": 1829, "currency": "EUR" }, "status": "delivered", "fulfilment": { "source": "uploaded", "reservation_id": null, "inventory_item_ids": ["01JZWY6P2R4T6V8X0Z2B4D6F8H"] }, "delivered_at": "2026-07-12T09:07:02Z" } ], "totals": { "gross": { "amount": 1999, "currency": "EUR" }, "commission": { "amount": 170, "currency": "EUR" }, "net": { "amount": 1829, "currency": "EUR" } } } ``` Commission is `max(round_half_up(gross × 0.085), 5)` per item: 1999 → 170 → 1829 net, exactly. See [Fees](https://pay.mmokick.com/developers/fees). ## 7. Receive and verify order.paid · 13:00 The pipeline pushes an `order.paid` event to the endpoint you registered. The exact HTTP request your server receives: ```http POST /hooks/cheap-keys HTTP/1.1 Host: your-app.example Content-Type: application/json User-Agent: cheap-keys-webhooks/1.0 (+https://pay.mmokick.com/developers) X-CK-Event: order.paid X-CK-Event-Id: 01JZY3B1M5P7R9T2V4X6Z8A0C2 X-CK-Delivery-Id: 01JZY3B2N6Q8S0V2X4Z6B8D0F2 X-CK-Timestamp: 1783519402193 X-CK-Signature: v1= {"id":"01JZY3B1M5P7R9T2V4X6Z8A0C2","type":"order.paid","mode":"test","api_version":"v1","test_fire":false,"created_at":"2026-07-12T09:07:01Z","data":{"order":{"id":"01JZY2H8K4M6N8P0Q2R4S6T8V0","number":"CK-01JZY2H8K4M6N8P0Q2R4S6T8V0","status":"completed","currency":"EUR"}}} ``` Verify it before trusting it. This is the canonical PHP verifier — compute `HMAC-SHA256(secret, "{timestamp}.{raw body}")` over the **raw** request bytes and compare in constant time: ```php function verify_ck_signature( string $secret, string $signatureHeader, string $timestampHeader, string $rawBody, int $toleranceMs = 300000 ): bool { // The header is a comma-separated list of scheme=value pairs (e.g. // "v1=,v2="). Pick the v1 entry; ignore unknown schemes. if (!preg_match('/(?:^|,\s*)v1=([a-f0-9]{64})/', $signatureHeader, $m)) return false; if (!ctype_digit($timestampHeader)) return false; if (abs((int) round(microtime(true) * 1000) - (int) $timestampHeader) > $toleranceMs) return false; $expected = hash_hmac('sha256', $timestampHeader . '.' . $rawBody, $secret); return hash_equals($expected, $m[1]); } // A multi-scheme header still verifies on the v1 entry (the garbage v2 is skipped): // verify_ck_signature($secret, "v1={$validHex},v2=deadbeef", $ts, $body) === true ``` Use the endpoint `secret` from step 5, `$_SERVER['HTTP_X_CK_SIGNATURE']`, `$_SERVER['HTTP_X_CK_TIMESTAMP']`, and the raw body from `file_get_contents('php://input')` (in Laravel, `$request->getContent()`). Return `2xx` fast and process asynchronously — a `2xx` within 10 seconds acks the delivery; any other status (or a timeout) fails it and we retry. The full scheme, Node and Python verifiers, and copy-paste test vectors are in the [Webhooks](https://pay.mmokick.com/developers/guides/webhooks) guide. ## 8. See the sale · 15:00 Read the order back and check your balance: ```bash curl "https://sandbox-pay.mmokick.com/api/v1/orders/01JZY2H8K4M6N8P0Q2R4S6T8V0" \ -H "Authorization: Bearer $CK_TEST_TOKEN" curl "https://sandbox-pay.mmokick.com/api/v1/balance" \ -H "Authorization: Bearer $CK_TEST_TOKEN" ``` `GET /balance` `200`: ```json { "currency": "EUR", "available": { "amount": 0, "currency": "EUR" }, "pending": { "amount": 1829, "currency": "EUR" }, "updated_at": "2026-07-12T09:07:03Z" } ``` Your €18.29 net is `pending` — it clears to `available` after the holding period (7 days on production, 10 minutes on sandbox so you can test it in one session). Inspect the ledger with `GET /balance/entries`. That is the whole integration for uploaded stock: create an offer, upload keys, and we handle routing, delivery, webhooks, and settlement. Declared stock — where keys stay on your side and we call your endpoints to provision them — is the next guide. ## Next steps - [Declared stock](https://pay.mmokick.com/developers/guides/declared-stock) — keep keys on your systems and implement the fulfilment contract. - [Webhooks](https://pay.mmokick.com/developers/guides/webhooks) — signature verification in PHP, Node, and Python, plus retries and auto-disable. - [Top-up products](https://pay.mmokick.com/developers/guides/topup-products) — validate → execute delivery into player accounts. --- # Declared stock Source: https://pay.mmokick.com/developers/guides/declared-stock.md 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. --- # Top-up products Source: https://pay.mmokick.com/developers/guides/topup-products.md Top-up products deliver virtual currency or a top-up straight into a player's account, so you confirm delivery with a receipt (`receipt_ref`). You implement a two-call handshake: we call your `validate` endpoint before payment to check the buyer's inputs, then your `execute` endpoint after payment to perform the top-up. Execute is **idempotent on `operation_id`** — deliver exactly once per operation. You host these endpoints; we call them, each signed with your offer's `cksec_` secret exactly as in the [Declared stock](https://pay.mmokick.com/developers/guides/declared-stock) guide. Top-up offers use `stock_mode: "declared"`, and `validate` serves as the pre-payment check in place of reservations. ## The two-call handshake ``` buyer fills the product's form_fields at checkout → we POST {base}/topup/validate (side-effect free; mints an operation_id) you: 200 { "valid": true, "account_label": "…" } or 200 { "valid": false, "code": "…" } [payment CAPTURED] → we POST {base}/topup/execute (idempotent on operation_id) you: 201 { "status": "completed", "receipt_ref": "…" } ``` | Method | Path (relative to `base_url`) | Purpose | Required | |---|---|---|---| | POST | `/topup/validate` | Pre-payment check of the buyer's `form_fields` | yes | | POST | `/topup/execute` | Perform the top-up (idempotent on `operation_id`) | yes | | GET | `/topup/operations/{operation_id}` | Status poll | only if you ever return `202` from execute | | GET | `/healthcheck` | Liveness | yes (shared with declared stock) | **Operation lifetime:** an `operation_id` is minted when we first call `validate` (this can be at the cart stage, before any order exists). If we re-validate — a stale validation over 15 minutes old, or routing switching to another supplier — we mint a **new** operation and abandon the old one. You will only ever receive `execute` for the most recently validated `operation_id`. ## How form_fields work The product declares its `form_fields`: the buyer inputs your top-up needs. You see them on the product: ```bash curl "https://sandbox-pay.mmokick.com/api/v1/products/01SANDBX00000000000000PD03" \ -H "Authorization: Bearer $CK_TEST_TOKEN" ``` `200` (abridged): ```json { "id": "01SANDBX00000000000000PD03", "type": "topup", "form_fields": [ { "key": "user_id", "label": "Player ID", "type": "text", "required": true, "pattern": "^[0-9]{6}$", "max_length": 6, "help_text": "Settings → Profile → Player ID.", "options": null }, { "key": "server_id", "label": "Server", "type": "select", "required": true, "pattern": null, "max_length": null, "help_text": null, "options": [ { "value": "2001", "label": "EU West" }, { "value": "2002", "label": "EU East" } ] } ] } ``` Field `type` is one of `text | number | select`. These fields become the buyer's checkout inputs. We validate the buyer's input for **shape** — `required`, `pattern`, `max_length`, `options` — before we call you. You validate it for **truth**: does this `user_id` exist on this `server_id`, and can it receive the top-up? You receive the values keyed by `key`. ## POST /topup/validate Called at checkout, after we have enforced the field shape. Timeout **10 s**, no retry. Must be **side-effect free**. Request: ```json { "operation_id": "01JZY2G9X1C3E5G7J9M2P4R6T8", "offer_id": "01JZWX5N3PQRS7TV9WXY2Z4A6B", "external_ref": "SKU-GOLD-1000", "product_id": "01SANDBX00000000000000PD03", "quantity": 1, "buyer_country": "FR", "form_fields": { "user_id": "100001", "server_id": "2001" } } ``` Respond `200` with `valid: true` when the account checks out. Echo `operation_id`, and return an `account_label` — we show it to the buyer for confirmation ("is this you?"): ```json { "operation_id": "01JZY2G9X1C3E5G7J9M2P4R6T8", "valid": true, "account_label": "DragonSlayer99 (EU West)" } ``` Respond `200` with `valid: false` when the input is wrong — keep the HTTP status `200`, because the validation itself ran fine: ```json { "operation_id": "01JZY2G9X1C3E5G7J9M2P4R6T8", "valid": false, "code": "invalid_user_id", "message": "No account with that ID on EU West." } ``` The `code` is a **closed vocabulary** — we map each value to a buyer-facing field error: | `code` | Meaning | |---|---| | `invalid_user_id` | No account with that ID | | `invalid_server` | The server does not exist or does not accept top-ups | | `account_ineligible` | The account cannot receive this top-up (e.g. a level or status gate) | | `limit_exceeded` | A per-account or per-period limit blocks it | | `other` | Anything else; put detail in `message` | A validate timeout or 5xx blocks checkout for your offer and costs 2 health points. See the [Errors](https://pay.mmokick.com/developers/errors) reference. ## POST /topup/execute Called after payment capture, with the same `operation_id` from the successful validate. **Idempotent on `operation_id`.** Timeout **30 s** per attempt, 3 attempts 10 seconds apart, hard completion window **15 minutes**. Request: ```json { "operation_id": "01JZY2G9X1C3E5G7J9M2P4R6T8", "order_id": "01JZY2H8K4M6N8P0Q2R4S6T8V0", "offer_id": "01JZWX5N3PQRS7TV9WXY2Z4A6B", "external_ref": "SKU-GOLD-1000", "quantity": 1, "form_fields": { "user_id": "100001", "server_id": "2001" } } ``` | Status | Body | Meaning | |---|---|---| | `201` | `{"operation_id": "…", "status": "completed", "receipt_ref": "TX-889123"}` | Done — `receipt_ref` (your transaction id, ≤ 128 chars) is stored and shown to the buyer | | `200` | the original result body | Idempotent replay of an already-executed operation | | `202` | `{"operation_id": "…", "status": "processing"}` | Async — we poll the status endpoint until `completed`/`failed`, ≤ 15 min | | `200`/`201` | `{"operation_id": "…", "status": "failed", "code": "account_banned", "message": "…"}` | Definitive failure — we auto-refund the buyer; you take −25 health + cooldown | Idempotency is the contract: if we retry `execute` with an `operation_id` you already completed, return the **original** result body and skip the top-up. Key your delivery on `operation_id`. Execute failure `code` is an **informational string** (≤ 64 chars, e.g. `account_banned`, `topup_failed`), where validate uses a closed vocabulary. Timeouts or 5xx responses on all attempts that leave the status unresolved by the 15-minute window are treated as `failed` (auto-refund, −25 health + cooldown). If you delivered but the response never reached us, reconcile via `GET /orders` and support — this is why `202` plus the status endpoint is the safer pattern for slow upstreams. ## GET /topup/operations/{operation_id} Only needed if you ever return `202` from execute. Respond `200`: ```json { "operation_id": "01JZY2G9X1C3E5G7J9M2P4R6T8", "status": "completed", "receipt_ref": "TX-889123" } ``` `status` is `processing | completed | failed` (with `code`/`message` when failed). We poll every 10 s for the first minute, then every 30 s until the 15-minute window closes. ## Test it in the sandbox Drive the whole handshake against your endpoints with `POST /sandbox/orders` on a top-up offer. `form_fields` is required for top-up offers: ```bash curl -X POST "https://sandbox-pay.mmokick.com/api/v1/sandbox/orders" \ -H "Authorization: Bearer $CK_TEST_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 01JZWTOPUP0000000000ORDER01" \ -d '{ "offer_id": "01JZWX5N3PQRS7TV9WXY2Z4A6B", "quantity": 1, "buyer_country": "FR", "scenario": "paid", "form_fields": { "user_id": "100001", "server_id": "2001" } }' ``` The sandbox top-up product's `user_id` regex is `^[0-9]{6}$`. Four magic 6-digit values drive fixed paths, so you can prove every branch: | `user_id` | Behavior | |---|---| | `100001` | Validates and executes cleanly (happy path) | | `999999` | Fails validation with code `invalid_user_id` | | `888888` | Validates, then fails execute with status `failed` and code `topup_failed` | | `777777` | Times out once, then succeeds on retry — proves your `operation_id` idempotency | Run `777777` twice-through the pipeline and confirm your endpoint delivers exactly once: the retry must replay the original result and leave the balance unchanged. ## Certify with the contract tester The supplier portal's contract tester (**Tools → Contract test**, `/supplier/tools/contract-test`) has a `topup` mode: healthcheck, signature rejection, `validate` with valid fields, `validate` with garbage fields, `execute`, an `execute` replay (idempotency), and `execute` with an unknown `operation_id`. Run it against your staging endpoint and fix anything that fails before going live. ## Next steps - [Declared stock](https://pay.mmokick.com/developers/guides/declared-stock) — the reservation → order → items contract for keys you hold. - [Webhooks](https://pay.mmokick.com/developers/guides/webhooks) — the signature verifier you reuse for our validate and execute calls. - [Contract reference](https://pay.mmokick.com/developers/reference/contract?spec=topup) — the OpenAPI spec for the top-up contract. --- # Webhooks Source: https://pay.mmokick.com/developers/guides/webhooks.md We push order and offer events to your endpoints over HTTPS, each one HMAC-SHA256 signed. This guide gives you copy-paste verification code in PHP, Node, and Python, the exact signature scheme, published test vectors you can check against, and the delivery, retry, and auto-disable behavior you build around. The same signature scheme and verifier are used for our declared-stock and top-up calls to you, so this is the one page to get right. ## Verify first — PHP Compute `HMAC-SHA256(secret, "{timestamp}.{raw body}")` over the **raw** request bytes and compare in constant time. This is the canonical verifier: ```php function verify_ck_signature( string $secret, string $signatureHeader, string $timestampHeader, string $rawBody, int $toleranceMs = 300000 ): bool { // The header is a comma-separated list of scheme=value pairs (e.g. // "v1=,v2="). Pick the v1 entry; ignore unknown schemes. if (!preg_match('/(?:^|,\s*)v1=([a-f0-9]{64})/', $signatureHeader, $m)) return false; if (!ctype_digit($timestampHeader)) return false; if (abs((int) round(microtime(true) * 1000) - (int) $timestampHeader) > $toleranceMs) return false; $expected = hash_hmac('sha256', $timestampHeader . '.' . $rawBody, $secret); return hash_equals($expected, $m[1]); } // A multi-scheme header still verifies on the v1 entry (the garbage v2 is skipped): // verify_ck_signature($secret, "v1={$validHex},v2=deadbeef", $ts, $body) === true ``` ## Node ```js const crypto = require('node:crypto'); function verifyCkSignature(secret, signatureHeader, timestampHeader, rawBody, toleranceMs = 300000) { // The header is a comma-separated list of scheme=value pairs (e.g. // "v1=,v2="). Pick the v1 entry; ignore unknown schemes. const m = /(?:^|,\s*)v1=([a-f0-9]{64})/.exec(signatureHeader ?? ''); if (!m || !/^\d+$/.test(timestampHeader ?? '')) return false; if (Math.abs(Date.now() - Number(timestampHeader)) > toleranceMs) return false; const expected = crypto.createHmac('sha256', secret) .update(`${timestampHeader}.${rawBody}`).digest(); const given = Buffer.from(m[1], 'hex'); return expected.length === given.length && crypto.timingSafeEqual(expected, given); } // A multi-scheme header still verifies on the v1 entry (the garbage v2 is skipped): // verifyCkSignature(secret, `v1=${validHex},v2=deadbeef`, ts, body) === true ``` ## Python ```python import hmac, hashlib, re, time def verify_ck_signature(secret: str, signature_header: str, timestamp_header: str, raw_body: bytes, tolerance_ms: int = 300_000) -> bool: # The header is a comma-separated list of scheme=value pairs (e.g. # "v1=,v2="). Pick the v1 entry; ignore unknown schemes. m = re.search(r"(?:^|,\s*)v1=([a-f0-9]{64})", signature_header or "") if not m or not (timestamp_header or "").isdigit(): return False if abs(time.time() * 1000 - int(timestamp_header)) > tolerance_ms: return False expected = hmac.new(secret.encode(), f"{timestamp_header}.".encode() + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, m.group(1)) # A multi-scheme header still verifies on the v1 entry (the garbage v2 is skipped): # verify_ck_signature(secret, f"v1={valid_hex},v2=deadbeef", ts, body) is True ``` **Read the raw body straight from the request.** The signature is over the exact bytes we sent, so keep those bytes intact: - **PHP:** `file_get_contents('php://input')`; in Laravel, `$request->getContent()`. - **Node / Express:** use `express.raw({ type: 'application/json' })` on the webhook route **before** any JSON body parser runs. - **Python / Django:** `request.body`. Sign the bytes as received: re-encoding the parsed JSON reorders keys and changes whitespace, which produces different bytes and a signature mismatch. ## Events Subscribe to any subset of these event types when you create or update an endpoint, or use `["*"]` for everything. | Type | Fires when | |---|---| | `order.created` | An order containing at least one of your items is placed (before payment) | | `order.expired` | An unpaid order containing at least one of your items expired (checkout abandoned) — a terminal event; the order status is `expired` and its items are `released` | | `order.paid` | Payment is captured. Under authorize → provision → capture, provisioning happened **before** this event, so declared-stock suppliers see `POST /orders` and item fetches first | | `order.refunded` | A refund covering at least one of your items is executed | | `order.disputed` | A buyer claim or card dispute touching one of your items is opened — **informational**; we are the merchant of record | | `offer.deactivated` | Your offer leaves `active`, whoever caused it (see `reason`) | | `offer.stock_low` | Uploaded stock crosses `low_stock_threshold` downward | | `stock.invalidated` | Staff invalidated one of your inventory items | | `webhook.disabled` | One of your endpoints was auto-disabled | Test-fires send a **real** event type with the envelope flag `test_fire: true` — the type is always one of the events above. Register an endpoint (the secret is shown once — save it): ```bash curl -X POST "https://sandbox-pay.mmokick.com/api/v1/webhooks" \ -H "Authorization: Bearer $CK_TEST_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 01JZWWEBHOOKS0000000CREATE1" \ -d '{ "url": "https://your-app.example/hooks/cheap-keys", "events": ["order.paid", "order.refunded", "order.disputed", "offer.stock_low"], "description": "Main ERP intake" }' ``` `201`: ```json { "id": "01JZW0DC8FGH2JK4MN6PQ8RS0T", "url": "https://your-app.example/hooks/cheap-keys", "events": ["order.paid", "order.refunded", "order.disputed", "offer.stock_low"], "description": "Main ERP intake", "status": "active", "secret": "whsec_4d9c2f81a7e35b60d1c8f42e9a0b7365", "consecutive_failures": 0, "disabled_at": null, "created_at": "2026-07-12T10:00:00Z" } ``` You may register up to **10 endpoints** per environment. ## The envelope Every delivery is an HTTPS POST with `Content-Type: application/json` and this envelope: ```json { "id": "01JZY3B1M5P7R9T2V4X6Z8A0C2", "type": "order.paid", "mode": "test", "api_version": "v1", "test_fire": false, "created_at": "2026-07-12T10:03:22Z", "data": { } } ``` `id` is your dedupe key. `mode` is `live` or `test`. `data` reuses the objects from the API reference — for order events, `data.order` is the supplier-scoped order exactly as `GET /orders/{id}` returns it. Delivery expectations: - **Success** is any `2xx` returned within **10 seconds**. Timeouts and `3xx`/`4xx`/`5xx` all fail the attempt. - **User agent:** `cheap-keys-webhooks/1.0 (+https://pay.mmokick.com/developers)`. - **Events can arrive in any order.** Consume idempotently: dedupe on envelope `id`. If you see `order.refunded` before `order.paid`, trust the embedded `data.order.status`. - Egress IPs stay unpublished and can change — authenticate deliveries **by signature only**, never by source IP. ## Signature scheme Every delivery carries these headers: ``` X-CK-Timestamp: X-CK-Signature: v1= X-CK-Event: order.paid X-CK-Event-Id: 01JZY3B1M5P7R9T2V4X6Z8A0C2 X-CK-Delivery-Id: 01JZY3B2N6Q8S0V2X4Z6B8D0F2 ``` The rules, as MUSTs: - Build the signed payload as `{X-CK-Timestamp value}` + `.` + `{raw request body bytes}`, and verify over the **raw body** exactly as received. - Compare signatures with a **constant-time** comparison (`hash_equals`, `crypto.timingSafeEqual`, `hmac.compare_digest`). - Reject when `|now_ms − timestamp| > 300000` (5 minutes) — this bounds replay. - The `v1=` prefix versions the scheme. A future scheme ships as `v2=` alongside `v1=` (comma-separated), so `v1=` stays in place while you migrate. Secrets are shown once at creation and are rotatable: - Webhook endpoint secrets: `whsec_` + 32 lowercase hex. - Offer fulfilment secrets (declared stock, top-ups): `cksec_` + 32 lowercase hex. One generation rule, two prefixes. When you rotate a secret, hold both the old and new secret during rollout and accept either, then retire the old one. ## Published test vectors These are the official vectors — verify your implementation against them before going live. `raw_body` is a single line, byte-exact, with no trailing newline. The shared test secret: ``` whsec_9a41c1f39b6a4f16bd1e2f7c8d3a5e70 ``` **Vector 1** — minimal body: ``` timestamp: 1767225600000 # 2026-01-01T00:00:00Z raw_body: {"hello":"cheap-keys"} expected: v1=8f82cbf191ac01a16c7801608cdcf53f4994664db9e5319fc79fea663a444d7a ``` **Vector 2** — full event envelope (test-fire of `order.paid`): ``` timestamp: 1767312000000 # 2026-01-02T00:00:00Z raw_body: {"id":"01JGY6ZK3M9P4Q6R8S0T2V4W6X","type":"order.paid","mode":"test","api_version":"v1","test_fire":true,"created_at":"2026-01-02T00:00:00Z","data":{"order":{"id":"01JGY6Z9ABCDEF2GHJKMNPQRST","number":"CK-01JGY6Z9ABCDEF2GHJKMNPQRST","status":"completed","currency":"EUR"}}} expected: v1=00aa28ad0d3892a33948f3be58aabd8e16f64a6e9c3a55b907e7d71242164ed4 ``` Self-check Vector 1 from a shell: ```bash echo -n "1767225600000.{\"hello\":\"cheap-keys\"}" | \ openssl dgst -sha256 -hmac "whsec_9a41c1f39b6a4f16bd1e2f7c8d3a5e70" ``` It prints `8f82cbf191ac01a16c7801608cdcf53f4994664db9e5319fc79fea663a444d7a`. Passing your verifier against both vectors means your raw-body handling and HMAC are correct. ## Retries and auto-disable If an attempt fails (non-`2xx` or timeout), we retry on a fixed ladder: ``` +0, +1 min, +5 min, +30 min, +2 h, +8 h, +24 h — 7 attempts total ``` After the seventh attempt the event is abandoned for that endpoint; you can still replay it with `POST /webhook-deliveries/{delivery_id}/resend` for 30 days. - **Auto-disable:** 30 **consecutive** failed attempts on an endpoint (the counter resets on any `2xx`) sets the endpoint to `status: disabled`, fires a `webhook.disabled` event to your other active endpoints, and sends a portal alert and email. - A `410 Gone` response disables the endpoint **immediately** — you are telling us it is dead. - **Re-enable** an auto-disabled endpoint by `PATCH`ing it back to `status: active`, which requires a passing `2xx` test-fire first (otherwise `409` / `invalid_state` with the failing probe result). Success resets `consecutive_failures` to 0. ## Operations **Test-fire.** From the portal, or `POST /webhooks/{id}/test`, send a fully signed canned fixture of any real event type (default `order.paid`) with envelope `test_fire: true`. It leaves `consecutive_failures` untouched: ```bash curl -X POST "https://sandbox-pay.mmokick.com/api/v1/webhooks/01JZW0DC8FGH2JK4MN6PQ8RS0T/test" \ -H "Authorization: Bearer $CK_TEST_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 01JZWWEBHOOKS00000TESTFIRE1" \ -d '{ "event": "order.paid" }' ``` `200`: ```json { "delivery_id": "01JZY3B2N6Q8S0V2X4Z6B8D0F2", "event_type": "order.paid", "response_status": 200, "duration_ms": 187, "succeeded": true } ``` **Delivery log and resend.** Every attempt is one row in the delivery log (retained 30 days). Search it with `GET /webhook-deliveries`, inspect a single attempt with `GET /webhook-deliveries/{delivery_id}` (which returns the exact signed `request_body` and the first 4 KB of `response_body`), and re-deliver with `POST /webhook-deliveries/{delivery_id}/resend`. The portal exposes the same log and a resend button. **Consume idempotently.** Events may arrive more than once and in any order. Dedupe on the envelope `id`, acknowledge with a fast `2xx`, and process asynchronously. Trust `data.order.status` over the order of arrival. ## Next steps - [Your first sale in 15 minutes](https://pay.mmokick.com/developers/guides/first-sale-in-15-minutes) — trigger a real signed `order.paid` in the sandbox. - [Declared stock](https://pay.mmokick.com/developers/guides/declared-stock) — the same signature scheme secures our calls to you. - [Errors](https://pay.mmokick.com/developers/errors) — the machine error-code catalog referenced throughout. --- # Errors Source: https://pay.mmokick.com/developers/errors.md Every machine-readable error code, its HTTP status, whether it is retryable, and what to do about it. Every error response is [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) `application/problem+json` with a stable `code`. Switch on `code`, never on the human `title` or `detail` — codes are append-only and never change meaning; the texts may. The `type` URI of every code resolves to its row on this page. ## The problem+json envelope ```json { "type": "https://pay.mmokick.com/developers/errors#rate_limited", "title": "Rate limited", "status": 429, "code": "rate_limited", "detail": "Write limit of 120 requests per minute exceeded.", "instance": "/api/v1/offers" } ``` ## HTTP 400 | `code` | Title | Retryable | What it means / what to do | |---|---|---|---| | `invalid_json` | Invalid JSON | no | The request body is not parseable JSON. Fix the request body. | | `invalid_cursor` | Invalid cursor | no | The pagination cursor is unknown or expired. Restart the listing without a cursor. | | `idempotency_key_invalid` | Invalid idempotency key | no | The `Idempotency-Key` header is empty or longer than 200 characters. Send a valid key. | ## HTTP 401 | `code` | Title | Retryable | What it means / what to do | |---|---|---|---| | `unauthenticated` | Unauthenticated | no | The `Authorization` header is missing or malformed. Send `Authorization: Bearer `. | | `invalid_token` | Invalid token | no | The token is unknown. Check the token, or create a new one in the portal. | | `token_revoked` | Token revoked | no | The token was revoked in the portal. Issue a new token. | | `wrong_environment` | Wrong environment | no | A `ck_test_` token was used on production, or a `ck_live_` token on sandbox. Use the token that matches the host. | ## HTTP 403 | `code` | Title | Retryable | What it means / what to do | |---|---|---|---| | `insufficient_scope` | Insufficient scope | no | The token lacks a scope the endpoint requires. Issue a token with the needed scope. | | `supplier_suspended` | Supplier suspended | no | The supplier account is suspended by staff. Contact support. | ## HTTP 404 | `code` | Title | Retryable | What it means / what to do | |---|---|---|---| | `not_found` | Not found | no | No such resource, or it does not belong to your supplier. Check the id. | ## HTTP 405 | `code` | Title | Retryable | What it means / what to do | |---|---|---|---| | `method_not_allowed` | Method not allowed | no | The HTTP method is not allowed on this path. Fix the verb. | ## HTTP 409 | `code` | Title | Retryable | What it means / what to do | |---|---|---|---| | `offer_exists` | Offer already exists | no | An offer already exists for this product. Update the existing offer instead of creating a second. | | `invalid_state` | Invalid state | no | The requested transition is not allowed from the resource's current state. Read the current state first. | | `stock_mode_mismatch` | Stock mode mismatch | no | An inventory operation was sent to a declared-stock offer, or a stock declaration to an uploaded offer. Use the operation that matches the offer's stock mode. | | `idempotency_conflict` | Idempotency conflict | no | The `Idempotency-Key` was reused with a different request body. Use a new key. | | `idempotency_in_flight` | Idempotency in flight | yes | The original request for this idempotency key is still running. Retry after the `Retry-After` interval. | ## HTTP 413 | `code` | Title | Retryable | What it means / what to do | |---|---|---|---| | `payload_too_large` | Payload too large | no | The request body exceeds 1 MB. Split the batch into smaller requests. | ## HTTP 415 | `code` | Title | Retryable | What it means / what to do | |---|---|---|---| | `unsupported_media_type` | Unsupported media type | no | The `Content-Type` is not `application/json`. Fix the header. | ## HTTP 422 | `code` | Title | Retryable | What it means / what to do | |---|---|---|---| | `validation_failed` | Validation failed | no | One or more fields failed validation (see the `errors` map in the response). Fix the fields. | | `product_not_sellable` | Product not sellable | no | The product is archived or paused for new offers. Pick another product. | | `fulfilment_unreachable` | Fulfilment endpoint unreachable | yes | The inline healthcheck of your fulfilment endpoint failed on activation or URL change. Fix your endpoint, then retry. | | `inventory_rejected` | Inventory rejected | no | A strict-mode upload contained invalid items (see `rejected_items`). Fix the items or upload in `partial` mode. | ## HTTP 429 | `code` | Title | Retryable | What it means / what to do | |---|---|---|---| | `rate_limited` | Rate limited | yes | A rate limit was hit. Honor the `Retry-After` header and back off. See the rate limits page. | ## HTTP 500 | `code` | Title | Retryable | What it means / what to do | |---|---|---|---| | `internal_error` | Internal error | yes | An error on our side, logged under the response `trace_id`. Retry with backoff; report it if it persists. | ## HTTP 503 | `code` | Title | Retryable | What it means / what to do | |---|---|---|---| | `service_unavailable` | Service unavailable | yes | A maintenance window is in effect (carries `Retry-After`). Retry after the given interval. | --- # Rate limits Source: https://pay.mmokick.com/developers/rate-limits.md The actual per-token limits, the per-supplier and per-IP ceilings, and the headers on every response. Sandbox limits are identical to production. Limits are per rolling 60-second window. Every response carries the three `X-RateLimit-*` headers below — read them and back off before you are throttled. When you exceed a limit you get a `429` with `rate_limited` and a `Retry-After` header. Sandbox limits are identical to production. ## Per-token classes | Class | Limit | Config key | |---|---|---| | reads — every GET, per token | 600 / min | `reads` | | writes — mutating endpoints not otherwise classed, per token | 120 / min | `writes` | | sync — PUT …/price, PUT …/stock, per token | 300 / min | `sync` | | bulk — POST …/inventory, per token | 12 / min | `bulk` | | simulate — POST /pricing/simulate, per token | 120 / min | `simulate` | | test — webhook test-fire, fulfilment check, sandbox simulator, per token | 6 / min | `test` | ## Ceilings | Scope | Limit | Config key | |---|---|---| | Per-supplier, across all of a supplier's tokens | 1200 / min | `per_supplier` | | Unauthenticated requests, per IP | 30 / min | `unauthenticated` | ## Response headers | Header | Meaning | |---|---| | `X-RateLimit-Limit` | The class ceiling for this request. | | `X-RateLimit-Remaining` | Requests left in the current window. | | `X-RateLimit-Reset` | Unix seconds when the window resets. | ## When you hit a limit ```json { "type": "https://pay.mmokick.com/developers/errors#rate_limited", "title": "Rate limited", "status": 429, "detail": "Write limit of 120 requests per minute exceeded; back off per Retry-After.", "code": "rate_limited", "trace_id": "req_9f3b2c61a8d44e0f" } ``` ## Staying under the limits Batch instead of looping: upload up to 1,000 inventory items in one `POST …/inventory` request rather than sending 1,000 requests. If your integration genuinely needs higher limits, contact support — limits are raisable on request. --- # Fees Source: https://pay.mmokick.com/developers/fees.md The machine-readable fee schedule: commission, settlement, and payout rules — identical to GET /api/v1/fees. Every number here is rendered from the same source as `GET /api/v1/fees`, so the schedule you read is the schedule you are charged. All amounts are integer minor units — `5` = €0.05. ## Schedule | Item | Detail | |---|---| | **Commission** | 8.5% per order item, on gross sale price, rounded half-up | | **Minimum fee** | €0.05 per order item | | **Listing fee** | None | | **Price-change fee** | None | | **Payment fees** | Not charged to the supplier or the buyer | | **Unfulfilled-sale fee** | None | | **Settlement** | Funds move from pending to available 7 days after delivery | | **Payout minimum** | €20.00 | | **Payout methods** | SEPA bank transfer, PayPal | | **Currency** | EUR (Supplier API v1 is EUR-only) | | **Effective from** | 2026-07-01T00:00:00Z | | **Version** | 2026-07-01 | ## As JSON The exact payload of `GET /api/v1/fees`: ```json { "version": "2026-07-01", "effective_from": "2026-07-01T00:00:00Z", "currency": "EUR", "commission": { "rate_bps": 850, "min_fee": { "amount": 5, "currency": "EUR" }, "applies": "per order item, on gross sale price, rounded half-up" }, "listing_fee": null, "price_change_fee": null, "payment_fees_charged_to_supplier": false, "payment_fees_charged_to_buyer": false, "settlement": { "holding_period_days": 7, "trigger": "delivery" }, "payout": { "methods": [ "sepa_bank_transfer", "paypal" ], "minimum": { "amount": 2000, "currency": "EUR" }, "fee": null }, "unfulfilled_sale_fee": null, "docs_url": "https://pay.mmokick.com/developers/fees" } ```