Webhooks
Receive a signed HTTP POST the moment something changes on one of your eSIMs — the profile is installed on a phone, the plan becomes active or expires, the customer is running out of data — instead of polling.
AI prompt — Webhooks
Paste into ChatGPT, Claude, Cursor, Copilot or any coding agent to generate this part of your integration. Built-in recommendation: Subscribe to esim.installed / esim.status.changed / esim.usage.threshold, verify X-Webhook-Signature on the raw body, dedupe on X-Webhook-Id, respond 2xx fast, apply events to your own eSIM record.
Show prompt text (69 lines)
# eSIMfly Business API — Webhooks (PUT/GET /webhooks) — prompt for AI coding assistants
TASK: configure the eSIMfly webhook once and implement a secure receiver, so our system learns in
real time when an eSIM is installed, changes status or runs low on data — instead of polling.
COMMON RULES (apply to every eSIMfly request)
- Base URL: https://esimfly.net/api/v1/business
- Headers on every call: RT-AccessCode (esf_...), RT-RequestID (fresh UUID v4 per request; reuse -> 400 DUPLICATE_REQUEST),
RT-Timestamp (ms since epoch; >5 min old -> 401 INVALID_TIMESTAMP),
RT-Signature = UPPERCASE hex HMAC-SHA256(secretKey, timestamp + requestId + accessCode + rawBody).
rawBody = "" for GET; for POST/PUT sign the exact body string you send, with Content-Type: application/json.
- Keep access code + secret key server-side in env vars. Never ship them to a browser or mobile app.
- Responses: { success: true, ... } or { success: false, error|message, code }. Branch on `success` and `code`.
- Rate limits are per API key, shown in the business dashboard (typically 100/minute, 1,000/hour, 10,000/day) -> RATE_LIMIT_EXCEEDED. Pace bulk work at <= 1 req/s.
- Read `currency` from responses (USD | IQD | EUR for enterprise). Never hard-code it. IQD amounts are integers.
- Package codes are opaque strings: store and send back verbatim, never parse or prefix them.
- Node.js/TypeScript: use the official SDK instead of raw HTTP — `npm install @esimfly/sdk`
(https://github.com/eSimfly-Official/esimfly-sdk-nodejs); it implements these rules. Other languages: implement the contract below.
- Full multi-endpoint prompt: https://docs.esimfly.net/llm/esimfly-api-full-prompt.txt
EVENTS (subscribe only to what you use)
esim.installed profile enabled on a device for the first time (seconds; eSIMfly-network eSIMs)
esim.profile.updated SM-DP+ state: BPP installation -> Enable -> Disable/Delete (seconds; chatty — usually skip)
esim.usage.threshold remaining data crossed 500 / 200 / 100 / 50 MB (seconds; not for unlimited plans)
esim.status.changed NEW -> ACTIVE -> DEPLETED / EXPIRED / CANCELLED (<= 30 min; all providers)
esim.provisioned async-provisioned order has its QR code (seconds; async packages only)
order.completed reserved, not emitted yet
Each transition is delivered once per eSIM even when we learn of it from two sources.
CONFIGURE (one-time, or from an admin screen)
PUT https://esimfly.net/api/v1/business/webhooks
Body: { "webhook_url": "https://our-server.com/api/esimfly-webhook",
"events": ["esim.installed", "esim.status.changed", "esim.usage.threshold"] }
-> { success: true, webhook: { url, secret: "whsec_...", events }, note }
SAVE `secret` in a secure secret store — it is shown once. Re-run PUT to rotate it.
GET https://esimfly.net/api/v1/business/webhooks -> { success, webhook: { url, events, api_key_name },
available_events: [{ event, description }], recent_deliveries: [{ id, event, order_reference, status, attempts, last_attempt, delivered_at }] }
(Also configurable in Business Dashboard > Settings > API Keys > Set URL.)
DELIVERY (eSIMfly -> us)
POST <webhook_url>, Content-Type: application/json, User-Agent: eSIMfly-Webhook/1.0
Headers: X-Webhook-Event, X-Webhook-Signature ("sha256=<hex>"), X-Webhook-Timestamp (ISO), X-Webhook-Id (unique delivery id)
Body: { "event": "<name>", "timestamp": "<ISO>", "data": { iccid, esim_id, package_code, order_reference, source, ...event fields } }
Event fields:
esim.installed { eid, imsi, installed_at }
esim.profile.updated { profile_status: "BPP installation"|"Enable"|"Disable"|"Delete", profile: "Enabled"|"Disabled"|"Not Installed", previous_profile_status, eid, imsi, changed_at }
esim.status.changed { old_status, new_status, changed_at, expiry_date }
esim.usage.threshold { threshold_remaining_mb: 500|200|100|50, used_percent, used_gb, total_gb, remaining_gb, package_id, reported_at }
esim.provisioned { iccid, qr_code_url, lpa_string, direct_apple_install_url, direct_android_install_url, package_name }
Retries if we do not answer 2xx within 10 s: 10 s, 30 s, 2 min, 10 min (5 attempts total), then marked failed.
RECEIVER — implement exactly
1. Read the RAW request body bytes (disable JSON body parsing on this route).
2. expected = "sha256=" + hex(HMAC-SHA256(secret, rawBody)); compare with X-Webhook-Signature using a
constant-time comparison. Mismatch or missing header -> 401, do nothing else.
3. Dedupe: store X-Webhook-Id with a unique constraint; if already seen -> respond 200 immediately.
4. Persist the payload, respond 200 within 10 seconds, process in a background job.
5. Job: find our esims row by data.iccid (fallback data.esim_id / order_reference) and apply:
- esim.installed -> mark installed_at, notify the customer their eSIM is on the phone
- esim.status.changed -> update status/expiry; on ACTIVE start the validity countdown, on DEPLETED/EXPIRED offer a top-up
- esim.usage.threshold -> update used/remaining GB; at 200 MB or below send a low-data message / top-up offer
- esim.profile.updated -> update profile state if we display it
6. Unknown ICCID (race with our own order insert) -> retry the job a few times before alerting.
7. Treat webhooks as the primary signal; keep GET /esims/usage/query and POST /esims/status for reconciliation only.
8. HTTPS only; never log the secret or put it in client code.
DELIVERABLE: setWebhook()/getWebhook() in the shared client, the raw-body receiver with signature check
and dedupe table, the background event applier, and an admin page showing recent_deliveries.
Events
| Event | When it fires | Latency | Availability |
|---|---|---|---|
esim.installed | The profile is enabled on a device for the first time | seconds | eSIMfly-network eSIMs |
esim.profile.updated | SM-DP+ profile state changes: BPP installation (download started) → Enable → Disable / Delete | seconds | eSIMfly-network eSIMs |
esim.usage.threshold | Remaining data crosses 500 / 200 / 100 / 50 MB | seconds | eSIMfly-network eSIMs (not unlimited plans) |
esim.status.changed | Lifecycle status changes: NEW → ACTIVE → DEPLETED / EXPIRED / CANCELLED | up to 30 min (immediate after a manual refresh or POST /esims/status) | all providers |
esim.provisioned | An asynchronously provisioned order has its QR code ready | seconds | async packages only |
order.completed | Reserved — not emitted yet | — | — |
"eSIMfly-network eSIMs" are packages that expose countries / networks in
Get All Packages. Their profile and usage events come straight from the
network in real time; other providers are polled, so they only receive esim.status.changed.
Each event for one eSIM is sent once per transition, even when we learn about it from two sources.
Setup
Via API
PUT /api/v1/business/webhooks
{
"webhook_url": "https://your-server.com/api/esimfly-webhook",
"events": ["esim.installed", "esim.status.changed", "esim.usage.threshold"]
}
Response:
{
"success": true,
"message": "Webhook settings updated",
"webhook": {
"url": "https://your-server.com/api/esimfly-webhook",
"secret": "whsec_a1b2c3d4e5...",
"events": ["esim.installed", "esim.status.changed", "esim.usage.threshold"]
},
"note": "Save your webhook_secret - it is used to verify webhook signatures via X-Webhook-Signature header."
}
The secret is shown once. Store it in your secret manager.
Via Dashboard
Business Dashboard → Settings → API Keys → Set URL on the key you use for the API.
View settings and recent deliveries
GET /api/v1/business/webhooks
{
"success": true,
"webhook": { "url": "https://your-server.com/api/esimfly-webhook", "events": ["esim.installed"], "api_key_name": "production-key" },
"available_events": [
{ "event": "esim.installed", "description": "Profile installed and enabled on a device for the first time" },
{ "event": "esim.profile.updated", "description": "SM-DP+ profile state changed (BPP installation / Enable / Disable / Delete)" },
{ "event": "esim.status.changed", "description": "Lifecycle status changed (NEW, ACTIVE, DEPLETED, EXPIRED, ...)" },
{ "event": "esim.usage.threshold", "description": "Remaining data crossed a threshold (500 / 200 / 100 / 50 MB)" },
{ "event": "esim.provisioned", "description": "eSIM QR code is ready (asynchronously provisioned packages)" },
{ "event": "order.completed", "description": "Order fully processed with all details" }
],
"recent_deliveries": [
{ "id": 1, "event": "esim.installed", "order_reference": "order_1776079539762_hvkrk", "status": "delivered", "attempts": 1, "last_attempt": "2026-09-13T10:05:31.000Z", "delivered_at": "2026-09-13T10:05:31.000Z" }
]
}
Delivery
Every delivery is a POST to your URL with these headers:
| Header | Description |
|---|---|
X-Webhook-Event | Event name, e.g. esim.usage.threshold |
X-Webhook-Signature | sha256=<hex HMAC-SHA256 of the raw body, keyed with your webhook secret> |
X-Webhook-Timestamp | ISO 8601 time the event was created |
X-Webhook-Id | Unique delivery id — use it to deduplicate |
Content-Type | application/json |
User-Agent | eSIMfly-Webhook/1.0 |
Body shape is the same for every event:
{
"event": "esim.usage.threshold",
"timestamp": "2026-09-13T15:05:20.391Z",
"data": {
"iccid": "8948010010092445039",
"esim_id": 23691,
"package_code": "2163597",
"order_reference": "order_1755559183090_vyf9w",
"source": "usage_threshold",
"...": "event-specific fields below"
}
}
data.iccid, data.esim_id, data.package_code and data.order_reference are present on every eSIM event.
Event payloads
esim.installed
{ "eid": "89049032020008884800235164256791", "imsi": "260010166196940", "installed_at": "2026-09-13T15:20:23.576Z" }
esim.profile.updated
{
"profile_status": "Enable",
"profile": "Enabled",
"previous_profile_status": "BPP installation",
"eid": "89049032020008884800235164256791",
"imsi": "260010166196940",
"changed_at": "2026-09-13T15:20:23.576Z"
}
profile_status is the raw network value (BPP installation, Enable, Disable, Delete); profile is the
friendly label used by eSIM Status (Enabled, Disabled, Not Installed).
esim.status.changed
{ "old_status": "NEW", "new_status": "ACTIVE", "changed_at": "2026-09-13T15:30:00.000Z", "expiry_date": "2026-10-13T15:30:00.000Z" }
esim.usage.threshold
{
"threshold_remaining_mb": 100,
"used_percent": 98,
"used_gb": 4.9,
"total_gb": 5,
"remaining_gb": 0.1,
"package_id": 213328290,
"reported_at": "2026-09-13T15:05:20.391Z"
}
Thresholds are fixed at 500, 200, 100 and 50 MB remaining; each fires once per package.
esim.provisioned
{ "iccid": "8981100000012345678", "qr_code_url": "data:image/png;base64,...", "lpa_string": "LPA:1$...", "direct_apple_install_url": "https://esimsetup.apple.com/...", "direct_android_install_url": "https://...", "package_name": "..." }
Signature verification
Always verify before trusting a delivery. Compute the HMAC over the raw request body.
Node.js
const crypto = require('crypto');
function verifyWebhookSignature(rawBody, signatureHeader, webhookSecret) {
const expected = 'sha256=' + crypto.createHmac('sha256', webhookSecret).update(rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader || '');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express.js - keep the body raw on this route
app.post('/api/esimfly-webhook', express.raw({ type: 'application/json' }), async (req, res) => {
const rawBody = req.body.toString();
if (!verifyWebhookSignature(rawBody, req.headers['x-webhook-signature'], process.env.ESIMFLY_WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const deliveryId = req.headers['x-webhook-id'];
if (await alreadyProcessed(deliveryId)) return res.json({ received: true });
const { event, data } = JSON.parse(rawBody);
await queue.add({ deliveryId, event, data }); // process asynchronously
res.json({ received: true }); // respond within 10 seconds
});
Python
import hmac, hashlib
from flask import Flask, request, jsonify
app = Flask(__name__)
WEBHOOK_SECRET = 'whsec_your_secret'
def verify_signature(raw_body: bytes, signature: str) -> bool:
expected = 'sha256=' + hmac.new(WEBHOOK_SECRET.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature or '')
@app.route('/api/esimfly-webhook', methods=['POST'])
def webhook():
if not verify_signature(request.get_data(), request.headers.get('X-Webhook-Signature')):
return jsonify({'error': 'Invalid signature'}), 401
event = request.get_json()
# enqueue event['event'], event['data'] and return quickly
return jsonify({'received': True})
Retry policy
We expect a 2xx within 10 seconds. Otherwise we retry:
| Attempt | Delay after previous |
|---|---|
| 1 | immediate |
| 2 | 10 seconds |
| 3 | 30 seconds |
| 4 | 2 minutes |
| 5 | 10 minutes |
After 5 failed attempts the delivery is marked failed and shows as such in recent_deliveries.
Best practices
- Verify the signature on the raw body — never on a re-serialised JSON object.
- Deduplicate on
X-Webhook-Id; retries and multi-source detection can both resend. - Respond fast, work later — persist the payload, return
200, process in a job. - Subscribe to what you use.
esim.profile.updatedis the chattiest event (every download / enable / disable); most integrations only needesim.installed,esim.status.changedandesim.usage.threshold. - Keep your local eSIM record as the source of truth and apply events to it; use Query eSIM Usage or eSIM Status only to reconcile.
- HTTPS only. Rotate the secret by calling
PUT /webhooksagain.
Support
- Email: support@esimfly.net