> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sakawave.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Sell an eSIM

> Mirror the catalog, quote in your customer's currency, order idempotently, wait for provisioning, hand over the install details, and run the profile for the rest of its life.

Every other tutorial here ends when money moves. This one does not: an eSIM is only sold once a person has installed a profile on a handset, which happens after the debit, on someone else's schedule, on a device you cannot see.

So persist your intent before you call. Store your `externalReference`, the plan slug, the paying account, the customer it is for, and the state you last saw — and treat everything after the order as something you resume rather than something you complete in one request.

```mermaid theme={null}
flowchart LR
    A["Catalog<br/>mirrored, cached"] --> B["Quote<br/>~2 min"]
    B --> C["Order<br/>debits now"]
    C --> D["Provisioning<br/>webhook or poll"]
    D --> E["Install details<br/>link, QR, manual"]
    E --> F["Live eSIM<br/>usage, top-up, end"]
```

## 1. Mirror the catalog

Two reads, top down, both paged and read-only.

```bash theme={null}
curl --silent --url "$SAKAWAVE_BASE_URL/v1/esims/catalog/regions?kind=country&pageSize=100" \
  --header "Authorization: Bearer $SAKAWAVE_API_KEY"

curl --silent --url "$SAKAWAVE_BASE_URL/v1/esims/catalog/regions/US/packages" \
  --header "Authorization: Bearer $SAKAWAVE_API_KEY"
```

Do not read the catalog on the customer's keystroke. Mirror it on a schedule — hourly is ample — and serve your own copy, storing the `slug` against your product record. Two fields decide what you can offer later, so keep them:

| Field | Why it matters later |
| - | - |
| `region.code` | A top-up must cover the same destination |
| `supportsTopUp` | A plan that cannot extend an eSIM is a dead end mid-trip |
| `supportsPlatformSms` | Whether you can push a message to the handset at all |

Re-read the plan before you quote if your copy is older than an hour. A plan withdrawn from the network between your mirror and your order is `404 esim_package_unavailable`, and the customer is already at a confirmation screen by then.

## 2. Quote, and show the figure you will charge

The catalog is USD. Your account may not be. Quote unless the paying account is USD, and put `payable` on the confirmation screen — never the USD `price`.

```bash theme={null}
curl --silent --show-error --fail-with-body \
  --request POST \
  --url "$SAKAWAVE_BASE_URL/v1/esims/quote" \
  --header "Authorization: Bearer $SAKAWAVE_API_KEY" \
  --header "Content-Type: application/json" \
  --data "{
    \"packageSlug\": \"united-states-5gb-30d-pkgus5gb\",
    \"quantity\": 1,
    \"debitAccountId\": \"$DEBIT_ACCOUNT_ID\"
  }"
```

```json theme={null}
{
  "price":   { "amount": "999",     "currency": "USD", "display": "9.99" },
  "payable": { "amount": "1568430", "currency": "NGN", "display": "15,684.30" },
  "rate": "1570.000000000000",
  "fxQuoteId": "01K2Z3AB4W5X6Y7Z8ABCDEFGHJ",
  "expiresAt": "2026-08-29T10:32:00.000Z"
}
```

A quote is bound to one plan, one quantity, and one account, and backs exactly one order. It lasts about two minutes, so quote *after* the customer has chosen and immediately before you charge — not while they are still browsing. If it lapses, quote again and show the new figure rather than charging it silently. The [quote window](/services/esim/setup-and-configuration) covers the failure modes; they all answer `409 esim_fx_quote_required` and they all end the same way.

## 3. Create the order

```bash theme={null}
curl --silent --show-error --fail-with-body \
  --request POST \
  --url "$SAKAWAVE_BASE_URL/v1/esims" \
  --header "Authorization: Bearer $SAKAWAVE_API_KEY" \
  --header "Content-Type: application/json" \
  --data "{
    \"packageSlug\": \"united-states-5gb-30d-pkgus5gb\",
    \"quantity\": 1,
    \"debitAccountId\": \"$DEBIT_ACCOUNT_ID\",
    \"customerId\": \"$CUSTOMER_ID\",
    \"fxQuoteId\": \"$FX_QUOTE_ID\",
    \"externalReference\": \"esim_20260901_001\"
  }"
```

<CodeGroup>
  ```ts Node.js theme={null}
  const body = {
    packageSlug: plan.slug,
    quantity: 1,
    debitAccountId: process.env.DEBIT_ACCOUNT_ID,
    customerId: customer.id,                  // optional; who the eSIM is for
    fxQuoteId: quote.fxQuoteId ?? undefined,  // omit for a USD account
    externalReference: `esim_${intent.id}`,   // stored before this call
  };

  const response = await fetch(`${process.env.SAKAWAVE_BASE_URL}/v1/esims`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.SAKAWAVE_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(body),
  });
  const order = await response.json();
  if (!response.ok) throw new Error(`${response.status}: ${order.code} — ${order.message}`);

  await intents.update(intent.id, { orderId: order.id, status: order.status });
  ```

  ```python Python theme={null}
  import os
  import requests

  body = {
      "packageSlug": plan["slug"],
      "quantity": 1,
      "debitAccountId": os.environ["DEBIT_ACCOUNT_ID"],
      "customerId": customer["id"],
      "fxQuoteId": quote.get("fxQuoteId"),
      "externalReference": f"esim_{intent_id}",
  }
  response = requests.post(
      f"{os.environ['SAKAWAVE_BASE_URL']}/v1/esims",
      json={k: v for k, v in body.items() if v is not None},
      headers={"Authorization": f"Bearer {os.environ['SAKAWAVE_API_KEY']}"},
      timeout=30,
  )
  order = response.json()
  if not response.ok:
      raise RuntimeError(f"{response.status_code}: {order.get('code')} — {order.get('message')}")
  ```
</CodeGroup>

The money leaves now; the eSIM arrives later. Expect `provisioning` with an empty `profileIds` and design for it — a "preparing" state the customer can walk away from, not a blocking spinner.

<Warning>
  `externalReference` is the idempotency key. Retrying with the same one returns the original order **whatever its status**, including a failed one. A genuine second attempt after a failure needs a fresh reference and a fresh quote.
</Warning>

Pass `customerId` when you know who the eSIM is for. It does not change who is billed — your account is debited either way — but it is what lets you list a customer's eSIMs later, and what a support agent will search on.

## 4. Wait for fulfilment

```mermaid theme={null}
stateDiagram-v2
    [*] --> provisioning: debited, sent to the network
    provisioning --> completed: profileIds populated
    provisioning --> failed: network refused
    failed --> refunded: money returned automatically
    completed --> partially_refunded: fewer issued than paid for
```

Take the webhook, and keep polling as the recovery path rather than the design:

* **`esim.order.completed`** carries the order, `profileIds` included. That is your signal to fetch install details.
* **`esim.order.failed`** means the network refused. The money is on its way back; you have nothing to claim.
* **Polling** [the order](/api-reference/esim/get-an-esim-order) every few seconds while `status` is `provisioning` is the fallback when a webhook is missed.

```ts theme={null}
export async function settle(orderId: string, deadlineMs = 120_000) {
  const until = Date.now() + deadlineMs;

  let order = await get(`/v1/esims/orders/${orderId}`);
  while (order.status === 'provisioning' && Date.now() < until) {
    await sleep(3_000);
    order = await get(`/v1/esims/orders/${orderId}`);
  }

  // Not an error. Sakawave chases the network for about two hours and
  // refunds if it never resolves, so leave the intent open and let the
  // webhook — or your next sweep — close it.
  if (order.status === 'provisioning') return { pending: true, order };

  return { pending: false, order };
}
```

Give up on the *request*, never on the *order*. An order still `provisioning` after your poll budget is not stuck: Sakawave asks the network on a widening backoff for roughly two hours, then fails and refunds by itself.

### When you bought more than one

`quantity` above 1 produces one order and up to that many eSIMs, and the network can fill part of an order. Read `profileIds` as the truth about how many you actually got:

```ts theme={null}
if (order.status === 'partially_refunded') {
  // Give out the eSIMs that exist, and tell the customer about the ones
  // that do not. `refunded` is already back in the debited currency.
  await issue(order.profileIds);
  await notifyShortfall(order.quantity - order.profileIds.length, order.refunded);
}
```

## 5. Hand it to the handset

One call, three forms of the same credential.

```bash theme={null}
curl --silent --url "$SAKAWAVE_BASE_URL/v1/esims/$PROFILE_ID/installation" \
  --header "Authorization: Bearer $SAKAWAVE_API_KEY"
```

```json theme={null}
{
  "universalLinkUrl": "https://esim.example.com/i/K2-1A2B3C4D5E",
  "qrCodePayload": "LPA:1$smdp.example.com$K2-1A2B3C4D5E",
  "manual": { "smdpAddress": "smdp.example.com", "activationCode": "K2-1A2B3C4D5E" },
  "iccid": "8944..."
}
```

Offer the universal link first, the QR second, the manual details last — and remember a phone cannot scan a QR shown on its own screen. [Installation and device support](/services/esim/installation-and-device-support) is the page to read before you design this screen; it is where integrations lose customers.

Ask before the eSIM exists and you get `409 esim_profile_not_ready`. That is a timing answer, not a failure: the profile is still `pending`.

<Warning>
  The activation code installs a working SIM. Treat it as a credential — show it to the person the eSIM is for, keep it out of logs and analytics, and never put it in an email you also send elsewhere.
</Warning>

## 6. Run the eSIM for the rest of its life

```mermaid theme={null}
stateDiagram-v2
    [*] --> pending: order placed
    pending --> ready: allocated
    ready --> in_use: first data
    in_use --> depleted: allowance spent
    in_use --> expired: validity elapsed
    ready --> expired: never installed
    ready --> suspended: you pause it
    in_use --> suspended: you pause it
    suspended --> ready: resumed
    suspended --> in_use: resumed
    ready --> cancelled: unused, refunded
    in_use --> revoked: torn down, not refunded
```

| You want to | Call | Allowed while |
| - | - | - |
| See data left | [Get live usage](/api-reference/esim/get-live-usage) | Any status; it re-reads from the network |
| Add data | [Add data to an eSIM](/api-reference/esim/add-data-to-an-esim) | `ready`, `in_use` |
| Pause | [Pause an eSIM](/api-reference/esim/pause-an-esim) | `ready`, `in_use` |
| Resume | [Resume a paused eSIM](/api-reference/esim/resume-a-paused-esim) | `suspended` |
| Refund an unused eSIM | [Cancel an unused eSIM](/api-reference/esim/cancel-an-unused-esim) | `ready`, and nothing used |
| End one permanently | [Revoke an eSIM](/api-reference/esim/revoke-an-esim) | Any live status |

Cancel and revoke are not two words for the same thing. Cancel returns the money because nobody used the eSIM; revoke returns nothing because the network has already been paid for the data. Revoking asks for `{"confirm": true}` for exactly that reason.

### Top up instead of selling a second eSIM

A top-up extends the profile already on the phone — same ICCID, nothing to reinstall. It must cover the same destination, and the plan must itself support being a top-up:

```ts theme={null}
const esim = await get(`/v1/esims/${esimId}`);
const plans = await get(`/v1/esims/catalog/regions/${esim.package.region.code}/packages`);
const candidates = plans.items.filter((plan) => plan.supportsTopUp);

const quote = await post(`/v1/esims/${esimId}/top-ups/quote`, {
  packageSlug: chosen.slug,
  debitAccountId: account.id,
});

await post(`/v1/esims/${esimId}/top-ups`, {
  packageSlug: chosen.slug,
  debitAccountId: account.id,
  fxQuoteId: quote.fxQuoteId ?? undefined,
  externalReference: `esim_topup_${topUpIntentId}`,
});
```

A plan from another destination is `409 esim_topup_region_mismatch`; a plan that cannot extend anything is `409 esim_package_not_topupable`.

<Note>
  Sell the top-up on `esim.usage.low` — under about 100 MB left — rather than waiting for the allowance to run out. A `depleted` eSIM is refused with `409 esim_profile_not_topupable`, so the offer has to reach the customer while data is still flowing.
</Note>

### Reaching the handset

If the plan reports `supportsPlatformSms`, you can push one 160-character message to the eSIM — ten per hour per eSIM, and `429 esim_message_rate_limited` past that. Accepted means the provider took it, not that anyone read it: an eSIM that was never installed swallows it silently. [The message log](/api-reference/esim/list-messages-sent-to-an-esim) is what support reads back.

It is a lifeline for a traveller with no other connectivity, not a notification channel. Use your own push and email for everything else.

## 7. Reconcile

Orders carry both figures, and they answer different questions:

```ts theme={null}
for (const order of await paginate('/v1/esims/orders', { pageSize: 100 })) {
  ledger.match(order.externalReference, order.paid.amount, order.paid.currency);
  revenue.record(order.package.code, order.price.amount);

  if (Number(order.refunded.amount) > 0) {
    ledger.matchCredit(order.id, order.refunded.amount, order.refunded.currency);
  }
}
```

`paid` is what left the account, in the account's currency — the only figure a bank statement will agree with. `price` is the USD list price, for reconciling against the catalog. Refunds have already happened; they are entries to record, not exceptions to chase. Then [reconcile the transaction](/tutorials/reconcile-transactions) alongside everything else.

## What goes wrong, and what to do

| Code | Status | What to do |
| - | - | - |
| `esim_account_ineligible` | 409 | Pay from an active, debitable account of your own — never a customer wallet |
| `esim_fx_quote_required` | 409 | Quote again, show the new figure, then buy |
| `esim_package_unavailable` | 404 | Refresh your catalog mirror and re-offer |
| `esim_profile_not_ready` | 409 | The eSIM is still `pending`; wait for `esim.order.completed` |
| `esim_profile_not_cancellable` | 409 | Data has already flowed — revoke, without a refund, or leave it |
| `esim_topup_region_mismatch` | 409 | Offer only plans from the eSIM's own destination |
| `esim_catalog_unavailable` | 503 | Serve your mirror and retry the sync later |
| `esim_feature_disabled` | 503 | eSIM is not enabled on this deployment; talk to us |

## Next

* [Setup and configuration](/services/esim/setup-and-configuration) — the quote window, idempotency, refunds, rate limits.
* [Use cases](/services/esim/use-cases) — the same flows written as recipes.
* [Webhook events](/webhooks/events) — every `esim.*` payload, and what is deliberately not in one.
* [Launch checklist](/go-live/launch-checklist) — before you point this at production.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.