eSIM Data Store — Wholesale API

Purchase eSIM plans, manage eSIMs, monitor usage, and control your wallet balance.

Two-file docs (2026-07-24): this page carries the hand-maintained narrative — Authentication, Idempotency, Error taxonomy, Throttling policy, Side-effect index (what emits what). For the machine-generated endpoint reference — every path, header, request/response schema, and example payload — see the auto-generated companion at /api/docs/generated (if opened locally as a file, use the sibling wholesale-api-docs.generated.html). That file is regenerated from the OpenAPI spec on every build (see docs/plans/2026-07-24-docs-simplification-design.md §5). The CI gate that actually blocks deploys is pnpm docs:check-spec, which byte-diffs the committed docs/wholesale-openapi.json against a fresh regeneration (§5.4); pnpm docs:check-api (rendered-HTML diff) is warn-only locally because Redocly output is not byte-deterministic across macOS ↔ Linux.
Base URL: https://api.esimdatastore.com/api

1. Introduction

Welcome to the eSIM Data Store API. This RESTful JSON API lets you browse eSIM plans, purchase eSIMs, monitor data usage, top up active eSIMs, and manage your prepaid wallet balance. All requests and responses use JSON and require authentication via API key.

2. Authentication

Authenticate every request by including your API key in the X-API-Key header. Each key is scoped to a single customer account — all resources you create and query are automatically isolated to your account.

Header Format

X-API-Key: your-api-key-here

API keys are provided during onboarding. If you need a key or need to rotate an existing one, contact support.

Important: Requests without a valid API key receive a 401 Unauthorized response. Keep your key secret and never expose it in client-side code.

3. Quick Start Guide

Follow these steps to make your first eSIM purchase and monitor it:

1

Browse Plans

Fetch the list of available eSIM plans with pricing and coverage details.

curl -X GET https://api.esimdatastore.com/api/plans \
  -H "X-API-Key: your-api-key"
2

Purchase a Plan

Buy an eSIM by submitting an order with the desired planId. Include an Idempotency-Key header to prevent double charges.

curl -X POST https://api.esimdatastore.com/api/orders \
  -H "X-API-Key: your-api-key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \
  -d '{"planId": "plan-uuid-here"}'
3

Get Your eSIM

List your eSIMs, then fetch a specific eSIM to get the esimQr value for device installation.

curl -X GET https://api.esimdatastore.com/api/esims \
  -H "X-API-Key: your-api-key"
4

Monitor Usage

Check data consumption, activation time, and expiry for any eSIM.

curl -X GET https://api.esimdatastore.com/api/esims/{esim-id}/usage \
  -H "X-API-Key: your-api-key"
5

Top Up

When data is running low, check available top-up plans and add more data.

# Check available top-ups
curl -X GET https://api.esimdatastore.com/api/esims/{esim-id}/topups/available \
  -H "X-API-Key: your-api-key"

# Apply a top-up
curl -X POST https://api.esimdatastore.com/api/esims/{esim-id}/topup \
  -H "X-API-Key: your-api-key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 660e8400-e29b-41d4-a716-446655440001" \
  -d '{"planId": "topup-plan-uuid"}'
6

Check Balance

View your prepaid wallet balance. Purchases are deducted automatically.

curl -X GET https://api.esimdatastore.com/api/wallets/balance \
  -H "X-API-Key: your-api-key"

4. Endpoints Reference

Plans

GET /plans

List all available eSIM plans with pricing and coverage details. Only returns plans that are not disabled, not removed from upstream, and priced for your account.

Response 200

Returns an array of PlanResponse objects:

FieldTypeDescription
idstring (UUID)Plan identifier
namestringDisplay name of the plan
descriptionstring | nullOptional description
pricestring (decimal)Price in USD, e.g. "9.99"
durationnumberValidity duration
durationUnitstringUnit of duration, e.g. "DAY"
dataAmountnumberIncluded data amount
dataAmountUnitstringUnit of data, e.g. "GB"
coveragestringCoverage region or country
provisioningFeestring (decimal)Fixed eSIM provisioning fee for NEW orders, e.g. "0.50". Top-ups are always "0.00".
Example Request
curl -X GET https://api.esimdatastore.com/api/plans \
  -H "X-API-Key: your-api-key"
Example Response
[
  {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "name": "Europe 5GB / 30 Days",
    "description": "High-speed data across 35 European countries",
    "price": "12.50",
    "duration": 30,
    "durationUnit": "DAY",
    "dataAmount": 5,
    "dataAmountUnit": "GB",
    "coverage": "Europe",
    "provisioningFee": "0.50"
  }
]
GET /plans/all Admin

Admin only. Returns the full catalog (all plans, including disabled and upstream-removed), scoped for operator visibility rather than customer purchasability. Response shape is AdminPlanResponse[] — an operator-focused view that surfaces the plan-lifecycle flags (disabled, disabledReason, removedFromUpstream) plus the plan-sync-derived fields used by voucher batch creation (destination, compatibleTopupProductIds) and the upstream identifiers (esimfxProductId, esimfxImsiProfile, upstreamCost) that the customer-facing PlanResponse intentionally hides. Not a strict superset: PlanResponse's customer price and provisioningFee are omitted here because pricing is per-customer (customer_plan_prices) and this endpoint is customer-agnostic. Requires Authorization: Bearer $ADMIN_TOKEN.

Response 200

Returns an array of AdminPlanResponse objects. Fields beyond the customer PlanResponse:

FieldTypeDescription
esimfxProductIdstringUpstream eSIMfx product identifier used for provisioning. Stable across syncs.
esimfxImsiProfilestringUpstream IMSI profile identifier.
upstreamCoststring (decimal)Wholesale cost from eSIMfx (USD). Used to derive margin against the customer price.
destinationstring | nullCountry / region key extracted from the product name during plan-sync. Used as the CSV Destination column when uploading per-customer prices. null when the sync could not derive a destination.
compatibleTopupProductIdsstring[] | nullUpstream product IDs valid as TOPUP companions for this plan (derived locally by plan-sync from the V2 catalog). null when the compat cache has not been written yet; [] when no compatible topups exist.
disabledbooleantrue when the plan is hidden from customer GET /plans and rejected by POST /orders.
disabledReasonenum | nullOne of MANUAL, NEGATIVE_MARGIN, DUPLICATE. Populated when disabled=true.
removedFromUpstreambooleantrue when the plan disappeared from the last successful upstream sync (stale but not deleted; retained for historical order references).
createdAtstring (ISO 8601)Row creation timestamp.
Example Request
curl -X GET https://api.esimdatastore.com/api/plans/all \
  -H "Authorization: Bearer $ADMIN_TOKEN"

Orders

POST /orders Idempotent

Purchase an eSIM plan. The cost is deducted from your wallet balance.

Sandbox: Sandbox customers receive dummy eSIMs (ICCID prefix 89990) with no real provisioning.

Request Body

FieldTypeRequiredDescription
planIdstring (UUID)YesID of the plan to purchase

Headers

HeaderRequiredDescription
Idempotency-KeyRecommendedUUID to prevent duplicate purchases. Same key within 24 hours returns the cached response.

Response 201

Returns an OrderResponse object:

FieldTypeDescription
idstring (UUID)Order identifier
planIdstring (UUID)Purchased plan ID
iccidstring | nullICCID of the provisioned eSIM (null while pending)
operationTypestring"NEW" or "TOPUP"
statusstringOne of: PENDING, COMPLETED, FAILED, REFUND_PENDING, REFUNDED, REFUND_FAILED, CONFIRM_PENDING, CONFIRM_FAILED
subscriptionStatusstring | nullOne of: PENDING, ACTIVE, EXPIRED, TERMINATED, or null
planPricestring (decimal)Plan price before provisioning fee
provisioningFeestring (decimal)Provisioning fee included in salePrice ("0.00" for top-ups)
salePricestring (decimal)Total price charged for this order (planPrice + provisioningFee)
refundRequestedAtstring (ISO 8601) | nullSet when the customer accepted a refund (order entered REFUND_PENDING).
refundedAtstring (ISO 8601) | nullSet when the wallet credit + status flip committed (status is REFUNDED).
refundedAmountstring (decimal) | nullEqual to planPrice (= salePriceprovisioningFee, per FIN-32 refund policy) when status is REFUNDED; null otherwise. TOPUP refunds credit the full salePrice since provisioningFee is "0.00". Use this to display the credited amount without a separate fetch.
createdAtstring (ISO 8601)Timestamp of order creation
Example Request
curl -X POST https://api.esimdatastore.com/api/orders \
  -H "X-API-Key: your-api-key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \
  -d '{"planId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"}'
Example Response
{
  "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "planId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "iccid": null,
  "operationType": "NEW",
  "status": "PENDING",
  "subscriptionStatus": "PENDING",
  "planPrice": "12.00",
  "provisioningFee": "0.50",
  "salePrice": "12.50",
  "createdAt": "2026-04-06T14:30:00.000Z"
}
GET /orders/:id

Retrieve details for a specific order.

Path Parameters

ParameterTypeDescription
idstring (UUID)Order identifier

Response 200

Returns an OrderResponse object (same schema as POST /orders).

Example Request
curl -X GET https://api.esimdatastore.com/api/orders/f47ac10b-58cc-4372-a567-0e02b2c3d479 \
  -H "X-API-Key: your-api-key"
GET /orders Paginated

List your orders with cursor-based pagination.

Query Parameters

ParameterTypeDefaultDescription
limitnumber50Number of results to return (max 100)
cursorstring (UUID)ID of the last order from the previous page

Response 200

Returns an array of OrderResponse objects.

Example Request
curl -X GET "https://api.esimdatastore.com/api/orders?limit=10" \
  -H "X-API-Key: your-api-key"

eSIMs

GET /esims

List all eSIMs on your account.

Response 200

Returns an array of EsimListItem objects:

FieldTypeDescription
idstring (UUID)eSIM identifier
iccidstringICCID of the eSIM
statusstringOne of: PROVISIONED, ACTIVE, EXPIRED, BLOCKED
createdAtstring (ISO 8601)Timestamp of creation

Note: The install QR (esimQr) is only available on the detail endpoint GET /esims/:id.

Example Request
curl -X GET https://api.esimdatastore.com/api/esims \
  -H "X-API-Key: your-api-key"
Example Response
[
  {
    "id": "c3d4e5f6-7890-abcd-ef12-345678901234",
    "iccid": "8901234567890123456",
    "status": "ACTIVE",
    "createdAt": "2026-04-06T14:31:00.000Z"
  }
]
GET /esims/:id

Get eSIM details with live status from the eSIM provider.

Sandbox: Sandbox eSIMs always show ACTIVE status.

Path Parameters

ParameterTypeDescription
idstring (UUID)eSIM identifier

Response 200

Returns an EsimResponse with an additional liveStatus object:

FieldTypeDescription
liveStatus.iccidstringICCID confirmed by provider
liveStatus.esimQrstringQR code data
liveStatus.statusstringCurrent provider status
Example Request
curl -X GET https://api.esimdatastore.com/api/esims/c3d4e5f6-7890-abcd-ef12-345678901234 \
  -H "X-API-Key: your-api-key"
Example Response
{
  "id": "c3d4e5f6-7890-abcd-ef12-345678901234",
  "iccid": "8901234567890123456",
  "esimQr": "LPA:1$provider.example.com$ACTIVATION_CODE",
  "status": "ACTIVE",
  "createdAt": "2026-04-06T14:31:00.000Z",
  "liveStatus": {
    "iccid": "8901234567890123456",
    "esimQr": "LPA:1$provider.example.com$ACTIVATION_CODE",
    "status": "ACTIVE"
  }
}
GET /esims/:id/usage

Get data usage, activation time, and expiry for an eSIM.

Sandbox: Sandbox returns simulated usage data.

Path Parameters

ParameterTypeDescription
idstring (UUID)eSIM identifier

Response 200

FieldTypeDescription
iccidstringICCID of the eSIM
usedAmountnumber | nullData consumed (null if not yet activated)
totalAmountnumberTotal data allowance
amountUnitstringUnit of data, e.g. "MB"
statusstringCurrent status of the eSIM subscription
activationTimestring (ISO 8601)When the eSIM was activated
expirystring (ISO 8601)When the data plan expires
Example Request
curl -X GET https://api.esimdatastore.com/api/esims/c3d4e5f6-7890-abcd-ef12-345678901234/usage \
  -H "X-API-Key: your-api-key"
Example Response
{
  "iccid": "8901234567890123456",
  "usedAmount": 1250,
  "totalAmount": 5120,
  "amountUnit": "MB",
  "status": "ACTIVE",
  "activationTime": "2026-04-06T15:00:00.000Z",
  "expiry": "2026-05-06T15:00:00.000Z"
}
GET /esims/:id/topups/available

List available top-up plans for a specific eSIM.

Sandbox: Sandbox returns all plans with customer pricing (no upstream filter).

Path Parameters

ParameterTypeDescription
idstring (UUID)eSIM identifier

Response 200

FieldTypeDescription
planIdstring (UUID)Top-up plan identifier
namestringDisplay name of the top-up plan
durationnumberValidity duration
durationUnitstringUnit of duration
amountnumberData amount included
amountUnitstringUnit of data
coveragestring[]List of covered regions/countries
pricestring (decimal)Price in USD
provisioningFeestring (decimal)Provisioning fee (always "0.00" for top-ups)
Example Request
curl -X GET https://api.esimdatastore.com/api/esims/c3d4e5f6-7890-abcd-ef12-345678901234/topups/available \
  -H "X-API-Key: your-api-key"
Example Response
[
  {
    "planId": "b2c3d4e5-f678-90ab-cdef-123456789012",
    "name": "Europe 3GB Top-Up",
    "duration": 30,
    "durationUnit": "DAY",
    "amount": 3,
    "amountUnit": "GB",
    "coverage": ["Europe"],
    "price": "8.00",
    "provisioningFee": "0.00"
  }
]
POST /esims/:id/topup Idempotent

Top up an existing eSIM with additional data.

Sandbox: Sandbox top-ups simulate upstream without real provisioning.

Path Parameters

ParameterTypeDescription
idstring (UUID)eSIM identifier

Request Body

FieldTypeRequiredDescription
planIdstring (UUID)YesID of the top-up plan (from available top-ups)

Headers

HeaderRequiredDescription
Idempotency-KeyRecommendedUUID to prevent duplicate top-ups

Response 201

Returns an OrderResponse object (same schema as POST /orders, with operationType: "TOPUP").

Example Request
curl -X POST https://api.esimdatastore.com/api/esims/c3d4e5f6-7890-abcd-ef12-345678901234/topup \
  -H "X-API-Key: your-api-key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 770e8400-e29b-41d4-a716-446655440002" \
  -d '{"planId": "b2c3d4e5-f678-90ab-cdef-123456789012"}'
Example Response
{
  "id": "d5e6f7a8-b901-2345-cdef-678901234567",
  "planId": "b2c3d4e5-f678-90ab-cdef-123456789012",
  "iccid": "8901234567890123456",
  "operationType": "TOPUP",
  "status": "PENDING",
  "subscriptionStatus": "ACTIVE",
  "planPrice": "8.00",
  "provisioningFee": "0.00",
  "salePrice": "8.00",
  "createdAt": "2026-04-06T16:00:00.000Z"
}

Wallet

GET /wallets/balance

Get your current prepaid wallet balance.

Response 200

FieldTypeDescription
balancestring (decimal)Current balance in USD, e.g. "150.00"
Example Request
curl -X GET https://api.esimdatastore.com/api/wallets/balance \
  -H "X-API-Key: your-api-key"
Example Response
{
  "balance": "150.00"
}

Refunds

POST /orders/:id/refund

Accepts a refund request (async). Returns 202 Accepted. The wallet credit and the REFUNDED status flip commit atomically only after upstream termination succeeds. Poll GET /orders/:id until status is REFUNDED (success) or REFUND_FAILED (60-day window missed or admin-forced).

Sandbox: Sandbox refunds skip real upstream termination and commit inline.

Path Parameters

ParameterTypeDescription
idstring (UUID)Order identifier

Response 202

FieldTypeDescription
orderIdstring (UUID)The order being refunded
statusstringREFUND_PENDING when the inline upstream call did not complete in time; REFUNDED when wallet credit + status flip committed
refundRequestedAtstring (ISO 8601)Always present — when the refund request was accepted
refundedAtstring (ISO 8601) | nullSet only when status === REFUNDED
refundedAmountstring (decimal) | nullEqual to the order's planPrice (= salePriceprovisioningFee, per FIN-32 refund policy) when status === REFUNDED; null while pending or failed. TOPUP refunds credit the full salePrice since provisioningFee is "0.00". Same value as OrderResponse.refundedAmount on subsequent GET /orders/:id calls.
Example Request
curl -X POST https://api.esimdatastore.com/api/orders/f47ac10b-58cc-4372-a567-0e02b2c3d479/refund \
  -H "X-API-Key: your-api-key"
Example Response — inline commit

NEW order with salePrice $15.00 and provisioningFee $0.50 — refund credit equals planPrice $14.50.

{
  "orderId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "status": "REFUNDED",
  "refundRequestedAt": "2026-05-13T12:00:00.000Z",
  "refundedAt": "2026-05-13T12:00:01.234Z",
  "refundedAmount": "14.50"
}
Example Response — async (worker will commit)
{
  "orderId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "status": "REFUND_PENDING",
  "refundRequestedAt": "2026-05-13T12:00:00.000Z",
  "refundedAt": null,
  "refundedAmount": null
}
Polling pattern
# Poll GET /orders/:id every ~2s until terminal state.
for i in {1..30}; do
  STATUS=$(curl -s "$API/orders/$ORDER_ID" \
    -H "X-API-Key: $API_KEY" | jq -r .status)
  echo "poll #$i: $STATUS"
  [[ "$STATUS" == "REFUNDED" || "$STATUS" == "REFUND_FAILED" ]] && break
  sleep 2
done
POST /admin/orders/:id/refund/force-complete

Admin-only. Bypasses the upstream-termination wait and commits the wallet credit + flips the order to REFUNDED. Use this when an out-of-band signal (eSIMfx support ticket, manual termination) confirms the upstream subscription is terminated but the worker hasn't yet caught up. Requires an admin bearer token; an Idempotency-Key header is strongly recommended (see Headers below). Emits REFUND_FORCED_BY_ADMIN with the supplied reason.

Headers

  • Authorization: Bearer <ADMIN_TOKEN> — required
  • Idempotency-Key: <uuid> — strongly recommended. The interceptor treats a missing key as a normal one-shot call (no replay protection); when present, the admin idempotency scope is keyed by method + url + key, so retries with the same key replay the cached result and using the same key against the other admin endpoint or a different order is collision-free.

Request Body

{ "reason": "eSIMfx ticket #12345 — termination confirmed" }

reason is required, 1–500 chars. The string lands in REFUND_FORCED_BY_ADMIN as the operator's audit record.

Response 200

Same shape as POST /orders/:id/refund's response ({ orderId, status, refundRequestedAt, refundedAt, refundedAmount }) — status is REFUNDED and refundedAmount is populated.

POST /admin/orders/:id/refund/force-fail

Admin-only. Flips the order to REFUND_FAILED without crediting the wallet. Use this for confirmed duplicates, fraud, or refund requests that should never have been accepted. The customer is not credited — handle remediation out of band. Same auth + idempotency semantics as force-complete (admin bearer required; Idempotency-Key recommended but not enforced). Emits REFUND_FORCED_BY_ADMIN.

Headers

  • Authorization: Bearer <ADMIN_TOKEN> — required
  • Idempotency-Key: <uuid> — strongly recommended (same semantics as force-complete: missing key is a one-shot call; present key replays the cached result on retry).

Request Body

{ "reason": "confirmed duplicate refund — support ticket #4321" }

Response 200

Same shape as POST /orders/:id/refund's response — status is REFUND_FAILED and refundedAmount is null.

Reports

Admin / Report token only. JSON / CSV / PDF financial export, scoped by month. Bearer-token auth on either ADMIN_TOKEN or the read-only ADMIN_REPORT_TOKEN.

GET /reports/financial

Generate a financial report for a given month, optionally filtered to a subset of sections, in JSON / CSV / PDF.

Headers

  • Authorization: Bearer <ADMIN_TOKEN | ADMIN_REPORT_TOKEN> — required.

Query Parameters

ParameterTypeDescription
monthstring (YYYY-MM)Required. Reporting month, UTC.
sectionsstringComma-separated subset of revenue, profitability, customers. Defaults to all three.
formatstringjson (default), csv, or pdf.

Response 200

JSON response shape (CSV / PDF formats serialize the same fields). The revenue, revenue.daily[], and profitability.plans[] objects each carry three FIN-32 net-of-upstream-refund fields: upstreamRefundedAmount, netUpstreamCost, netMargin.

Errors

  • 401 — missing or invalid bearer token (admin OR report token)
  • 422 INVALID_REFERENCE — invalid month format (must be YYYY-MM), unknown sections value, or ValidationPipe rejection of the query string
  • 429 — throttled (5 reports/min/IP — report generation pulls multi-month aggregates + the PDF format runs pdf-lib synchronously, so the bound is intentionally tight)
  • 500 DATABASE_ERROR — uncategorized server error reached the outer wrapper: unexpected Drizzle / pg failure on a report SELECT, raw pdf-lib exception during PDF export, or CSV exporter exception. The controller wraps non-ServiceError throws so the response stays application/problem+json with code: DATABASE_ERROR.
FieldTypeDescription
monthstringEchoes the month query parameter
generatedAtstring (ISO 8601)Timestamp the report was rendered
revenue.grossRevenuestring (decimal)Sum of salePrice across orders in the month
revenue.totalUpstreamCoststring (decimal)Gross upstream cost (no refund netting)
revenue.grossMarginstring (decimal)grossRevenue − totalUpstreamCost
revenue.marginPercentstring (decimal)Gross margin as a percentage
revenue.refundedAmountstring (decimal)Sum of customer-side refund credits (= planPrice)
revenue.refundCountnumberRefunded-order count
revenue.netRevenuestring (decimal)grossRevenue − refundedAmount
revenue.upstreamRefundedAmountstring (decimal)Sum of orders.upstream_refunded_amount — upstream cost reclaimed via refunds
revenue.netUpstreamCoststring (decimal)totalUpstreamCost − upstreamRefundedAmount
revenue.netMarginstring (decimal)netRevenue − netUpstreamCost — true after-refund margin
revenue.orderCounts{ new, topup }Per-operation-type order count
revenue.daily[]arrayPer-day breakdown. Same three net fields are present on every day. Bucketed by order created_at date, not refund commit date.
profitability.plans[]arrayPer-plan profitability. Carries orderCount, refundCount, grossRevenue, refundedAmount, netRevenue, grossMargin, unitMargin, plus the three FIN-32 net fields.
customers[]arrayPer-customer spend rollup (revenue side only — no upstream/cost fields)
Example Request
curl -H "Authorization: Bearer $ADMIN_REPORT_TOKEN" \
  "https://api.esimdatastore.com/api/reports/financial?month=2026-03§ions=revenue,profitability"
Example Response — abbreviated revenue + profitability
{
  "month": "2026-03",
  "generatedAt": "2026-04-03T12:00:00.000Z",
  "revenue": {
    "grossRevenue": "40.00",
    "totalUpstreamCost": "24.00",
    "grossMargin": "16.00",
    "marginPercent": "40.00",
    "refundedAmount": "5.00",
    "refundCount": 1,
    "netRevenue": "35.00",
    "upstreamRefundedAmount": "3.00",
    "netUpstreamCost": "21.00",
    "netMargin": "14.00",
    "orderCounts": { "new": 2, "topup": 1 },
    "daily": [
      {
        "date": "2026-03-01",
        "grossRevenue": "20.00",
        "upstreamCost": "12.00",
        "grossMargin": "8.00",
        "refundedAmount": "0.00",
        "refundCount": 0,
        "orders": 1,
        "upstreamRefundedAmount": "0.00",
        "netUpstreamCost": "12.00",
        "netMargin": "8.00"
      }
    ]
  },
  "profitability": {
    "plans": [
      {
        "planId": "019db811-6b8f-70a7-96a1-f30b7bf45523",
        "planName": "EU 5GB",
        "orderCount": 3,
        "refundCount": 1,
        "grossRevenue": "40.00",
        "refundedAmount": "5.00",
        "netRevenue": "35.00",
        "grossMargin": "16.00",
        "unitMargin": "5.33",
        "upstreamRefundedAmount": "3.00",
        "netUpstreamCost": "21.00",
        "netMargin": "14.00"
      }
    ]
  }
}
CSV / PDF: The CSV and PDF exporters serialize the same fields. CSV adds Upstream Refunded Amount, Net Upstream Cost, and Net Margin columns alongside the existing rollup columns; PDF mirrors them inline in the daily breakdown and per-plan tables.

Origination & Install (Phase 1)

Path B reseller-originated install flow. An admin issues an eSIM to a specific traveller; the traveller receives a one-tap install URL by email and redeems it anonymously from their device. All errors on these endpoints emit application/problem+json with shape { type, title, status, detail }.

POST /origination/issue Idempotent

Admin only. Issue an eSIM to a traveller via the Path B flow: findBySourceRef replay short-circuit → customer/plan existence checks → withAdvisoryLock per (sourceRefKind, sourceRefId) → eSIMfx createOrder('NEW', plan.esimfxProductId) → persist esims row → sign install token → esim.issued + install_token.signed audit emits → SES install email → install_email.sent on success, or install_email.failed + INSTALL_EMAIL_DELIVERY_FAILED critical event on SES throw, 10s timeout, OR QR/template render throw. No pricing or wallet path runs here — origination is upstream-issue + audit + email only. The DB partial-unique index on (source_ref_kind, source_ref_id) is the structural backstop; resignForExisting re-signs install tokens that have passed their original expiry without re-issuing the eSIM. Replay does not 409 on a different request body for the same tuple — the stored eSIM wins.

Headers

  • Authorization: Bearer <ADMIN_TOKEN> — required
  • Idempotency-Key: <uuid> — recommended. Combined with the DB unique on (sourceRefKind, sourceRefId) makes the call replay-safe under retries.

Request Body

FieldTypeRequiredDescription
customerIdstring (UUIDv7)YesThe reseller's customer record. The lookup only verifies the customer exists; no wallet / pricing path runs in this endpoint.
planIdstring (UUIDv7)YesPlan to issue
emailstring (email)YesTraveller's recipient email — install URL is sent here
sourceRefobjectYes{ kind: "admin_order" | "voucher_redemption", id: <UUIDv7> }. The (kind, id) tuple is the replay-safety key.
locale"en" | "de"NoDefault "en". Used both for the install email template and the install URL path segment.
expiresInDaysinteger (1..90)NoDefault 90. Hard-capped at 90 by class-validator @Max(90).
travelerNamestring (1..100)NoTraveller name for the email salutation.

Response 201

FieldTypeDescription
esimIdstring (UUIDv7)Provisioned eSIM record
installTokenstring (JWT)Signed Ed25519 install token (base64url JWT)
installUrlstring (URL)${INSTALL_URL_BASE}/${locale}/install/${installToken} — the URL emailed to the traveller

Errors

  • 401 — missing or invalid admin token
  • 422 — request validation failure (bad UUID, bad email shape, missing required field, enum miss on sourceRef.kind or locale) — Nest's global ValidationPipe is configured with errorHttpStatusCode: UNPROCESSABLE_ENTITY; OR business-rule violation (customer not found, plan not found) via ServiceError.invalidReference
  • 429 — throttle (30/min/IP)
  • 502 UPSTREAM_ERROR — three triggers, two distinct lifecycle stages. Pre-persist: (a) eSIMfx createOrder failure or 10s call-side timeout (AV-36; on timeout a UPSTREAM_TERMINATION_FAILED critical_financial event fires with state UNKNOWN message — recovery is NOT to match the sourceRef in eSIMfx admin (sourceRef is never sent upstream); instead the message provides the timeout timestamp + esimfx_product_id for a time-windowed scan against eSIMfx admin, cross-referenced with esims.esimfx_order_id to find orphans. See runbook §14 Mode D). No eSIM row exists on this branch; nothing to replay. Post-persist install-email try/catch (eSIM row + install token already in DB): (b) SES send failure or 10s timeout for the install email — INSTALL_EMAIL_DELIVERY_FAILED critical_financial event fires; (c) QRCode.toDataURL or Handlebars template render throws during install-email assembly — same INSTALL_EMAIL_DELIVERY_FAILED event fires (iter-7 fix). For (b) and (c) recovery: out-of-band delivery via POST /origination/issue with the same sourceRef + fresh Idempotency-Key returns the existing eSIM + a re-signed install URL (replay does NOT trigger SES re-send; see runbook §11.4).
  • 500 DATABASE_ERROR — uncategorized server error reached the outer wrapper (unexpected Drizzle / pg failure on a SELECT or INSERT that is NOT inside the install-email try/catch). Raw QR / template / SES exceptions are caught inside that block and rethrown as 502, not 500. Operator-actionable detail is in Loki under service: ServiceErrorFilter.
  • 503 — upstream circuit breaker open (EsimfxCircuitOpenFilter adds retryAfterSeconds)
Replay: a second call with the same sourceRef.{kind,id} short-circuits at findBySourceRef and returns the existing eSIM via resignForExisting, with a new install_token.signed audit event. The eSIMfx upstream is not re-hit and the install email is not re-sent. Token bytes MAY equal the original on a same-second replay (the signer uses second-granular iat / nbf); on expiry refresh they change.

Note on Idempotency-Key: the HTTP interceptor caches successful responses byte-for-byte for 24h, keyed on the header value. Reusing the same Idempotency-Key replays the cached body verbatim — including the original installToken — without invoking the service-level replay path. Send a fresh key per logical request (or omit the header) when you need a refreshed token from resignForExisting.

Example Request
curl -X POST https://api.esimdatastore.com/api/origination/issue \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 660e8400-e29b-41d4-a716-446655440099" \
  -d '{
    "customerId": "019d0001-0000-7000-8000-000000000001",
    "planId": "019d0001-0000-7000-8000-000000000010",
    "email": "[email protected]",
    "sourceRef": { "kind": "admin_order", "id": "019d0005-0000-7000-8000-00000000abcd" },
    "locale": "en",
    "expiresInDays": 90,
    "travelerName": "Traveller Name"
  }'
Example Response
{
  "esimId": "019d0007-3a20-71b0-8c12-7e0a1b2c3d4e",
  "installToken": "eyJhbGciOiJFZERTQSIs...",
  "installUrl": "https://esimdatastore.com/en/install/eyJhbGciOiJFZERTQSIs..."
}
POST /admin/invoices/run

Admin only. Generate a monthly invoice PDF for a customer. Both periodStart and periodEnd are bare-date strings (YYYY-MM-DD) that must round-trip as real calendar dates. Idempotent on the (customer_id, period_start) unique index (with a 23505 catch as the race-safety fallback).

Headers

  • Authorization: Bearer <ADMIN_TOKEN> — required

Request Body

FieldTypeRequiredDescription
customerIdstring (UUIDv7)YesCustomer to invoice
periodStartstring (YYYY-MM-DD)YesBare-date UTC. Calendar-valid round-trip required (no Feb 31, no month=13). Timezones and datetimes rejected.
periodEndstring (YYYY-MM-DD)YesSame shape and validation as periodStart.
localestring ("en" | "sr")NoDefault "en". "sr" renders Serbian Latin script (Faktura heading, PDV VAT-line labels, SR date format via i18n.ts). v2.9.0+.
exchangeRateobject { rateDate, rateUsdToRsd, source }NoManual USD-to-RSD override that skips the auto-fetch. All three subfields required together: rateDate (bare YYYY-MM-DD, effective NBS date the operator pulled the rate from), rateUsdToRsd (positive decimal string, up to 6 fractional digits, matches numeric(18,6) storage), source ("NBS_KURS" when copied verbatim from NBS / kurs.resenje.org; "manual" for any other source). Applied verbatim regardless of buyer country — useful for audit replay, mirror-outage fallback, or adding an RSD conversion to a non-RS invoice at the operator's discretion. v2.10.0+.

Response 200

application/pdf binary — the rendered invoice (pdf-lib).

Errors

  • 401 — missing or invalid admin token
  • 404 — nothing billable falls in the requested window. The invoice combines three row-sources: (1) admin-flow eSIMs (source_ref_kind = admin_order or NULL for pre-Block-B legacy rows), (2) voucher lines with redeemed_at in the period (status REDEEMED or REFUNDED — a REFUNDED row still emits its positive charge here so a same-period redeem+refund is not naked-credited; the negative comes from source #3), and (3) voucher refunds with refunded_at in the period (signed negative credit-memo lines). 404 fires only when all three are empty. ServiceError.notFound('invoice', '${customerId}:${periodStart}')
  • 422 INVALID_REFERENCE — multiple causes: (a) request validation failure (bad UUID, bad date shape, calendar-invalid dates like Feb 31, periodEnd <= periodStart, periodStart/periodEnd not at UTC midnight); (b) customer not found; (c) FIN-37 missing rate-card — one or more Phase 1 eSIMs in the period have no customer_plan_prices row, so the line item cannot be priced. The invoice run is aborted before any partial write; a DATA_CONSISTENCY_ERROR critical event fires with up to 25 affected esim/plan IDs. Operator must insert the missing rate-card rows (see runbook §14 DATA_CONSISTENCY_ERROR row for the diagnosis query) then re-run. Order-flow eSIMs (esims.plan_id IS NULL) are excluded from this billing path so they cannot trigger this case.
  • 409 — an invoice already exists for this (customer_id, period_start) (ServiceError.conflict from 23505)
  • 429 — throttle exceeded (10 invoice runs/min/IP — invoice generation is multi-join + pdf-lib)
  • 502 UPSTREAM_ERROR — the NBS rate mirror (kurs.resenje.org) was unreachable, timed out, or returned an invalid payload for a Serbian buyer with no manual exchangeRate override. Operator retries with the manual-override escape hatch: fetch the current NBS rate directly and re-submit with { rateDate, rateUsdToRsd, source: "NBS_KURS" } in the request body. See design doc docs/plans/2026-07-16-invoice-rsd-nbs-rate.md §9 for the full failure-mode catalogue.
  • 500 DATABASE_ERROR — uncategorized server error reached the outer wrapper. Examples: unexpected Drizzle / pg failure on a read or transaction; raw pdf-lib exception during render. The service wraps non-ServiceError throws so the response stays application/problem+json with code: DATABASE_ERROR; operator-actionable detail is in Loki under service: ServiceErrorFilter.

Window semantics: periodStart is the inclusive UTC-midnight start; periodEnd is the exclusive UTC-midnight end. For a May 2026 invoice use 2026-05-01 / 2026-06-01.

Example Request
curl -X POST https://api.esimdatastore.com/api/admin/invoices/run \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"customerId": "019d0001-0000-7000-8000-000000000001", "periodStart": "2026-05-01", "periodEnd": "2026-06-01"}' \
  -o invoice.pdf
GET /admin/email-suppressions

Admin only. List email addresses in the SES suppression table. The SES bounce/complaint receiver (POST /api/webhooks/ses, v2.8.0+) writes rows on hard bounces (bounceType='Permanent') and complaints; EmailSender.send() then refuses to send to any address present here (throws 409). Use this endpoint to audit or discover addresses before invoking POST /admin/email-suppressions/remove or /remove-batch.

PII note on pattern: query params CAN appear in URL request logs (the logger.factory req.body.* redaction rules only cover the body). Prefer class-of-rows LIKE patterns like traveller+%@esimdatastore.com or %@disposable.example rather than specific customer addresses. For per-address introspection use the sibling body-based endpoints.

Query Parameters

FieldTypeRequiredDescription
patternstring (SQL LIKE)NoSQL LIKE pattern matched against email. Wildcards % and _. Undefined = no email filter. @MinLength(1) + @MaxLength(320).
reasonenumNoOne of hard_bounce | complaint | manual. Exact-match filter.
limitintegerNo1..500. Default 100.
offsetintegerNoZero-based, default 0. Ordering is suppressed_at DESC, email DESC for pagination stability across ties.

Response 200

{
  "items": [
    {
      "email": "[email protected]",
      "reason": "hard_bounce",
      "suppressedAt": "2026-07-11T04:29:26.870Z",
      "firstNotificationId": "265d09b0-2099-5fc5-9cbb-6b7cb1b33d0b"
    }
  ],
  "total": 43,
  "limit": 100,
  "offset": 0
}

Errors

  • 401 — invalid or missing admin bearer token
  • 422 — invalid reason, out-of-range limit/offset, or overlong pattern
  • 429 — throttle (30 req/min/IP on admin endpoints)
POST /admin/email-suppressions/remove

Admin only. Remove a single email from the suppression table. Idempotent no-op if the row doesn't exist (returns 200 with deleted: false). Body-based (not URL-based) so the standard req.body.email redaction covers PII in request logs.

Unsuppress is NOT a resend trigger. Removing a row means the next legitimate send WILL be attempted. If the address is still bad (typo, closed mailbox), the next send re-bounces and adds a fresh row. Every hard-bounce fresh row hurts sender reputation. Only unsuppress after the underlying deliverability issue is genuinely fixed. See voucher-runbook.html §13 for the safe cleanup pattern.

Request Body

FieldTypeRequiredDescription
emailstringYesRecipient address. Validation is intentionally lenient (no @IsEmail) — the service normalises trim + lowercase and returns deleted: false if no row matches. @MinLength(1) + @MaxLength(320) + @Matches(/\S/) to reject whitespace-only.

Response 200

{ "emailNormalised": "[email protected]", "deleted": true }

emailNormalised echoes the trim+lowercase form that was looked up — useful when the operator pasted an address from a log line.

Errors

  • 401 — invalid or missing admin bearer token
  • 422 — empty, whitespace-only, or overlong email; missing field
  • 429 — throttle
POST /admin/email-suppressions/remove-batch

Admin only. Bulk-remove 1..500 emails in one call. Each entry follows the same per-entry semantics as the single endpoint. Emits one email_suppression.unsuppressed audit event per successfully-deleted row (domain-only per SEC-45).

Request Body

{
  "emails": [
    "[email protected]",
    "[email protected]"
  ]
}

Array size 1..500 (ArrayMinSize + ArrayMaxSize). Duplicates in the input are preserved in the response order — the first occurrence sees deleted: true if the row existed, subsequent duplicates see false.

Response 200

{
  "results": [
    { "emailNormalised": "[email protected]", "deleted": true },
    { "emailNormalised": "[email protected]", "deleted": true }
  ],
  "summary": { "removed": 2, "skipped": 0 }
}

Errors

  • 401 — invalid or missing admin bearer token
  • 422 — empty array, more than 500 entries, or per-entry validation failure (empty / whitespace / overlong)
  • 429 — throttle
GET /install/:token

Public & anonymous. Resolves the Ed25519 JWT install token and returns the LPA + QR payload for one-tap activation. No Authorization header, no API key. The path parameter is the JWT itself — anyone with the URL can fetch the payload while it is valid. Response carries Cache-Control: no-store and Pragma: no-cache via InstallNoStoreMiddleware. The endpoint is read-only — fetching /install/:token does not mark the eSIM as redeemed. The status field reflects whatever esims.redeemed_at happens to be at the time of the call; no production code path currently writes redeemed_at (a traveller-facing redeem write path is a Phase 1B follow-up).

Throttling: Per-IP 10 req/min AND per-install-token 30 req/min (token hashed with SHA-256 before keying the limiter — the raw JWT is never stored on the limit bucket).

Path Parameters

ParameterTypeDescription
tokenstring (JWT)Ed25519-signed install token issued by POST /origination/issue. Key rotation accepted via INSTALL_TOKEN_PUBLIC_KEY_PREVIOUS.

Response 200

FieldTypeDescription
statusstring"ready" when esims.redeemed_at IS NULL; "redeemed" when it is non-null (one-shot credentials blanked). The flip is governed solely by esims.redeemed_at; orders.subscription_status is unrelated.
lpaStringstringLPA activation string (LPA:1$...). Blank when status === "redeemed".
qrDataUristringdata:image/png;base64,... QR rendering of the LPA. Blank when status === "redeemed".
planNamestringDisplay name of the plan
countryNamestringCoverage country/region
dataAmountstringIncluded data, formatted (e.g. "5GB")
validityDaysnumberValidity window in days
No iccid in this response (SEC-23): The ICCID was removed from the install payload — the LPA string and QR data URI are sufficient for activation, and exposing the ICCID anonymously was an unnecessary disclosure. Run pnpm dump:spec from esim-backend to regenerate openapi.json; FE picks up the contract via pnpm gen:api.

Errors

All emit application/problem+json:

  • 422 INVALID_REFERENCE — JWT shape is invalid, unparseable, or fails signature verification (ServiceError.invalidReference)
  • 410 GONE — JWT exp in the past, OR esims.install_token_expires_at < now(), OR underlying eSIM is EXPIRED / BLOCKED
  • 404 NOT_FOUND — token verifies but the eSIM row referenced by it no longer exists
  • 422 MALFORMED_ENTITY — stored eSIM row is in a state that prevents serving (esim_qr missing or not in LPA shape, or plan_id references a missing plan). Logged at warn by ServiceErrorFilter AND emits a DATA_CONSISTENCY_ERROR critical_infra event (iter-11 fix — pageable signal so ops sees the corruption proactively rather than via support complaint)
  • 429 — throttle exceeded (per-IP 10/min or per-token 30/min, SHA-256 hashed)

There is no 502 path on this endpoint — GET /install/:token reads from local DB only and does not call any upstream.

Example Request
# Anonymous — no auth header.
curl https://api.esimdatastore.com/api/install/eyJhbGciOiJFZERTQSIs...
Example Response — ready
{
  "status": "ready",
  "lpaString": "LPA:1$consumer.esim.example$ACT-4F2A-9B1C",
  "qrDataUri": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg...",
  "planName": "Europe 5GB / 30 Days",
  "countryName": "Europe",
  "dataAmount": "5GB",
  "validityDays": 30
}
Example Response — redeemed
{
  "status": "redeemed",
  "lpaString": "",
  "qrDataUri": "",
  "planName": "Europe 5GB / 30 Days",
  "countryName": "Europe",
  "dataAmount": "5GB",
  "validityDays": 30
}

Vouchers (Phase 1B)

Reseller voucher codes: an admin creates a batch of N codes against a customer + plan; per-code price resolves from customer_plan_prices at REDEEMED commit and is snapshotted onto voucher_codes.price_minor (see Pricing + plan guards below). Codes are distributed (CSV / printable QR PDF) by the reseller; travellers redeem one code anonymously on a public lander and receive an eSIM install link by email. Five admin endpoints sit behind the ADMIN_TOKEN bearer; two public endpoints under /r/:code are anonymous and heavily throttled.

Endpoints

  • POST /admin/code-batches — create a batch + generate N codes atomically (STANDARD: 1 code per row, or BUNDLE: pairs of 2 when topupPlanId is supplied — see Bundle batches below)
  • GET /admin/code-batches — list batches (cross-customer admin view)
  • GET /admin/code-batches/:id — batch detail + per-status code counts
  • GET /admin/code-batches/:id/codes (2026-06-23) — paginated enumeration of voucher codes inside a batch with full per-row metadata. Filters: ?status, ?pairKind, ?limit, ?offset. Sort matches the bundle export so operators can map a CSV row position to a codeId without re-indexing: STANDARD batches sort by code ASC, BUNDLE batches sort by pair_id ASC, pair_kind ASC. Privileged: returns the redeemable code string + email fields; admin-only with Cache-Control: no-store so the response isn't retained by browsers / CDN intermediaries. Closes the operator-triage gap that previously forced direct psql to resolve a code string back to its UUID for single-code revoke.
  • POST /admin/code-batches/:id/revoke — cascade ISSUED codes to REVOKED (bundle: also cascades through PENDING_BINDING halves and the paired side of any single revoke)
  • GET /admin/code-batches/:id/bundle.zip — stream CSV + QR PDF ZIP (BUNDLE batches: wide-format CSV with both pair halves per row + two-QRs-per-card PDF)
  • POST /admin/codes/:id/revoke — revoke a single code (bundle: cascades to the paired half if present)
  • GET /admin/voucher-pairs/:pairId (2026-06-15) — bundle diagnostic: both halves + linked eSIM lifecycle + up to 10 recent audit_events, in one round-trip
  • GET /admin/voucher-codes/:codeId/pair (2026-06-15) — same response shape as /admin/voucher-pairs/:pairId, but resolved via either half's code id; 404 for standalone (non-bundle) codes
  • GET /r/:code — public state view (lander UI uses this to render the redemption page)
  • POST /r/:code — public redeem. 201 response is discriminated by kind: { kind: "esim", installToken, installUrl } for standalone codes and bundle ESIM-halves (provisions eSIM, sends install email, returns the install token + canonical install URL); { kind: "topup", topupOrderId, dataAmount, dataAmountUnit, validityDays } for bundle TOPUP-halves (applies the topup against the bound ICCID; no install URL because the eSIM is already installed). See src/domain/vouchers/rest/redeem-code.response.ts for the OpenAPI schema. BUNDLE: the ESIM-half is redeemed first; the TOPUP-half is bound at the same commit and becomes redeemable thereafter.

Bundle batches (2026-06-15)

Admin pair lookup endpoints (2026-06-15)

Authentication and throttle ceilings

Pricing + plan guards (2026-06-15 architecture; supersedes prior FIN-43)

Concurrency + AV-41 REDEEMING semantics

The redeem path uses an intermediate REDEEMING code status to release DB pool connections during the eSIMfx + SES roundtrip. Concurrent same-code redeems serialise on a FOR UPDATE row-lock during Phase 1; the loser sees REDEEMING and gets a retryable 409 CONFLICT (the post-commit retry resolves cleanly via origination idempotency on sourceRef). From the public GET /r/:code view, REDEEMING is surfaced as the redeemed kind so anonymous polling cannot distinguish "in progress" from "completed". See wholesale-state-machines.html §6.5 for the full voucher state machine.

Public /r/:code information-leak guard

Error inventory

All voucher endpoints emit application/problem+json. The status codes specific to voucher semantics:

5. Idempotency

To prevent duplicate charges, include an Idempotency-Key header with a unique value (UUID recommended) on supported endpoints.

Supported Endpoints

  • POST /orders
  • POST /esims/:id/topup
  • POST /origination/issue (Phase 1; admin-scoped)
  • POST /admin/code-batches (Phase 1B; admin-scoped)
  • POST /admin/code-batches/:id/revoke (Phase 1B; admin-scoped)
  • POST /admin/codes/:id/revoke (Phase 1B; admin-scoped)

How It Works

Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Tip: Always use idempotency keys for purchase and top-up operations. Network retries without an idempotency key can result in double charges.

6. Pagination

The GET /orders endpoint supports cursor-based pagination. Pass the id of the last order from the previous page as the cursor query parameter.

ParameterTypeDefaultDescription
limitnumber50Results per page (max 100)
cursorstring (UUID)Last order ID from previous page

Example: Paginating Through Results

# First page
curl -X GET "https://api.esimdatastore.com/api/orders?limit=10" \
  -H "X-API-Key: your-api-key"

# Response includes 10 orders, last one has id "abc123..."

# Next page — pass last order's id as cursor
curl -X GET "https://api.esimdatastore.com/api/orders?limit=10&cursor=abc123-def456-..." \
  -H "X-API-Key: your-api-key"

# Continue until an empty array is returned
Note: When the response returns an empty array, you have reached the end of the results.

7. Error Handling

All API errors emit application/problem+json per RFC 7807. Phase 1 widened this envelope across the /api/* surface via ServiceErrorFilter, EsimfxUpstreamFilter, EsimfxCircuitOpenFilter, and a global HttpExceptionToProblemFilter registered last so any uncaught HttpException still emits the envelope. The shared OpenAPI schema is ProblemDetailResponse. Note: the webhook receiver (POST /webhook/...) is internal-to-eSIMfx and still returns a bespoke { received: false } 500 body for upstream-protocol compatibility; the problem+json envelope is for the API surface that customers / admin tools call.

Error Format (RFC 7807)

Required fields: type, title, status, detail. Optional extensions per RFC 7807 §3.2: code (machine-readable error code), errors (per-field validation details on 422 / CSV row errors), retryAfterSeconds (on circuit-open 503).

HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{
  "type": "https://esimdatastore.com/errors/conflict",
  "title": "Conflict",
  "status": 409,
  "detail": "Duplicate request in progress",
  "code": "CONFLICT"
}

Validation errors (422) include per-field details in errors:

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json

{
  "type": "https://esimdatastore.com/errors/validation",
  "title": "Unprocessable Entity",
  "status": 422,
  "detail": "Request body failed validation",
  "errors": [
    { "property": "planId", "constraints": { "isUuid": "planId must be a UUID" } }
  ]
}

Circuit-open (503) responses carry retryAfterSeconds:

HTTP/1.1 503 Service Unavailable
Content-Type: application/problem+json

{
  "type": "https://esimdatastore.com/errors/service-unavailable",
  "title": "Service Unavailable",
  "status": 503,
  "detail": "Service temporarily unavailable",
  "retryAfterSeconds": 30
}

5xx detail is collapsed to the status title to avoid leaking internals; per-error type URIs disambiguate when needed.

Status Codes

CodeMeaningDescription
400Bad RequestInvalid path parameter format (e.g. non-UUID ID)
401UnauthorizedMissing or invalid API key
404Not FoundResource does not exist or belongs to another account
409ConflictDuplicate idempotency key with an in-flight request
422Unprocessable EntityValidation error (request body failed validation)
429Too Many RequestsRate limit exceeded
502Bad GatewayeSIM provider error — retry recommended
503Service UnavailableeSIM provider temporarily unavailable — retry after delay
Note: Validation errors return 422 Unprocessable Entity, not 400 Bad Request. Only malformed path parameters (e.g. a non-UUID string where a UUID is expected) return 400.

Example Error Responses

401 Unauthorized
HTTP/1.1 401 Unauthorized
Content-Type: application/problem+json

{
  "type": "https://esimdatastore.com/errors/unauthorized",
  "title": "Unauthorized",
  "status": 401,
  "detail": "Unauthorized"
}
409 Conflict (Duplicate Idempotency Key)
HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{
  "type": "https://esimdatastore.com/errors/conflict",
  "title": "Conflict",
  "status": 409,
  "detail": "Duplicate request in progress",
  "code": "CONFLICT"
}
422 Unprocessable Entity (Validation Error)
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json

{
  "type": "https://esimdatastore.com/errors/validation",
  "title": "Unprocessable Entity",
  "status": 422,
  "detail": "Request body failed validation",
  "errors": [
    {
      "property": "planId",
      "constraints": { "isUuid": "planId must be a UUID" }
    }
  ]
}

8. Rate Limits

Requests are rate-limited per API key. When a limit is exceeded, the API returns 429 Too Many Requests with a Retry-After header indicating how many seconds to wait.

CategoryEndpointsLimit
Standard GET /plans, GET /orders, GET /esims, GET /wallets/balance 30 requests / minute
Sensitive GET /esims/:id, GET /esims/:id/usage, GET /esims/:id/topups/available, POST /orders, POST /esims/:id/topup 10 requests / minute
Refunds POST /orders/:id/refund 5 requests / minute
Best practice: Implement exponential backoff when you receive a 429 response. Check the Retry-After header for the recommended wait time.