Quick start
1. Create an API key
Sign up (free, about two minutes), verify your email, then open Developer → API keys in the console
and create a key. It starts with sk_live_ and is shown once: store it in your secrets manager, never in front-end code.
Placing orders needs a balance: top up from US$100 under Billing. Reading the catalogue does not.
2. Read the catalogue
curl "https://api.esimdistributor.com/api/v1/reseller/catalog?country=FR&sort=price&pageSize=5" \
-H "X-Api-Key: $ESD_API_KEY" {
"items": [{
"sku": "ESA-FR-3GB-30D",
"name": "France 3GB 30 Days",
"slug": "france-3gb-30-days",
"coverage": { "type": "country", "code": "FR", "name": "France", "countries": ["FR"] },
"dataType": "fixed",
"dataGb": 3,
"dailyDataGb": null,
"durationDays": 30,
"speed": "5G",
"topUpSupported": true,
"wholesalePrice": 3.15,
"suggestedRetailPrice": 6.30,
"currency": "USD",
"imageUrl": "https://img.esimnest.com/..."
}],
"page": 1, "pageSize": 5, "total": 14
} wholesalePrice is what you pay. suggestedRetailPrice applies your retail multiplier (Brand settings, default 2.0).
3. Place an order
Every order needs an Idempotency-Key header. Use something unique to the purchase on your side, such as your own order number,
so a retry after a timeout can never buy twice.
curl -X POST "https://api.esimdistributor.com/api/v1/reseller/orders" \
-H "X-Api-Key: $ESD_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: SHOP-1001" \
-d '{
"items": [{ "sku": "ESA-FR-3GB-30D", "quantity": 1 }],
"externalRef": "SHOP-1001",
"customerEmail": "traveller@example.com",
"sendEmail": true
}' HTTP/1.1 201 Created
{
"id": "100234",
"number": "100234",
"status": "completed",
"total": 3.15,
"currency": "USD",
"createdAt": "2026-10-30T09:12:44Z",
"externalRef": "SHOP-1001",
"items": [{ "sku": "ESA-FR-3GB-30D", "name": "France 3GB 30 Days", "quantity": 1, "unitPrice": 3.15 }],
"esims": [{
"iccid": "8985...",
"orderId": "100234",
"sku": "ESA-FR-3GB-30D",
"planName": "France 3GB 30 Days",
"status": "not_installed",
"lpa": "LPA:1$rsp.example.com$ABCD-1234",
"smdpAddress": "rsp.example.com",
"matchingId": "ABCD-1234",
"qrCodeUrl": "https://api.esimdistributor.com/api/v1/reseller/esims/8985.../qr.png",
"dataTotalMb": 3072,
"dataUsedMb": 0,
"expiresAt": null,
"activatedAt": null,
"customerEmail": "traveller@example.com"
}]
}
Show the customer qrCodeUrl (fetch it server-side with your key) or the lpa string, or set
sendEmail: true and we email the eSIM in your brand. If status is processing, wait for the
order.completed webhook.
4. Verify webhooks
Add an endpoint under Developer → Webhooks and copy its whsec_ secret. Verify the signature on the
raw request body before you trust a delivery. See Webhooks for the scheme.
import crypto from 'node:crypto';
import express from 'express';
const SECRET = process.env.ESD_WEBHOOK_SECRET; // whsec_... (shown once when you create the webhook)
const TOLERANCE_SECONDS = 5 * 60;
/** True when header = "t=<unix seconds>,v1=<hex HMAC-SHA256(secret, '<t>.<raw body>')>" matches the body. */
export function verifyEsdSignature(rawBody, header, secret = SECRET) {
if (!header) return false;
const parts = {};
for (const part of header.split(',')) {
const i = part.indexOf('=');
if (i > 0) parts[part.slice(0, i).trim()] = part.slice(i + 1).trim();
}
const t = Number(parts.t);
if (!Number.isInteger(t) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false; // replayed or stale
const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.`).update(rawBody).digest();
const given = Buffer.from(parts.v1, 'hex');
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}
const app = express();
// Verify the RAW body: parsing and re-serialising JSON changes the bytes and breaks the signature.
app.post('/webhooks/esd', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyEsdSignature(req.body, req.get('X-ESD-Signature'))) return res.sendStatus(400);
const event = JSON.parse(req.body.toString('utf8'));
// Deduplicate on event.id: a delivery can arrive more than once.
if (event.type === 'order.completed') {
// fulfil event.data.order ...
}
res.sendStatus(200);
}); using System.Security.Cryptography;
using System.Text;
public static class EsdWebhook
{
private const int ToleranceSeconds = 5 * 60;
// True when header = "t=<unix seconds>,v1=<hex HMAC-SHA256(secret, '<t>.<raw body>')>" matches the body.
public static bool Verify(string rawBody, string? header, string secret)
{
if (string.IsNullOrEmpty(header)) return false;
string? t = null, v1 = null;
foreach (var part in header.Split(','))
{
var i = part.IndexOf('=');
if (i <= 0) continue;
var key = part[..i].Trim();
var value = part[(i + 1)..].Trim();
if (key == "t") t = value;
else if (key == "v1") v1 = value;
}
if (!long.TryParse(t, out var timestamp) || v1 is null) return false;
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - timestamp) > ToleranceSeconds) return false;
var expected = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes($"{t}.{rawBody}"));
byte[] given;
try { given = Convert.FromHexString(v1); } catch (FormatException) { return false; }
return CryptographicOperations.FixedTimeEquals(expected, given);
}
}
// ASP.NET Core minimal API
app.MapPost("/webhooks/esd", async (HttpRequest request, IConfiguration config) =>
{
using var reader = new StreamReader(request.Body, Encoding.UTF8);
var rawBody = await reader.ReadToEndAsync();
if (!EsdWebhook.Verify(rawBody, request.Headers["X-ESD-Signature"], config["Esd:WebhookSecret"]!))
return Results.BadRequest();
using var doc = System.Text.Json.JsonDocument.Parse(rawBody);
// Deduplicate on doc.RootElement.GetProperty("id"): a delivery can arrive more than once.
return Results.Ok();
}); Authentication
| Caller | Header | How to get it |
|---|---|---|
| Your servers, the storefront template | X-Api-Key: sk_live_... | Console → Developer → API keys |
| Console (browser) | Authorization: Bearer <jwt> | POST /auth/login |
Your account is taken from the credential, never from a header or the host name. /auth/* and /public/*
need no credential. Keep API keys on the server: anyone holding one can spend your balance.
Conventions
- Base URL:
https://api.esimdistributor.com/api/v1/reseller(staging:https://api-staging.esimdistributor.com/api/v1/reseller). - Format: JSON, UTF-8, camelCase property names.
- Money: a number in USD with 2 decimals (
12.34) plus"currency": "USD". - Times: ISO 8601 strings in UTC.
- Pagination:
?page=1&pageSize=25(max 100) returns{ "items": [...], "page": 1, "pageSize": 25, "total": 123 }.
Errors
Errors use the HTTP status and a stable, machine-readable code. Show message to people; branch on code in code.
HTTP/1.1 402 Payment Required
{ "error": { "code": "insufficient_balance", "message": "Balance 12.00 USD is below the order total 18.90 USD." } } HTTP/1.1 400 Bad Request
{ "error": { "code": "validation_error", "message": "...", "fields": { "email": "..." } } } | HTTP | Codes |
|---|---|
400 | validation_error (also error.fields: { "email": "..." }) |
401 | unauthorized, email_not_verified |
402 | insufficient_balance |
403 | forbidden |
404 | not_found |
409 | idempotency_conflict, email_taken |
422 | below_minimum_topup (minimum 100 USD), plan_unavailable |
429 | rate_limited |
5xx | server_error, supplier_error |
Retry 429 and 5xx with backoff, using the same Idempotency-Key for orders and top-ups.
Idempotency
POST /orders and POST /esims/{iccid}/topups require an Idempotency-Key header of up to 100 characters.
A retried call with the same key returns the first result instead of charging again. Reusing a key for a different request returns
409 idempotency_conflict.
Webhooks
We POST a JSON event to each endpoint subscribed to that event type:
POST /your/webhook/endpoint
Content-Type: application/json
X-ESD-Signature: t=1793351564,v1=5f2b0c...e91a
{
"id": "evt_...",
"type": "order.completed",
"createdAt": "2026-10-30T09:12:44Z",
"data": { "order": { "id": "100234", "status": "completed", "...": "..." } }
} | Event | Payload | Sent when |
|---|---|---|
ping | – | You press “Send test” or call POST /webhooks/{id}/test. |
order.completed | data.order | An order’s eSIMs are provisioned (immediately or after processing). |
order.failed | data.order | An order could not be fulfilled. |
esim.status_changed | data.esim | An eSIM is installed, activated, depleted, expires or is cancelled. |
esim.usage_low | data.esim | An eSIM has used 80% of its data. |
balance.low | data.balance | Your balance falls below your low-balance threshold. |
Signature
Each delivery carries X-ESD-Signature: t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of "<t>.<raw body>" keyed with your endpoint’s secret.
- Split the header on
,and readtandv1. - Reject the delivery if
tis more than 5 minutes old. - Compute
HMAC-SHA256(secret, t + "." + rawBody)as hex and compare it tov1in constant time.
Delivery and retries
Any 2xx response counts as delivered. Otherwise we retry after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours (5 attempts).
Respond quickly and do the work asynchronously, and deduplicate on the event id. The console shows every delivery under
Developer → Webhooks, and POST /webhooks/{id}/test sends a ping.
Endpoint reference
All paths are relative to https://api.esimdistributor.com/api/v1/reseller.
Auth
Used by the console. Server-to-server integrations normally use an API key instead and never call these.
/auth/signup No credential Create a reseller account. The account is created immediately, with no review, and a verification email is sent.
- Password: 8+ characters.
{ "email": "a@b.com", "password": "...", "companyName": "Acme Travel", "country": "IE", "turnstileToken": "..." } 201 { "message": "verification_email_sent" } /auth/verify-email No credential Confirm the email address from the link in the verification email (/app/verify-email?userId=&token=).
{ "userId": "123", "token": "..." } Response: 200 LoginResponse
/auth/login No credential Sign in and receive a JWT for the Authorization: Bearer header.
- Roles: owner, admin, operator (orders/eSIMs only), finance (billing only).
{ "email": "...", "password": "..." } {
"token": "eyJ...", "expiresAt": "2026-11-01T10:00:00Z",
"user": { "id": "123", "email": "a@b.com", "fullName": "Ann Byrne", "role": "owner" },
"tenant": { "id": "456", "name": "Acme Travel", "slug": "acme-travel" }
} /auth/refresh Bearer token Exchange a valid token for a fresh one.
Response: 200 LoginResponse
/auth/forgot-password No credential Send a password reset email.
{ "email": "..." } Response: 202
/auth/reset-password No credential Set a new password with the token from the reset email.
{ "email": "...", "token": "...", "password": "..." } Response: 200
Account
/me Bearer token or X-Api-Key The signed-in user, the reseller account, the balance and onboarding progress.
{
"user": { ... }, "tenant": { ... },
"balance": { "amount": 0.00, "currency": "USD" },
"minimumTopUp": 100.00,
"onboarding": { "emailVerified": true, "hasTopUp": false, "hasOrder": false, "hasApiKey": false, "hasStorefront": false }
} Catalog
Prices are your current wholesale price, below eSimNest’s live selling price. They change when eSimNest prices change.
/catalog Bearer token or X-Api-Key Search and filter plans. Paged.
- Query: q, country (ISO code, e.g. FR), region (e.g. europe), type (fixed | daily), minDataGb, maxDays, sort (price (default, low to high) | destination | data | days), page, pageSize (max 100).
- dataType "daily" means dailyDataGb per day for durationDays; dataGb is then null.
- suggestedRetailPrice = wholesalePrice × retailMultiplier (Brand settings, default 2.0).
{
"items": [{
"sku": "ESA-FR-3GB-30D", "name": "France 3GB 30 Days", "slug": "france-3gb-30-days",
"coverage": { "type": "country", "code": "FR", "name": "France", "countries": ["FR"] },
"dataType": "fixed", "dataGb": 3, "dailyDataGb": null, "durationDays": 30,
"speed": "5G", "topUpSupported": true,
"wholesalePrice": 3.15, "suggestedRetailPrice": 6.30, "currency": "USD",
"imageUrl": "https://img.esimnest.com/..."
}],
"page": 1, "pageSize": 25, "total": 3259
} /catalog/destinations Bearer token or X-Api-Key Every destination with its plan count and lowest wholesale price.
[{ "type": "country" | "region" | "global", "code": "FR", "name": "France", "planCount": 14, "fromPrice": 0.63 }] /catalog/{sku} Bearer token or X-Api-Key One plan, plus the networks it uses in each country.
{ ...catalog item, "networks": [{ "country": "FR", "operators": ["Orange", "SFR"] }] } /public/catalog No credential Public copy of /catalog for websites (this site’s rates page uses it). Same query and shape, without suggestedRetailPrice. Cached 15 minutes.
/public/catalog/destinations No credential Public copy of /catalog/destinations. Cached 15 minutes.
Orders
{
"id": "100234", "number": "100234", "status": "processing|completed|failed|refunded",
"total": 3.15, "currency": "USD", "createdAt": "...", "externalRef": "SHOP-1001",
"items": [{ "sku": "...", "name": "...", "quantity": 1, "unitPrice": 3.15 }],
"esims": [ ESim ]
} /orders Bearer token or X-Api-Key Buy one or more eSIMs, paid from your balance.
- Header Idempotency-Key (required, ≤ 100 characters): retried calls with the same key return the first result.
- sendEmail sends the white-labelled eSIM email (Brand settings) to customerEmail.
- Provisioning is usually instant. When it is not, status is "processing" and an order.completed webhook follows.
{ "items": [{ "sku": "ESA-FR-3GB-30D", "quantity": 1 }], "externalRef": "SHOP-1001", "customerEmail": "traveller@x.com", "sendEmail": true } Response: 201 Order
/orders Bearer token or X-Api-Key List orders (without esims). Paged.
- Query: status, from, to, q, page, pageSize.
Response: paged Order
/orders/{id} Bearer token or X-Api-Key One order, with its eSIMs.
Response: Order
eSIMs
qrCodeUrl needs the same credential as the API call (Bearer or X-Api-Key): download it server-side before showing it to a customer.
{
"iccid": "8985...", "orderId": "100234", "sku": "...", "planName": "France 3GB 30 Days",
"status": "not_installed|installed|active|depleted|expired|cancelled",
"lpa": "LPA:1$rsp.example.com$ABCD-1234", "smdpAddress": "rsp.example.com", "matchingId": "ABCD-1234",
"qrCodeUrl": "https://api.esimdistributor.com/api/v1/reseller/esims/8985.../qr.png",
"dataTotalMb": 3072, "dataUsedMb": 512, "expiresAt": "...", "activatedAt": "...", "customerEmail": "..."
} /esims Bearer token or X-Api-Key List eSIMs. Paged.
- Query: status, q, page, pageSize.
Response: paged ESim
/esims/{iccid} Bearer token or X-Api-Key One eSIM. Usage is refreshed from the network at most every 10 minutes.
Response: ESim
/esims/{iccid}/qr.png Bearer token or X-Api-Key The installation QR code as a PNG image.
/esims/{iccid}/topups Bearer token or X-Api-Key Catalog items that can top up this eSIM.
/esims/{iccid}/topups Bearer token or X-Api-Key Buy a top-up for this eSIM, paid from your balance.
- Header Idempotency-Key (required).
{ "sku": "..." } Response: 201 Order
/esims/{iccid}/resend-email Bearer token or X-Api-Key Send the eSIM email again, optionally to another address.
{ "email": "optional override" } Response: 202
Billing
/billing/balance Bearer token or X-Api-Key Current balance and auto top-up settings.
{ "amount": 412.50, "currency": "USD", "lowBalanceThreshold": 20, "autoTopUp": { "enabled": false, "threshold": 50, "amount": 100, "cardLast4": "4242" } } /billing/transactions Bearer token or X-Api-Key Balance movements. Paged.
- Query: type (topup | purchase | refund | adjustment), page.
{ "id", "type", "amount" (signed), "balanceAfter", "createdAt", "description", "orderId" } /billing/topups Bearer token or X-Api-Key Start a top-up. Minimum 100 USD, otherwise 422 below_minimum_topup.
- Revolut returns paymentUrl to redirect the payer to.
{ "amount": 100, "provider": "stripe|revolut", "saveCard": true } { "topUpId": "tu_8Fq2K0x7a", "provider": "stripe", "clientSecret": "...", "publishableKey": "pk_...", "paymentUrl": null } /billing/topups/{topUpId} Bearer token or X-Api-Key Status of a top-up.
{ "status": "pending|processing|completed|failed|cancelled|expired", "amount", "creditedAt" } /billing/auto-topup Bearer token or X-Api-Key Turn auto top-up on or off.
{ "enabled": true, "threshold": 50, "amount": 100 } /billing/transactions/{id}/invoice.pdf Bearer token or X-Api-Key Invoice for a transaction, as PDF.
Brand & preferences
/brand Bearer token or X-Api-Key Brand used in eSIM emails and the storefront. PUT /brand takes the same body.
{
"brandName": "Acme Travel", "logoUrl": "https://...", "primaryColor": "#0B5FFF",
"websiteUrl": "https://acme.example", "supportEmail": "help@acme.example",
"retailMultiplier": 2.0, "lowBalanceThreshold": 20
} /brand Bearer token or X-Api-Key Update brand settings (same body as GET /brand).
/brand/logo Bearer token or X-Api-Key Upload a logo: multipart field "file", png/jpg/svg/webp, up to 4 MB.
{ "logoUrl": "..." } Team
/team Bearer token or X-Api-Key Members and pending invitations.
[{ "userId", "email", "fullName", "role", "status": "active|invited" }] /team/invitations Bearer token or X-Api-Key Invite a colleague.
{ "email", "role": "admin|operator|finance" } Response: 201
/team/{userId} Bearer token or X-Api-Key Change a member’s role.
{ "role" } /team/{userId} Bearer token or X-Api-Key Remove a member.
API keys & webhooks
/api-keys Bearer token or X-Api-Key Your API keys (prefix only).
[{ "id", "name", "prefix": "sk_live_ab12", "createdAt", "lastUsedAt", "expiresAt" }] /api-keys Bearer token or X-Api-Key Create a key. The full key is shown once.
{ "name": "My shop" } 201 { "id", "name", "key": "sk_live_..." } /api-keys/{id} Bearer token or X-Api-Key Revoke a key.
/webhooks Bearer token or X-Api-Key Your webhook endpoints.
[{ "id", "url", "events": [...], "createdAt", "lastDeliveryStatus" }] /webhooks Bearer token or X-Api-Key Add an endpoint. The signing secret is shown once.
{ "url": "https://...", "events": ["order.completed", ...] } 201 { ..., "secret": "whsec_..." } /webhooks/{id} Bearer token or X-Api-Key Change an endpoint’s URL or events.
/webhooks/{id} Bearer token or X-Api-Key Remove an endpoint.
/webhooks/{id}/test Bearer token or X-Api-Key Send a ping event to the endpoint.
/webhooks/{id}/deliveries Bearer token or X-Api-Key Delivery log. Paged; each item:
{ "id", "event", "status": "succeeded|failed|pending", "httpStatus", "attempts", "createdAt" } Storefront
/storefront/config Bearer token or X-Api-Key The site.config.json the storefront template expects: brand, colours, logo, support email, retail multiplier and API base URL. Never contains secrets.