> ## Documentation Index
> Fetch the complete documentation index at: https://docs.esimstory.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> How issued eSIMs and refund decisions reach you, and how to verify they came from us

Once an eSIM is issued, we `POST` its activation data to your webhook URL as
`application/json`. Return any `2xx` to acknowledge it.

**One unit per webhook.** A product ordered with `qty: 2` produces two separate
deliveries, each of which may be retried. Match each to your order with
`external_order_id`, and to the unit with `topup_id`.

Receiving the webhook means the eSIM exists and can be handed to your customer.
It does not mean the plan has started — that happens when the profile first
connects to a network at the destination, which is usually days later. See
[Installed is not activated](https://docs.esimstory.com/endpoints/esim-status.md#installed-is-not-activated).

The same URL also receives [refund decisions](#refund-events), if you request
refunds through the API. Tell them apart by the `event` field that leads every
body:

| `event` | Delivery |
| - | - |
| `esim.issued` | An eSIM is ready — the [payload](#payload) below |
| `refund.approved` | A [refund request](https://docs.esimstory.com/endpoints/request-refund.md) was approved |
| `refund.rejected` | A refund request was rejected |

[Verify the signature](#verifying-the-signature), then branch on `event`. The
`X-Esim-Story-Event` header carries the same value, so you can route a delivery
before parsing it — but only the body is signed, so decide on the body field.
We may add events; ignore and acknowledge any you do not handle, with a `2xx`,
or they will be retried.

Every delivery is signed. [Verify the signature](#verifying-the-signature)
before you process the payload — without that check, anyone who learns your
webhook URL can post fabricated eSIM data to it.

> [!WARNING]
>
> **Delivery is at-least-once, so expect the same `topup_id` twice.** If your
> endpoint is down, times out, or returns a non-`2xx`, we retry up to 10 times
> with exponential backoff — 11 attempts in all, spread over roughly four and a
> half hours. A delivery you processed but acknowledged too slowly will arrive
> again. Make your handler idempotent: for `esim.issued`, `topup_id` is unique
> per unit and never reused, so it is the natural deduplication key. A refund
> event carries the same `topup_id` as the eSIM it is about, so deduplicate on
> the event and `topup_id` together.
>
> Acknowledge fast — enqueue the payload and process it asynchronously rather
> than doing work before responding. We wait 10 seconds for your response.
>
> If all attempts fail, the delivery is logged on our side and we stop. Contact
> support to recover the eSIM data. A missed refund decision can be read back
> with [Get a refund request](https://docs.esimstory.com/endpoints/get-refund.md).

## Payload

**event** `string`

Always `esim.issued` for an eSIM delivery. See the event table above.

**topup\_id** `string`

Matches a `topup_id` from the order creation response.

**option\_id** `string`

The option ID that was ordered.

**external\_order\_id** `string`

The `external_order_id` you supplied when creating the order.

**iccid** `string`

ICCID of the issued eSIM profile.

**smdp** `string`

SM-DP+ server address for the profile.

**activate\_code** `string`

Matching ID / activation code for the profile.

**download\_link** `string`

Full LPA activation string, formatted `LPA:1$<smdp>$<activate_code>`. Use for
manual / universal-link activation where scanning a QR code is not possible.

**qrcode\_img\_url** `string`

Hosted QR code image for `download_link`, provided as a convenience. Use the
URL exactly as provided.

Treat it as **opaque**: do not parse it, and do not assume the host stays the
same. Where we host these images may change without notice.
`download_link` is the durable value — the QR encodes that string and nothing
else, so you can always render your own.

In **sandbox** every order returns the same placeholder image. Identify a unit
by `topup_id`, never by this URL.

> [!TIP]
>
> **Rendering the QR code yourself — recommended.**
>
> The QR encodes `download_link` and nothing else. This is fixed by the GSMA
> eSIM specification (SGP.22): the device's LPA reads the activation string out
> of the image, so any QR generated from the same string is byte-equivalent in
> effect, on iOS, Android and anything else implementing the spec.
>
> That means you do not have to link to our image at all. Render it in your own
> stack, serve it from your own domain, and your customer sees only your brand
> from purchase through activation — which is the point of integrating at the
> API level rather than sending them to a storefront. It also removes a
> third-party host from your activation flow: one less dependency between your
> customer and a working eSIM.
>
> ```bash CLI theme={null}
> qrencode -o qr.png -s 8 -m 4 -l M "$DOWNLOAD_LINK"
> ```
>
> ```python Python theme={null}
> import qrcode
>
> img = qrcode.make(
>     download_link,
>     error_correction=qrcode.constants.ERROR_CORRECT_M,
>     box_size=8,
>     border=4,
> )
> img.save("qr.png")
> ```
>
> ```javascript Node.js theme={null}
> import QRCode from "qrcode";
>
> await QRCode.toFile("qr.png", downloadLink, {
>   errorCorrectionLevel: "M",
>   margin: 4,
>   scale: 8,
> });
> ```
>
> ```ruby Ruby theme={null}
> require "rqrcode"
>
> png = RQRCode::QRCode.new(download_link, level: :m)
>                      .as_png(border_modules: 4, module_px_size: 8)
> File.binwrite("qr.png", png.to_s)
> ```
>
> **Use these parameters.** Error correction **M**, a **4-module quiet zone**,
> and at least **8 px per module** (\~330 px for a typical activation string).
> The quiet zone is the blank margin around the code — the QR specification
> requires four modules of it, and cameras fail to lock on when it is thinner.
> A cramped or undersized QR is the most common cause of "the code will not
> scan", and it surfaces as a support ticket long after the order succeeded.
>
> Do not overlay a logo on the code area. Never re-encode a screenshot of a
> rendered QR — generate from `download_link` every time.

**expired\_date** `string`

Date (`YYYY-MM-DD`) after which the profile can no longer be installed.

## Example payload

```json theme={null}
{
  "event": "esim.issued",
  "topup_id": "20250129123456123456789012",
  "option_id": "A1B2C3D4-E5F6-7890-ABCD-EF1234567890",
  "iccid": "89852341234567890123",
  "smdp": "rsp1.cmlink.com",
  "activate_code": "A1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6",
  "download_link": "LPA:1$rsp1.cmlink.com$A1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6",
  "qrcode_img_url": "https://assets.esimstory.com/qr/p_7f3a9c2e5b1d4f8a6e0c3b9d2a7f5e1c.png",
  "expired_date": "2026-07-28",
  "external_order_id": "ORDER-123456"
}
```

## Refund events

Sent once our team decides a [refund request](https://docs.esimstory.com/endpoints/request-refund.md). The
body is the refund request — the same fields
[Request a refund](https://docs.esimstory.com/endpoints/request-refund.md#response) returns — with `event`
in front, as on every webhook.

```json theme={null}
{
  "event": "refund.approved",
  "topup_id": "20250129123456123456789012",
  "external_order_id": "ORDER-123456",
  "status": "approved",
  "reason": "activation_failed",
  "note": "iPhone 13, installed before departure. No service in Tokyo with roaming on; restarted twice.",
  "rejection_reason": null,
  "requested_at": "2026-10-28T03:12:45Z",
  "decided_at": "2026-10-29T01:40:12Z"
}
```

Each request is decided once, so expect one refund event per `topup_id` —
twice only if a delivery is retried. Signing, retries and the 10-second timeout
are the same as for `esim.issued`.

You receive refund events only for requests you open through the API. A refund
arranged with support by email is confirmed by email.

## Verifying the signature

Every webhook carries two signature headers, alongside `X-Esim-Story-Event`:

| Header | Value |
| - | - |
| `X-Esim-Story-Webhook-Timestamp` | Unix seconds at the time we signed the delivery. |
| `X-Esim-Story-Webhook-Signature` | Comma-separated list of hex HMAC-SHA256 signatures. Accept the delivery if **any** entry matches. |

The signed string is the timestamp, a full stop, and the raw request body:

```
{timestamp}.{raw_body}
```

Compute `HMAC-SHA256` over that string using your **webhook signing secret**, hex-encode it, and compare against the header.

> [!WARNING]
>
> The webhook signing secret is not your secret key. The secret key signs the
> requests you send us; the webhook signing secret verifies the webhooks we send
> you. Either can be replaced without affecting the other.

Sandbox and production have separate signing secrets, just as they have
separate API keys. Your production secret is shown in your partner dashboard;
your sandbox secret is issued with the rest of your sandbox credentials. The
signing scheme is identical in both, so verification code written against
sandbox works unchanged in production.

Four details decide whether your implementation works:

**1. Use the raw body, not a re-serialised object.** Sign the bytes exactly as
received. Parsing the JSON and serialising it again reorders keys and changes
whitespace, so the signature will never match. Capture the raw body before any
body-parsing middleware runs.

**2. Use the secret verbatim.** The signing secret is a plain string. It is not
Base64 and must not be decoded first — unlike the secret key you sign requests
with.

**3. Compare in constant time.** A plain `==` returns as soon as two characters
differ, and that timing difference leaks the expected signature one character at
a time. Use your language's constant-time comparison.

**4. Reject stale timestamps.** The timestamp is inside the signed string, so a
captured delivery cannot be replayed under a new one. Rejecting anything older
than five minutes closes the remaining window.

```javascript Node.js theme={null}
const crypto = require("crypto");

// express.raw() keeps the body as a Buffer — express.json() would not.
app.post("/webhooks/esim", express.raw({ type: "application/json" }), (req, res) => {
  const rawBody = req.body.toString("utf8");
  const timestamp = req.get("X-Esim-Story-Webhook-Timestamp");
  const header = req.get("X-Esim-Story-Webhook-Signature") || "";

  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    return res.sendStatus(401);
  }

  const expected = crypto
    .createHmac("sha256", process.env.ESIM_STORY_WEBHOOK_SECRET)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  const matched = header.split(",").some((candidate) => {
    const received = Buffer.from(candidate.trim(), "utf8");
    const computed = Buffer.from(expected, "utf8");

    return (
      received.length === computed.length &&
      crypto.timingSafeEqual(received, computed)
    );
  });

  if (!matched) return res.sendStatus(401);

  // Acknowledge first, process asynchronously. Deliveries are retried, so
  // enqueue() should ignore a topup_id it has already handled.
  res.sendStatus(200);
  enqueue(JSON.parse(rawBody));
});
```

```python Python theme={null}
import hashlib
import hmac
import os
import time

from flask import Flask, request

app = Flask(__name__)


@app.post("/webhooks/esim")
def esim_webhook():
    raw_body = request.get_data(as_text=True)
    timestamp = request.headers.get("X-Esim-Story-Webhook-Timestamp", "")
    header = request.headers.get("X-Esim-Story-Webhook-Signature", "")

    if abs(time.time() - int(timestamp)) > 300:
        return "", 401

    expected = hmac.new(
        os.environ["ESIM_STORY_WEBHOOK_SECRET"].encode(),
        f"{timestamp}.{raw_body}".encode(),
        hashlib.sha256,
    ).hexdigest()

    if not any(
        hmac.compare_digest(expected, candidate.strip())
        for candidate in header.split(",")
    ):
        return "", 401

    # Deliveries are retried, so enqueue() should ignore a topup_id it has
    # already handled.
    enqueue(request.get_json())

    return "", 200
```

```ruby Ruby theme={null}
require "openssl"
require "rack/utils"

post "/webhooks/esim" do
  raw_body = request.body.read
  timestamp = request.env["HTTP_X_ESIM_STORY_WEBHOOK_TIMESTAMP"].to_s
  header = request.env["HTTP_X_ESIM_STORY_WEBHOOK_SIGNATURE"].to_s

  halt 401 if (Time.now.to_i - timestamp.to_i).abs > 300

  expected = OpenSSL::HMAC.hexdigest(
    "SHA256",
    ENV.fetch("ESIM_STORY_WEBHOOK_SECRET"),
    "#{timestamp}.#{raw_body}"
  )

  matched = header.split(",").any? do |candidate|
    Rack::Utils.secure_compare(expected, candidate.strip)
  end

  halt 401 unless matched

  # Deliveries are retried, so enqueue() should ignore a topup_id it has
  # already handled.
  enqueue(JSON.parse(raw_body))

  status 200
end
```

#### Test vector

Check your implementation against these fixed values before going live.

```
secret    : whsec_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
timestamp : 1754400000
body      : {"topup_id":"202409092148515858437840","iccid":"8944478000000123456","external_order_id":"1234567890"}

signed string : 1754400000.{"topup_id":"202409092148515858437840","iccid":"8944478000000123456","external_order_id":"1234567890"}
signature     : 6e3bf52df05c6ad552d6ba4cca004db3254f12293ecfcc6a7fa70357b26fdabe
```

> [!NOTE]
>
> The signature header is a list because it may one day carry more than one
> entry — during a secret changeover we would sign with both the outgoing and
> the incoming secret so neither side has downtime. Code that accepts a match
> against any entry today needs no change when that happens. Today exactly one
> signature is sent.

## In sandbox

Sandbox delivers on the same clock production does: the `201` comes back first,
and the webhook follows about **five seconds later** — one per unit, a second
apart. Between the two, the order exists with its `topup_id` and no activation
data yet.

That gap is deliberate. A handler written against an instant webhook assumes the
order response is already stored when the delivery lands, and that assumption
breaks the first time it meets a real provider.

| `external_order_id` starts with | Webhook arrives |
| - | - |
| anything else | \~5 seconds after the `201` |
| `SANDBOX-SLOW` | \~3 minutes after the `201` |

Use `SANDBOX-SLOW` to see what your own code does while an order is still
pending. It is a prefix match, so `SANDBOX-SLOW-001` and so on work too.

In production the wait is not fixed — usually seconds, occasionally minutes.
Treat "issued" as something the webhook tells you, never something to assume
from the time elapsed.

Refund decisions are made within seconds in sandbox — see [Request a refund in
sandbox](https://docs.esimstory.com/endpoints/request-refund.md#in-sandbox) for how to get a rejection.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.