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 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:
curl "https://sandbox-pay.mmokick.com/api/v1/products/01SANDBX00000000000000PD03" \
-H "Authorization: Bearer $CK_TEST_TOKEN"
200 (abridged):
{
"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:
{
"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?"):
{ "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:
{
"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 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:
{
"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:
{ "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:
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 — the reservation → order → items contract for keys you hold.
- Webhooks — the signature verifier you reuse for our validate and execute calls.
- Contract reference — the OpenAPI spec for the top-up contract.