# Top-up products

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.
