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
PENDINGand finish in the background.
- Topups — typically asynchronous. The order returns quickly; status flips from
PENDINGto 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
PENDINGand 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
| Quantity | Mode | Response | Codes available when |
|---|---|---|---|
| 1–5 | Sync | 201 DELIVERED with vouchers: [...] | Immediately, in the response body |
| 6–5,000 | Async | 201 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:
| HTTP | status | What it means |
|---|---|---|
200 | PENDING | Typical. Order accepted, fulfilment in progress. Move to polling or wait for the email/webhook. |
200 | DELIVERED | Fulfilled inside the request (uncommon — happens when the product fulfils synchronously). |
200 | FAILED with is_user_fixable: true | Rejected 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:
| HTTP | status | What it means |
|---|---|---|
200 OK | DELIVERED | Best case. activation_code (LPA string) and iccid are in the response. Render the QR / LPA immediately. |
200 OK | FAILED with is_user_fixable: true | Rejected inside the request. Surface failure_reason. Wallet auto-refunded. |
200 OK | PENDING | Fulfilment 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
| Vouchers | Topups | eSIM | |
|---|---|---|---|
| Default mode | Sync (qty ≤ 5) | Async | Sync attempt, 60s deadline |
| What lands inline (best case) | vouchers[] codes | status: DELIVERED (rare) | activation_code + iccid |
| Per-call quantity | 1–5,000 | always 1 | always 1 |
| Typical fulfilment latency | sync; or 10s–1min async | 5–30s | < 60s; rarely longer |
| Worst-case latency | minutes for huge batches | minutes (retry cron) | minutes (retry cron) |
| Webhook events shipped | yes — order.delivered, order.partially_delivered, order.failed, order.cancelled (plus wallet.credited / wallet.debited) | not yet | not yet |
| Recommended fallback for PENDING | webhook + poll | poll (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:
| Vertical | Detail endpoint |
|---|---|
| Vouchers | GET /api/v1/orders/:id |
| Topups | GET /api/v1/topups/orders/:id |
| eSIM | GET /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))
donePolling 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:
| Vertical | Endpoint |
|---|---|
| Vouchers | PATCH /api/v1/orders/:id/notification-email |
| Topups | PATCH /api/v1/topups/orders/:id/notification-email |
| eSIM | PATCH /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.deliveredorder.partially_deliveredorder.failedorder.cancelledwallet.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 case | Best fit |
|---|---|
| User clicks "Buy" on a checkout — single voucher | Voucher sync (qty 1) |
| CSR manually issuing a voucher | Voucher sync |
| End-of-campaign batch (e.g. 1,000 vouchers) | Voucher async + webhook |
| Real-time game top-up at checkout | Topup, polling foreground for ~30s then email/webhook |
| Mobile recharge from a wallet app | Topup, polling foreground for ~10s |
| Travel-eSIM purchase at airport | eSIM, 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 system | Async-friendly — already off the user's critical path |
| Integration test suite | Sync where possible; for async paths, use Octopus Cards's polling helper rather than sleeping |
Statuses across verticals
| Status | Vouchers | Topups | eSIM |
|---|---|---|---|
PENDING | Async order accepted, codes not yet generated | Order accepted, fulfilment in progress | 60s deadline elapsed without fulfilment |
DELIVERED | All codes generated and visible | Top-up fulfilled | Activation code issued |
PARTIAL | Some codes generated; others failed | ||
FAILED | All codes failed; wallet refunded | Order rejected; wallet refunded | Order rejected; wallet refunded |
CANCELLED | Cancelled administratively | Cancelled administratively | Cancelled 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: DELIVEREDwith activation code if fulfilled in time,status: PENDINGotherwise. Either way the HTTP response is200 OK.
- The wallet is debited synchronously in every case.
PENDINGrefers only to fulfilment, not payment.FAILEDauto-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.