Orders
Create an order
Create a new eSIM order
POST
Authentication
This endpoint requires HMAC-SHA256 signature authentication. See Authentication guide for details.The playground below cannot sign for you. It sends whatever you type, and
this endpoint rejects anything unsigned — so filling in a body and pressing
send returns
401. That is the endpoint working, not a fault.To use it, generate the signature elsewhere and paste it in:- Build the signature over the exact body string you are about to send — copy it out of the playground first, or paste a body you have already hashed.
- Paste the result into
X-Esim-Story-Signature, with the same timestamp you signed with inX-Esim-Story-Timestamp. - Send within 5 minutes of that timestamp, and do not edit the body afterwards — either one invalidates the signature.
Headers
string
required
Your partner access key. Used for authentication.
string
required
HMAC-SHA256 signature of the request. Generated using your secret key. See
Authentication guide
for signature generation details.
string
required
Unix timestamp in seconds (UTC). Must be within 5 minutes of server
time, in either direction.
Request body
string
required
Your order identifier in your system. Must be unique within your partner
account. Re-sending the same value is safe — see
Idempotency.
array
required
One entry per product you want to order.
One order carries at most 100 eSIMs in total, counted across every entry —
there is no separate limit on how many entries or destinations you combine.
Split a larger batch across several orders, each with its own
external_order_id. Contact support if your volume needs a higher ceiling.option_id values are not discoverable through the API. Your product catalogue
is issued to you as a CSV when your partner account is set up. Contact support
if you need an updated one.Reading your catalogue
The CSV carries one row per orderable product, with these columns:
Only
option_id belongs in the payload. Do not send the other columns —
duration, data allowance, and country are already fixed by the option_id you
choose, and there is no field to override them.
wholesale_price_usd is your contracted price for that product. It is specific
to your account, so it is not comparable with another partner’s catalogue.
option_id is matched case-insensitively, so send it exactly as your CSV spells
it. There is no need to change its case to match the examples on this page.
Response
A successful request returns201 Created.
201 means the order was accepted and provisioning was requested. It does not
contain the eSIM. That arrives later, one delivery per unit, on your
webhook.
array
required
One entry per unit ordered — a product with
qty: 2 produces two entries.Webhooks
Once an eSIM is issued, wePOST its activation data to your webhook URL as
application/json. Return any 2xx to acknowledge it.
One unit per webhook. A product ordered with qty: 2 produces two separate
deliveries, each of which may be retried. Match each to your order with
external_order_id, and to the unit with topup_id.
Receiving the webhook means the eSIM exists and can be handed to your customer.
It does not mean the plan has started — that happens when the profile first
connects to a network at the destination, which is usually days later. See
Installed is not activated.
Every delivery is signed. Verify the signature
before you process the payload — without that check, anyone who learns your
webhook URL can post fabricated eSIM data to it.
Payload
string
Matches a
topup_id from the order creation response.string
The option ID that was ordered.
string
The
external_order_id you supplied when creating the order.string
ICCID of the issued eSIM profile.
string
SM-DP+ server address for the profile.
string
Matching ID / activation code for the profile.
string
Full LPA activation string, formatted
LPA:1$<smdp>$<activate_code>. Use for
manual / universal-link activation where scanning a QR code is not possible.string
Hosted QR code image for
download_link, provided as a convenience. Use the
URL exactly as provided.Treat it as opaque: do not parse it, and do not assume the host stays the
same. Where we host these images may change without notice.
download_link is the durable value — the QR encodes that string and nothing
else, so you can always render your own.In sandbox every order returns the same placeholder image. Identify a unit
by topup_id, never by this URL.string
Date (
YYYY-MM-DD) after which the profile can no longer be installed.Example payload
Verifying the signature
Every webhook carries two extra headers:
The signed string is the timestamp, a full stop, and the raw request body:
HMAC-SHA256 over that string using your webhook signing secret, hex-encode it, and compare against the header.
Sandbox and production have separate signing secrets, just as they have
separate API keys. Your production secret is shown in your partner dashboard;
your sandbox secret is issued with the rest of your sandbox credentials. The
signing scheme is identical in both, so verification code written against
sandbox works unchanged in production.
Four details decide whether your implementation works:
1. Use the raw body, not a re-serialised object. Sign the bytes exactly as
received. Parsing the JSON and serialising it again reorders keys and changes
whitespace, so the signature will never match. Capture the raw body before any
body-parsing middleware runs.
2. Use the secret verbatim. The signing secret is a plain string. It is not
Base64 and must not be decoded first — unlike the secret key you sign requests
with.
3. Compare in constant time. A plain == returns as soon as two characters
differ, and that timing difference leaks the expected signature one character at
a time. Use your language’s constant-time comparison.
4. Reject stale timestamps. The timestamp is inside the signed string, so a
captured delivery cannot be replayed under a new one. Rejecting anything older
than five minutes closes the remaining window.
Test vector
Check your implementation against these fixed values before going live.The signature header is a list because it may one day carry more than one
entry — during a secret changeover we would sign with both the outgoing and
the incoming secret so neither side has downtime. Code that accepts a match
against any entry today needs no change when that happens. Today exactly one
signature is sent.
Idempotency
external_order_id is the idempotency key. It is unique per partner account,
and re-sending a request with an external_order_id you have already used is
safe:
- If the order exists but no eSIMs were issued yet (a previous attempt failed during provisioning), provisioning is retried and no duplicate order items are created.
- If the order was already fully provisioned, the original
topup_idvalues are returned unchanged. No second eSIM is issued and you are not charged twice.
422 provisioning failures and 5xx responses safe to retry
verbatim. Always retry with the same external_order_id — generating a new one
will create a genuinely new order.
Errors
400 Bad Request
The request body could not be parsed as JSON. Theerror value is the raw
parser message and its exact wording is not stable — do not match on it.
- Malformed or truncated JSON in the request body
- Missing or empty request body
401 Unauthorized
Authentication failed. The body is always this shape:Invalid signature. is the one worth debugging carefully — the
Authentication guide lists its causes in
order of likelihood, and a test vector to check your signing code against.
422 Unprocessable Entity
Validation or provisioning errors. The body always has anerrors array of
strings.
Invalid request payload — the order is rejected before anything is created,
so nothing is charged and no order record exists:
A single invalid entry rejects the whole request — products are never partially
ordered.
Provisioning failed — the eSIM could not be issued. Four variants, told
apart by their prefix. All four are retryable with the same
external_order_id:
A
422 from provisioning means the order record was already created but no
eSIM was issued. Retry with the same external_order_id — the retry is
idempotent and will not create a duplicate order. See
Idempotency. A 422 from payload validation creates nothing,
so fix the payload and send it as a new request.Testing provisioning failures in sandbox
Sandbox issues an eSIM for every order, so the422 above never occurs there on
its own. To exercise your error handling, start the external_order_id with one
of these:
It is a prefix match, so append your own suffix to keep each test order unique —
SANDBOX-FAIL-001, SANDBOX-FAIL-002, and so on.
These prefixes do nothing in production. An order sent there with a name
beginning SANDBOX-FAIL is provisioned normally.
Webhook timing in sandbox
Sandbox delivers on the same clock production does: the201 comes back first,
and the webhook follows about five seconds later — one per unit, a second
apart. Between the two, the order exists with its topup_id and no activation
data yet.
That gap is deliberate. A handler written against an instant webhook assumes the
order response is already stored when the delivery lands, and that assumption
breaks the first time it meets a real provider.
Use
SANDBOX-SLOW to see what your own code does while an order is still
pending — the same prefix rules apply, so SANDBOX-SLOW-001 and so on.
In production the wait is not fixed — usually seconds, occasionally minutes.
Treat “issued” as something the webhook tells you, never something to assume
from the time elapsed.
The simulation reproduces the aftermath, not just the status code. As in
production, the order and its items are created before provisioning is
attempted — so a simulated failure leaves an order that exists with no
topup_id, and retrying it with the same external_order_id succeeds and
issues the eSIM. That fail-then-retry sequence is the part worth rehearsing:
it is what a real provider outage looks like from your side.429 Too Many Requests
Rate limit exceeded. Check theRetry-After response header (in seconds) before retrying. See Rate Limits.
500 Internal Server Error
Returned for unexpected server-side failures. The response body is not guaranteed to be JSON — do not parse it. Treat any5xx as retryable with the
same external_order_id.
Debugging by symptom
The tables above are indexed by status code. This one is indexed by what you actually see first, including the two failures that return no error at all.Last modified on August 24, 2026