Webhooks
A webhook is a request eSIMify sends to your server when something changes, so you do not have to keep asking. You give us an HTTPS address, called an endpoint, and each event arrives there as a POST with a JSON body.
Setting up an endpoint
Section titled “Setting up an endpoint”- Sign in to the Business portal and open Developers, then the Webhooks tab.
- Choose Add endpoint, enter your address and choose its mode: Test or Live.
- Choose the events it should receive: all of them, or a list.
- Save, and copy the signing secret. It starts with
whsec_.
Each endpoint has its own secret. You can show it again in the portal later, and you can rotate it.
Rules for the address:
- It must use
https, on port 443 or 8443. - It must be reachable on the public internet. Private and local addresses are refused.
- Redirects are not followed. Give the final address.
- It can be up to 500 characters long.
A test-mode endpoint receives sandbox events only. A live-mode endpoint receives live events only. You can have up to 5 endpoints in each mode.
Events
Section titled “Events”| Event | Sent when | data holds |
|---|---|---|
order.completed |
An order has reached completed or partially_completed. |
The Order |
order.failed |
An order ended as failed. Nothing was issued and the amount was returned. |
The Order |
esim.activated |
The eSIM has been used for the first time. | An eSIM summary |
esim.usage_80 |
80% of the eSIM’s data has been used. | An eSIM summary |
esim.usage_100 |
All of the eSIM’s data has been used. | An eSIM summary |
esim.expired |
The plan’s validity has ended. | An eSIM summary |
topup.completed |
A top-up has been added to an eSIM. | The Topup |
balance.low |
An order or top-up has taken your balance below your low-balance threshold. | balance and threshold |
ping |
You pressed Send test event in the portal. It is never sent on its own. | An empty object |
Good to know:
- Read
data.statusonorder.completed. A partially completed order holds fewer eSIMs thanquantity.data.esimslists the ones that were issued anddata.refundedshows what went back to your balance. - Order and top-up events are sent for every live order on your account, whether it was placed through the API or by your staff in the Business portal. Ignore the ones whose
customer_refyou do not recognise. A portal order can hold several packages: thenitemslists them and the top-levelpackage_idandquantityarenull. - A top-up that fails sends no event. The response to the top-up request tells you, with
statusfailed. - An order or eSIM that is cancelled sends no event.
balance.lowis sent once when your balance crosses the threshold, and again only after the balance has gone back above it and dropped once more. It is not sent while no threshold is set. In the sandbox it follows the same threshold against the virtual balance.pingis not stored. It does not appear inGET /v1/events.
eSIM events in live
Section titled “eSIM events in live”Live esim.* events come from a regular check of your eSIMs, not from the moment of the change itself.
- They are produced only while your account has at least one enabled live endpoint. With no live endpoint, or with your only one disabled, they are not recorded and do not appear in
GET /v1/events. Order, top-up and balance events are always recorded. - When an endpoint is added or enabled again, eSIM changes from the last 72 hours are sent. Older ones are skipped for good.
- Each event is sent once for an eSIM. The two usage events are sent once for each data allowance: after a completed top-up,
esim.usage_80andesim.usage_100can be sent again when the new allowance is used. datais the eSIM asGET /v1/esims/{iccid}shows it when the event is created. If an eSIM jumps from below 80% to 100% between two checks, both usage events are sent,esim.usage_80first, and both already carrystatusdepleted.- Expect a delay. The check runs every 10 minutes, and usage reaches eSIMify from the mobile network first: usually within 15 minutes, and up to about an hour on networks that only report when asked. Some networks do not report usage at all, and unlimited plans send no usage events. For a fresh reading, fetch the eSIM.
In the sandbox the same events are sent at once by the simulate endpoint, whether or not an endpoint exists.
The event object
Section titled “The event object”The body of every webhook is an Event:
{ "id": "evt_5b81c0d2e4f6a7b8c9d0e1f2", "type": "order.completed", "mode": "test", "created": "2026-10-02T09:14:08Z", "sequence": 41, "data": { "id": "ord_test_3f9a1c2b7d4e", "status": "completed", "mode": "test", "package_id": "pkg_10482", "quantity": 1, "items": [ { "package_id": "pkg_10482", "quantity": 1 } ], "customer_ref": "booking-84512", "total": { "amount": "249.00", "currency": "INR" }, "refunded": { "amount": "0.00", "currency": "INR" }, "balance_after": { "amount": "999751.00", "currency": "INR" }, "created": "2026-10-02T09:14:07Z", "esims": [ { "iccid": "8999999000000012345", "status": "ready" } ] }}An esim.* event carries the eSIM summary:
{ "id": "evt_9a3f7c21b0d4e5f6a7b8c9d0", "type": "esim.usage_80", "mode": "live", "created": "2026-10-05T16:20:11Z", "sequence": 57, "data": { "iccid": "8910000000000012345", "status": "active", "order_id": "ord_po2610ab12cd", "customer_ref": "booking-84512", "data": { "unlimited": false, "total_mb": 1024, "used_mb": 835, "remaining_mb": 189 }, "expires_at": "2026-10-09T11:02:40Z" }}A balance.low event carries two amounts:
{ "id": "evt_c4d5e6f7a8b9c0d1e2f3a4b5", "type": "balance.low", "mode": "live", "created": "2026-10-05T18:02:44Z", "sequence": 58, "data": { "balance": { "amount": "4210.00", "currency": "INR" }, "threshold": { "amount": "5000.00", "currency": "INR" } }}sequence is a whole number that grows with every event on your account, counted separately for test and live. Use it to put events in order. Numbers can be skipped. A ping has sequence 0.
Request headers
Section titled “Request headers”| Header | Value |
|---|---|
Content-Type |
application/json |
User-Agent |
eSIMify-Webhooks/1.0 |
X-Esimify-Event-Id |
The event id, the same as id in the body. |
X-Esimify-Event-Type |
The event type, the same as type in the body. |
X-Esimify-Delivery-Id |
The id of this delivery. It is the same on every attempt to deliver this event to this endpoint. |
X-Esimify-Timestamp |
When the request was signed, in seconds since the Unix epoch. |
X-Esimify-Signature |
The signature. See below. |
Check the signature
Section titled “Check the signature”Anyone can send a request to your endpoint, so check that each one really came from eSIMify.
The X-Esimify-Signature header has two parts and nothing else: no spaces, one t and one v1.
X-Esimify-Signature: t=1790932447,v1=5f2b8c1e...tis the time the request was signed, in seconds since the Unix epoch.v1is an HMAC-SHA256, written as 64 lower-case hex characters. The key is your signing secret. The message ist, a dot, and the raw request body.
To verify a request:
- Read
tandv1from the header. Reject a header that is not exactly in this form. - Reject the request if
tis more than 5 minutes from your server’s time. This stops an old request from being replayed. - Compute the HMAC-SHA256 of
t + "." + rawBodywith your signing secret. - Compare your value with
v1using a constant-time comparison. - If they differ, answer
400and do nothing else.
Never verify with an empty secret. If the secret is missing from your configuration, refuse every request.
import crypto from 'node:crypto';import express from 'express';
const app = express();const TOLERANCE_SECONDS = 300;
function isFromEsimify(rawBody, header, secret) { if (typeof secret !== 'string' || secret === '') return false; // never verify with an empty secret // The body must be the bytes that arrived: a Buffer, or the same text as a string. const body = typeof rawBody === 'string' ? Buffer.from(rawBody, 'utf8') : rawBody; if (!Buffer.isBuffer(body)) return false; // the body was parsed, or the content type was not JSON // header is exactly: t=1790932447,v1=<64 lower-case hex characters> const m = /^t=([0-9]{1,12}),v1=([0-9a-f]{64})$/.exec(String(header || '')); if (!m) return false; const [, t, v1] = m; if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_SECONDS) return false;
const expected = crypto .createHmac('sha256', secret) .update(`${t}.`) .update(body) // the raw bytes, not re-serialised JSON .digest();
return crypto.timingSafeEqual(expected, Buffer.from(v1, 'hex'));}
// express.raw keeps the body as a Buffer, exactly as it arrived.app.post('/webhooks/esimify', express.raw({ type: 'application/json' }), (req, res) => { const ok = isFromEsimify( req.body, req.get('X-Esimify-Signature'), process.env.ESIMIFY_WEBHOOK_SECRET, ); if (!ok) return res.sendStatus(400);
const event = JSON.parse(req.body.toString('utf8')); // Store event.id and queue the work, then answer straight away. res.sendStatus(200);});<?phpconst TOLERANCE_SECONDS = 300;
$secret = getenv('ESIMIFY_WEBHOOK_SECRET');if ($secret === false || $secret === '') { // Never verify with an empty secret. http_response_code(500); exit;}
// php://input is the raw body, exactly as it arrived.$rawBody = file_get_contents('php://input');$header = $_SERVER['HTTP_X_ESIMIFY_SIGNATURE'] ?? '';
// $header is exactly: t=1790932447,v1=<64 lower-case hex characters>$ok = false;if (preg_match('/\At=([0-9]{1,12}),v1=([0-9a-f]{64})\z/', $header, $m) === 1) { [, $t, $v1] = $m; $ok = abs(time() - (int) $t) <= TOLERANCE_SECONDS && hash_equals(hash_hmac('sha256', $t . '.' . $rawBody, $secret), $v1);}
if (!$ok) { http_response_code(400); exit;}
$event = json_decode($rawBody, true);// Store $event['id'] and queue the work, then answer straight away.http_response_code(200);import hashlibimport hmacimport jsonimport osimport reimport time
from flask import Flask, request
app = Flask(__name__)TOLERANCE_SECONDS = 300# The header is exactly: t=1790932447,v1=<64 lower-case hex characters>SIGNATURE = re.compile(r"t=([0-9]{1,12}),v1=([0-9a-f]{64})")
def is_from_esimify(raw_body: bytes, header: str, secret: str) -> bool: if not secret: return False # never verify with an empty secret m = SIGNATURE.fullmatch(header or "") if not m: return False t, v1 = m.group(1), m.group(2) if abs(time.time() - int(t)) > TOLERANCE_SECONDS: return False expected = hmac.new( secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected.encode(), v1.encode())
@app.post("/webhooks/esimify")def esimify_webhook(): raw_body = request.get_data() # the raw bytes, not request.json ok = is_from_esimify( raw_body, request.headers.get("X-Esimify-Signature", ""), os.environ["ESIMIFY_WEBHOOK_SECRET"], ) if not ok: return "", 400
event = json.loads(raw_body) # Store event["id"] and queue the work, then answer straight away. return "", 200Three things go wrong most often:
- The body was parsed first. Use the body exactly as it arrived. Parsing it to JSON and turning it back into text changes it, and the signature no longer matches. Many frameworks parse JSON for you, so ask yours for the raw body.
- The server clock is wrong. The 5-minute check needs a clock that is kept in sync.
- The wrong secret is used. Each endpoint has its own secret, and test and live endpoints never share one.
Answering an event
Section titled “Answering an event”- Reply with a
2xxstatus as soon as you have stored the event. Do slow work afterwards. - You have 10 seconds to answer, counted from the moment we start to look up your address. A slower answer counts as a failure.
- Any status other than
2xxcounts as a failure, including a redirect. ARetry-Afterheader in your answer is not used. - The same event can arrive more than once. Use its
idto ignore repeats.
Order of events
Section titled “Order of events”Your endpoint gets one request at a time, in sequence order. A retry of an older event can still arrive after a newer one.
- Order events by
sequence, not bycreated.createdis given to the second, so two events can share it. - Ignore an event whose
sequenceis lower than one you have already handled for the same order or eSIM. - When it matters, fetch the object (
GET /v1/orders/{id},GET /v1/esims/{iccid}) for its current state.
Retries
Section titled “Retries”If a delivery fails, it is tried again. There are up to 8 attempts in total, over about 45 hours.
| Attempt | Wait before this attempt |
|---|---|
| 1 | Sent straight away |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 hours |
| 7 | 12 hours |
| 8 | 24 hours |
The first attempt normally leaves within a second of the event, and within 15 seconds at the latest. Retries are picked up every 15 seconds, so a wait can be up to 15 seconds longer than shown.
After the eighth failed attempt the delivery is marked failed and is not tried again on its own.
When an endpoint keeps failing
Section titled “When an endpoint keeps failing”- Pause. After 5 failed attempts in a row, or one attempt that hangs for 5 seconds or more, deliveries to the endpoint are paused for 30 seconds. Each further failure doubles the pause, up to 15 minutes. After a pause one request is sent as a probe, and one
2xxanswer ends the pausing. A paused delivery keeps its attempts and its schedule. - Automatic disable. An endpoint is disabled automatically when 20 deliveries in a row have each used up all 8 attempts, or when it has done nothing but fail for 3 days. One successful delivery resets both counts. A single failed attempt does not count. The portal shows that the endpoint is disabled and why, the change appears on the Activity tab, and one email goes to your account’s email address.
When an endpoint is switched off
Section titled “When an endpoint is switched off”This applies whether you disabled or deleted the endpoint, or it was disabled automatically.
- Deliveries that were still waiting for a retry are marked
failedat once. They are not resumed when you enable the endpoint again. - Events created while it was disabled are not sent to it.
- Read what you missed with
GET /v1/events. Remember that liveesim.*events are not produced while you have no enabled live endpoint.
Fix your server, then enable the endpoint again in the portal.
Delivery log and manual retry
Section titled “Delivery log and manual retry”The Webhooks tab in the portal keeps a log of deliveries for each endpoint. For every delivery you can see:
- the event type and id
- the status:
pending,sending,delivered,retryingorfailed - how many attempts were made, the last HTTP status your server gave, the last error and how long it took
- the body we sent, and the first 500 characters of your server’s answer
Deliveries are kept for 30 days.
You can retry a delivery that is failed, retrying or pending. It is sent again straight away. A delivery that was already delivered cannot be re-sent from the portal. A delivery can be retried by hand up to 5 times, and an account up to 30 times in 15 minutes. A manual retry never counts towards the automatic disable.
Send test event sends a ping to the endpoint and shows the result at once: whether it was delivered, the status your server gave and how long it took. A ping is not written to the delivery log. You can send up to 10 a minute to one endpoint.
Reconciling
Section titled “Reconciling”Webhooks are the fast path, not the only one. Every stored event is kept for 30 days and can be read with GET /v1/events. Run a job on a schedule that lists recent events and handles any id you have not seen. Then a missed webhook never becomes a missed order.
curl "$ESIMIFY_API_URL/v1/events?type=order.completed&limit=100" \ -H "Authorization: Bearer $ESIMIFY_API_KEY"For orders, top-ups and your balance this list is complete. For live eSIM events it holds only what was produced while you had an enabled live endpoint, so reconcile eSIM state with GET /v1/esims as well.
Rotating the signing secret
Section titled “Rotating the signing secret”Rotate a secret in the portal when it may have leaked, or on a schedule. Rotating gives the endpoint a new secret. Update your server with it straight away, because requests are signed with the new secret from then on, including retries of deliveries that were queued before.
If the portal says a secret cannot be shown, rotate it. Deliveries to that endpoint wait, without using up attempts, until you do.
If your account is deactivated
Section titled “If your account is deactivated”Nothing is sent to a deactivated partner account. Deliveries that were waiting are marked failed, and no new events are produced until the account is active again.

