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

Requestbash
curl "https://api.esimdistributor.com/api/v1/reseller/catalog?country=FR&sort=price&pageSize=5" \
  -H "X-Api-Key: $ESD_API_KEY"
Responsejson
{
  "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.

Requestbash
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
  }'
Responsehttp
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.

Node.js (Express)js
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);
});
C# (.NET 8)csharp
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

CallerHeaderHow to get it
Your servers, the storefront templateX-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": "..." } } }
HTTPCodes
400validation_error (also error.fields: { "email": "..." })
401unauthorized, email_not_verified
402insufficient_balance
403forbidden
404not_found
409idempotency_conflict, email_taken
422below_minimum_topup (minimum 100 USD), plan_unavailable
429rate_limited
5xxserver_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", "...": "..." } }
}
EventPayloadSent when
ping–You press “Send test” or call POST /webhooks/{id}/test.
order.completeddata.orderAn order’s eSIMs are provisioned (immediately or after processing).
order.faileddata.orderAn order could not be fulfilled.
esim.status_changeddata.esimAn eSIM is installed, activated, depleted, expires or is cancelled.
esim.usage_lowdata.esimAn eSIM has used 80% of its data.
balance.lowdata.balanceYour 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.

  1. Split the header on , and read t and v1.
  2. Reject the delivery if t is more than 5 minutes old.
  3. Compute HMAC-SHA256(secret, t + "." + rawBody) as hex and compare it to v1 in 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.

POST /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.
Bodyjson
{ "email": "a@b.com", "password": "...", "companyName": "Acme Travel", "country": "IE", "turnstileToken": "..." }
Responsejson
201 { "message": "verification_email_sent" }
POST /auth/verify-email No credential

Confirm the email address from the link in the verification email (/app/verify-email?userId=&token=).

Bodyjson
{ "userId": "123", "token": "..." }

Response: 200 LoginResponse

POST /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).
Bodyjson
{ "email": "...", "password": "..." }
Responsejson
{
  "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" }
}
POST /auth/refresh Bearer token

Exchange a valid token for a fresh one.

Response: 200 LoginResponse

POST /auth/forgot-password No credential

Send a password reset email.

Bodyjson
{ "email": "..." }

Response: 202

POST /auth/reset-password No credential

Set a new password with the token from the reset email.

Bodyjson
{ "email": "...", "token": "...", "password": "..." }

Response: 200

Account

GET /me Bearer token or X-Api-Key

The signed-in user, the reseller account, the balance and onboarding progress.

Responsejson
{
  "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.

GET /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).
Responsejson
{
  "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
}
GET /catalog/destinations Bearer token or X-Api-Key

Every destination with its plan count and lowest wholesale price.

Responsejson
[{ "type": "country" | "region" | "global", "code": "FR", "name": "France", "planCount": 14, "fromPrice": 0.63 }]
GET /catalog/{sku} Bearer token or X-Api-Key

One plan, plus the networks it uses in each country.

Responsejson
{ ...catalog item, "networks": [{ "country": "FR", "operators": ["Orange", "SFR"] }] }
GET /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.

GET /public/catalog/destinations No credential

Public copy of /catalog/destinations. Cached 15 minutes.

Orders

Order objectjson
{
  "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 ]
}
POST /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.
Bodyjson
{ "items": [{ "sku": "ESA-FR-3GB-30D", "quantity": 1 }], "externalRef": "SHOP-1001", "customerEmail": "traveller@x.com", "sendEmail": true }

Response: 201 Order

GET /orders Bearer token or X-Api-Key

List orders (without esims). Paged.

  • Query: status, from, to, q, page, pageSize.

Response: paged Order

GET /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.

ESim objectjson
{
  "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": "..."
}
GET /esims Bearer token or X-Api-Key

List eSIMs. Paged.

  • Query: status, q, page, pageSize.

Response: paged ESim

GET /esims/{iccid} Bearer token or X-Api-Key

One eSIM. Usage is refreshed from the network at most every 10 minutes.

Response: ESim

GET /esims/{iccid}/qr.png Bearer token or X-Api-Key

The installation QR code as a PNG image.

GET /esims/{iccid}/topups Bearer token or X-Api-Key

Catalog items that can top up this eSIM.

POST /esims/{iccid}/topups Bearer token or X-Api-Key

Buy a top-up for this eSIM, paid from your balance.

  • Header Idempotency-Key (required).
Bodyjson
{ "sku": "..." }

Response: 201 Order

POST /esims/{iccid}/resend-email Bearer token or X-Api-Key

Send the eSIM email again, optionally to another address.

Bodyjson
{ "email": "optional override" }

Response: 202

Billing

GET /billing/balance Bearer token or X-Api-Key

Current balance and auto top-up settings.

Responsejson
{ "amount": 412.50, "currency": "USD", "lowBalanceThreshold": 20, "autoTopUp": { "enabled": false, "threshold": 50, "amount": 100, "cardLast4": "4242" } }
GET /billing/transactions Bearer token or X-Api-Key

Balance movements. Paged.

  • Query: type (topup | purchase | refund | adjustment), page.
Responsejson
{ "id", "type", "amount" (signed), "balanceAfter", "createdAt", "description", "orderId" }
POST /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.
Bodyjson
{ "amount": 100, "provider": "stripe|revolut", "saveCard": true }
Responsejson
{ "topUpId": "tu_8Fq2K0x7a", "provider": "stripe", "clientSecret": "...", "publishableKey": "pk_...", "paymentUrl": null }
GET /billing/topups/{topUpId} Bearer token or X-Api-Key

Status of a top-up.

Responsejson
{ "status": "pending|processing|completed|failed|cancelled|expired", "amount", "creditedAt" }
PUT /billing/auto-topup Bearer token or X-Api-Key

Turn auto top-up on or off.

Bodyjson
{ "enabled": true, "threshold": 50, "amount": 100 }
GET /billing/transactions/{id}/invoice.pdf Bearer token or X-Api-Key

Invoice for a transaction, as PDF.

Brand & preferences

GET /brand Bearer token or X-Api-Key

Brand used in eSIM emails and the storefront. PUT /brand takes the same body.

Responsejson
{
  "brandName": "Acme Travel", "logoUrl": "https://...", "primaryColor": "#0B5FFF",
  "websiteUrl": "https://acme.example", "supportEmail": "help@acme.example",
  "retailMultiplier": 2.0, "lowBalanceThreshold": 20
}
PUT /brand Bearer token or X-Api-Key

Update brand settings (same body as GET /brand).

Team

GET /team Bearer token or X-Api-Key

Members and pending invitations.

Responsejson
[{ "userId", "email", "fullName", "role", "status": "active|invited" }]
POST /team/invitations Bearer token or X-Api-Key

Invite a colleague.

Bodyjson
{ "email", "role": "admin|operator|finance" }

Response: 201

PUT /team/{userId} Bearer token or X-Api-Key

Change a member’s role.

Bodyjson
{ "role" }
DELETE /team/{userId} Bearer token or X-Api-Key

Remove a member.

API keys & webhooks

GET /api-keys Bearer token or X-Api-Key

Your API keys (prefix only).

Responsejson
[{ "id", "name", "prefix": "sk_live_ab12", "createdAt", "lastUsedAt", "expiresAt" }]
POST /api-keys Bearer token or X-Api-Key

Create a key. The full key is shown once.

Bodyjson
{ "name": "My shop" }
Responsejson
201 { "id", "name", "key": "sk_live_..." }
DELETE /api-keys/{id} Bearer token or X-Api-Key

Revoke a key.

GET /webhooks Bearer token or X-Api-Key

Your webhook endpoints.

Responsejson
[{ "id", "url", "events": [...], "createdAt", "lastDeliveryStatus" }]
POST /webhooks Bearer token or X-Api-Key

Add an endpoint. The signing secret is shown once.

Bodyjson
{ "url": "https://...", "events": ["order.completed", ...] }
Responsejson
201 { ..., "secret": "whsec_..." }
PUT /webhooks/{id} Bearer token or X-Api-Key

Change an endpoint’s URL or events.

DELETE /webhooks/{id} Bearer token or X-Api-Key

Remove an endpoint.

POST /webhooks/{id}/test Bearer token or X-Api-Key

Send a ping event to the endpoint.

GET /webhooks/{id}/deliveries Bearer token or X-Api-Key

Delivery log. Paged; each item:

Responsejson
{ "id", "event", "status": "succeeded|failed|pending", "httpStatus", "attempts", "createdAt" }

Storefront

GET /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.

Ready to build?

Create a free account, generate a key and place a test order today.