GuidesWalkthroughs

Sync vs. async orders

When each vertical's create-order endpoint returns the result inline vs. queues it for fulfilment - and how to handle both

Each create-order endpoint has its own sync/async character. The shape of the response depends on whether fulfilment completed inside the request:

  • Vouchers — a threshold rule. Small orders complete inline; large orders return PENDING and finish in the background.
  • Topups — typically asynchronous. The order returns quickly; status flips from PENDING to terminal over seconds to minutes.
  • eSIM — a 60-second hybrid. The handler waits up to 60s for fulfilment, then either returns the activation code inline or returns PENDING and continues in the background.

What's identical across every vertical: the wallet is debited synchronously. A PENDING order is already paid for; only fulfilment is pending. Failed terminal orders auto-refund — the debit and refund are two separate wallet transactions, both visible in the wallet log.

Vouchers — the threshold model

POST /api/v1/orders

QuantityModeResponseCodes available when
1–5Sync201 DELIVERED with vouchers: [...]Immediately, in the response body
6–5,000Async201 PENDING with vouchers: []Later, via webhook or poll

The threshold is fixed at 5. There is no way to force async for small orders, or sync for large ones — quantity is the only knob.

Sync response (qty ≤ 5):

{
  "id": 1234,
  "status": "DELIVERED",
  "amount": 100.00,
  "discount": 3.50,
  "client_reference": "order-abc",
  "vouchers": [
    { "card_number": "STEAM-XXXX-XXXX", "pin_code": "1234", "expires_at": "2027-06-01T00:00:00Z" },
    { "card_number": "STEAM-YYYY-YYYY", "pin_code": "5678", "expires_at": "2027-06-01T00:00:00Z" }
  ]
}

Async response (qty > 5):

{
  "id": 1235,
  "status": "PENDING",
  "amount": 5000.00,
  "discount": 175.00,
  "client_reference": "campaign-q1-0001",
  "vouchers": []
}

Async voucher orders complete in 10–60 seconds typically; merchant-side slowdowns can push that to minutes.

Topups — typically asynchronous

POST /api/v1/topups/orders

There is no quantity threshold — topup orders are 1-at-a-time. The endpoint returns quickly with one of three immediate outcomes:

HTTPstatusWhat it means
200PENDINGTypical. Order accepted, fulfilment in progress. Move to polling or wait for the email/webhook.
200DELIVEREDFulfilled inside the request (uncommon — happens when the product fulfils synchronously).
200FAILED with is_user_fixable: trueRejected immediately for a customer-correctable reason (wrong player ID, suspended account, etc.). Wallet auto-refunded.

Most gaming top-ups and mobile recharges resolve from PENDING to terminal in 5–30 seconds. Slow paths can push that to minutes — the retry cron keeps trying server-side, so even a long-running order eventually reaches terminal status.

We strive to eventually deliver every order

Transient fulfilment issues — timeouts, rate limits, transient declines — do not surface as FAILED. They keep the order in PENDING and the retry pipeline keeps working until the order either delivers or hits a clearly customer-actionable failure. An order can sit in PENDING for minutes (typical), hours (slow day), or, rarely, longer — that's the system working, not stuck.

eSIM — the 60-second hybrid

POST /api/v1/esim/orders

The handler waits up to 60 seconds for fulfilment before returning. Every response is 200 OK — branch on the body status field:

HTTPstatusWhat it means
200 OKDELIVEREDBest case. activation_code (LPA string) and iccid are in the response. Render the QR / LPA immediately.
200 OKFAILED with is_user_fixable: trueRejected inside the request. Surface failure_reason. Wallet auto-refunded.
200 OKPENDINGFulfilment took longer than the 60s inline deadline. Poll until terminal.

After a status: PENDING response, a server-side dispatch goroutine keeps trying; polling GET /esim/orders/:id returns the latest known state on each call.

eSIM has a second async stage that's customer-driven, not server-driven: installation. After status: DELIVERED, the customer still has to download the LPA profile onto their device. The order's is_installed boolean flips to true once that happens — minutes later, hours later, sometimes never. Treat install as user-paced; never block on it.

Order placed                                           Customer installs
     │            ←   server-async   →                       │
     ▼                                                       ▼
  PENDING ─────────→ DELIVERED ──────────────────────→ is_installed: true
                    (LPA code issued)                  (device downloaded profile)

See eSIM lifecycle for the install-side state machine.

Side-by-side

VouchersTopupseSIM
Default modeSync (qty ≤ 5)AsyncSync attempt, 60s deadline
What lands inline (best case)vouchers[] codesstatus: DELIVERED (rare)activation_code + iccid
Per-call quantity1–5,000always 1always 1
Typical fulfilment latencysync; or 10s–1min async5–30s< 60s; rarely longer
Worst-case latencyminutes for huge batchesminutes (retry cron)minutes (retry cron)
Webhook events shippedyes — order.delivered, order.partially_delivered, order.failed, order.cancelled (plus wallet.credited / wallet.debited)not yetnot yet
Recommended fallback for PENDINGwebhook + pollpoll (webhook planned)poll (webhook planned)

All create-order endpoints return 200 OK — branch on the body status field (DELIVERED / PENDING / FAILED), not on the HTTP status code.

Handling a PENDING order

The same polling shape works across verticals — only the URL changes:

VerticalDetail endpoint
VouchersGET /api/v1/orders/:id
TopupsGET /api/v1/topups/orders/:id
eSIMGET /api/v1/esim/orders/:id

The polling endpoints refresh status against fulfilment on each call for in-flight orders — so a single fetch returns the freshest state.

# Exponential backoff: 5s, 10s, 20s, 30s, 30s...
ORDER_ID=9123456
URL_BASE=$HOST/api/v1/topups/orders  # or /orders, /esim/orders

delay=5
deadline=$((SECONDS + 600))   # 10 min cap
while [ $SECONDS -lt $deadline ]; do
  status=$(curl -s "$URL_BASE/$ORDER_ID" \
    -H "Authorization: Bearer $TOKEN" | jq -r '.status')
  case "$status" in
    DELIVERED|FAILED|CANCELLED|PARTIAL) echo "Terminal: $status"; break ;;
  esac
  sleep "$delay"
  [ "$delay" -lt 30 ] && delay=$((delay * 2))
done

Polling guidelines:

  • Start at 5s, back off to 30s. Most orders finish well inside the first minute.
  • Cap foreground polling at ~10 minutes. After that, the retry cron is still working; switch to a notification-based wait or surface "processing — we'll email you" UX.
  • Never poll faster than every 2 seconds. That's rate-limit territory.

Notification email (server-driven)

Every vertical supports attaching an email address to an order after the fact:

VerticalEndpoint
VouchersPATCH /api/v1/orders/:id/notification-email
TopupsPATCH /api/v1/topups/orders/:id/notification-email
eSIMPATCH /api/v1/esim/orders/:id/notification-email

Once attached, the customer receives a templated receipt at every terminal transition (DELIVERED, FAILED, CANCELLED). This is the no-polling, no-webhook option for end-user-facing flows where the customer's email is the natural delivery target.

Webhooks

Outbound webhooks are live for vouchers today:

  • order.delivered
  • order.partially_delivered
  • order.failed
  • order.cancelled
  • wallet.credited / wallet.debited

Topup and eSIM webhooks are planned and will follow the same envelope shape. The expected events are documented under Topup webhooks and eSIM webhooks. Until they ship, polling (or the notification email) is the way to learn about terminal transitions.

Webhook + polling fallback is the highest-reliability pattern: webhook is primary, polling kicks in after a few minutes of silence as a belt-and-braces backstop.

Which mode for which use case

Use caseBest fit
User clicks "Buy" on a checkout — single voucherVoucher sync (qty 1)
CSR manually issuing a voucherVoucher sync
End-of-campaign batch (e.g. 1,000 vouchers)Voucher async + webhook
Real-time game top-up at checkoutTopup, polling foreground for ~30s then email/webhook
Mobile recharge from a wallet appTopup, polling foreground for ~10s
Travel-eSIM purchase at airporteSIM, render activation code inline if status: DELIVERED; otherwise show a "we'll have it shortly" screen and poll
Order triggered by an inbound webhook from another systemAsync-friendly — already off the user's critical path
Integration test suiteSync where possible; for async paths, use Octopus Cards's polling helper rather than sleeping

Statuses across verticals

StatusVouchersTopupseSIM
PENDINGAsync order accepted, codes not yet generatedOrder accepted, fulfilment in progress60s deadline elapsed without fulfilment
DELIVEREDAll codes generated and visibleTop-up fulfilledActivation code issued
PARTIALSome codes generated; others failed
FAILEDAll codes failed; wallet refundedOrder rejected; wallet refundedOrder rejected; wallet refunded
CANCELLEDCancelled administrativelyCancelled administrativelyCancelled administratively

Per-vertical detail: Voucher orders · Topup orders · eSIM orders.

Summary

  • Vouchers are sync for quantity ≤ 5, async beyond that. Codes return inline in sync mode.
  • Topups are usually async — the order endpoint returns quickly; status flips to terminal in seconds to minutes.
  • eSIM is a 60-second hybrid — status: DELIVERED with activation code if fulfilled in time, status: PENDING otherwise. Either way the HTTP response is 200 OK.
  • The wallet is debited synchronously in every case. PENDING refers only to fulfilment, not payment. FAILED auto-refunds.
  • Polling works the same way across every vertical — the same loop, only the URL changes.
  • Webhooks are live for vouchers today; planned for the other verticals.
  • The notification-email patch endpoint exists on every vertical as a server-driven alternative to webhooks/polling for customer-facing flows.

On this page