# eSIM Plus Partner API This document is self-contained: it includes the base URL, the auth scheme, request/response examples, and the webhook signature algorithm — everything needed to integrate without another source. **Base URL:** `https://api.esimplus.net/api/partner/v1` **Auth:** Bearer — Bearer authentication with your partner API integration token. Send it as: Authorization: Bearer {api_token}. ## Catalog ### GET /phone-numbers/countries **List countries that have available virtual numbers** **Auth:** Bearer {api_token} #### Responses #### 200 Countries #### 401 Domain error (dispatch on `code`, not HTTP status) ### GET /phone-numbers/countries/{iso_name2}/catalog **List available numbers in a country** **Auth:** Bearer {api_token} #### Parameters | Name | In | Type | Required | Description | Example | | --- | --- | --- | --- | --- | --- | | iso_name2 | path | string | yes | ISO 3166-1 alpha-2 country code | | | type | query | string | no | | | | features | query | array | no | Capability filter. Case-sensitive; only "SMS" is accepted. Response items still emit lowercase feature values. | | | region | query | string | no | Region/state code (e.g. US state "DC"). See US_STATES on the FE. | | | reserved | query | boolean | no | | | | page | query | integer | no | | | | perPage | query | integer | no | A value above the maximum is **clamped to 50**, not rejected — asking for 100 returns a 200 with a 50-row page, so never read the request back as the page size; read `pagination.perPage`. | | #### Responses #### 200 Catalog page #### 403 Country is not available for this partner (code 1305) #### 422 One or more country ISO codes are invalid (code 1304, errors.isoCodes) ### POST /phone-numbers/quote **Price a prospective order without placing it** Returns what an order of these lines would cost at the partner's CURRENT standing — the undiscounted total, the discounted total, and the rate that produced it. **Nothing is purchased, reserved, frozen or provisioned**, no `Idempotency-Key` is required, and calling it has no effect a client can observe other than the answer. It never answers 402: affordability stays the accept path's question, asked against the balance at acceptance, so submission must not be gated on this figure. The discount depends on `activeNumbers + Σ quantity`, so the rate for a 10-number order is not the rate shown on a single catalog row, and it cannot be derived from one — a client-side derivation disagrees with the server exactly at the tier boundaries, and disagrees silently. One rate is resolved for the whole order: the same resolution acceptance freezes, through the same pricing code. **A quote is indicative, not binding**, for three reasons worth knowing: the order is re-priced at acceptance and the fleet can move in between (including downward, if numbers are released); a line's `pricedQuantity` can fall short of its `quantity` when supply is thin; and the upstream catalog shuffles its results, so the same draft quoted twice can price a different selection of available numbers, varying by the spread of catalog prices for that country/type. Accepts the same line shape and the same caps as `POST /phone-numbers/bulk` (1..100 lines, 1..100 per line, `Σ quantity` <= 500), so a draft that quotes cleanly is a draft that will not be rejected for its shape. A single catalog row is just `quantity: 1`. Throttled **30/min per partner** — debounce the draft rather than quoting on every keystroke. Note for implementers: the literal `quote` segment must route ahead of `/phone-numbers/{sid}`, exactly as `/phone-numbers/bulk` already does. **Auth:** Bearer {api_token} #### Request body Schema: ```json { "type": "object", "description": "The same line shape and the same caps as `BulkPurchaseRequest`, so one draft can feed both. Deliberately identical rather than merely similar: a draft that quotes must be a draft that submits.", "required": [ "items" ], "properties": { "items": { "type": "array", "items": { "type": "object", "description": "One request line. `country` and `type` are case-insensitive — the server upper-cases the country and lower-cases the type. Duplicate identical lines are legal and are allocated independently.", "required": [ "country", "type", "quantity" ], "properties": { "country": { "type": "string", "description": "ISO 3166-1 alpha-2. Case-insensitive; upper-cased server-side." }, "type": { "type": "string", "enum": [ "local", "mobile" ] }, "quantity": { "type": "integer", "description": "1..100 for this line. Independent of the order total, which must still be <= 500 across all lines." }, "features": { "type": "array", "description": "Optional. Only \"SMS\" (upper-case) is accepted here.", "items": { "type": "string", "enum": [ "SMS" ] } }, "region": { "type": "string", "description": "Optional. Sent to the provider with your casing. NOT echoed back on a slot." } } } } } } ``` #### Responses #### 200 The estimate. A 200 is NOT an affordability guarantee and NOT a promise of availability — it says nothing about whether the catalog can source the numbers. #### 403 A country in the request is not available for this partner (code 1305). NOTE this check is **stricter than the accept path** — `POST /phone-numbers/bulk` does not make it, so a draft can quote 403 and still submit. #### 422 Validation failure — bad `items`, `quantity` outside 1..100, an unknown `type`, a 3-letter `country`, or either cap exceeded. Envelope B: no `code`. #### 429 More than 30 quotes in a minute for this partner. Envelope B. Back off — nothing is wrong with the draft. ## Numbers ### GET /phone-numbers **List the partner's purchased numbers** **Auth:** Bearer {api_token} #### Parameters | Name | In | Type | Required | Description | Example | | --- | --- | --- | --- | --- | --- | | country | query | string | no | Free-text country search (case-insensitive substring against the country name; a 2-letter ISO code also matches). Sent verbatim from the `/numbers` filter input — do NOT constrain to an ISO code. Requires at least 1 character (422, code 1306, if empty). | | | type | query | string | no | | | | msisdn | query | string | no | | | | sort | query | string | no | Field to sort by — `createdAt` (purchase date), `msisdn` (number), or `paidUntil` (paid-until date). For `paidUntil`, numbers with no active billing (null `paidUntil`) sort as the earliest/empty value: they trail on `desc` and lead on `asc`. | | | order | query | string | no | Sort direction; applies when `sort` is set. Defaults to `desc`. | | | tags | query | array | no | Tag ids to filter by (repeatable). Match-ANY: a number is returned if it carries at least one of the given tags. | | | page | query | integer | no | | | | perPage | query | integer | no | A value above the maximum is **clamped to 50**, not rejected — asking for 100 returns a 200 with a 50-row page, so never read the request back as the page size; read `pagination.perPage`. | | #### Responses #### 200 Purchased numbers page #### 401 Domain error (dispatch on `code`, not HTTP status) ### POST /phone-numbers **Buy a virtual number** Buys one number synchronously and returns it. **NOT idempotent** — a retried request can buy a second number, so never retry it automatically. Throttled 10/min. The wallet is debited only when wallet billing is enabled for your account. To buy many numbers, use `POST /phone-numbers/bulk` instead: this endpoint's rate limit makes one-at-a-time bulk buying impractical. **Auth:** Bearer {api_token} #### Request body Schema: ```json { "type": "object", "required": [ "msisdn" ], "properties": { "msisdn": { "type": "string" } } } ``` #### Responses #### 201 Purchased #### 402 Insufficient wallet balance — only when wallet_enforced (code 1315) #### 403 Country is not available for this partner (code 1305) #### 404 Phone number is not available for purchase (code 1307) #### 409 Phone number has already been purchased (code 1308) #### 422 Unable to determine country from msisdn (code 1306), or Telnyx regulated phone numbers are not supported for partners (code 1310) #### 429 Envelope C — the rate limiter's own body, identical in shape to envelope B. **No `Retry-After` and no `X-RateLimit-*` header**: the exception handler rebuilds the body and drops the exception's headers, so a countdown cannot be rendered from the response. Cooldowns are a client-side convention. #### 502 Phone number purchase failed, funds not charged (code 1309) ### GET /phone-numbers/bulk **List your bulk orders** Your own bulk orders, newest first, scoped to the authenticated partner — there is no parameter that widens that. Ordered by creation time descending, with an id tiebreaker so paging stays stable for orders accepted in the same second. Each row is the whole-order summary **without** `items[]`: a page of 30 orders of 500 slots would be 15 000 rows nobody asked for. Per-slot detail lives on `GET /phone-numbers/bulk/{orderId}`. An empty result is a `200` with `data: []`, never a `404` — a partner with no orders and a `status` filter matching nothing look the same. **Auth:** Bearer {api_token} #### Parameters | Name | In | Type | Required | Description | Example | | --- | --- | --- | --- | --- | --- | | page | query | integer | no | | | | perPage | query | integer | no | A value above the maximum is **clamped to 50**, not rejected — asking for 100 returns a 200 with a 50-row page, so never read the request back as the page size; read `pagination.perPage`. | | | status | query | string | no | Filter by order status. Omitted → every order, whatever its state. | | #### Responses #### 200 A page of bulk orders (summaries, no slots) #### 422 Invalid pagination or an unknown `status`. Envelope B: no `code`. ### POST /phone-numbers/bulk **Order virtual numbers in bulk (asynchronous)** Queues an asynchronous bulk order and returns immediately with an `orderId` — **nothing is provisioned inside this request**. Poll `GET /phone-numbers/bulk/{orderId}` until the order reaches a terminal status. The `numbers.bulk_completed` webhook may push you to refresh sooner, but it is an accelerator, not the channel: a partner with no active endpoint receives nothing, and that is not an error. Best-effort **per slot** — an order does not succeed or fail as a unit, and partial success is the normal outcome rather than an error state. Two independent caps are enforced: at most **100 request lines**, at most **100 per line**, and the order total `Σ items[].quantity` must be **<= 500**. So five lines of 100 is valid, and a single line of 101 is rejected even though 101 < 500. Practical ceiling: the upstream catalog can source at most ~100 distinct numbers per country/type per run, and often fewer in a small country, so one line will not deliver more than that however much is requested. Reaching 500 delivered numbers needs five or more different country/type lines. Processing runs on one sequential shared worker, so a large order can take tens of minutes — never block a screen on completion. Throttled 5/min per partner, on top of the general 60/min limiter shared with every other call. **Auth:** Bearer {api_token} #### Parameters | Name | In | Type | Required | Description | Example | | --- | --- | --- | --- | --- | --- | | Idempotency-Key | header | string | yes | One key per user intent, 1..128 characters. Replaying the same key with the same body returns the same `orderId` and queues nothing new; replaying it with a DIFFERENT body is a 422 — generate a new key when the order changes. Bodies that differ only in JSON key order, `country`/`region` case, `features` order, or `"5"` vs `5` are the same request and will replay. | | #### Request body Schema: ```json { "type": "object", "required": [ "items" ], "properties": { "items": { "type": "array", "description": "1..100 request lines. `Σ items[].quantity` must be <= 500 — a separate, independent cap from the per-line maximum of 100. So five lines of 100 is valid, and one line of 101 is rejected even though 101 < 500.", "items": { "type": "object", "description": "One request line. `country` and `type` are case-insensitive — the server upper-cases the country and lower-cases the type. Duplicate identical lines are legal and are allocated independently.", "required": [ "country", "type", "quantity" ], "properties": { "country": { "type": "string", "description": "ISO 3166-1 alpha-2. Case-insensitive; upper-cased server-side." }, "type": { "type": "string", "enum": [ "local", "mobile" ] }, "quantity": { "type": "integer", "description": "1..100 for this line. Independent of the order total, which must still be <= 500 across all lines." }, "features": { "type": "array", "description": "Optional. Only \"SMS\" (upper-case) is accepted here.", "items": { "type": "string", "enum": [ "SMS" ] } }, "region": { "type": "string", "description": "Optional. Sent to the provider with your casing. NOT echoed back on a slot." } } } } } } ``` #### Responses #### 202 Accepted and queued. NOT a purchase confirmation and NOT an affordability guarantee — for a wallet_enforced partner the acceptance check is a pre-check over visible catalog prices that reserves nothing, and each number is charged as it is issued. #### 402 Insufficient wallet balance for the order estimate — only when wallet_enforced (code 1315). No order is created. #### 422 Validation failure — bad `items`, `quantity <= 0`, an unknown `type`, a 3-letter `country`, `features` other than "SMS", a MISSING `Idempotency-Key`, either cap exceeded, or a key replayed with a different body. Envelope B: no `code` and no per-field error map, so dispatch on `message`. #### 429 More than 5 accepted bulk requests in a minute, or the general 60/min partner limiter. Back off and retry — no order is marked failed. #### 500 Upstream catalog unavailable during the estimate (wallet_enforced only). No order is created, so the same `Idempotency-Key` can be retried cleanly. ### GET /phone-numbers/bulk/{orderId} **Get a bulk order's status and per-slot results** Poll this until `status` is terminal (`completed`, `completed_with_errors` or `failed`) — equivalently until `finishedAt` is non-null. Start about 2s after the 202, then back off (2s -> 5s -> 10s, capped at 15s), and pause while the tab is hidden. The general limiter is 60 req/min per partner, shared with every other call; on a 429, back off and retry rather than treating it as an order failure. The counters are **whole-order**; `items[]` is **one page**. Never sum `items[]` to build the totals. Slot rows can change from `pending` to `success`/`failed` between polls, so re-fetch the current page each time. **Auth:** Bearer {api_token} #### Parameters | Name | In | Type | Required | Description | Example | | --- | --- | --- | --- | --- | --- | | orderId | path | string | yes | ULID identifying a bulk purchase order. | | | page | query | integer | no | | | | perPage | query | integer | no | A value above the maximum is **clamped to 50**, not rejected — asking for 100 returns a 200 with a 50-row page, so never read the request back as the page size; read `pagination.perPage`. | | #### Responses #### 200 Order status with one page of slots #### 404 Bulk purchase order not found (code 1329) — returned identically for an unknown `orderId` and for an order belonging to another partner, so the response is not an existence oracle. #### 422 Invalid pagination — `perPage` above 50, `page` below 1, or a non-integer. Envelope B: no `code`. ### GET /phone-numbers/{sid} **Get a purchased number** **Auth:** Bearer {api_token} #### Parameters | Name | In | Type | Required | Description | Example | | --- | --- | --- | --- | --- | --- | | sid | path | string | yes | ULID identifying an owned phone number. | | #### Responses #### 200 Number #### 404 Phone number not found (code 1311) ### DELETE /phone-numbers/{sid} **Release a purchased number** **Auth:** Bearer {api_token} #### Parameters | Name | In | Type | Required | Description | Example | | --- | --- | --- | --- | --- | --- | | sid | path | string | yes | ULID identifying an owned phone number. | | #### Responses #### 204 Released (enters grace period) #### 404 Phone number not found (code 1311) #### 409 Phone number cannot be released in its current status (code 1312) ### GET /tags **List the partner's number tags** **Auth:** Bearer {api_token} #### Responses #### 200 Tags #### 401 Domain error (dispatch on `code`, not HTTP status) ### POST /tags **Create a custom tag** **Auth:** Bearer {api_token} #### Request body Schema: ```json { "type": "object", "required": [ "name", "color" ], "properties": { "name": { "type": "string" }, "color": { "type": "string", "description": "Preset palette key; maps to chip classes on the FE (no raw hex).", "enum": [ "blue", "green", "amber", "red", "violet", "teal", "pink", "slate" ] } } } ``` #### Responses #### 201 Created #### 422 Invalid tag data (code 1326) ### DELETE /tags/{id} **Delete a custom tag** Permanently deletes the tag and detaches it from every number it is attached to (server-side cascade — the id is removed from each `OwnedNumber.tagIds`). Idempotent: deleting a tag that does not exist (or was already deleted) also returns 204. **Auth:** Bearer {api_token} #### Parameters | Name | In | Type | Required | Description | Example | | --- | --- | --- | --- | --- | --- | | id | path | integer | yes | | | #### Responses #### 204 Deleted (cascade-detached from all numbers) #### 401 Domain error (dispatch on `code`, not HTTP status) ### POST /phone-numbers/tags **Bulk add/remove tags across numbers** Applies tag changes to several numbers at once. Idempotent: adding a tag a number already has (or removing one it lacks) is a no-op. **Auth:** Bearer {api_token} #### Request body Schema: ```json { "type": "object", "required": [ "sids" ], "properties": { "sids": { "type": "array", "items": { "type": "string" } }, "add": { "type": "array", "items": { "type": "integer" } }, "remove": { "type": "array", "items": { "type": "integer" } } } } ``` #### Responses #### 200 Applied #### 422 Invalid tag data (code 1326) ## SMS ### GET /sms **List the partner's SMS messages** Returns the SMS messages on the numbers **owned by the authenticated partner**, newest first, filtered and paginated server-side. Scoping is enforced by the backend from the session: a partner can never receive an SMS for a number it does not own, and there is no parameter to widen that scope. Lists **both directions** — outbound messages carry `direction: sent` plus the optional lifecycle and price fields of `Sms`, and interleave with inbound ones in one chronological list. All filters combine with AND; all are optional (no filters → the full history, paginated). **Auth:** Bearer {api_token} #### Parameters | Name | In | Type | Required | Description | Example | | --- | --- | --- | --- | --- | --- | | sender | query | string | no | Filter by sender number — **case-insensitive substring** match against the stored E.164 number, so a partner can search by the last digits they remember. The client normalizes the input (strips spaces, parentheses and dashes, keeps an optional leading `+` and digits) before sending; the backend should match against the normalized stored value. Requires at least 1 character (empty → omit the param). | | | recipient | query | string | no | Filter by recipient number — your own number the SMS was received on. **Case-insensitive substring** match, normalized exactly like `sender`. Scoping still applies: only your own numbers are ever searched, so a value the partner does not own yields an empty page, never another partner's data. `minLength 1`, `maxLength 20`. | | | from | query | string | no | Inclusive lower bound on `receivedAt`, as a **UTC calendar day** (`YYYY-MM-DD`). Interpreted as `receivedAt >= {from}T00:00:00Z`. A bare date (no time) is always interpreted at the start of that UTC day. Must be <= `to` when both are given (else 422). | | | to | query | string | no | Inclusive upper bound on `receivedAt`, as a **UTC calendar day** (`YYYY-MM-DD`). Interpreted as `receivedAt <= {to}T23:59:59.999Z`, so the whole day is included. Must be >= `from` when both are given. | | | direction | query | string | no | Filter by message direction. Omitted → all directions. | | | sort | query | string | no | Field to sort by. Only `receivedAt` is supported. | | | order | query | string | no | Sort direction. Defaults to `desc` (newest first). | | | page | query | integer | no | | | | perPage | query | integer | no | A value above the maximum is **clamped to 50**, not rejected — asking for 100 returns a 200 with a 50-row page, so never read the request back as the page size; read `pagination.perPage`. | | #### Responses #### 200 A page of SMS messages (scoped to the partner's numbers) #### 401 Domain error (dispatch on `code`, not HTTP status) #### 422 Invalid query parameters — a malformed date, `perPage` out of range, or `from` later than `to`. Envelope B: **no** domain `code` and no per-field map, so dispatch on `message`. ### POST /sms **Send an SMS from one of your numbers** Sends an SMS from one of your own numbers. Scoping is derived from your token: `from` must be a number your account owns. A number you do not own answers exactly as an unknown one does. **Answers `202`, not `201`** — the message is accepted and queued, and has not yet reached a carrier. The hand-off to the provider is asynchronous, so a provider problem appears later as `status: failed` on the message rather than as an error on this call. Poll `GET /sms` or subscribe to the status callback to follow it to `delivered` or `failed`. **Two independent ceilings** apply to `text`, and the second is not implied by the first: at most **1530 characters** *and* at most **10 segments**. For a GSM-7 message the two coincide, but 1530 Cyrillic characters is roughly 23 segments — well-formed by `maxLength` and refused all the same. Both are checked before anything is written, charged or queued, and both answer `422` (see that response). **Auth:** Bearer {api_token} #### Parameters | Name | In | Type | Required | Description | Example | | --- | --- | --- | --- | --- | --- | | Idempotency-Key | header | string | yes | Client-generated key that makes a retry safe. **Required** — a network retry without one would send, and charge for, a second message. Same key with the same body replays the original message; the same key with a different body is refused with `409`. The replay is evaluated **before** your balance, so a retry after a timeout returns the original message even if your balance has since run out — it does not re-litigate a decision that has changed in the meantime. Keys are scoped to your account. | | #### Request body Schema: ```json { "type": "object", "required": [ "from", "to", "text" ], "properties": { "from": { "type": "string", "description": "The sending number — must be a number your account owns and which is capable of sending. An msisdn rather than a `sid`: every message already speaks msisdn, and you know your numbers by phone number.\n\n**The leading `+` is optional on input.** Both `12025550101` and `+12025550101` are accepted and mean the same line; the value is canonicalized before it is stored. Note that `GET /phone-numbers` returns `msisdn` **without** the `+`, so a value taken straight from there can be sent unchanged." }, "to": { "type": "string", "description": "Recipient number. As with `from`, the leading `+` is optional on input and the value is canonicalized before it is stored." }, "text": { "type": "string", "description": "Message body. **May not be empty**, unlike an inbound message's `text`.\n\nTwo ceilings apply and the second is not implied by the first: 1530 characters *and* **10 segments**. 1530 is ten GSM-7 segments, so for Latin text they coincide — but 1530 Cyrillic characters is roughly 23 segments, and is refused with `422` despite satisfying `maxLength`. Count segments before sending." } } } ``` #### Responses #### 200 Idempotent replay — this `Idempotency-Key` was already used with an identical body. The original message is returned unchanged, and nothing was sent or charged twice. #### 202 Accepted and queued. The message is immediately visible through `GET /sms` with `direction: sent` and `status: queued`. #### 401 Domain error (dispatch on `code`, not HTTP status) #### 402 Your balance cannot cover the message (code `1315`). Affordability is checked against `availableBalance`, never `balance`. #### 404 `from` is not a number your account owns (code `1311`). Returned **identically** for an unknown number and for one belonging to someone else. #### 409 This `Idempotency-Key` was already used with a **different** body (code `1356`). Nothing was sent. Use a new key for a new message. #### 422 A refusal. Dispatch on the domain `code`, never on the `422` alone: - `1357` — the request is malformed: a missing or over-long `Idempotency-Key`, a number that is not E.164, an empty `text`, or `text` past either ceiling (1530 characters, 10 segments). For this code, and only this one, `message` is the **specific field error** rather than a fixed sentence; - `1354` — `from` is one of your numbers and is active, but cannot originate messages, because its provider does not support outbound. Send from another number; - `1352` — the message was blocked by content or sending-pattern checks. An appealable judgement; - `1353` — the recipient has opted out of messages from this number; - `1355` — your account is not enabled for this destination. `message` is a finished, partner-facing sentence for every one of these and is safe to show verbatim. `errors` is always `[]` on this endpoint — it is **not** a per-field map, so do not build field-level UI from it. #### 429 Throttled. **No** domain code and **no `Retry-After` header**, so back off on your side. Applies API-wide, not only here. ### POST /sms/quote **What an outbound SMS would cost** Prices a message without sending it — segment count, encoding and total, at your account's current rates. A POST because the body carries the message text, but it **reads without writing**: nothing is stored, held or queued, it is safe to repeat, and it takes **no `Idempotency-Key`**. The answer is an **estimate**. Pricing is recalculated when the message is accepted, so present it as an estimate and do not block a send on it. It applies the same `from` checks the send path does, so a number you cannot send from cannot be quoted either. It has **its own rate limit**, separate from sending: re-pricing a draft as the user types cannot lock you out of sending it. **Auth:** Bearer {api_token} #### Request body Schema: ```json { "type": "object", "required": [ "from", "to", "text" ], "properties": { "from": { "type": "string", "description": "The sending number. As on `POST /sms`, the leading `+` is optional." }, "to": { "type": "string", "description": "Recipient number. As on `POST /sms`, the leading `+` is optional." }, "text": { "type": "string" } } } ``` #### Responses #### 200 The estimated cost #### 401 Domain error (dispatch on `code`, not HTTP status) #### 404 `from` is not a number your account owns (code `1311`). #### 422 Either a malformed body (code `1357`: empty `text`, a non-E.164 number, or `text` past either ceiling) or a `from` that cannot originate messages (code `1354`). As on the send endpoint, `1357` carries the specific field error in its `message`. #### 429 Throttled — no domain code and no `Retry-After` header. This endpoint has its own bucket, separate from `POST /sms`. ## eSIM ### GET /esims/destinations **List eSIM destinations (coverage bundles) available for purchase** A Top Connect eSIM is a **full** SIM profile — a real phone number that can receive calls and SMS, plus mobile data — and a profile can carry a **second** number in a second country. A flat country list therefore cannot express what is on sale: "Estonia + United Kingdom" is one product, not two countries. This endpoint lists those bundles. A destination whose `countryCodes` has one entry is a single-number profile; two entries is a dual-number profile, and the order is significant — the first is the primary number's country (`Esim.countryCode`), the second is the extra number's (`Esim.extraCountryCode`). **Auth:** Bearer {api_token} #### Responses #### 200 Destinations #### 401 Domain error (dispatch on `code`, not HTTP status) ### GET /esims/destinations/{code}/catalog **List eSIM profiles available for purchase in a destination** One page of purchasable profiles, **cheapest first** with a stable `offerId` tiebreaker. The order is the server's guarantee, not a client choice: there are no filter or sort parameters here, because a destination holds a couple of dozen near-identical profiles and the only question a partner actually asks of them is "which is cheapest". `offerId` is what `POST /esims` buys. It identifies a *reservation-free* offer: nothing is held for you, so an offer listed here can be sold to somebody else before you buy it (code 1342). As built, an `offerId` is one specific free profile drawn from a pool shared with the consumer app, which is what makes 1342 a genuine race rather than a theoretical one, and what makes `availableCount` an honest count. **`page` and `perPage` are the only parameters.** Anything else is ignored rather than rejected, so a client cannot tell a typo'd filter from an unfiltered page — do not send one. A **known** destination with no free profiles answers an **empty page**, not a 404. 1341 means the slug itself is unknown. The two are different facts and the UI says different things about them: an empty page is "sold out right now, try later", a 1341 is "this link is wrong". **Auth:** Bearer {api_token} #### Parameters | Name | In | Type | Required | Description | Example | | --- | --- | --- | --- | --- | --- | | code | path | string | yes | Destination slug from `GET /esims/destinations` (e.g. `EE-GB`). Opaque — do not construct one from country codes. | | | page | query | integer | no | | | | perPage | query | integer | no | A value above the maximum is **clamped to 50**, not rejected — asking for 100 returns a 200 with a 50-row page, so never read the request back as the page size; read `pagination.perPage`. | | #### Responses #### 200 Catalog page #### 401 Domain error (dispatch on `code`, not HTTP status) #### 404 Unknown destination slug (code 1341). A known destination that is simply sold out returns 200 with an empty `data`. ### GET /esims **List the eSIMs you own** **Auth:** Bearer {api_token} #### Parameters | Name | In | Type | Required | Description | Example | | --- | --- | --- | --- | --- | --- | | country | query | array | no | ISO alpha-2 codes to filter by (repeatable), match-ANY, matched against the primary OR the extra country. A code list rather than free text: eSIMs exist in a handful of countries, so the control is a multi-select and a multi-select cannot express "starts with EE". | | | phoneNumber | query | string | no | Substring match against the **primary** number only. It stopped matching the extra number when `extraPhoneNumber` became its own filter: with two inputs on screen, one that silently searched both columns would make the pair unusable together. | | | extraPhoneNumber | query | string | no | Substring match against the extra number only. | | | status | query | string | no | | | | tags | query | array | no | Tag ids to filter by (repeatable). Match-ANY — the same semantics the phone-number list uses, and the same `/tags` plane. | | | sort | query | string | no | `icc`, `phoneNumber`, `extraPhoneNumber`, `createdAt` (purchase date), or `balance` (money left on the SIM) — one sortable field per sortable column on the inventory table, exactly as `GET /phone-numbers` has. `extraPhoneNumber` is nullable, and rows without one sort **last** in both directions: a null is an absent number, not a number smaller than every other, and floating them to the top of an ascending sort buries what was asked for. | | | order | query | string | no | | | | page | query | integer | no | | | | perPage | query | integer | no | A value above the maximum is **clamped to 50**, not rejected — asking for 100 returns a 200 with a 50-row page, so never read the request back as the page size; read `pagination.perPage`. | | #### Responses #### 200 Owned eSIMs page #### 401 Domain error (dispatch on `code`, not HTTP status) ### POST /esims **Buy one eSIM profile** Buys one profile synchronously and returns it. **NOT idempotent** — a retried request can buy a second profile, so never retry it automatically, and an `Idempotency-Key` header is **ignored** here rather than honoured: sending one buys a second profile and looks like it did not. Throttled 10/min. To buy many, use `POST /esims/bulk` instead: this endpoint's rate limit makes one-at-a-time bulk buying impractical. Deliberately shaped like `POST /phone-numbers`, down to the throttle and the non-idempotency: the two catalogs are the same job on two products, and a partner integrating both should not have to learn two purchase models. **Two figures leave the wallet, and they are one charge.** The wallet is debited `offer.price + package.price`; the SIM is delivered carrying `package.price` on its own balance. The profile price is the product; the package is calling credit. They are billed together and reported together, and neither is ever netted against the other later. The package code is resolved **against the offer**, so `pp-3` is $23.00 on a single-number profile and $25.00 on a dual-number one — the same reason a client renders `price` and sends `code`. The returned profile's `status` is **`pending`**: claiming a profile and provisioning it upstream are not one transaction, there is no `esim.activated` webhook in this release, and `activation` is `null` until it lands. Re-read `GET /esims/{icc}` to watch it become `active`. **Auth:** Bearer {api_token} #### Request body Schema: ```json { "type": "object", "required": [ "offerId", "prepaidPackage" ], "properties": { "offerId": { "type": "string", "description": "An `offerId` from the destination catalog." }, "prepaidPackage": { "type": "string", "description": "A `code` from that offer's `prepaidPackages`. **Required** — there is no \"no package\" purchase, because a profile with a zero balance is a profile that cannot call. A code outside the offer's own ladder is a `FormRequest` 422, not a domain error: it is a malformed request, not a state the partner can reach from the interface." } } } ``` #### Responses #### 201 Purchased #### 402 Insufficient wallet balance — only when wallet billing is enforced (code 1315) #### 403 Email address not confirmed (code 1332) #### 404 The offer is no longer available — bought by someone else, or the destination has been withdrawn (code 1342) #### 429 Envelope C — the rate limiter's own body, identical in shape to envelope B. **No `Retry-After` and no `X-RateLimit-*` header**: the exception handler rebuilds the body and drops the exception's headers, so a countdown cannot be rendered from the response. Cooldowns are a client-side convention. #### 502 The provider could not issue the profile. **Funds were not charged** (code 1345). ### POST /esims/tags **Bulk add/remove tags across eSIMs** Applies tag changes to several eSIMs at once. Idempotent: adding a tag an eSIM already has (or removing one it lacks) is a no-op. The twin of `POST /phone-numbers/tags`, over the same `/tags` plane and with the same request shape — only the identifier list differs (`iccs` rather than `sids`). Tags are one plane across products: the same tag id may sit on a number and on an eSIM, and deleting it detaches it from both. **Auth:** Bearer {api_token} #### Request body Schema: ```json { "type": "object", "required": [ "iccs" ], "properties": { "iccs": { "type": "array", "description": "ICCIDs of the eSIMs to change.", "items": { "type": "string" } }, "add": { "type": "array", "items": { "type": "integer" } }, "remove": { "type": "array", "items": { "type": "integer" } } } } ``` #### Responses #### 200 Applied #### 422 Invalid tag data (code 1326) ### GET /esims/bulk **List your bulk eSIM orders** Your own bulk eSIM orders, newest first, scoped to the authenticated partner — there is no parameter that widens that. Ordered by creation time descending, with an id tiebreaker so paging stays stable for orders accepted in the same second. Each row is the whole-order summary **without** `items[]`, for the same reason the phone-number list omits them: a page of orders times their slots is thousands of rows nobody asked for. Per-slot detail lives on `GET /esims/bulk/{orderId}`. An empty result is a `200` with `data: []`, never a `404`. **Auth:** Bearer {api_token} #### Parameters | Name | In | Type | Required | Description | Example | | --- | --- | --- | --- | --- | --- | | page | query | integer | no | | | | perPage | query | integer | no | A value above the maximum is **clamped to 50**, not rejected — asking for 100 returns a 200 with a 50-row page, so never read the request back as the page size; read `pagination.perPage`. | | | status | query | string | no | Filter by order status. Omitted → every order. | | #### Responses #### 200 A page of bulk eSIM orders (summaries, no slots) #### 422 Invalid pagination or an unknown `status`. Envelope B — no `code`. ### POST /esims/bulk **Order eSIM profiles in bulk (asynchronous)** Queues an asynchronous bulk eSIM order and returns immediately with an `orderId` — **nothing is provisioned inside this request**. Poll `GET /esims/bulk/{orderId}` until the order reaches a terminal status. The `esims.bulk_completed` webhook may push you to refresh sooner, but it is an accelerator, not the channel. Deliberately the same shape as `POST /phone-numbers/bulk`: same status enum, same per-slot best-effort semantics, same idempotency rule, same `requested == succeeded + failed + pending` invariant. A partner who has integrated bulk numbers has already integrated this. **A line names a destination and a quantity, never specific profiles.** Which `offerId` each slot lands on is the server's choice, exactly as a bulk number line does not name msisdns. A partner who wants particular numbers buys them one at a time from the catalog. Two independent caps: at most **10 request lines**, at most **50 per line**, and `Σ items[].quantity` must be **<= 100**. The practical ceiling is lower still — a destination holds only as many profiles as `EsimDestination.availableCount` reports, and a line cannot deliver more than that however much is requested. **Two normalisation rules in one body.** `destination` is upper-cased for you; `prepaidPackage` is **not**, because the ladder's codes are lower-case slugs (`pp-3`) and folding them would fail every line. Send the `code` exactly as the catalog gave it. **Idempotency, precisely.** The key is unique per partner. Same key + a byte-identical body replays: 202 with the original `orderId`. Same key + anything changed — including `prepaidPackage` — is a **422**, not a second order. A client that wants a genuinely new order must mint a new key. The shape that works is to derive the key from a fingerprint of the lines and discard it once an order is accepted: a network retry then replays, an edit mints a new key, and placing the same order twice on purpose is a second order rather than a replay of the first. Throttled 5/min per partner, on top of the general 60/min limiter. **Auth:** Bearer {api_token} #### Parameters | Name | In | Type | Required | Description | Example | | --- | --- | --- | --- | --- | --- | | Idempotency-Key | header | string | yes | Client-generated key making a retried purchase safe. Same key + same body replays the first result; same key + different body is a 422. Keep it stable for as long as the draft is unchanged, and mint a new one the moment the selection changes. | | #### Request body Schema: ```json { "type": "object", "required": [ "items" ], "properties": { "items": { "type": "array", "description": "1..10 request lines. `Σ items[].quantity` must be <= 100 — a separate, independent cap from the per-line maximum of 50.", "items": { "type": "object", "description": "One request line: a destination and how many profiles to buy in it. Duplicate identical lines are legal and are allocated independently.", "required": [ "destination", "quantity", "prepaidPackage" ], "properties": { "destination": { "type": "string", "description": "Destination slug from `GET /esims/destinations` (e.g. `EE-GB`). Case-insensitive; echoed back on each slot in its canonical form." }, "quantity": { "type": "integer", "description": "1..50 for this line. Independent of the order total, which must still be <= 100 across all lines." }, "prepaidPackage": { "type": "string", "description": "A `code` from that destination's `prepaidPackages`, applied to every profile the line delivers. Required for the same reason the single purchase requires it — a bulk order that delivered fifty unusable SIMs would be the worst possible place to discover the rule. Per line, not per order: two lines may fund differently." } } } } } } ``` #### Responses #### 202 Accepted and queued. NOT a purchase confirmation and NOT an affordability guarantee — the acceptance check is a pre-check over visible catalog prices that reserves nothing, and each profile is charged as it is issued. #### 402 Insufficient wallet balance for the order estimate — only when wallet billing is enforced (code 1315). No order is created. #### 403 Email address not confirmed (code 1332) #### 422 Validation failure — bad `items`, `quantity <= 0`, an unknown destination, a MISSING `Idempotency-Key`, either cap exceeded, or a key replayed with a different body. Envelope B: no `code`. #### 429 Envelope C — the rate limiter's own body, identical in shape to envelope B. **No `Retry-After` and no `X-RateLimit-*` header**: the exception handler rebuilds the body and drops the exception's headers, so a countdown cannot be rendered from the response. Cooldowns are a client-side convention. ### GET /esims/bulk/{orderId} **Get a bulk eSIM order's status and per-slot results** Poll this until `status` is terminal (`completed`, `completed_with_errors` or `failed`) — equivalently until `finishedAt` is non-null. Start about 2s after the 202, then back off (2s → 5s → 10s, capped at 15s), and pause while the tab is hidden. The counters are **whole-order**; `items[]` is **one page**. Never sum `items[]` to build the totals. **Auth:** Bearer {api_token} #### Parameters | Name | In | Type | Required | Description | Example | | --- | --- | --- | --- | --- | --- | | orderId | path | string | yes | ULID identifying a bulk eSIM order. | | | page | query | integer | no | | | | perPage | query | integer | no | A value above the maximum is **clamped to 50**, not rejected — asking for 100 returns a 200 with a 50-row page, so never read the request back as the page size; read `pagination.perPage`. | | #### Responses #### 200 Order status with one page of slots #### 404 Bulk eSIM order not found (code 1348) — returned identically for an unknown `orderId` and for an order belonging to another partner, so the response is not an existence oracle. #### 422 Invalid pagination. Envelope B — no `code`. ### GET /esims/{icc} **Read one owned eSIM** **Auth:** Bearer {api_token} #### Parameters | Name | In | Type | Required | Description | Example | | --- | --- | --- | --- | --- | --- | | icc | path | string | yes | ICCID of an eSIM you own. | | #### Responses #### 200 eSIM #### 404 eSIM not found (code 1343) ### DELETE /esims/{icc} **Delete (terminate) an eSIM** Terminates the profile at the provider and removes it from your inventory. **Irreversible, and any balance left on the SIM is forfeited** — it is not returned to the wallet. Read `Esim.balance` before calling this and show it to whoever is confirming: the figure is the decision. **Auth:** Bearer {api_token} #### Parameters | Name | In | Type | Required | Description | Example | | --- | --- | --- | --- | --- | --- | | icc | path | string | yes | ICCID of an eSIM you own. | | #### Responses #### 204 Deleted #### 404 eSIM not found (code 1343) #### 409 eSIM cannot be deleted in its current status — already deleted, or a top-up is still in flight (code 1344) ### GET /esims/{icc}/usage **Itemized spend on an eSIM (calls, data, SMS)** One page of usage records **plus** a `summary` computed over the whole selected period, not over the page. Never sum `data[]` into a total — the page is a window, the summary is the answer. `kind` narrows the summary too; only `pagination` is page-shaped. **The itemized feed is voice-only, and that is a provider limit rather than a gap.** Top Connect exposes exactly one callable CDR command and it returns call records. So `kind=data` and `kind=sms` short-circuit without reaching the provider, and the `data` and `sms` buckets report **zero with their correct units**. Those zeros are a true statement about what can be seen, not a transient to retry around. **Do not poll this.** The provider's CDR is account-wide per date range with no per-ICC endpoint, so every page costs one account-wide upstream call. A record's `id` is deterministic, so de-duplicating by id across pages is safe. **Auth:** Bearer {api_token} #### Parameters | Name | In | Type | Required | Description | Example | | --- | --- | --- | --- | --- | --- | | icc | path | string | yes | ICCID of an eSIM you own. | | | kind | query | string | no | | | | from | query | string | no | Inclusive lower bound on `occurredAt` (ISO 8601). **Defaults to 30 days ago** — the provider requires a bounded range, so omitting this does NOT mean "all time". A client offering an all-time window would be showing 30 days under the wrong label; send the bound you mean. | | | to | query | string | no | Exclusive upper bound on `occurredAt` (ISO 8601). Defaults to now. | | | page | query | integer | no | | | | perPage | query | integer | no | A value above the maximum is **clamped to 50**, not rejected — asking for 100 returns a 200 with a 50-row page, so never read the request back as the page size; read `pagination.perPage`. | | #### Responses #### 200 Usage page + period summary #### 404 eSIM not found (code 1343) ### POST /esims/{icc}/credentials **Reveal PIN1/PIN2/PUK1/PUK2 for an eSIM** A `POST` although it reads: revealing SIM secrets is an audited, rate-limited event, and a `GET` would be replayed by caches, prefetch and browser history. The response is served `Cache-Control: no-store` and the values are never included in any list or detail payload — they exist only in this response body. Throttled 5/min per partner, on its own bucket. **There is no `GET` alias**: a `GET` here answers 405, deliberately, so the cacheable shape cannot be reached by accident. The reveal is audited server-side — partner, acting user, icc and time, never the values themselves. **Both roles may reveal.** A `member` seat is not gated here; the one eSIM capability a member lacks is topping up a SIM balance. **Auth:** Bearer {api_token} #### Parameters | Name | In | Type | Required | Description | Example | | --- | --- | --- | --- | --- | --- | | icc | path | string | yes | ICCID of an eSIM you own. | | #### Responses #### 200 Credentials — do not persist, do not log #### 403 The token may not reveal SIM credentials — a wrong ability (code 1327). Not a role gate: see the description. #### 404 eSIM not found (code 1343) #### 409 The provider could not return credentials for this profile right now — retryable (code 1346) #### 429 Envelope C — the rate limiter's own body, identical in shape to envelope B. **No `Retry-After` and no `X-RateLimit-*` header**: the exception handler rebuilds the body and drops the exception's headers, so a countdown cannot be rendered from the response. Cooldowns are a client-side convention. ### GET /esims/{icc}/topups **Top-up history for an eSIM** **Auth:** Bearer {api_token} #### Parameters | Name | In | Type | Required | Description | Example | | --- | --- | --- | --- | --- | --- | | icc | path | string | yes | ICCID of an eSIM you own. | | | page | query | integer | no | | | | perPage | query | integer | no | A value above the maximum is **clamped to 50**, not rejected — asking for 100 returns a 200 with a 50-row page, so never read the request back as the page size; read `pagination.perPage`. | | #### Responses #### 200 Top-ups page #### 404 eSIM not found (code 1343) ### POST /esims/{icc}/topups **Top up the balance of an eSIM** Adds calling/data credit **to the SIM**, through the same payment providers the wallet uses (`GET /wallet/topups/providers`). This is not a wallet transaction: the wallet is the partner's account balance, the eSIM balance is what the SIM spends on calls and traffic, and money does not move between them. Asynchronous, exactly like `POST /wallet/topups`: the response carries a `payUrl` to the provider's hosted page, and **the SIM is credited only when the top-up's `status` becomes `completed`** — never when the partner returns to `successUrl`. Poll `GET /esims/{icc}/topups` (or wait for the `esim.topped_up` webhook). Not idempotent; never retry automatically. **Admin only.** A `member` seat gets 403/1335 here and nowhere else on the eSIM surface. A `partner:api` machine token has no person behind it and is not role-gated, deliberately. **Auth:** Bearer {api_token} #### Parameters | Name | In | Type | Required | Description | Example | | --- | --- | --- | --- | --- | --- | | icc | path | string | yes | ICCID of an eSIM you own. | | #### Request body Schema: ```json { "type": "object", "required": [ "amount", "provider", "successUrl", "failureUrl" ], "properties": { "amount": { "type": "integer", "description": "Minor units (integer cents) — the same unit `POST /wallet/topups` takes, deliberately, so one money helper serves both. It is the ONE place on the eSIM surface where money is not a `Price` object, and it is that way on purpose.\n**The documented minimum of 100 is unreachable.** Every configured provider's band starts at **500**, so an amount between 100 and 499 is contract-valid and always answers 409/1347. Read the band from `GET /wallet/topups/providers` and floor the control at 500." }, "provider": { "type": "string", "description": "A provider id from `GET /wallet/topups/providers`." }, "successUrl": { "type": "string", "format": "uri", "description": "Where the browser is sent after paying. Validated server-side more strictly than `format: uri` suggests: https only, no credentials in the URL, no localhost or loopback host, the host must resolve, and no resolved address may be private or reserved. A URL failing any of these is refused with a 422 carrying no domain code." }, "failureUrl": { "type": "string", "format": "uri", "description": "Where the browser is sent when payment fails. Validated exactly as `successUrl`." } } } ``` #### Responses #### 201 Top-up created — redirect the partner to `payUrl` #### 403 Email address not confirmed (code 1332), **or the signed-in user is a `member`** (code 1335). Topping up an eSIM balance is the one eSIM capability a member does not have — they may buy, delete, reveal PIN/PUK and tag. Dispatch on the code: the two have the same status and entirely different remedies. #### 404 eSIM not found (code 1343) #### 409 The provider rejected the amount for this profile — below the provider minimum for SIM credit, or the currency is not supported (code 1347) #### 422 Envelope B — a `FormRequest` failure: no `success`, no domain `code` and no per-field map, just one concatenated human message. Discriminate on `typeof body.code === "number"`. #### 429 Envelope C — the rate limiter's own body, identical in shape to envelope B. **No `Retry-After` and no `X-RateLimit-*` header**: the exception handler rebuilds the body and drops the exception's headers, so a countdown cannot be rendered from the response. Cooldowns are a client-side convention. ## Webhooks ### Delivery - **Method:** POST - **Content-Type:** application/json - **Event field:** `event` - **Timeout:** 10 seconds ### Headers | Header | Description | | --- | --- | | X-Esimplus-Signature | Signature of the request body, format `sha256=` | | X-Esimplus-Timestamp | Send time, Unix seconds | ### Events Outbound delivery is reported by three lifecycle events — `sms.outbound.sent`, `sms.outbound.delivered` and `sms.outbound.failed`. There is no `queued` event: `queued` is the status `POST /sms` already answered with, so the message is in your hands before the first callback fires. The status words mean exactly what they mean in `GET /sms` — one vocabulary covers the REST API and the callbacks. **Delivery confirmation is best-effort.** `sms.outbound.delivered` fires only when the mobile operator returns a delivery receipt, and not all of them do, and a message that never produces one stays `sent` indefinitely — there is no timeout and no event marking it as failed. Treat `sent` as the last state you are guaranteed to see: its absence of a follow-up means **unknown**, never **failed**. A `sms.outbound.failed` is always a real failure. Events are delivered **only to the endpoints subscribed to them**. A new event appears in your settings unchecked and arrives nowhere until you tick it — so an endpoint that already receives `sms.inbound` receives no outbound events until you add them. #### `sms.inbound` inbound SMS received Payload: ```json { "event": "sms.inbound", "id": "01J9Z3M8Q9V2K7X4B6N0R5T1AF", "phone_number": "+12025550123", "from": "+447700900123", "text": "Your verification code is 481920", "received_at": "2026-08-05T12:34:56+00:00" } ``` | Field | Type | Description | | --- | --- | --- | | id | string | Unique message id (idempotency key) | | phone_number | string | The partner number that received the SMS (msisdn) | | from | string | Sender number | | text | string | Message body | | received_at | string (ISO 8601) | When the SMS was received (UTC) | #### `sms.outbound.sent` outbound SMS handed off to the carrier Payload: ```json { "event": "sms.outbound.sent", "id": "01JB9F2K7X4B6N0R5T1AFQ9V2M", "phone_number": "+12025550101", "to": "+447700900123", "sent_at": "2026-10-06T12:04:29+00:00" } ``` | Field | Type | Description | | --- | --- | --- | | id | string | Message id from the `POST /sms` response. The same id appears on every lifecycle event for this message, so the idempotency key is the id **together with** the event name | | phone_number | string | The partner number the SMS was sent from (msisdn) | | to | string | Recipient number | | sent_at | string (ISO 8601) | Hand-off to the provider (UTC) | #### `sms.outbound.delivered` outbound SMS confirmed delivered to the handset Payload: ```json { "event": "sms.outbound.delivered", "id": "01JB9F2K7X4B6N0R5T1AFQ9V2M", "phone_number": "+12025550101", "to": "+447700900123", "delivered_at": "2026-10-06T12:04:31+00:00" } ``` | Field | Type | Description | | --- | --- | --- | | id | string | Message id from the `POST /sms` response — the same message as the `sms.outbound.sent` event above | | phone_number | string | The partner number the SMS was sent from (msisdn) | | to | string | Recipient number | | delivered_at | string (ISO 8601) | Carrier confirmation of handset delivery (UTC) | #### `sms.outbound.failed` outbound SMS could not be delivered — terminal, no further event follows for this message Payload: ```json { "event": "sms.outbound.failed", "id": "01JB9F3Q7Y5C8P2S4V6X0Z1TGH", "phone_number": "+12025550101", "to": "+447700900456", "failed_at": "2026-10-06T12:07:02+00:00" } ``` | Field | Type | Description | | --- | --- | --- | | id | string | Message id from the `POST /sms` response | | phone_number | string | The partner number the SMS was sent from (msisdn) | | to | string | Recipient number | | failed_at | string (ISO 8601) | When delivery failed (UTC) | #### `wallet.topped_up` wallet balance topped up Payload: ```json { "event": "wallet.topped_up", "topup_id": "01J9Z3P4W2H8Y6D0C3F5G7K9BQ", "balance": "1549.0000", "balance_cents": 154900, "topped_up_at": "2026-08-05T12:35:10+00:00" } ``` | Field | Type | Description | | --- | --- | --- | | topup_id | string | Unique top-up id (idempotency key) | | balance | string | New wallet balance — exact 4-decimal signed USD string (source of truth) | | balance_cents | integer | New wallet balance in rounded minor units (cents) — legacy projection, kept for back-compat | | topped_up_at | string (ISO 8601) | When the top-up completed (UTC) | #### `numbers.bulk_completed` bulk phone-number order finished (at most one per order; no per-slot detail — read the slots from GET /phone-numbers/bulk/{orderId}) Payload: ```json { "event": "numbers.bulk_completed", "order_id": "01JB8ZC5K3QN7V2M4X6P9R1TDE", "requested": 60, "succeeded": 55, "failed": 5, "completed_at": "2026-08-13T10:05:00Z" } ``` | Field | Type | Description | | --- | --- | --- | | order_id | string | Bulk order id (ULID; idempotency key) | | requested | integer | Σ requested quantity for the order | | succeeded | integer | Slots that delivered a number | | failed | integer | Slots that ended without a number | | completed_at | string (ISO 8601) | When the order reached a terminal status (UTC) | #### `esims.bulk_completed` bulk eSIM order finished (at most one per order; no per-slot detail — read the slots from GET /esims/bulk/{orderId}) Payload: ```json { "event": "esims.bulk_completed", "order_id": "01JC2P7T4M9K1X5V8B0D3F6HJN", "requested": 12, "succeeded": 10, "failed": 2, "completed_at": "2026-09-29T11:20:00Z" } ``` | Field | Type | Description | | --- | --- | --- | | order_id | string | Bulk eSIM order id (ULID; idempotency key) | | requested | integer | Σ requested quantity for the order | | succeeded | integer | Slots that delivered an eSIM | | failed | integer | Slots that ended without an eSIM | | completed_at | string (ISO 8601) | When the order reached a terminal status (UTC) | #### `esim.topped_up` eSIM balance topped up — the SIM's own calling/data credit, NOT the partner wallet (that is wallet.topped_up). Delivered when the top-up reaches status completed, which is the only moment the SIM is credited. Payload: ```json { "event": "esim.topped_up", "topup_id": "01JC1M4Q8R2T6V0X3Z5B7D9FGH", "icc": "8937201234567890123", "amount_cents": 1000, "balance": "22.4000", "topped_up_at": "2026-09-29T09:12:00Z" } ``` | Field | Type | Description | | --- | --- | --- | | topup_id | string | Unique eSIM top-up id (ULID) | | icc | string | ICCID of the topped-up eSIM | | amount_cents | integer | Amount credited, in minor units (cents) | | balance | string | New eSIM balance after the credit — a decimal USD string, not a number, so no float rounding sits between the server and your ledger. Do not assume a fixed number of decimal places. | | topped_up_at | string (ISO 8601) | When the top-up completed (UTC) | #### `webhook.test` test delivery, sent only when you ask for one from your account settings Payload: ```json { "event": "webhook.test", "id": "01JD4K2P7T9M3X5V8B0D2F4HJN", "endpoint_id": 7, "sent_at": "2026-10-07T12:00:00+00:00" } ``` | Field | Type | Description | | --- | --- | --- | | id | string | Unique event id (idempotency key) | | endpoint_id | integer | Which callback endpoint was tested | | sent_at | string (ISO 8601) | When the test was sent (UTC) | ### Signature - **Algorithm:** HMAC-SHA256 - **Signed string:** `{timestamp}.{raw_body}` - **Header:** `X-Esimplus-Signature` - **Format:** `sha256=` Each callback endpoint has its **own** signing secret, so verify a delivery with the secret of the endpoint that received it — not a single account-wide key. The signature is over the raw request body as received — never a re-serialized JSON — because any re-encoding can change bytes and break the comparison. Use a constant-time comparison for the two hashes. JavaScript: ```js import crypto from "node:crypto"; function verifyEsimplusWebhook(rawBody, timestamp, signatureHeader, secret) { const expected = "sha256=" + crypto .createHmac("sha256", secret) .update(`${timestamp}.${rawBody}`) .digest("hex"); const a = Buffer.from(signatureHeader); const b = Buffer.from(expected); return a.length === b.length && crypto.timingSafeEqual(a, b); } ``` Python: ```python import hashlib import hmac def verify_esimplus_webhook(raw_body: bytes, timestamp: str, signature_header: str, secret: str) -> bool: signed = timestamp.encode() + b"." + raw_body expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature_header) ``` PHP: ```php