cheapkeys

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 shaperequired, 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.