GuidesWalkthroughs

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.90

The 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:

HTTPBody statusNext
200DELIVEREDBest case. Render the QR/LPA right now. Move to step 4.
200FAILEDRejected. Show failure_reason. Wallet auto-refunded. Done.
200PENDINGFulfilment took longer than the 60s inline deadline. Move to step 5; render a "we'll have your eSIM shortly" state.
400 / 404errorFix 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-XYZ

Render 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

FailureLikely causeNext
status: FAILED, is_user_fixable: true, failure_code mentions plan unavailableThe plan is no longer availableShow alternatives. Wallet refunded.
status: FAILED, is_user_fixable: falseTransient internal errorThe retry cron handles it; surface "processing" then check again.
status: DELIVERED, customer never installsCustomer dropped offNothing 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 startedCustomer installed elsewhere; or install state is out of syncRare. Surface to support.
activation_status: EXPIRED while customer expected dataValidity window elapsed without installCustomer 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 2xx response. A 200 OK means the order was placed and the wallet was debited — even if status came back as PENDING.
  • For 5xx or network errors, two safe options:
    • Re-POST with the same client_reference. The first request that actually reached the server wins; the retry returns 400 "Duplicate client_reference" and the existing order can be fetched via GET /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.
  • 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. quantity is always 1.
  • Hybrid sync/async: every response is 200 OK — branch on body status (DELIVERED or FAILED are terminal, PENDING means 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.

On this page