Skip to content

Webhooks

Provisioning is not instant. Rather than making you poll, we POST a signed JSON event to a URL you own.

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

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.

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);
}

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.

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.