eSIM Data Store — Wholesale API
Purchase eSIM plans, manage eSIMs, monitor usage, and control your wallet balance.
/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.
Table of Contents
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.
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:
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"
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"}'
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"
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"
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"}'
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
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:
| Field | Type | Description |
|---|---|---|
id | string (UUID) | Plan identifier |
name | string | Display name of the plan |
description | string | null | Optional description |
price | string (decimal) | Price in USD, e.g. "9.99" |
duration | number | Validity duration |
durationUnit | string | Unit of duration, e.g. "DAY" |
dataAmount | number | Included data amount |
dataAmountUnit | string | Unit of data, e.g. "GB" |
coverage | string | Coverage region or country |
provisioningFee | string (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"
}
]
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:
| Field | Type | Description |
|---|---|---|
esimfxProductId | string | Upstream eSIMfx product identifier used for provisioning. Stable across syncs. |
esimfxImsiProfile | string | Upstream IMSI profile identifier. |
upstreamCost | string (decimal) | Wholesale cost from eSIMfx (USD). Used to derive margin against the customer price. |
destination | string | null | Country / 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. |
compatibleTopupProductIds | string[] | null | Upstream 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. |
disabled | boolean | true when the plan is hidden from customer GET /plans and rejected by POST /orders. |
disabledReason | enum | null | One of MANUAL, NEGATIVE_MARGIN, DUPLICATE. Populated when disabled=true. |
removedFromUpstream | boolean | true when the plan disappeared from the last successful upstream sync (stale but not deleted; retained for historical order references). |
createdAt | string (ISO 8601) | Row creation timestamp. |
Example Request
curl -X GET https://api.esimdatastore.com/api/plans/all \
-H "Authorization: Bearer $ADMIN_TOKEN"
Orders
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
| Field | Type | Required | Description |
|---|---|---|---|
planId | string (UUID) | Yes | ID of the plan to purchase |
Headers
| Header | Required | Description |
|---|---|---|
Idempotency-Key | Recommended | UUID to prevent duplicate purchases. Same key within 24 hours returns the cached response. |
Response 201
Returns an OrderResponse object:
| Field | Type | Description |
|---|---|---|
id | string (UUID) | Order identifier |
planId | string (UUID) | Purchased plan ID |
iccid | string | null | ICCID of the provisioned eSIM (null while pending) |
operationType | string | "NEW" or "TOPUP" |
status | string | One of: PENDING, COMPLETED, FAILED, REFUND_PENDING, REFUNDED, REFUND_FAILED, CONFIRM_PENDING, CONFIRM_FAILED |
subscriptionStatus | string | null | One of: PENDING, ACTIVE, EXPIRED, TERMINATED, or null |
planPrice | string (decimal) | Plan price before provisioning fee |
provisioningFee | string (decimal) | Provisioning fee included in salePrice ("0.00" for top-ups) |
salePrice | string (decimal) | Total price charged for this order (planPrice + provisioningFee) |
refundRequestedAt | string (ISO 8601) | null | Set when the customer accepted a refund (order entered REFUND_PENDING). |
refundedAt | string (ISO 8601) | null | Set when the wallet credit + status flip committed (status is REFUNDED). |
refundedAmount | string (decimal) | null | Equal to planPrice (= salePrice − provisioningFee, 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. |
createdAt | string (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"
}
Retrieve details for a specific order.
Path Parameters
| Parameter | Type | Description |
|---|---|---|
id | string (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"
List your orders with cursor-based pagination.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | number | 50 | Number of results to return (max 100) |
cursor | string (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
List all eSIMs on your account.
Response 200
Returns an array of EsimListItem objects:
| Field | Type | Description |
|---|---|---|
id | string (UUID) | eSIM identifier |
iccid | string | ICCID of the eSIM |
status | string | One of: PROVISIONED, ACTIVE, EXPIRED, BLOCKED |
createdAt | string (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 eSIM details with live status from the eSIM provider.
Sandbox: Sandbox eSIMs always show ACTIVE status.
Path Parameters
| Parameter | Type | Description |
|---|---|---|
id | string (UUID) | eSIM identifier |
Response 200
Returns an EsimResponse with an additional liveStatus object:
| Field | Type | Description |
|---|---|---|
liveStatus.iccid | string | ICCID confirmed by provider |
liveStatus.esimQr | string | QR code data |
liveStatus.status | string | Current 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 data usage, activation time, and expiry for an eSIM.
Sandbox: Sandbox returns simulated usage data.
Path Parameters
| Parameter | Type | Description |
|---|---|---|
id | string (UUID) | eSIM identifier |
Response 200
| Field | Type | Description |
|---|---|---|
iccid | string | ICCID of the eSIM |
usedAmount | number | null | Data consumed (null if not yet activated) |
totalAmount | number | Total data allowance |
amountUnit | string | Unit of data, e.g. "MB" |
status | string | Current status of the eSIM subscription |
activationTime | string (ISO 8601) | When the eSIM was activated |
expiry | string (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"
}
List available top-up plans for a specific eSIM.
Sandbox: Sandbox returns all plans with customer pricing (no upstream filter).
Path Parameters
| Parameter | Type | Description |
|---|---|---|
id | string (UUID) | eSIM identifier |
Response 200
| Field | Type | Description |
|---|---|---|
planId | string (UUID) | Top-up plan identifier |
name | string | Display name of the top-up plan |
duration | number | Validity duration |
durationUnit | string | Unit of duration |
amount | number | Data amount included |
amountUnit | string | Unit of data |
coverage | string[] | List of covered regions/countries |
price | string (decimal) | Price in USD |
provisioningFee | string (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"
}
]
Top up an existing eSIM with additional data.
Sandbox: Sandbox top-ups simulate upstream without real provisioning.
Path Parameters
| Parameter | Type | Description |
|---|---|---|
id | string (UUID) | eSIM identifier |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
planId | string (UUID) | Yes | ID of the top-up plan (from available top-ups) |
Headers
| Header | Required | Description |
|---|---|---|
Idempotency-Key | Recommended | UUID 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 your current prepaid wallet balance.
Response 200
| Field | Type | Description |
|---|---|---|
balance | string (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
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
| Parameter | Type | Description |
|---|---|---|
id | string (UUID) | Order identifier |
Response 202
| Field | Type | Description |
|---|---|---|
orderId | string (UUID) | The order being refunded |
status | string | REFUND_PENDING when the inline upstream call did not complete in time; REFUNDED when wallet credit + status flip committed |
refundRequestedAt | string (ISO 8601) | Always present — when the refund request was accepted |
refundedAt | string (ISO 8601) | null | Set only when status === REFUNDED |
refundedAmount | string (decimal) | null | Equal to the order's planPrice (= salePrice − provisioningFee, 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
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>— requiredIdempotency-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 bymethod + 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.
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>— requiredIdempotency-Key: <uuid>— strongly recommended (same semantics asforce-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.
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
| Parameter | Type | Description |
|---|---|---|
month | string (YYYY-MM) | Required. Reporting month, UTC. |
sections | string | Comma-separated subset of revenue, profitability, customers. Defaults to all three. |
format | string | json (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— invalidmonthformat (must beYYYY-MM), unknownsectionsvalue, or ValidationPipe rejection of the query string429— 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-ServiceErrorthrows so the response staysapplication/problem+jsonwithcode: DATABASE_ERROR.
| Field | Type | Description |
|---|---|---|
month | string | Echoes the month query parameter |
generatedAt | string (ISO 8601) | Timestamp the report was rendered |
revenue.grossRevenue | string (decimal) | Sum of salePrice across orders in the month |
revenue.totalUpstreamCost | string (decimal) | Gross upstream cost (no refund netting) |
revenue.grossMargin | string (decimal) | grossRevenue − totalUpstreamCost |
revenue.marginPercent | string (decimal) | Gross margin as a percentage |
revenue.refundedAmount | string (decimal) | Sum of customer-side refund credits (= planPrice) |
revenue.refundCount | number | Refunded-order count |
revenue.netRevenue | string (decimal) | grossRevenue − refundedAmount |
revenue.upstreamRefundedAmount | string (decimal) | Sum of orders.upstream_refunded_amount — upstream cost reclaimed via refunds |
revenue.netUpstreamCost | string (decimal) | totalUpstreamCost − upstreamRefundedAmount |
revenue.netMargin | string (decimal) | netRevenue − netUpstreamCost — true after-refund margin |
revenue.orderCounts | { new, topup } | Per-operation-type order count |
revenue.daily[] | array | Per-day breakdown. Same three net fields are present on every day. Bucketed by order created_at date, not refund commit date. |
profitability.plans[] | array | Per-plan profitability. Carries orderCount, refundCount, grossRevenue, refundedAmount, netRevenue, grossMargin, unitMargin, plus the three FIN-32 net fields. |
customers[] | array | Per-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"
}
]
}
}
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 }.
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>— requiredIdempotency-Key: <uuid>— recommended. Combined with the DB unique on(sourceRefKind, sourceRefId)makes the call replay-safe under retries.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
customerId | string (UUIDv7) | Yes | The reseller's customer record. The lookup only verifies the customer exists; no wallet / pricing path runs in this endpoint. |
planId | string (UUIDv7) | Yes | Plan to issue |
email | string (email) | Yes | Traveller's recipient email — install URL is sent here |
sourceRef | object | Yes | { kind: "admin_order" | "voucher_redemption", id: <UUIDv7> }. The (kind, id) tuple is the replay-safety key. |
locale | "en" | "de" | No | Default "en". Used both for the install email template and the install URL path segment. |
expiresInDays | integer (1..90) | No | Default 90. Hard-capped at 90 by class-validator @Max(90). |
travelerName | string (1..100) | No | Traveller name for the email salutation. |
Response 201
| Field | Type | Description |
|---|---|---|
esimId | string (UUIDv7) | Provisioned eSIM record |
installToken | string (JWT) | Signed Ed25519 install token (base64url JWT) |
installUrl | string (URL) | ${INSTALL_URL_BASE}/${locale}/install/${installToken} — the URL emailed to the traveller |
Errors
401— missing or invalid admin token422— request validation failure (bad UUID, bad email shape, missing required field, enum miss onsourceRef.kindorlocale) — Nest's globalValidationPipeis configured witherrorHttpStatusCode: UNPROCESSABLE_ENTITY; OR business-rule violation (customer not found, plan not found) viaServiceError.invalidReference429— throttle (30/min/IP)502 UPSTREAM_ERROR— three triggers, two distinct lifecycle stages. Pre-persist: (a) eSIMfxcreateOrderfailure or 10s call-side timeout (AV-36; on timeout aUPSTREAM_TERMINATION_FAILED critical_financialevent 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 withesims.esimfx_order_idto 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) SESsendfailure or 10s timeout for the install email —INSTALL_EMAIL_DELIVERY_FAILED critical_financialevent fires; (c)QRCode.toDataURLor Handlebars template render throws during install-email assembly — sameINSTALL_EMAIL_DELIVERY_FAILEDevent fires (iter-7 fix). For (b) and (c) recovery: out-of-band delivery viaPOST /origination/issuewith 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 underservice: ServiceErrorFilter.503— upstream circuit breaker open (EsimfxCircuitOpenFilteraddsretryAfterSeconds)
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..."
}
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
| Field | Type | Required | Description |
|---|---|---|---|
customerId | string (UUIDv7) | Yes | Customer to invoice |
periodStart | string (YYYY-MM-DD) | Yes | Bare-date UTC. Calendar-valid round-trip required (no Feb 31, no month=13). Timezones and datetimes rejected. |
periodEnd | string (YYYY-MM-DD) | Yes | Same shape and validation as periodStart. |
locale | string ("en" | "sr") | No | Default "en". "sr" renders Serbian Latin script (Faktura heading, PDV VAT-line labels, SR date format via i18n.ts). v2.9.0+. |
exchangeRate | object { rateDate, rateUsdToRsd, source } | No | Manual 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 token404— nothing billable falls in the requested window. The invoice combines three row-sources: (1) admin-flow eSIMs (source_ref_kind = admin_orderor NULL for pre-Block-B legacy rows), (2) voucher lines withredeemed_atin 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 withrefunded_atin 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 nocustomer_plan_pricesrow, so the line item cannot be priced. The invoice run is aborted before any partial write; aDATA_CONSISTENCY_ERRORcritical event fires with up to 25 affected esim/plan IDs. Operator must insert the missing rate-card rows (see runbook §14DATA_CONSISTENCY_ERRORrow 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.conflictfrom 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 manualexchangeRateoverride. 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 docdocs/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-ServiceErrorthrows so the response staysapplication/problem+jsonwithcode: DATABASE_ERROR; operator-actionable detail is in Loki underservice: 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
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.
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
| Field | Type | Required | Description |
|---|---|---|---|
pattern | string (SQL LIKE) | No | SQL LIKE pattern matched against email. Wildcards % and _. Undefined = no email filter. @MinLength(1) + @MaxLength(320). |
reason | enum | No | One of hard_bounce | complaint | manual. Exact-match filter. |
limit | integer | No | 1..500. Default 100. |
offset | integer | No | Zero-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 token422— invalidreason, out-of-rangelimit/offset, or overlongpattern429— throttle (30 req/min/IP on admin endpoints)
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.
voucher-runbook.html §13 for the safe cleanup pattern.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Recipient 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 token422— empty, whitespace-only, or overlong email; missing field429— throttle
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 token422— empty array, more than 500 entries, or per-entry validation failure (empty / whitespace / overlong)429— throttle
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).
Path Parameters
| Parameter | Type | Description |
|---|---|---|
token | string (JWT) | Ed25519-signed install token issued by POST /origination/issue. Key rotation accepted via INSTALL_TOKEN_PUBLIC_KEY_PREVIOUS. |
Response 200
| Field | Type | Description |
|---|---|---|
status | string | "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. |
lpaString | string | LPA activation string (LPA:1$...). Blank when status === "redeemed". |
qrDataUri | string | data:image/png;base64,... QR rendering of the LPA. Blank when status === "redeemed". |
planName | string | Display name of the plan |
countryName | string | Coverage country/region |
dataAmount | string | Included data, formatted (e.g. "5GB") |
validityDays | number | Validity window in days |
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— JWTexpin the past, OResims.install_token_expires_at < now(), OR underlying eSIM isEXPIRED/BLOCKED404 NOT_FOUND— token verifies but the eSIM row referenced by it no longer exists422 MALFORMED_ENTITY— stored eSIM row is in a state that prevents serving (esim_qrmissing or not in LPA shape, orplan_idreferences a missing plan). Logged at warn byServiceErrorFilterAND emits aDATA_CONSISTENCY_ERROR critical_infraevent (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 whentopupPlanIdis 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 acodeIdwithout re-indexing: STANDARD batches sort bycode ASC, BUNDLE batches sort bypair_id ASC, pair_kind ASC. Privileged: returns the redeemablecodestring + email fields; admin-only withCache-Control: no-storeso 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 throughPENDING_BINDINGhalves 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 recentaudit_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 bykind:{ 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). Seesrc/domain/vouchers/rest/redeem-code.response.tsfor 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)
- Discriminator: pass
topupPlanIdonPOST /admin/code-batchesto mint a BUNDLE batch (kind =BUNDLEin the response). Omit it for the existing STANDARD path. The discriminator is persisted asvoucher_batches.topup_plan_id(NOT NULL on bundles, NULL on standard batches). - Pair layout: a BUNDLE batch with
count: Nmints 2Nvoucher_codesrows: N ESIM-halves (statusISSUED,pair_kind='ESIM') + N TOPUP-halves (statusPENDING_BINDING,pair_kind='TOPUP'). Each pair shares apair_id: a UUIDv7 generated once per pair at batch creation and written identically to both halves.pair_idis a plainuuidcolumn with no database-level FK — the grouping is enforced by thecreateBatchInnertransaction at insert time, andpair_kind('ESIM'|'TOPUP') discriminates the half. The partial-unique constraint(pair_id, pair_kind) WHERE pair_id IS NOT NULLguarantees at most one of each half perpair_id. - Redemption order is enforced. The TOPUP-half is born
PENDING_BINDINGand the publicPOST /r/:coderefuses to redeem it in that state (status-specific 409, message references the unbound state). The traveller must redeem the ESIM-half first; at the paired ESIM Phase 3 commit, the TOPUP-half is atomically flipped toISSUEDin the same transaction and stamped withbound_iccid+bound_email+bound_atfrom the just-issued eSIM (binding side-effect, emitsvoucher_pair.bound). - Bound-ICCID redemption (iter-4 #H1): Once bound, the TOPUP-half is redeemable against the bound ICCID by any submitted email — the submitted email becomes the recipient of the topup-ready email and gets stamped as
redeemed_email.bound_email(set at ESIM Phase 3) is preserved for audit / diagnostic purposes but is NOT enforced as the redemption recipient — and the same relaxation applies on theresume_after_upstreamretry path. A different ICCID is structurally impossible (the redeem path resolves the bound eSIM bybound_iccidvia the partial-unique index). The single-use controls are the bound ICCID + the partial-unique index + the persistedtopup_order_idon the resume path. If the integrator wants the topup-ready email to go to the original ESIM redeemer, they must submit that email on the TOPUP redeem call; the backend does not auto-route tobound_email. - G2 race (TOPUP revoked mid-ESIM-Phase-2): the ESIM commit still proceeds, but the binding side-effect detects the TOPUP-half is no longer
PENDING_BINDINGand emitsvoucher_pair.binding_skippedinstead ofvoucher_pair.bound. The traveller still gets the starter eSIM; the topup is forfeit. See runbook §14 "G2 race recovery procedure". - G4 sandbox short-circuit: sandbox does not expose
get_available_topups; the sandbox TOPUP redeem path invouchers.service.tsbypassesOriginationService.createUpstreamTopupentirely and synthesises thetopup_order_idlocally viauuidv7(). Audit-backlog UPS-37 stays open by design — the gap is bypassed by service-level sandbox branching, not closed. - Pricing: both halves snapshot their per-customer rate at redemption commit (same
customer_plan_prices.priceresolution as STANDARD), so invoice generation reports both halves as separate billable line items.
Admin pair lookup endpoints (2026-06-15)
GET /admin/voucher-pairs/:pairId— UUIDv7 pair-id input. Returns both halves' full state + linked eSIM lifecycle (when the ESIM-half has been redeemed) + up to 10 most-recentaudit_eventsscoped to{pairId, esimCodeId, topupCodeId, esimId}. Standalone (non-bundle) codes carrypair_id = NULLand are unreachable here by design (returns404).GET /admin/voucher-codes/:codeId/pair— alternative entry by either half'scodeId(UUIDv7). Same response shape as the pair-id endpoint.404for both "code doesn't exist" and "code exists but is standalone" — the two cases collapse to keep the response shape uniform; CS triages via the lookup target.- Auth + throttle: admin Bearer token, 60/min/IP. Both endpoints set
Cache-Control: no-store.
Authentication and throttle ceilings
- Admin endpoints:
Authorization: Bearer $ADMIN_TOKEN. The bundle download is throttled at 5/min/IP; other admin endpoints share the standard admin rate limit. - Public GET
/r/:code: anonymous; throttled per-IP (10/min) AND per-code-hash (30/min). Each handler has its own counter — the 10/min IP ceiling on GET is independent of the 5/min IP ceiling on POST. - Public POST
/r/:code: anonymous; tighter throttle (5/min/IP, 5/min/code-hash) because the operation provisions real upstream inventory + SES email.
Pricing + plan guards (2026-06-15 architecture; supersedes prior FIN-43)
- Per-customer voucher pricing. Each voucher code is billed at the customer-specific
customer_plan_prices.priceresolved live at REDEEMED commit (Phase 3 of the redeem state machine;SELECT ... FOR SHAREagainstcustomer_plan_pricesinside the same transaction that flips REDEEMING → REDEEMED). The resolved minor-unit amount is snapshotted ontovoucher_codes.price_minorso the invoice billing path reads from the snapshot, not the live rate-card. unitPriceMinorandcurrencyare no longer accepted onPOST /admin/code-batches(removed 2026-06-15). Sending either field returns a generic422validation error (detail: "Validation failed"with the offending field in theerrorsextension) — emitted by the globalValidationPipe'sforbidNonWhitelistedguard. This is a class-validator rejection, NOT a service-layerINVALID_REFERENCE.- Pre-flight rate-card check.
POST /admin/code-batchesrequires the target customer to already have acustomer_plan_pricesrow for the chosen plan; without one, the call returns422 INVALID_REFERENCEwith fieldplan_price. Operators must upload the customer CSV before issuing a voucher batch against a new plan. - Margin floor moved upstream. The structural margin floor (plan-level rate must be ≥ upstream cost) is enforced by
reconcilePlanMargins, called byCustomerPricesService.uploadPriceson every CSV upload. An underwater rate triggers aNEGATIVE_MARGIN_PLANcritical event and disables the plan for the affected customer. This happens at CSV upload time — not at batch creation. - FIN-44 (unchanged). The referenced plan must be neither
disablednorremovedFromUpstream; admin can't bind a long-lived batch to a plan they later disable.
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
- The
:codepath parameter is length-capped at 32 chars (SEC-29); overlong inputs return 404 with the same problem+json detail as "unknown code". - The 404 detail does NOT echo the raw caller-supplied code (SEC-28 GET path, SEC-36 POST path) — the access log is the only place the raw value appears, gated behind operator-only log access.
- Throttled responses (429) are emitted before the service is consulted, so brute-force enumeration is bounded even if the entropy budget (32^8 per
(country, year)partition) is somehow narrowed.
Error inventory
All voucher endpoints emit application/problem+json. The status codes specific to voucher semantics:
404 NOT_FOUND— unknown code; unknown / overlong path param; unknown batch / code on admin endpoints.409 CONFLICT— code already redeemed by a different email; AV-41 mid-flight redemption in progress; bundle TOPUP-half still inPENDING_BINDING(public POST/GET on the TOPUP-half before the paired ESIM has been redeemed — hint: "Redeem your eSIM voucher first"); bundle TOPUP-half inREDEEMING, split into two cases by whether the upstream side-effect marker is persisted: (a)topup_order_id NOT NULL— the upstreamcreate_order(NEW_TOPUP)already committed and the marker was written; a customer retry follows theresume_after_upstreamreplay flow and will succeed without a second upstream call (no double-charge); (b)topup_order_id IS NULL— ambiguous in-flight (Phase 2c concurrent submitter still running) OR stuck (Phase 2d failed AFTER the upstream commit but before the marker was persisted); the sweep refuses to auto-revert this row (iter-3 #H — would double-charge if upstream actually committed) and operator action is required via runbook §11.6b case (2); revoke against a REDEEMED / EXPIRED / REVOKED / REDEEMING code; batch already revoked.410 GONE— code REVOKED or EXPIRED (code-level or batch-level).422 INVALID_REFERENCE— unknown customer / plan; disabled or removed-from-upstream plan (FIN-44); customer kind notVOUCHER; missingcustomer_plan_pricesrow for the target plan (operator must upload customer CSV first).422generic validation (nocode: INVALID_REFERENCE,detail: "Validation failed"+errors) — class-validator rejections: label charset, count out of range, OR attempt to send the removedunitPriceMinor/currencyfields (caught by the globalValidationPipe'sforbidNonWhitelistedguard before the service is invoked).502 UPSTREAM_ERROR— propagated from origination (eSIMfxcreateOrderfail, SES throw, install-email timeout) on the ESIM / standalone path, and fromOriginationService.createUpstreamTopup(eSIMfxcreate_order(NEW_TOPUP)fail) on the bundle TOPUP-half redeem path. UNKNOWN upstream state on the TOPUP path leaves the row inREDEEMINGwithtopup_order_id IS NULL; sweep refuses to auto-revert (iter-3 #H — would double-charge if upstream actually committed). Operator action via runbook §11.6b case (2).
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
- If a request with the same
Idempotency-Keyis received within 24 hours, the API returns the original cached response without creating a duplicate order. - If a duplicate request arrives while the original is still being processed, the API returns
409 Conflict. - Each key should be a UUID. Reusing keys across different request bodies may produce unexpected results.
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
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.
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | number | 50 | Results per page (max 100) |
cursor | string (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
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
| Code | Meaning | Description |
|---|---|---|
400 | Bad Request | Invalid path parameter format (e.g. non-UUID ID) |
401 | Unauthorized | Missing or invalid API key |
404 | Not Found | Resource does not exist or belongs to another account |
409 | Conflict | Duplicate idempotency key with an in-flight request |
422 | Unprocessable Entity | Validation error (request body failed validation) |
429 | Too Many Requests | Rate limit exceeded |
502 | Bad Gateway | eSIM provider error — retry recommended |
503 | Service Unavailable | eSIM provider temporarily unavailable — retry after delay |
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.
| Category | Endpoints | Limit |
|---|---|---|
| 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 |
Retry-After header for the recommended wait time.