Skip to main content

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

Open raw .txt

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.

Building the whole integration? Use the complete prompt for all endpoints instead of combining the per-endpoint ones.

Events​

EventWhen it firesLatencyAvailability
esim.installedThe profile is enabled on a device for the first timesecondseSIMfly-network eSIMs
esim.profile.updatedSM-DP+ profile state changes: BPP installation (download started) → Enable → Disable / DeletesecondseSIMfly-network eSIMs
esim.usage.thresholdRemaining data crosses 500 / 200 / 100 / 50 MBsecondseSIMfly-network eSIMs (not unlimited plans)
esim.status.changedLifecycle status changes: NEW → ACTIVE → DEPLETED / EXPIRED / CANCELLEDup to 30 min (immediate after a manual refresh or POST /esims/status)all providers
esim.provisionedAn asynchronously provisioned order has its QR code readysecondsasync packages only
order.completedReserved — 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:

HeaderDescription
X-Webhook-EventEvent name, e.g. esim.usage.threshold
X-Webhook-Signaturesha256=<hex HMAC-SHA256 of the raw body, keyed with your webhook secret>
X-Webhook-TimestampISO 8601 time the event was created
X-Webhook-IdUnique delivery id — use it to deduplicate
Content-Typeapplication/json
User-AgenteSIMfly-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:

AttemptDelay after previous
1immediate
210 seconds
330 seconds
42 minutes
510 minutes

After 5 failed attempts the delivery is marked failed and shows as such in recent_deliveries.

Best practices​

  1. Verify the signature on the raw body — never on a re-serialised JSON object.
  2. Deduplicate on X-Webhook-Id; retries and multi-source detection can both resend.
  3. Respond fast, work later — persist the payload, return 200, process in a job.
  4. Subscribe to what you use. esim.profile.updated is the chattiest event (every download / enable / disable); most integrations only need esim.installed, esim.status.changed and esim.usage.threshold.
  5. 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.
  6. HTTPS only. Rotate the secret by calling PUT /webhooks again.

Support​