eSIM purchase flow
End-to-end pattern for an eSIM - product → variant → order → render QR → poll install → handle expiry
eSIM purchases differ from vouchers and top-ups in three ways: orders are always quantity 1, the deliverable is an LPA activation code + ICCID, and there's a second lifecycle — installation — that lives on the customer's device after delivery. This guide walks through the full flow.
The map
┌──────────────────┐
│ 1. Pick product │ GET /api/v1/esim/products?country_code=...
└─────────┬────────┘
▼
┌──────────────────┐
│ 2. List variants │ GET /api/v1/esim/products/:id/variants
└─────────┬────────┘
▼
┌──────────────────┐
│ 3. Place order │ POST /api/v1/esim/orders
└─────────┬────────┘
▼
┌──────────────────┐
│ 4. Render QR/LPA │ (UI work, when status=DELIVERED)
└─────────┬────────┘
▼
┌──────────────────┐ (only if status=PENDING)
│ 5. Wait for code │ GET /api/v1/esim/orders/:id (loop)
└─────────┬────────┘
▼
┌──────────────────┐
│ 6. Poll install │ GET /api/v1/esim/orders/:id (loop)
└─────────┬────────┘
▼
┌──────────────────┐
│ 7. Plan expires │ activation_status: EXPIRED
└──────────────────┘Steps 1–3 take seconds. Step 4 is critical. Step 6 is a slow poll (minutes to hours).
Step 1 — Pick a product
country_code is ISO 3166-1 alpha-3 — JPN, USA, IND, or GLO for global products.
curl "$HOST/api/v1/esim/products?country_code=JPN" \
-H "Authorization: Bearer $TOKEN"Each entry is a country or region. Cache for 1 hour — eSIM catalogues are stable.
Step 2 — List variants
curl "$HOST/api/v1/esim/products/712/variants" \
-H "Authorization: Bearer $TOKEN"Each variant has amount, data_amount_gb, and validity_days. Render these as plan choices:
Japan eSIM:
✓ 1 GB / 7 days — $4.50
✓ 5 GB / 30 days — $14.90
✓ 20 GB / 30 days — $39.90The amount passed to /esim/orders is what resolves the variant. Don't compute it client-side; always echo the variant's amount exactly — (product_id, amount) is how Octopus Cards picks the variant.
If the product detail endpoint already returned variants[] (the inlined shape from GET /esim/products/:id), this step can be skipped — same data.
Step 3 — Place the order
curl -X POST "$HOST/api/v1/esim/orders" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"product_id": 712,
"amount": 4.50,
"quantity": 1,
"client_reference": "TRIP_TOKYO_OCT"
}'Three immediate outcomes:
| HTTP | Body status | Next |
|---|---|---|
200 | DELIVERED | Best case. Render the QR/LPA right now. Move to step 4. |
200 | FAILED | Rejected. Show failure_reason. Wallet auto-refunded. Done. |
200 | PENDING | Fulfilment took longer than the 60s inline deadline. Move to step 5; render a "we'll have your eSIM shortly" state. |
400 / 404 | error | Fix the request and retry. |
quantity is always coerced to 1. Send quantity: 1 for clarity, but any other value is silently overridden — one order = one eSIM.
Step 4 — Render the QR / LPA
When status: DELIVERED, the response includes activation_code — the LPA Activation Code:
LPA:1$rsp.example.com$ACTIVATION-TOKEN-XYZRender it two ways — a QR code (the universal path) and a copyable text string. Example (React + qrcode.react):
import { QRCodeSVG } from 'qrcode.react';
function EsimDelivery({ order }) {
return (
<div>
<QRCodeSVG value={order.activation_code} size={256} />
<p>Or paste this into Settings → Cellular → Add eSIM:</p>
<code>{order.activation_code}</code>
<p className="text-sm">ICCID: <code>{order.iccid}</code></p>
</div>
);
}Critical: capture the activation code on this response. Once the customer installs, subsequent fetches of GET /esim/orders/:id will omit the activation_code field. Octopus Cards burns it on install to avoid leaking single-use credentials. If the UI ever shows a "view eSIM again" affordance, the code must have been persisted on first delivery.
For iOS 17.4+, the LPA string can also be passed to a Settings.app deep link — apple-system-settings://cellular?addCellularPlan=LPA:1$... — but the QR code is the universal path.
Step 5 — Poll fulfilment (PENDING orders only)
For status: PENDING responses, fulfilment is still in progress. Poll the detail endpoint until terminal — GET /esim/orders/:id refreshes against fulfilment on every call while the order is PENDING.
func waitForEsim(host, token string, orderID uint64) (*esimOrder, error) {
deadline := time.Now().Add(5 * time.Minute)
delay := 5 * time.Second
for time.Now().Before(deadline) {
url := fmt.Sprintf("%s/api/v1/esim/orders/%d", host, orderID)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, _ := http.DefaultClient.Do(req)
var out esimOrder
_ = json.NewDecoder(resp.Body).Decode(&out)
_ = resp.Body.Close()
switch out.Status {
case "DELIVERED", "FAILED", "CANCELLED":
return &out, nil
}
time.Sleep(delay)
if delay < 30*time.Second {
delay *= 2
}
}
return nil, errors.New("eSIM did not deliver within 5 minutes")
}The retry cron also keeps trying server-side, so even when the foreground poll times out, the order eventually resolves. For UX, surface "we'll email you when it's ready" — though the email path isn't wired yet for eSIM, the retry cron is.
Step 5b — Poll install status (DELIVERED orders)
Once status: DELIVERED, the order endpoint auto-refreshes installation state on each fetch. Watch for is_installed: true:
// Be patient: customers take minutes to install, sometimes hours.
func waitForInstall(host, token string, orderID uint64) (*esimOrder, error) {
deadline := time.Now().Add(15 * time.Minute)
delay := 30 * time.Second
for time.Now().Before(deadline) {
url := fmt.Sprintf("%s/api/v1/esim/orders/%d", host, orderID)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, _ := http.DefaultClient.Do(req)
var out esimOrder
_ = json.NewDecoder(resp.Body).Decode(&out)
_ = resp.Body.Close()
if out.IsInstalled {
return &out, nil
}
time.Sleep(delay)
}
return nil, errors.New("not installed within 15 minutes — customer may install later")
}Don't block the UX on install — it's a customer-side action that may happen days later. Foreground polling is mostly useful for analytics ("did the customer install?"), not for required-path logic.
Handling failure modes
| Failure | Likely cause | Next |
|---|---|---|
status: FAILED, is_user_fixable: true, failure_code mentions plan unavailable | The plan is no longer available | Show alternatives. Wallet refunded. |
status: FAILED, is_user_fixable: false | Transient internal error | The retry cron handles it; surface "processing" then check again. |
status: DELIVERED, customer never installs | Customer dropped off | Nothing to do — eSIMs can't be "un-issued". The plan validity won't start until install, so they can still install later. |
is_installed: false after 7 days, validity has started | Customer installed elsewhere; or install state is out of sync | Rare. Surface to support. |
activation_status: EXPIRED while customer expected data | Validity window elapsed without install | Customer purchased and forgot. No refund — credential was issued. Suggest a new purchase. |
Idempotency
eSIM uses client_reference as the client-supplied dedup + lookup key (same contract as top-ups and vouchers). Send a value (printable ASCII, ≤ 255 chars) on create and a second request with the same (client_id, client_reference) is rejected with 400 "Duplicate client_reference". Omit it and you lose dedup protection on retries.
To avoid double-charging on retries:
- Don't retry a
2xxresponse. A200 OKmeans the order was placed and the wallet was debited — even ifstatuscame back asPENDING. - For
5xxor network errors, two safe options:- Re-POST with the same
client_reference. The first request that actually reached the server wins; the retry returns400 "Duplicate client_reference"and the existing order can be fetched viaGET /api/v1/esim/orders?client_reference=<value>. The wallet is debited exactly once. - Or do not auto-retry — surface to a human and let them reconcile.
- Re-POST with the same
- Track outgoing eSIM creates locally until the response is seen — combined with
client_reference, this is protection against double-debits.
Top-ups and renewals
The current API does not support in-place data top-ups or validity extensions. To give a customer "more data", create a brand-new order — they'll get a new ICCID and a new activation code. Single-ICCID-multi-plan products (one physical eSIM, swap plans on it) are not yet exposed via the API.
End-to-end example
func purchaseEsim(host, token string, productID uint64, amount float64, clientRef string) (*esimOrder, error) {
order, _, err := createEsimOrder(host, token, productID, amount, clientRef)
if err != nil {
return nil, err
}
switch order.Status {
case "DELIVERED":
return order, nil
case "FAILED":
// Terminal — wallet auto-refunded.
return order, fmt.Errorf("esim failed: %s", order.FailureReason)
case "PENDING":
// 60s inline deadline elapsed — poll until terminal.
return waitForEsim(host, token, order.ID)
default:
return nil, fmt.Errorf("unexpected status: %s", order.Status)
}
}Summary
- One order = one eSIM.
quantityis always 1. - Hybrid sync/async: every response is
200 OK— branch on bodystatus(DELIVEREDorFAILEDare terminal,PENDINGmeans the 60s deadline elapsed and you should poll). - The activation code is single-use; capture it on first delivery, render the QR, and never expect it back from Octopus Cards once installed.
- ICCID is the stable identifier across the lifecycle.
- Don't block user-facing flows on
is_installed— that's a customer-side action that may take hours or days.