# 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](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=<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:

```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=<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](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.
