cheapkeys

Your first sale in 15 minutes

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 first — it takes 30 seconds.

1. Get a token · 0:30

export CK_TEST_TOKEN="ck_test_..."

Confirm it works:

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 if you get a 401.

2. Pick a product · 2:00

Offers attach to platform-owned products. Use the seeded sandbox game key:

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:

{
  "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 EUR1999 = €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:

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

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

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:

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

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.

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:

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

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

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

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:

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=<hex hmac of "1783519402193.{raw body}">

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

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=<hex>,v2=<hex>"). 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 guide.

8. See the sale · 15:00

Read the order back and check your balance:

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:

{
  "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 — keep keys on your systems and implement the fulfilment contract.
  • Webhooks — signature verification in PHP, Node, and Python, plus retries and auto-disable.
  • Top-up products — validate → execute delivery into player accounts.