Webhooks
Provisioning is not instant. Rather than making you poll, we POST a signed JSON event to a URL you own.
The envelope
Section titled “The envelope”Every event has the same outer shape. Only data varies by type.
{ "id": "evt_7d3f9a2c1b8e", "type": "order.completed", "created_at": "2026-08-22T09:14:03.512Z", "data": { "order_id": "ord_4b21c9e7", "order_number": "ESF-2026-0004821", "status": "COMPLETED", "provisioned": 2, "failed": 0, "refunded": "0" }}Like everything else in the API, data keys are snake_case. Treat data as
a pointer, not as the record itself — fetch the order or eSIM when you need the
full, current state.
Delivery carries three headers:
| Header | Meaning |
|---|---|
X-Esimfo-Signature |
t=<unix seconds>,v1=<hex hmac> — verify this |
X-Esimfo-Event |
Event type, so you can route before parsing |
X-Esimfo-Delivery |
Unique per delivery attempt, not per event |
Events
Section titled “Events”| Event | Fires when |
|---|---|
order.completed |
Every eSIM on the order was provisioned |
order.partially_failed |
Some eSIMs were provisioned, some were not |
order.failed |
No eSIM could be provisioned; balance is returned |
order.refunded |
An order was refunded |
refund_request.approved |
A refund request you opened was approved and credited |
refund_request.rejected |
A refund request you opened was rejected |
esim.provisioned |
One eSIM became installable |
esim.expiring |
An eSIM is approaching its expiry date |
esim.depleted |
An eSIM used up its data |
balance.low |
Balance fell below your threshold |
balance.depleted |
Balance reached zero — orders will now be rejected |
tier.changed |
Your pricing tier moved |
balance.low is the one to wire into an alert. It is the difference between
topping up on a schedule and discovering the problem through failed orders.
What data carries
Section titled “What data carries”| Event | Fields |
|---|---|
order.completed, order.partially_failed, order.failed |
order_id, order_number, status, provisioned, failed, refunded |
order.refunded |
order_id, order_number, esim_id, amount, reason — one event per refunded eSIM |
refund_request.approved, refund_request.rejected |
refund_request_id, esim_id, iccid, status, reason, review_note, refunded_amount, currency — the same fields as Get a refund request |
esim.expiring |
count |
balance.low, balance.depleted |
balance, threshold |
An order.failed raised by a failed payment rather than by provisioning
carries order_id, payment_id and status instead.
Verifying the signature
Section titled “Verifying the signature”Do not skip this. Without verification your endpoint accepts an order confirmation from anyone who learns the URL.
The signed payload is the timestamp, a literal dot, then the raw request body — before any JSON parsing.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(rawBody, header, secret) { const parts = Object.fromEntries( header.split(',').map((p) => p.split('=')), ); const { t, v1 } = parts; if (!t || !v1) return false;
// Reject anything older than five minutes: a captured, valid // delivery must not stay replayable forever. if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = createHmac('sha256', secret) .update(`${t}.${rawBody}`) .digest('hex');
const a = Buffer.from(expected, 'hex'); const b = Buffer.from(v1, 'hex'); return a.length === b.length && timingSafeEqual(a, b);}Retries
Section titled “Retries”We treat any 2xx as success. Anything else, including a timeout, is retried.
- Timeout: 15 seconds per attempt.
- Attempts: 6.
- Backoff: doubling from one minute — 1m, 2m, 4m, 8m, 16m, then 32m.
After six failures the event is dropped. Endpoints that fail repeatedly are flagged, so a broken URL surfaces rather than silently swallowing your events.
Writing a good handler
Section titled “Writing a good handler”Be idempotent on id. A retry that crosses paths with a slow 2xx will
deliver the same event twice. Record processed event ids and ignore repeats.
Note that X-Esimfo-Delivery changes per attempt — it is for tracing, not
deduplication. The envelope id is what you deduplicate on.
Answer quickly. Acknowledge with 2xx, queue the work, return. If you
provision, email, and update inventory inline you will breach the 15-second
timeout and be sent a duplicate.
Do not trust ordering. Independent events can arrive out of order. Treat
each as a statement of fact at created_at, and let your own state machine
decide whether it is news.
Do not rely on webhooks alone. They are an optimisation over polling, not a guarantee. Keep a slow reconciliation job that fetches anything still unresolved, so a delivery outage cannot strand an order forever.
