eSIM Data Store API (1.0.0)

Download OpenAPI specification:

eSIM Data Store — Wholesale API

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

Base URL: https://api.esimdatastore.com/api

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. The wholesale customer surface documented here is JSON and API-key authenticated. A few sibling surfaces on the same host behave differently and are noted where they appear: the public voucher-redemption and install-landing routes (/r/*, /install/*) are anonymous; admin routes require a bearer token; report endpoints can return text/csv or application/pdf when the caller passes ?format=csv or ?format=pdf on the query string (JSON is the default).

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.

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.

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

Quick Start

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"

Idempotency

To prevent duplicate charges on write operations, include an Idempotency-Key header with a unique value (UUID recommended). When the API sees the same key within a 24-hour retention window, it returns the original cached response instead of re-executing the request. This is the safe way to retry after a network timeout — see the Orders tag for the full list of idempotent endpoints and their per-endpoint semantics.

If a duplicate arrives while the original is still in flight, the API returns 409 Conflict. Reusing the same key with a different request body is undefined behaviour — always mint a fresh UUID per logical operation.

Error taxonomy

All API errors emit application/problem+json per RFC 7807. The envelope carries type, title, status, detail, and — for machine-driven callers — a code field naming the error family:

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

The code values you should branch on: VALIDATION_ERROR (request shape or field rejection — 422), NOT_FOUND (unknown resource or one that belongs to another account — 404), CONFLICT (idempotency collision, state conflict, in-flight duplicate — 409), UPSTREAM_ERROR (eSIMfx call failed or timed out — 502), RATE_LIMITED (throttle exceeded — 429, always paired with a Retry-After header), UNAUTHORIZED (missing or invalid API key — 401), and INTERNAL_ERROR (uncategorized server failure — 500). Validation errors carry a per-field errors extension; circuit-open 503 and throttled 429 responses carry a retryAfterSeconds extension mirroring the header.

Throttling

Requests are rate-limited per API key. When a limit is exceeded, the API returns 429 Too Many Requests with a Retry-After header (in seconds) indicating how long to wait before retrying. Typical ceilings are 30 requests/minute for cheap reads, 10/minute for writes and sensitive reads, and 5/minute for refunds — but do not hard-code these numbers; treat the Retry-After header as authoritative.

Implement exponential backoff on 429. Retrying inside the Retry-After window will not succeed and can compound the throttle.

Customer Prices

Customer Prices

Admin surface for managing per-customer plan pricing. Each customer has an independent rate-card in customer_plan_prices that determines the price returned by GET /plans and the amount debited on POST /orders. Prices are uploaded in bulk via CSV; the upload path also enforces the plan-level margin floor (rate must be greater than or equal to upstream cost) and auto-disables plans that go underwater for a given customer, emitting a NEGATIVE_MARGIN_PLAN critical event.

A customer must have a rate-card row for a plan before that plan is orderable or votable for a voucher batch — the pre-flight check in POST /admin/code-batches returns 422 INVALID_REFERENCE if a row is missing. Requires an admin bearer token.

Upload customer price list (CSV)

Authorizations:
apiKeybearer
path Parameters
customerId
required
string
Request Body schema: multipart/form-data
required
file
required
string <binary>

Responses

Response samples

Content type
application/json
{
  • "applied": 0,
  • "removed": 0,
  • "warnings": [
    ]
}

List customer plan prices

Authorizations:
apiKeybearer
path Parameters
customerId
required
string

Responses

Response samples

Content type
application/json
[
  • {
    }
]

Bulk-enable NEGATIVE_MARGIN customer prices for one customer (FIN-65)

Authorizations:
apiKeybearer
path Parameters
customerId
required
string
Request Body schema: application/json
required
object (BulkEnableCustomerPricesRequest)

Responses

Request samples

Content type
application/json
{ }

Response samples

Content type
application/json
{
  • "enabled": 0
}

eSIMs

eSIMs

Manage the eSIMs on your account. GET /esims lists inventory with status and creation time; GET /esims/:id returns the full record including the LPA esimQr string used for device installation and a live liveStatus block fetched from the upstream provider. GET /esims/:id/usage returns data consumption, activation time, and expiry — polled with a 60-second per-ICCID window to protect the upstream (window-hit returns 429 with Retry-After, never stale data).

Top-ups live under the same resource: GET /esims/:id/topups/available lists the top-up plans valid for a given eSIM, and POST /esims/:id/topup applies one against the ICCID (idempotent, wallet-charged). Sandbox eSIMs always report ACTIVE and return simulated usage.

Get upstream order history for an eSIM by ICCID (admin)

Authorizations:
apiKeybearer
path Parameters
iccid
required
string

Responses

Response samples

Content type
application/json
[
  • {
    }
]

Activate a pending eSIM subscription

Activates a PENDING subscription for the given ICCID. Sandbox activations return a simulated ACTIVE status.

Authorizations:
apiKey
path Parameters
iccid
required
string

Responses

Response samples

Content type
application/problem+json
{}

List eSIMs

Authorizations:
apiKey

Responses

Response samples

Content type
application/json
[
  • {
    }
]

Get eSIM details

Sandbox eSIMs always show ACTIVE status.

Authorizations:
apiKey
path Parameters
id
required
string

Responses

Response samples

Content type
application/json
{
  • "id": "string",
  • "iccid": "string",
  • "esimQr": "string",
  • "status": "PROVISIONED",
  • "createdAt": "2019-08-24T14:15:22Z",
  • "liveStatus": {
    }
}

Get eSIM data usage

Sandbox returns simulated usage data. Polls the upstream provider at most once per 60 seconds per eSIM (per-ICCID window on EsimUsagePollService). Calls within the 60-second window since the last successful poll receive 429 Too Many Requests with a Retry-After: <seconds> header and a retryAfterSeconds problem+json extension. Integrators should honour Retry-After and cache the response for the interval — polling below the ceiling wastes quota against SENSITIVE_THROTTLE (10 req/min/endpoint) without gaining fresher data.

Authorizations:
apiKey
path Parameters
id
required
string

Responses

Response samples

Content type
application/json
{
  • "iccid": "string",
  • "usedAmount": 0,
  • "totalAmount": 0,
  • "amountUnit": "string",
  • "status": "string",
  • "activationTime": "string",
  • "expiry": "string"
}

List available top-up plans for eSIM

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

Authorizations:
apiKey
path Parameters
id
required
string

Responses

Response samples

Content type
application/json
[
  • {
    }
]

Top up an eSIM

Sandbox top-ups simulate upstream without real provisioning.

Authorizations:
apiKey
path Parameters
id
required
string
Request Body schema: application/json
required
planId
required
string

ID of the top-up plan to apply

Responses

Request samples

Content type
application/json
{
  • "planId": "string"
}

Response samples

Content type
application/json
{
  • "id": "string",
  • "planId": "string",
  • "iccid": "string",
  • "operationType": "NEW",
  • "status": "PENDING",
  • "subscriptionStatus": "PENDING",
  • "salePrice": "9.99",
  • "planPrice": "5.99",
  • "provisioningFee": "0.50",
  • "createdAt": "2019-08-24T14:15:22Z",
  • "refundRequestedAt": "string",
  • "refundedAt": "string",
  • "refundedAmount": "14.50"
}

Install

Install

Public, anonymous eSIM install surface. GET /install/:token resolves an Ed25519-signed JWT install token and returns the LPA activation string plus a data-URI QR image for one-tap device activation — no Authorization header, no API key, and no ICCID in the response. The token itself is the credential: anyone with the URL can fetch the payload while it is valid, so responses carry Cache-Control: no-store and are throttled per-IP (10/min) and per-token (30/min, SHA-256 hashed).

Install tokens are minted by admin-scoped origination flows (POST /origination/issue) and by anonymous voucher redemption. Malformed or unverifiable tokens return 422 INVALID_REFERENCE; a valid token whose eSIM row no longer exists returns 404 NOT_FOUND; expired eSIMs return 410 GONE; already-redeemed eSIMs return the metadata with blank lpaString and qrDataUri and status: "redeemed".

Resolve install token to eSIM install payload (public)

path Parameters
token
required
string

Responses

Response samples

Content type
application/json
{
  • "status": "ready",
  • "lpaString": "string",
  • "qrDataUri": "string",
  • "planName": "string",
  • "countryName": "string",
  • "dataAmount": "string",
  • "validityDays": 0
}

Orders

Orders

Purchase eSIM plans and retrieve order state. POST /orders charges your prepaid wallet, provisions an eSIM upstream, and returns an OrderResponse that tracks the full lifecycle — from PENDING through COMPLETED, REFUND_PENDING, REFUNDED, or one of the terminal failure states. Include an Idempotency-Key header on every write; the same key within 24 hours replays the cached response instead of double-charging.

Fetch a single order via GET /orders/:id or paginate the full list via GET /orders?limit=&cursor= (cursor-based, max 100 per page). Sandbox customers receive dummy eSIMs (ICCID prefix 89990) with no real upstream provisioning.

Purchase an eSIM plan

Sandbox customers receive dummy eSIMs with no real provisioning.

Authorizations:
apiKey
Request Body schema: application/json
required
planId
required
string

ID of the plan to purchase

Responses

Request samples

Content type
application/json
{
  • "planId": "string"
}

Response samples

Content type
application/json
{
  • "id": "string",
  • "planId": "string",
  • "iccid": "string",
  • "operationType": "NEW",
  • "status": "PENDING",
  • "subscriptionStatus": "PENDING",
  • "salePrice": "9.99",
  • "planPrice": "5.99",
  • "provisioningFee": "0.50",
  • "createdAt": "2019-08-24T14:15:22Z",
  • "refundRequestedAt": "string",
  • "refundedAt": "string",
  • "refundedAmount": "14.50"
}

List orders

Authorizations:
apiKey

Responses

Response samples

Content type
application/json
[
  • {
    }
]

Get order details

Authorizations:
apiKey
path Parameters
id
required
string

Responses

Response samples

Content type
application/json
{
  • "id": "string",
  • "planId": "string",
  • "iccid": "string",
  • "operationType": "NEW",
  • "status": "PENDING",
  • "subscriptionStatus": "PENDING",
  • "salePrice": "9.99",
  • "planPrice": "5.99",
  • "provisioningFee": "0.50",
  • "createdAt": "2019-08-24T14:15:22Z",
  • "refundRequestedAt": "string",
  • "refundedAt": "string",
  • "refundedAmount": "14.50"
}

Plans

Plans

Browse the eSIM plan catalog. GET /plans returns the plans that are available to your account — plans that are neither disabled nor removed from upstream, and priced under your customer rate-card. Each PlanResponse carries the display metadata (name, coverage, duration, data allowance), the customer-facing price in USD, and the fixed provisioningFee charged on top of the plan price for NEW orders (top-ups always carry a "0.00" fee). The catalog is the source of planId values you pass to POST /orders and POST /esims/:id/topup.

List active eSIM plans

Authorizations:
apiKey

Responses

Response samples

Content type
application/json
[
  • {
    }
]

List all plans (including inactive)

Authorizations:
apiKeybearer

Responses

Response samples

Content type
application/json
[
  • {
    }
]

Start async plan sync from eSIMfx

Authorizations:
apiKeybearer

Responses

Response samples

Content type
application/json
{
  • "status": "idle",
  • "startedAt": "2019-08-24T14:15:22Z",
  • "finishedAt": "2019-08-24T14:15:22Z",
  • "result": {
    },
  • "error": { }
}

Get plan sync status

Authorizations:
apiKeybearer

Responses

Response samples

Content type
application/json
{
  • "status": "idle",
  • "startedAt": "2019-08-24T14:15:22Z",
  • "finishedAt": "2019-08-24T14:15:22Z",
  • "result": {
    },
  • "error": { }
}

Bulk re-enable plans by disabledReason

Authorizations:
apiKeybearer
Request Body schema: application/json
required
disabledReason
required
string
Enum: "MANUAL" "NEGATIVE_MARGIN" "DUPLICATE"

Re-enable all currently-disabled plans whose disabledReason matches this value. Required to prevent accidental whole-catalog flips.

Responses

Request samples

Content type
application/json
{
  • "disabledReason": "MANUAL"
}

Response samples

Content type
application/json
{
  • "enabled": 0,
  • "disabledReason": "MANUAL"
}

Update plan disabled status

Authorizations:
apiKeybearer
path Parameters
id
required
string
Request Body schema: application/json
required
disabled
required
boolean

Whether the plan is disabled

Responses

Request samples

Content type
application/json
{
  • "disabled": true
}

Response samples

Content type
application/json
{
  • "id": "string",
  • "esimfxProductId": "string",
  • "esimfxImsiProfile": "string",
  • "name": "string",
  • "description": "string",
  • "upstreamCost": "string",
  • "duration": 30,
  • "durationUnit": "DAY",
  • "dataAmount": 10,
  • "dataAmountUnit": "GB",
  • "coverage": "US",
  • "destination": "string",
  • "compatibleTopupProductIds": [
    ],
  • "disabled": true,
  • "disabledReason": "MANUAL",
  • "removedFromUpstream": true,
  • "createdAt": "2019-08-24T14:15:22Z"
}

Public: Voucher Redemption

Public voucher redemption

The /r/:code surface lets anonymous holders resolve and redeem voucher codes without an API key. GET /r/:code returns the current redemption state (used by the public lander to render the redeem page); POST /r/:code performs the redemption. POST /r/:code for an eSIM voucher returns 201 with { kind: "esim", installToken, installUrl } on synchronous success, or 202 with { kind: "processing", pollAfterMs } when eSIMfx accepted the order but the LPA isn't yet available. On 202 the traveller-facing lander polls GET /r/:code every pollAfterMs ms until the state settles; a second POST while the code is PROVISIONING returns 409 CONFLICT pointing at GET /r/:code. Terminal REDEEM_FAILED (worker exhausted attempts or hit INVALID_REFERENCE) collapses to an opaque 410 GONE — the same shape as REVOKED / EXPIRED / REFUNDED — so the internal redeem_fail_reason never leaks to the anonymous caller. GET /r/:code/usage returns remaining-data for a redeemed code, polled with a 60-second per-ICCID window and always-200 with a discriminated status field.

Access is IP-bucket and per-code-hash throttled (GET /r/:code: 10/min/IP + 30/min/code; POST /r/:code: 5/min/IP + 5/min/code; GET /r/:code/usage: 5/min/IP + 15/min/code) — never API-key authenticated. The :code path parameter is length-capped at 32 characters; error responses do not echo the raw caller-supplied code, and throttled responses are emitted before the service is consulted so brute-force enumeration stays bounded.

Resolve a voucher code to its public state and plan summary.

path Parameters
code
required
string

Responses

Response samples

Content type
application/json
{
  • "kind": "issued",
  • "code": "string",
  • "batchLabel": "string",
  • "expiresAt": "string",
  • "planSummary": {
    },
  • "pollAfterMs": 0
}

Redeem a voucher code: provision eSIM, send install email, return install token. Phase 1F: returns 202 with a poll hint when eSIMfx accepts the order but the LPA is not yet available (worker completes the fulfillment).

path Parameters
code
required
string
Request Body schema: application/json
required
email
required
string

Traveller email where the install link will be delivered. VouchersService normalises this to lower-case + trimmed before persistence and idempotency comparison.

locale
string
Enum: "en" "de"

Locale for the install email + install page. Defaults to 'en'. 'de' falls back to en until Phase 2 fills the DE template (mirrors OriginationService default).

Responses

Request samples

Content type
application/json
{
  • "email": "string",
  • "locale": "en"
}

Response samples

Content type
application/json
{
  • "kind": "esim",
  • "installToken": "string",
  • "installUrl": "string",
  • "topupOrderId": "string",
  • "dataAmount": "string",
  • "dataAmountUnit": "string",
  • "validityDays": 0,
  • "pollAfterMs": 0
}

Resolve a voucher code to remaining-data + expiry. Always 200; the status field discriminates.

path Parameters
code
required
string

Responses

Response samples

Content type
application/json
{
  • "status": "active",
  • "plan": "string",
  • "isUnlimited": true,
  • "data": {
    },
  • "expiresAt": "string",
  • "usageUpdatedAt": "string",
  • "nextPollAfterMs": 0
}

Refunds

Refunds

Refunds are asynchronous. POST /orders/:id/refund returns 202 Accepted with a refundRequestedAt timestamp; the wallet credit and the REFUNDED status flip commit atomically only after upstream termination succeeds. Poll GET /orders/:id until status reaches REFUNDED (success) or REFUND_FAILED (60-day window exceeded, or admin-forced fail).

The credited amount equals the order's planPrice — that is, salePrice − provisioningFee. The $0.50 provisioning fee is retained on NEW orders because the upstream does not refund ESIM_CARD on termination; TOPUP refunds credit the full salePrice since their provisioningFee is "0.00". Sandbox refunds skip real upstream termination and commit inline.

Request a refund (async — poll GET /orders/:id for status)

Accepts the refund and returns 202. The body reports REFUNDED if upstream termination completes inline within ~3s; otherwise it reports REFUND_PENDING and a background worker drives the commit. Poll GET /orders/:id until status becomes REFUNDED (success) or REFUND_FAILED (60-day window missed or admin-forced). Sandbox refunds skip real upstream termination.

Authorizations:
apiKey
path Parameters
id
required
string

Responses

Response samples

Content type
application/json
{
  • "orderId": "string",
  • "status": "REFUND_PENDING",
  • "refundRequestedAt": "2026-05-13T12:00:00.000Z",
  • "refundedAt": "2026-05-13T12:00:01.234Z",
  • "refundedAmount": "14.50"
}

Reports

Reports

Admin-only financial exports, scoped by calendar month. GET /reports/financial?month=YYYY-MM returns per-month revenue, profitability, and per-customer rollups — with optional sections filtering (revenue, profitability, customers) and format selection (json, csv, pdf). Every response carries net-of-upstream-refund fields (upstreamRefundedAmount, netUpstreamCost, netMargin) alongside the gross figures so you can reconcile true after-refund margin.

Authenticate with either the full ADMIN_TOKEN or the read-only ADMIN_REPORT_TOKEN as a bearer credential. Throttled at 5 reports/minute/IP because report generation pulls multi-month aggregates and PDF exports run pdf-lib synchronously.

List webhook deliveries for a customer. Filterable by status, paginated with an opaque cursor. Newest-first.

Authorizations:
apiKeybearer
path Parameters
id
required
string
query Parameters
status
string
Enum: "PENDING" "IN_FLIGHT" "DELIVERED" "FAILED"

Filter by delivery status. Omit to list across all statuses (newest-first).

limit
number [ 1 .. 200 ]
Default: 50

Page size (1-200). Defaults to 50.

cursor
string

Opaque cursor returned as nextCursor from a prior page. Encodes <createdAt>|<id> in base64. Omit for the first page.

Responses

Response samples

Content type
application/json
{
  • "items": [
    ],
  • "nextCursor": "string"
}

Generate financial report

Authorizations:
apiKeybearer
query Parameters
month
required
string
Example: month=2026-03

Month in YYYY-MM format

format
string
Default: "json"
Enum: "json" "csv" "pdf"

Export format

sections
string
Example: sections=revenue,profitability

Comma-separated sections to include

Responses

Response samples

Content type
{
  • "month": "string",
  • "generatedAt": "string",
  • "revenue": {
    },
  • "profitability": {
    },
  • "customers": {
    }
}

Wallet

Wallet

Your prepaid wallet funds every purchase. GET /wallets/balance returns the current balance in USD as a decimal string (e.g. "150.00"). Purchases via POST /orders and POST /esims/:id/topup debit the wallet atomically at order creation; approved refunds credit it back once upstream termination completes. There is no self-serve top-up endpoint — wallet reloads are handled out of band during onboarding and via account manager.

Get current wallet balance

Authorizations:
apiKey

Responses

Response samples

Content type
application/json
{
  • "balance": "25.50"
}

Credit a customer wallet

Authorizations:
apiKeybearer
Request Body schema: application/json
required
customerId
required
string

Customer ID to credit

amount
required
string

Amount to credit in decimal string format

referenceId
required
string

Idempotency key — prevents duplicate credits on retry

Responses

Request samples

Content type
application/json
{
  • "customerId": "string",
  • "amount": "100.00",
  • "referenceId": "01930000-0000-7000-8000-000000000001"
}

Response samples

Content type
application/json
{
  • "customerId": "string",
  • "credited": "string",
  • "referenceId": "string",
  • "applied": true
}

Webhooks

Webhooks

Wholesale customers can register an HTTPS endpoint to receive real-time eSIM lifecycle events. PUT /webhooks/endpoint mints a fresh signing secret, POSTs a synchronous webhook.test event under a 10-second timeout, and persists the URL only on 2xx — the plaintext secret is returned in the response body exactly once, so capture it immediately. Companion routes let you rotate the secret, read the current configuration, and delete the endpoint (which clears URL, secret, and circuit state).

Delivered events are signed with HMAC-SHA256 in Stripe format (X-Esimds-Signature: t=<sec>,v1=<hex>). We enforce HTTPS, block private/loopback/metadata hosts against SSRF, and DNS-re-check the hostname at delivery time. Retryable failures (5xx, 408, 429, network errors) follow a 1m → 5m → 30m → 2h → 6h → 24h retry curve; other 4xx responses are treated as terminal on the first attempt. Sustained failure cools off the endpoint and eventually auto-disables it (a fresh PUT /webhooks/endpoint from you is the only way to re-enable). A self-contained reference verifier is shipped at docs/wholesale-webhooks-verifier.ts.

Get current webhook endpoint config

Authorizations:
apiKey

Responses

Response samples

Content type
application/json
{}

Register (or re-register) a webhook endpoint

Generates a fresh signing secret, POSTs a signed webhook.test event to the supplied URL synchronously, and persists only on 2xx. The plaintext secret is returned exactly once — store it out-of-band. Non-2xx / network error / SSRF-blocked URL → 422 (no persistence).

Authorizations:
apiKey
Request Body schema: application/json
required
url
required
string

HTTPS URL that will receive signed webhook deliveries. Must be publicly reachable — private/loopback/link-local addresses are rejected.

Responses

Request samples

Content type
application/json

Response samples

Content type
application/json
{}

Disable webhook endpoint

Clears the registered URL, secret hash, and cool-off state. Idempotent — safe to call when no endpoint is registered.

Authorizations:
apiKey

Responses

Response samples

Content type
application/problem+json
{}

Rotate the webhook signing secret

Re-runs the handshake against the existing URL with a fresh secret and swaps the stored hash only on 2xx. The plaintext new secret is returned exactly once. The old secret remains valid until this call commits — in-flight deliveries signed under it verify normally on the caller side. 404 if no endpoint is registered or the endpoint has been auto-disabled.

Authorizations:
apiKey

Responses

Response samples

Content type
application/json
{}

Webhook

WebhookController_handleEsimfxWebhook

Responses

WebhookController_handleSesWebhook

Responses

Admin: Webhooks

List webhook deliveries for a customer. Filterable by status, paginated with an opaque cursor. Newest-first.

Authorizations:
apiKeybearer
path Parameters
id
required
string
query Parameters
status
string
Enum: "PENDING" "IN_FLIGHT" "DELIVERED" "FAILED"

Filter by delivery status. Omit to list across all statuses (newest-first).

limit
number [ 1 .. 200 ]
Default: 50

Page size (1-200). Defaults to 50.

cursor
string

Opaque cursor returned as nextCursor from a prior page. Encodes <createdAt>|<id> in base64. Omit for the first page.

Responses

Response samples

Content type
application/json
{
  • "items": [
    ],
  • "nextCursor": "string"
}

Replay a FAILED webhook delivery. Resets the row to PENDING with attempt_count=0 so the dispatcher redelivers on its next tick. Records a webhook_delivery.replayed audit event.

Authorizations:
apiKeybearer
path Parameters
id
required
string
deliveryId
required
string
Request Body schema: application/json
required
reason
string <= 500 characters

Optional operator note recorded on the audit trail. Truncated at 500 characters.

Responses

Request samples

Content type
application/json
{
  • "reason": "string"
}

Response samples

Content type
application/problem+json
{}

Admin

Replay a FAILED webhook delivery. Resets the row to PENDING with attempt_count=0 so the dispatcher redelivers on its next tick. Records a webhook_delivery.replayed audit event.

Authorizations:
apiKeybearer
path Parameters
id
required
string
deliveryId
required
string
Request Body schema: application/json
required
reason
string <= 500 characters

Optional operator note recorded on the audit trail. Truncated at 500 characters.

Responses

Request samples

Content type
application/json
{
  • "reason": "string"
}

Response samples

Content type
application/problem+json
{}

Create a new customer

Authorizations:
apiKeybearer
Request Body schema: application/json
required
name
required
string

Customer name

environment
required
string
Enum: "live" "sandbox"

Customer environment

kind
required
string
Enum: "WHOLESALE" "VOUCHER"

Commercial product the customer is onboarded under. Required and immutable. WHOLESALE: customer-keyed API places wallet-funded orders + activates / tops-up eSIMs. VOUCHER: admin mints code batches against this customer; end-travellers redeem at /r/:code. Pricing for both kinds resolves from customer_plan_prices.price; the kind governs which endpoints the customer can call and how invoice lines are shaped (no provisioning-fee split on voucher lines).

country
required
string

Buyer country as ISO 3166-1 alpha-2, uppercase. Required and IMMUTABLE after create. Drives the VAT/PDV rate applied at invoice generation (via country_vat_rates lookup); unlisted countries fall back to 0% + label "VAT" (matches Serbian izvoz-usluga export exemption). Common values: RS (Serbia), HR (Croatia), DE (Germany), US (United States). A relocated reseller must be created as a new customer to avoid retroactively changing the tax basis of historical invoices.

addressLine1
required
string

Buyer street address, line 1. Required on the printed invoice.

addressLine2
string

Buyer street address, line 2 (apartment / suite / floor).

city
required
string

Buyer city. Required on the printed invoice.

postalCode
required
string

Buyer postal code. Required on the printed invoice.

taxId
required
string

Buyer tax ID (PIB for Serbia, VAT number for EU, etc.). Required at create per invoice design §13.2 — Serbian law requires the buyer PIB on every valid račun, and we are B2B-only, so every legitimate customer has a tax identifier of some kind. If a buyer has no jurisdiction-level tax ID at all (rare edge case), pass a placeholder here and update the row before generating a real invoice.

registrationId
string

Buyer business registration id (MB in Serbia; equivalent elsewhere). Optional.

Responses

Request samples

Content type
application/json
{
  • "name": "Acme Corp",
  • "environment": "live",
  • "kind": "WHOLESALE",
  • "country": "RS",
  • "addressLine1": "Kneza Miloša 12",
  • "addressLine2": "Sprat 4",
  • "city": "Beograd",
  • "postalCode": "11000",
  • "taxId": "108xxxxxx",
  • "registrationId": "21xxxxxx"
}

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "environment": "live",
  • "kind": "WHOLESALE",
  • "balance": "150.00",
  • "createdAt": "2019-08-24T14:15:22Z",
  • "country": "RS",
  • "addressLine1": "Kneza Miloša 12",
  • "addressLine2": "Sprat 4",
  • "city": "Beograd",
  • "postalCode": "11000",
  • "taxId": "108xxxxxx",
  • "registrationId": "21xxxxxx",
  • "apiKey": "string"
}

List all customers

Authorizations:
apiKeybearer

Responses

Response samples

Content type
application/json
[
  • {
    }
]

Get a customer by ID

Authorizations:
apiKeybearer
path Parameters
id
required
string

Responses

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "environment": "live",
  • "kind": "WHOLESALE",
  • "balance": "150.00",
  • "createdAt": "2019-08-24T14:15:22Z",
  • "country": "RS",
  • "addressLine1": "Kneza Miloša 12",
  • "addressLine2": "Sprat 4",
  • "city": "Beograd",
  • "postalCode": "11000",
  • "taxId": "108xxxxxx",
  • "registrationId": "21xxxxxx"
}

Rotate API key for a customer

Authorizations:
apiKeybearer
path Parameters
id
required
string

Responses

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "environment": "live",
  • "kind": "WHOLESALE",
  • "balance": "150.00",
  • "createdAt": "2019-08-24T14:15:22Z",
  • "country": "RS",
  • "addressLine1": "Kneza Miloša 12",
  • "addressLine2": "Sprat 4",
  • "city": "Beograd",
  • "postalCode": "11000",
  • "taxId": "108xxxxxx",
  • "registrationId": "21xxxxxx",
  • "apiKey": "string"
}

Create an ENTERPRISE-kind customer (admin-token only)

Creates a new customer with kind = ENTERPRISE. Idempotent on (name, taxId): repeat requests with the same pair return the same row. ENTERPRISE customers authenticate via session cookies (/auth/* + /app/*) and hold no customer-keyed API key.

Authorizations:
apiKeybearer
Request Body schema: application/json
required
name
required
string

Enterprise customer display name.

country
required
string

Buyer country as ISO 3166-1 alpha-2, uppercase. Required and IMMUTABLE after create. Drives the VAT/PDV rate applied at invoice generation (via country_vat_rates lookup).

addressLine1
required
string

Buyer street address, line 1. Required on the printed invoice.

addressLine2
string

Buyer street address, line 2 (apartment / suite / floor).

city
required
string

Buyer city. Required on the printed invoice.

postalCode
required
string

Buyer postal code. Required on the printed invoice.

taxId
required
string

Buyer tax ID (PIB for Serbia, VAT number for EU, etc.). Required per invoice design §13.2.

registrationId
string

Buyer business registration id (MB in Serbia; equivalent elsewhere). Optional.

creditLimitMinor
required
string

Credit ceiling in minor units (e.g. EUR cents) as a non-negative decimal string. Represented as a string so callers preserve full precision on ceilings above 2^53 (same convention as the /app/me credit fields — see MeResponse for the encoding rationale).

currency
required
string
Value: "EUR"

ISO 4217 currency of the credit line. Hardcoded to EUR today (mirrors the AppSessionService read path). Validated up-front so callers get a clear 422 rather than a silent accept-and-ignore if they forward a stale currency selection from a future multi-currency screen. Not persisted on the customers row — promotion to a per-customer currency column lands with the mixed-currency ledger design (Slice C or later).

environment
string
Default: "live"
Enum: "live" "sandbox"

Customer environment — live (default) or sandbox. ENTERPRISE customers currently run on live only; the field is kept for parity with WHOLESALE + VOUCHER create.

Responses

Request samples

Content type
application/json
{
  • "name": "Acme Enterprise",
  • "country": "RS",
  • "addressLine1": "Kneza Miloša 12",
  • "addressLine2": "Sprat 4",
  • "city": "Beograd",
  • "postalCode": "11000",
  • "taxId": "108xxxxxx",
  • "registrationId": "21xxxxxx",
  • "creditLimitMinor": "100000",
  • "currency": "EUR",
  • "environment": "live"
}

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "environment": "live",
  • "country": "RS",
  • "addressLine1": "Kneza Miloša 12",
  • "addressLine2": "Sprat 4",
  • "city": "Beograd",
  • "postalCode": "11000",
  • "taxId": "108xxxxxx",
  • "registrationId": "21xxxxxx",
  • "kind": "ENTERPRISE",
  • "currency": "EUR",
  • "creditLimitMinor": "100000",
  • "creditOutstandingMinor": "0",
  • "createdAt": "string",
  • "updatedAt": "string"
}

Seed the first admin user for an ENTERPRISE customer (admin-token only)

Creates the initial portal user for a freshly-created ENTERPRISE customer, links it via customer_users, mints a better-auth setup-password verification token, and sends a welcome email with the token embedded in the link. Idempotent on (customer_id, email): repeat calls return the existing user without resending the email. Returns 404 if the customer does not exist OR is not kind = ENTERPRISE (no reveal). Returns 409 with body detail EMAIL_SUPPRESSED: ... if the target email is on the SES suppression list.

Authorizations:
apiKeybearer
path Parameters
id
required
string

Enterprise customer id (UUID v7).

Request Body schema: application/json
required
email
required
string

Email address of the first admin user. Normalised to lowercase server-side before the customer_users + email_suppressions lookups.

name
required
string

Display name of the first admin user.

Responses

Request samples

Content type
application/json
{}

Response samples

Content type
application/json
{
  • "userId": "018f2b5d-a5cd-71a8-9e0e-8d7f5b0f2c73",
  • "email": "[email protected]",
  • "name": "Admin One",
  • "customerId": "string"
}

Update the credit ceiling on an ENTERPRISE customer (admin-token only)

Sets credit_limit_minor to the caller-supplied non-negative value and appends an audit_events row with action = customer.credit_limit_updated. Returns 404 if the customer does not exist OR is not kind = ENTERPRISE (no reveal). No reciprocal invariant with credit_outstanding_minor — the ledger design permits lowering the ceiling below current outstanding.

Authorizations:
apiKeybearer
path Parameters
id
required
string

Enterprise customer id (UUID v7).

Request Body schema: application/json
required
creditLimitMinor
required
string

New credit ceiling in minor units (e.g. EUR cents) as a non-negative integer decimal string.

Responses

Request samples

Content type
application/json
{
  • "creditLimitMinor": "250000"
}

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "environment": "live",
  • "country": "RS",
  • "addressLine1": "Kneza Miloša 12",
  • "addressLine2": "Sprat 4",
  • "city": "Beograd",
  • "postalCode": "11000",
  • "taxId": "108xxxxxx",
  • "registrationId": "21xxxxxx",
  • "kind": "ENTERPRISE",
  • "currency": "EUR",
  • "creditLimitMinor": "100000",
  • "creditOutstandingMinor": "0",
  • "createdAt": "string",
  • "updatedAt": "string"
}

Update identity fields on an ENTERPRISE customer (admin-token only)

Updates any subset of { name, country, addressLine1, addressLine2, city, postalCode, taxId, registrationId }. Every field is optional; undefined (omitted) leaves the column untouched. addressLine2 and registrationId accept explicit null to clear a previously-set value. Refuses creditLimitMinor (use the dedicated /credit-limit endpoint), currency (EUR-only today), kind, and environment (both immutable after create) via the whitelist validation pipe — unknown fields return 422. Writes an audit_events row with action = customer.identity_updated capturing only the changed fields (before/after). Returns 404 if the customer does not exist OR is not kind = ENTERPRISE (no reveal). Slice C preamble P1.

Authorizations:
apiKeybearer
path Parameters
id
required
string

Enterprise customer id (UUID v7).

Request Body schema: application/json
required
name
string

Enterprise customer display name.

country
string

Buyer country as ISO 3166-1 alpha-2, uppercase. Country changes affect the VAT/PDV rate applied on the NEXT invoice — historic invoices are not re-computed.

addressLine1
string

Buyer street address, line 1.

addressLine2
object or null

Buyer street address, line 2. Pass null to clear an existing value; omit to leave untouched.

city
string

Buyer city.

postalCode
string

Buyer postal code.

taxId
string

Buyer tax ID (PIB for Serbia, VAT number for EU, etc.). WARNING: the create endpoint's (name, taxId) idempotency key means a later POST with the OLD tax id + name will spawn a duplicate row. Ops should follow a tax-id change with a fresh review of any automation that stores the pair.

registrationId
object or null

Buyer business registration id (MB in Serbia; equivalent elsewhere). Pass null to clear an existing value; omit to leave untouched.

Responses

Request samples

Content type
application/json
{
  • "name": "eSIM Data Store Internal",
  • "country": "RS",
  • "addressLine1": "Kneza Miloša 12",
  • "addressLine2": "Sprat 4",
  • "city": "Beograd",
  • "postalCode": "11000",
  • "taxId": "108xxxxxx",
  • "registrationId": "21xxxxxx"
}

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "environment": "live",
  • "country": "RS",
  • "addressLine1": "Kneza Miloša 12",
  • "addressLine2": "Sprat 4",
  • "city": "Beograd",
  • "postalCode": "11000",
  • "taxId": "108xxxxxx",
  • "registrationId": "21xxxxxx",
  • "kind": "ENTERPRISE",
  • "currency": "EUR",
  • "creditLimitMinor": "100000",
  • "creditOutstandingMinor": "0",
  • "createdAt": "string",
  • "updatedAt": "string"
}

Purge an ENTERPRISE customer and its portal users (admin-token only)

Wipes the customer row, every linked customer_users row, every users row that was ONLY linked to this customer (cascades to sessions + accounts via schema FKs), and every orphan verifications row for those users. All inside one transaction — a failure at any step rolls the whole delete back. Returns 204 on success. Refuses with 409 CUSTOMER_HAS_ORDERS if the customer has any order_groups OR orders rows, CUSTOMER_HAS_LEDGER for credit_line_ledger rows, or the generic CUSTOMER_HAS_DOWNSTREAM_ROWS (with the offending pg constraint name) for any other FK-referencing table we did not explicitly guard (customer_plan_prices, invoices, esims, voucherBatches, customerWallets, …) — partially-consumed credit lines and finance-relevant history carry legal + accounting consequences that must be resolved with finance BEFORE purging. Returns 404 if the customer does not exist OR is not kind = ENTERPRISE (no reveal). Slice C preamble P1.

Authorizations:
apiKeybearer
path Parameters
id
required
string

Enterprise customer id (UUID v7).

Responses

Response samples

Content type
application/problem+json
{}

Record a manual credit-outstanding adjustment (admin-token only)

Writes an ADJUSTMENT row to credit_line_ledger and increments customers.credit_outstanding_minor by the signed deltaMinor (positive grows debt; negative shrinks it). Both writes atomic in one transaction with a FOR UPDATE lock on the customer row. Returns 422 for zero delta (no-op ledger rows are a caller bug). Returns 404 if the customer does not exist OR is not kind = ENTERPRISE (SEC-37 no-reveal).

Authorizations:
apiKeybearer
path Parameters
id
required
string

Enterprise customer id (UUID v7).

Request Body schema: application/json
required
deltaMinor
required
string

Signed decimal-string delta in minor units. -1000 shrinks outstanding by 10.00; 1000 grows it by 10.00. Zero rejected as 422. Range: up to 18 digits (positive) / 17 digits + minus sign (negative) — conservatively below the PostgreSQL BIGINT ceiling of ±9.2e18 so overflow surfaces as 422 not 500.

reason
required
string

Free-form operator context (between 5 and 500 chars). Persisted on the ledger row notes column and mirrored into the audit event.

Responses

Request samples

Content type
application/json
{
  • "deltaMinor": "-1500",
  • "reason": "Compensation for reconciler drift #4321 — root cause: retry storm"
}

Response samples

Content type
application/json
{
  • "ledgerRowId": "string",
  • "deltaMinor": "-1500",
  • "newOutstandingMinor": "3900"
}

Replay the monthly-invoice generation for a specific period (admin-token only)

Reruns the per-customer invoice-generation transaction for the supplied (periodStart, periodEnd) window. Ops uses this after a monthly cron failedCount > 0 tick, once the source-data drift (e.g. currency mismatch surfaced by the H1 fail-loud path) has been corrected. Idempotent — a second replay for a period whose invoice already exists returns outcome: 'skipped' without touching the sequence or writing a second invoice row. Emits an enterprise_invoice.admin_replay audit event on every invocation (including failed attempts, via try/finally) so ops has a forensic trail. Returns 404 if the customer does not exist OR is not kind = ENTERPRISE (SEC-37 no-reveal). Returns 422 on any of: calendar-invalid date (e.g. 2026-02-31), non-UTC-midnight, periodEnd <= periodStart, a period whose shape does not match first-of-month → first-of-next-month (codex iter-3 High-1 — keeps the replay idempotency key aligned with the natural monthly cron so admin + cron cannot double-bill overlapping activity windows), or a period that is NOT a closed prior month (periodEnd > start-of-current-month UTC) — replays for the current or future month are rejected because they would persist a partial invoice and cause the natural cron to later skip the same key (codex iter-4 High-1). Throttled to 10 per minute PER CUSTOMER — bucket keyed on the :id path param via a custom generateKey (codex iter-3 Medium-2) so IP rotation cannot storm a single customer.

Authorizations:
apiKeybearer
path Parameters
id
required
string

Enterprise customer id (UUID v7).

Request Body schema: application/json
required
periodStart
required
string

Inclusive UTC-midnight start of the billing period. MUST be the first day of a month (e.g. 2026-07-01T00:00:00.000Z). Non-first-of-month is rejected with 422 to keep the replay idempotency key aligned with the natural monthly cron.

periodEnd
required
string

Exclusive UTC-midnight end of the billing period. MUST be the first day of the month immediately following periodStart. Non-monthly spans (>1 month, <1 month, or non-first-of-month) are rejected with 422.

Responses

Request samples

Content type
application/json
{
  • "periodStart": "2026-07-01T00:00:00.000Z",
  • "periodEnd": "2026-08-01T00:00:00.000Z"
}

Response samples

Content type
application/json
{
  • "outcome": "generated",
  • "invoiceId": "01948a7d-3d5e-7a52-9c8a-42b8bcf28aae"
}

Record an out-of-band settlement on an ENTERPRISE invoice (admin-token only)

Writes an INVOICE_PAYMENT credit-line-ledger row, decrements customers.credit_outstanding_minor by the customer-net subtotal (re-derived from invoice_line_items — VAT is pass-through to the tax authority and stays out of the credit line), and stamps invoices.paid_at. All three writes are atomic in one transaction. Returns 409 invoice already paid if paid_at is already set (idempotency guard — no duplicate ledger row on the 409 path). Returns 409 cannot mark-paid a non-positive-subtotal invoice for a refund-heavy period (credit-memo settlement is a separate flow, not yet implemented) or a zero-subtotal invoice (data-bug surface). Returns 404 for unknown invoice ids OR invoices whose parent customer is not kind = ENTERPRISE (SEC-37 no-reveal).

Authorizations:
apiKeybearer
path Parameters
id
required
string

Invoice id (UUID v7).

Request Body schema: application/json
required
paidAt
required
string

When the payment was received (ISO 8601 date or date-time). Stamped verbatim onto invoices.paid_at.

method
required
string

Short label for the settlement channel (bank_transfer, wire, manual_adjustment, ...). Max 40 chars.

reference
required
string

Bank / wire reference or tracking id for reconciliation. Max 200 chars.

Responses

Request samples

Content type
application/json
{
  • "paidAt": "2026-08-09T14:00:00Z",
  • "method": "bank_transfer",
  • "reference": "REF-2026-08-000123"
}

Response samples

Content type
application/json
{
  • "invoiceId": "string",
  • "paidAt": "string",
  • "ledgerRowId": "string"
}

Credit a customer wallet

Authorizations:
apiKeybearer
Request Body schema: application/json
required
customerId
required
string

Customer ID to credit

amount
required
string

Amount to credit in decimal string format

referenceId
required
string

Idempotency key — prevents duplicate credits on retry

Responses

Request samples

Content type
application/json
{
  • "customerId": "string",
  • "amount": "100.00",
  • "referenceId": "01930000-0000-7000-8000-000000000001"
}

Response samples

Content type
application/json
{
  • "customerId": "string",
  • "credited": "string",
  • "referenceId": "string",
  • "applied": true
}

List all plans (including inactive)

Authorizations:
apiKeybearer

Responses

Response samples

Content type
application/json
[
  • {
    }
]

Start async plan sync from eSIMfx

Authorizations:
apiKeybearer

Responses

Response samples

Content type
application/json
{
  • "status": "idle",
  • "startedAt": "2019-08-24T14:15:22Z",
  • "finishedAt": "2019-08-24T14:15:22Z",
  • "result": {
    },
  • "error": { }
}

Get plan sync status

Authorizations:
apiKeybearer

Responses

Response samples

Content type
application/json
{
  • "status": "idle",
  • "startedAt": "2019-08-24T14:15:22Z",
  • "finishedAt": "2019-08-24T14:15:22Z",
  • "result": {
    },
  • "error": { }
}

Bulk re-enable plans by disabledReason

Authorizations:
apiKeybearer
Request Body schema: application/json
required
disabledReason
required
string
Enum: "MANUAL" "NEGATIVE_MARGIN" "DUPLICATE"

Re-enable all currently-disabled plans whose disabledReason matches this value. Required to prevent accidental whole-catalog flips.

Responses

Request samples

Content type
application/json
{
  • "disabledReason": "MANUAL"
}

Response samples

Content type
application/json
{
  • "enabled": 0,
  • "disabledReason": "MANUAL"
}

Update plan disabled status

Authorizations:
apiKeybearer
path Parameters
id
required
string
Request Body schema: application/json
required
disabled
required
boolean

Whether the plan is disabled

Responses

Request samples

Content type
application/json
{
  • "disabled": true
}

Response samples

Content type
application/json
{
  • "id": "string",
  • "esimfxProductId": "string",
  • "esimfxImsiProfile": "string",
  • "name": "string",
  • "description": "string",
  • "upstreamCost": "string",
  • "duration": 30,
  • "durationUnit": "DAY",
  • "dataAmount": 10,
  • "dataAmountUnit": "GB",
  • "coverage": "US",
  • "destination": "string",
  • "compatibleTopupProductIds": [
    ],
  • "disabled": true,
  • "disabledReason": "MANUAL",
  • "removedFromUpstream": true,
  • "createdAt": "2019-08-24T14:15:22Z"
}

Issue an eSIM to a traveller: reserve upstream, persist, sign install token, email install link.

Authorizations:
apiKeybearer
Request Body schema: application/json
required
customerId
required
string

Customer owning the eSIM.

planId
required
string

Plan to issue.

email
required
string

Recipient email for the install link.

required
object (SourceRef)
locale
string
Enum: "en" "de"

Email + install-page locale. Defaults to 'en'. 'de' falls back to en until Phase 2 fills the DE template.

expiresInDays
number [ 1 .. 360 ]

Install token lifetime in days. Defaults to 360. Capped at 360 by InstallTokenService.sign() to track the eSIMfx upstream activate_by window.

travelerName
string

Optional first name surfaced in the email greeting.

Responses

Request samples

Content type
application/json
{
  • "customerId": "string",
  • "planId": "string",
  • "email": "string",
  • "sourceRef": {
    },
  • "locale": "en",
  • "expiresInDays": 1,
  • "travelerName": "string"
}

Response samples

Content type
application/json
{
  • "esimId": "string",
  • "installToken": "string",
  • "installUrl": "string"
}

Force a stuck REFUND_PENDING refund to commit (credits wallet)

Bypasses the upstream check and atomically credits the wallet + flips status to REFUNDED. Use when ops has confirmed out-of-band that the upstream subscription is terminated. The reason is recorded in the audit log.

Authorizations:
apiKeybearer
path Parameters
id
required
string
Request Body schema: application/json
required
reason
required
string [ 1 .. 500 ] characters

Non-empty rationale for the manual override; recorded in the audit trail.

Responses

Request samples

Content type
application/json
{
  • "reason": "eSIMfx support confirmed termination via ticket #12345"
}

Response samples

Content type
application/json
{
  • "orderId": "string",
  • "status": "REFUND_PENDING",
  • "refundRequestedAt": "2026-05-13T12:00:00.000Z",
  • "refundedAt": "2026-05-13T12:00:01.234Z",
  • "refundedAmount": "14.50"
}

Force a stuck REFUND_PENDING refund to terminal failure

Flips status to REFUND_FAILED without crediting the wallet. Use when the refund is irrecoverable (e.g. confirmed fraud, duplicate, or upstream never had the order). The reason is recorded in the audit log.

Authorizations:
apiKeybearer
path Parameters
id
required
string
Request Body schema: application/json
required
reason
required
string [ 1 .. 500 ] characters

Non-empty rationale for the manual override; recorded in the audit trail.

Responses

Request samples

Content type
application/json
{
  • "reason": "eSIMfx support confirmed termination via ticket #12345"
}

Response samples

Content type
application/json
{
  • "orderId": "string",
  • "status": "REFUND_PENDING",
  • "refundRequestedAt": "2026-05-13T12:00:00.000Z",
  • "refundedAt": "2026-05-13T12:00:01.234Z",
  • "refundedAmount": "14.50"
}

Get upstream order history for an eSIM by ICCID (admin)

Authorizations:
apiKeybearer
path Parameters
iccid
required
string

Responses

Response samples

Content type
application/json
[
  • {
    }
]

Generate a customer invoice (PDF binary response). Returns 200 + application/pdf body; persists invoices + invoice_line_items rows.

Authorizations:
apiKeybearer
Request Body schema: application/json
required
customerId
required
string

Customer to invoice.

periodStart
required
string

Inclusive UTC-midnight start of the invoicing window. Bare-date ISO string ('YYYY-MM-DD'). Datetimes / timezones / calendar-invalid dates rejected.

periodEnd
required
string

Exclusive UTC-midnight end of the invoicing window. Bare-date ISO string ('YYYY-MM-DD').

locale
string
Enum: "en" "sr"

PDF language. 'en' (English, default) or 'sr' (Serbian Latin script). Controls all label text, date format (ISO YYYY-MM-DD vs DD.MM.YYYY.), money decimal separator (period vs comma), and line-item description prefixes. Font (Noto Sans) is Unicode-safe for both.

object

Manual override of the USD-to-RSD exchange rate. Auto-fetches from kurs.resenje.org (NBS mirror) at invoice-generation time when omitted for RS-country buyers. When supplied it is used verbatim regardless of buyer country -- useful for audit replay, mirror-outage fallback, and adding an RSD conversion to a non-RS invoice on the operator's discretion. Provide all three subfields together.

Responses

Request samples

Content type
application/json
{
  • "customerId": "string",
  • "periodStart": "2026-05-01",
  • "periodEnd": "2026-06-01",
  • "locale": "en",
  • "exchangeRate": {
    }
}

Response samples

Content type
application/problem+json
{}

Upload customer price list (CSV)

Authorizations:
apiKeybearer
path Parameters
customerId
required
string
Request Body schema: multipart/form-data
required
file
required
string <binary>

Responses

Response samples

Content type
application/json
{
  • "applied": 0,
  • "removed": 0,
  • "warnings": [
    ]
}

List customer plan prices

Authorizations:
apiKeybearer
path Parameters
customerId
required
string

Responses

Response samples

Content type
application/json
[
  • {
    }
]

Bulk-enable NEGATIVE_MARGIN customer prices for one customer (FIN-65)

Authorizations:
apiKeybearer
path Parameters
customerId
required
string
Request Body schema: application/json
required
object (BulkEnableCustomerPricesRequest)

Responses

Request samples

Content type
application/json
{ }

Response samples

Content type
application/json
{
  • "enabled": 0
}

Create a voucher batch + N codes atomically. Returns batch id + code count.

Authorizations:
apiKeybearer
Request Body schema: application/json
required
customerId
required
string

Customer UUID who will be invoiced for each redemption.

planId
required
string

Plan UUID to issue against each redemption.

count
required
number [ 1 .. 2000 ]

Number of codes to generate. Hard cap 2,000 to keep the bundle in-memory; larger jobs must split or move to DO Spaces (deferred).

expiresAt
object

Batch expiry (ISO 8601, UTC). null or omitted = no expiry. Past the expiry, redemption returns 410 GONE and the public state view reports expired.

label
required
string <= 120 characters

Human-readable batch label. PUBLIC — shown to end-travellers in the /r/[code] redemption-page footer. Do NOT include PII, internal identifiers, or domain-shaped strings. Period (.) is excluded so labels cannot impersonate domain.tld; use spaces, -, or () for visual separation.

createdBy
required
string <= 120 characters

Operator identifier (email or name) for the audit trail. REQUIRED. Persisted on voucher_batches.created_by AND echoed onto every audit_events row this batch produces. Must contain at least one non-whitespace character — a whitespace-only " " would otherwise satisfy @Length(1, 120) and leave a meaningless audit-trail entry.

topupPlanId
string

UUID of the companion TOPUP plan. When set, this batch is a BUNDLE: each code generates a paired TOPUP code (PENDING_BINDING) that becomes redeemable after the paired ESIM code is redeemed. Must be in the eSIM plan's compatible_topup_product_ids cache (populated by the daily plan-sync); a 422 INVALID_REFERENCE surfaces otherwise.

Responses

Request samples

Content type
application/json
{
  • "customerId": "string",
  • "planId": "string",
  • "count": 1,
  • "expiresAt": { },
  • "label": "VisaCo Croatia 2026-06",
  • "createdBy": "string",
  • "topupPlanId": "string"
}

Response samples

Content type
application/json
{
  • "batchId": "string",
  • "codeCount": 0,
  • "kind": "STANDARD",
  • "topupPlanId": "string"
}

Paginated list of voucher batches. Filters: customerId, status. Defaults to 50 newest-first.

Authorizations:
apiKeybearer
query Parameters
customerId
string

Filter by customer UUID. Omit to list across all customers.

status
string
Enum: "ACTIVE" "REVOKED"

Filter by batch status. ACTIVE includes batches whose individual codes may have been revoked; REVOKED is set only when the WHOLE batch was revoked.

limit
number [ 1 .. 200 ]
Default: 50

Page size (1-200). Defaults to 50.

offset
number >= 0
Default: 0

Row offset (>=0). Defaults to 0.

Responses

Response samples

Content type
application/json
{
  • "items": [
    ],
  • "total": 0
}

Voucher batch detail: stored batch row + per-status code counts (issued / redeemed / revoked / expired) via one GROUP BY scan.

Authorizations:
apiKeybearer
path Parameters
id
required
string

Responses

Response samples

Content type
application/json
{
  • "batch": {
    },
  • "stats": {
    }
}

Enumerate voucher codes inside a single batch. Backlog task #31: closes the operator-triage gap surfaced by the 2026-06-23 manual voucher test — POST /admin/codes/:id/revoke takes a voucher_code.id UUID, but operators only ever see the `code` string (CSV / support ticket / bundle PDF). Without this endpoint the only workaround was direct psql against the prod DB. Sort mirrors the bundle exporter so row position maps 1:1 to the CSV: STANDARD batches sort by `code ASC`; BUNDLE batches sort by `pair_id ASC, pair_kind ASC` so each pair's ESIM half precedes its TOPUP half (matches the wide-format CSV's `esim_code` / `topup_code` column order). The `code` field is PRIVILEGED — admin-only by design and the response carries `Cache-Control: no-store` so intermediaries can't retain it.

Authorizations:
apiKeybearer
path Parameters
id
required
string
query Parameters
status
string
Enum: "ISSUED" "REDEEMING" "PROVISIONING" "REDEEMED" "REVOKED" "EXPIRED" "PENDING_BINDING" "REFUNDED" "REDEEM_FAILED"

Filter by code status. Common operator paths: ?status=ISSUED to find a still-redeemable code for single-code revoke (backlog task #31 motivator), ?status=REDEEMED for invoice/audit triage.

pairKind
string
Enum: "ESIM" "TOPUP"

Filter by bundle half. ESIM = starter half, TOPUP = companion half. STANDARD-batch rows carry pair_kind = NULL and never match either filter.

limit
number [ 1 .. 200 ]
Default: 50

Page size (1-200). Defaults to 50.

offset
number >= 0
Default: 0

Row offset (>=0). Defaults to 0.

Responses

Response samples

Content type
application/json
{
  • "items": [
    ],
  • "total": 0
}

Cascade-revoke a batch: flip the batch row + every ISSUED code in it to REVOKED. REDEEMED / EXPIRED / already-REVOKED codes are intentionally untouched (status_invariants forbids the mixed state; a redeemed code is in the user's hands and revoke cannot retroactively claw it back).

Authorizations:
apiKeybearer
path Parameters
id
required
string
Request Body schema: application/json
required
reason
object <= 500 characters

Optional revoke reason. If null / empty / whitespace, voucher_codes.revoke_reason is coerced to the sentinel "no reason provided" at the service layer (the voucher_codes_status_invariants check requires revoke_reason NOT NULL on REVOKED rows). The audit_events row preserves the raw operator input so ops can distinguish "no reason given" from "literal sentinel" downstream.

revokedBy
required
string <= 120 characters

Operator identifier (email or name) for the audit trail. REQUIRED. Stored only on the audit_events row; voucher_codes itself has no revoked_by column (revoke_reason carries the human context, audit captures the actor). Must contain at least one non-whitespace character — a whitespace-only " " would otherwise satisfy @Length(1, 120) and leave a meaningless audit entry.

Responses

Request samples

Content type
application/json
{
  • "reason": { },
  • "revokedBy": "string"
}

Response samples

Content type
application/json
{
  • "batchId": "string",
  • "revokedCodeCount": 0,
  • "failedCodeCount": 0
}

Stream the customer-facing voucher bundle as ZIP{codes.csv, codes.pdf}. Generated in-memory; capped at the batch creation hard-limit of 2,000 codes per batch. Response body is application/zip (binary).

Authorizations:
apiKeybearer
path Parameters
id
required
string

Responses

Response samples

Content type
application/problem+json
{}

Revoke a single ISSUED voucher code. REDEEMED / EXPIRED / already-REVOKED codes return 409 CONFLICT — revoking REDEEMED would require simultaneously setting revoke + redemption columns, which voucher_codes_status_invariants forbids.

Authorizations:
apiKeybearer
path Parameters
id
required
string
Request Body schema: application/json
required
reason
object <= 500 characters

Optional revoke reason. If null / empty / whitespace, voucher_codes.revoke_reason is coerced to the sentinel "no reason provided" at the service layer (the voucher_codes_status_invariants check requires revoke_reason NOT NULL on REVOKED rows). The audit_events row preserves the raw operator input so ops can distinguish "no reason given" from "literal sentinel" downstream.

revokedBy
required
string <= 120 characters

Operator identifier (email or name) for the audit trail. REQUIRED. Stored only on the audit_events row; voucher_codes itself has no revoked_by column (revoke_reason carries the human context, audit captures the actor). Must contain at least one non-whitespace character — a whitespace-only " " would otherwise satisfy @Length(1, 120) and leave a meaningless audit entry.

Responses

Request samples

Content type
application/json
{
  • "reason": { },
  • "revokedBy": "string"
}

Response samples

Content type
application/json
{
  • "codeId": "string"
}

Resolve a voucher pair_id to a full diagnostic view: both halves + linked eSIM lifecycle + up to 10 most-recent audit_events scoped to {pairId, esimCodeId, topupCodeId, esimId}.

Authorizations:
apiKeybearer
path Parameters
pairId
required
string

Responses

Response samples

Content type
application/json
{
  • "pairId": "string",
  • "batchId": "string",
  • "customerId": "string",
  • "esim": {
    },
  • "topup": {
    },
  • "recentAuditEvents": [
    ]
}

Resolve EITHER an ESIM-half or TOPUP-half code id to the full pair view. Returns 404 when the code is standalone (NULL pair_id) or does not exist.

Authorizations:
apiKeybearer
path Parameters
codeId
required
string

Responses

Response samples

Content type
application/json
{
  • "pairId": "string",
  • "batchId": "string",
  • "customerId": "string",
  • "esim": {
    },
  • "topup": {
    },
  • "recentAuditEvents": [
    ]
}

Triage diagnostics for a voucher. Accepts exactly one of email|code|codeId. Returns local row + batch + linked eSIM + upstream snapshot + paired half (for bundles) + last 10 audit events.

Authorizations:
apiKeybearer
query Parameters
email
string

Look up by traveller email (redeemed_email OR bound_email). The most-recent match is returned when an email appears on multiple codes.

code
string

Look up by human-displayable voucher code string.

codeId
string

Look up by voucher_codes.id (UUIDv7). Accepted for programmatic clients; operators normally use email or code.

fresh
boolean

Bypass the 60s upstream eSIMfx snapshot cache. Defaults to false. Set when triaging suspected upstream/local divergence.

Responses

Response samples

Content type
application/json
{
  • "voucherCode": {
    },
  • "batch": {
    },
  • "esim": {
    },
  • "upstream": {
    },
  • "upstreamOrder": {
    },
  • "esimHalf": {
    },
  • "topupHalf": {
    },
  • "recentAuditEvents": [
    ]
}

Resend the install email for a REDEEMED voucher. Supports a destination override (typo correction); does NOT overwrite the on-disk redeemed_email. SES failure on the send is NOT a 502 -- the response returns 200 with `emailDelivered=false` and the freshly-signed install token + URL still populated so the operator can hand-deliver (iter-14 M1).

Authorizations:
apiKeybearer
Request Body schema: application/json
required
code
required
string

Human-displayable voucher code string. Must be the ESIM-half on bundle codes — TOPUP-halves have no install link.

email
string

Override destination email. Defaults to the on-disk redeemed_email. Use for typo corrections; the on-disk value is NOT overwritten.

locale
string

Render locale for the install email template. Defaults to the eSIM row's stored locale, else en.

reason
required
string

Free-form operator-provided reason (recorded in the audit event). Surfaced in support tooling so the support agent can see why an admin resent.

resentBy
required
string

Operator handle (admin email / system actor). Recorded in the voucher_redemption.install_email_resent (or _resend_failed) audit event so post-hoc attribution is possible. Matches the sibling refundedBy / actor / reissuedBy fields on refund / chargeback / reissue.

Responses

Request samples

Content type
application/json
{
  • "code": "string",
  • "email": "string",
  • "locale": "string",
  • "reason": "string",
  • "resentBy": "string"
}

Response samples

Content type
application/json
{
  • "installUrl": "string",
  • "sentTo": "string",
  • "emailDelivered": true
}

Terminate the eSIM upstream + flip local lifecycle to BLOCKED (chargeback path). No credit memo — reseller absorbs the chargeback loss with their processor. ESIM-half cascades to TOPUP-half (ISSUED/PENDING_BINDING only); TOPUP-half called directly terminates only the TOPUP order.

Authorizations:
apiKeybearer
Request Body schema: application/json
required
code
required
string

Voucher code string (ESIM-half, TOPUP-half, or standalone).

disputeId
required
string

Stripe / payment-processor dispute identifier. Free-form text — recorded in audit so support can correlate the chargeback ticket.

reason
required
string

Free-form chargeback reason (fraud, friendly-fraud, etc). Recorded verbatim in the audit row.

actor
required
string

Operator handle (admin email / system actor) — recorded as actor in audit. No FK; admin users are not modelled.

Responses

Request samples

Content type
application/json
{
  • "code": "string",
  • "disputeId": "string",
  • "reason": "string",
  • "actor": "string"
}

Response samples

Content type
application/json
{
  • "code": "string",
  • "esimId": "string",
  • "terminatedAt": "2019-08-24T14:15:22Z",
  • "cascaded": true
}

Refund a REDEEMED voucher (REDEEMED → REFUNDED). Terminates the upstream subscription, stamps the refund triplet, and cascades to the paired bundle half (recursive refund for REDEEMED TOPUPs, revoke for ISSUED/PENDING_BINDING TOPUPs).

Authorizations:
apiKeybearer
Request Body schema: application/json
required
code
required
string

Voucher code string. ESIM-half refund cascades to the paired TOPUP-half.

reason
required
string

Free-form refund reason (defective device, customer dispute, etc.). Recorded verbatim on voucher_codes.refund_reason + audit.

refundedBy
required
string

Operator handle (admin email / system actor). Recorded as refunded_by on the voucher_codes row + audit.

notifyTraveller
required
boolean

Send a "your voucher has been refunded" email to the on-disk redeemed_email (bundle cascade sends one email per pair). Required — the operator must make an explicit choice per call. Set true for customer-initiated refunds where the traveller is expecting confirmation; set false for fraud/hostile-usage triage or when the reseller handles the customer-facing communication through their own channels (respect the reseller's customer relationship). See voucher-runbook.html §2.3 for the case-by-case triage.

Responses

Request samples

Content type
application/json
{
  • "code": "string",
  • "reason": "string",
  • "refundedBy": "string",
  • "notifyTraveller": true
}

Response samples

Content type
application/json
{
  • "code": "string",
  • "refundedAt": "2019-08-24T14:15:22Z",
  • "cascaded": true,
  • "cascadeKind": "refund"
}

Reissue a fresh eSIM for a REDEEMED voucher (profile-fault default). Terminates old upstream, provisions new, updates esims in place, migrates bundle TOPUP-half bound_iccid, then resends install email. Rejects TOPUP-half codes and bundle ESIM-halves where the paired TOPUP is already REDEEMED. SES failure on the post-swap email send is NOT a 502 -- the swap is durable and the response returns 200 with `emailDelivered=false`; operator can call /admin/vouchers/resend-install-email separately.

Authorizations:
apiKeybearer
Request Body schema: application/json
required
code
required
string

Voucher code string (ESIM-half or standalone). TOPUP-half codes are rejected with 422.

reason
required
string

Free-form reissue reason (profile fault, scan failure, etc). Recorded in audit.

reissuedBy
required
string

Operator handle (admin email / system actor). Recorded in audit.

locale
string

Render locale for the install email template. Defaults to the eSIM row's stored locale, else en.

email
string

Destination override for the new install email. Defaults to the on-disk redeemed_email. Use when support previously corrected the address via resend-install-email and the reissue should NOT fall back to the original (stale) one.

Responses

Request samples

Content type
application/json
{
  • "code": "string",
  • "reason": "string",
  • "reissuedBy": "string",
  • "locale": "string",
  • "email": "string"
}

Response samples

Content type
application/json
{
  • "code": "string",
  • "action": "swapped",
  • "esimId": "string",
  • "iccid": "string",
  • "installUrl": "string",
  • "sentTo": "string",
  • "emailDelivered": true
}

List suppressions with optional filters. Powers the OPS-7-style cleanup workflow: enumerate matching rows here, then feed the emails into POST /admin/email-suppressions/remove-batch or the docs/ops/unsuppress-cleanup.sh helper. Filters: `pattern` (SQL LIKE, e.g. `traveller+%@esimdatastore.com`), `reason` (hard_bounce | complaint | manual), `limit` (1..500, default 100), `offset` (default 0). Ordered by (suppressed_at DESC, email DESC) so offset pagination is stable across ties in suppressed_at. Returns full email addresses (admin-scope). PII note: `pattern` lives in the query string so it ends up in request-log envelopes; use class-of-rows patterns rather than specific customer addresses when possible.

Authorizations:
apiKeybearer

Responses

Response samples

Content type
application/json
{
  • "items": [
    ],
  • "total": 43,
  • "limit": 100,
  • "offset": 0
}

Delete a recipient from the email_suppressions table so EmailSender's pre-send guard stops blocking future sends. Idempotent; returns `deleted=false` when no row matches (already-unsuppressed or never-suppressed). Empty / whitespace-only email inputs are rejected upstream as 422. Input address is normalised (trim + lowercase) to match the storage-canonical form. Recipient lives in the request body (not the URL) so request-path logging never captures it. Emits `email_suppression.unsuppressed` audit event with domain-only recipient (SEC-45).

Authorizations:
apiKeybearer
Request Body schema: application/json
required
object (UnsuppressEmailRequest)

Responses

Request samples

Content type
application/json
{ }

Response samples

Content type
application/json
{}

Batch variant of POST /remove. Accepts 1..500 emails in one call so bulk cleanup flows (OPS-7, one-click unsubscribe rollback batches) don't pay N HTTP + N DB round-trips. One SQL DELETE ... WHERE email IN (...) RETURNING email under the hood; per-entry result preserves input order. Emits one `email_suppression.unsuppressed` audit event per DELETED row (domain-only, SEC-45). Empty / whitespace-only entries fail upstream as 422.

Authorizations:
apiKeybearer
Request Body schema: application/json
required
object (BatchUnsuppressRequest)

Responses

Request samples

Content type
application/json
{ }

Response samples

Content type
application/json
{
  • "results": [],
  • "summary": {
    }
}

BetterAuth

BetterAuthController_handle_get

Responses

BetterAuthController_handle_post

Responses

BetterAuthController_handle_put

Responses

BetterAuthController_handle_delete

Responses

BetterAuthController_handle_patch

Responses

BetterAuthController_handle_options

Responses

BetterAuthController_handle_head

Responses

EnterprisePortal

Get authenticated user + customer + credit summary

Authorizations:
apiKey

Responses

Response samples

Content type
application/json
{
  • "user": {},
  • "customer": {
    },
  • "credit": {
    }
}

Paginated list of the caller's ENTERPRISE invoices

Authorizations:
sessionCookie
query Parameters
limit
number [ 1 .. 100 ]
Default: 20

Page size (1..100). Defaults to 20.

offset
number >= 0
Default: 0

Row offset (>=0). Defaults to 0.

Responses

Response samples

Content type
application/json
{
  • "rows": [
    ]
}

Rendered PDF for an ENTERPRISE monthly invoice

Renders and streams the invoice PDF. Returns 200 with application/pdf bytes and Content-Disposition: attachment. 404 for any invoice the caller does not own (SEC-37 non-existence-leak — the response body does not distinguish "not found" from "not owned").

Authorizations:
sessionCookie
path Parameters
id
required
string

Responses

Response samples

Content type
application/problem+json
{}

List customer-priced ENTERPRISE plans

Authorizations:
sessionCookie

Responses

Response samples

Content type
application/json
[
  • {
    }
]

Fetch a single customer-priced ENTERPRISE plan by id

Authorizations:
sessionCookie
path Parameters
id
required
string

Plan id (UUID v7).

Responses

Response samples

Content type
application/json
{
  • "id": "019ad4d5-8a3c-7000-8000-000000000001",
  • "name": "Japan 10GB / 7 days",
  • "country": "JP",
  • "duration": 7,
  • "durationUnit": "DAY",
  • "dataAmount": 10,
  • "dataAmountUnit": "GB",
  • "isUnlimited": false,
  • "price": "18.50",
  • "currency": "EUR"
}

Fetch the ENTERPRISE credit-line summary + top 5 recent debits and credits

Authorizations:
sessionCookie

Responses

Response samples

Content type
application/json
{
  • "creditLimit": "100000",
  • "outstanding": "25000",
  • "available": "75000",
  • "currency": "EUR",
  • "recentDebits": [
    ],
  • "recentCredits": [
    ]
}

Paginated customer-safe credit-line ledger

Authorizations:
sessionCookie
query Parameters
limit
number [ 1 .. 100 ]
Default: 20

Page size (1..100). Defaults to 20.

offset
number >= 0
Default: 0

Row offset (>=0). Defaults to 0.

from
string
Example: from=2026-01-01T00:00:00Z

Inclusive lower bound on created_at (ISO 8601 datetime). Omit to leave open-ended below.

to
string
Example: to=2026-01-31T23:59:59Z

Inclusive upper bound on created_at (ISO 8601 datetime). Omit to leave open-ended above.

Responses

Response samples

Content type
application/json
{
  • "rows": [
    ],
  • "total": 42
}

Create an ENTERPRISE order group (checkout)

Debit the credit line for the cart total, insert one order_groups row + one orders row per unit, and post an ORDER_DEBIT ledger entry — all inside a single tx. Returns immediately with the created IDs; the receipt PDF (receiptPdfUrl) is filled by a post-commit hook (D10) and available via GET /app/order-groups/:id/receipt.pdf (D11).

Authorizations:
sessionCookie
header Parameters
idempotency-key
required
string
Request Body schema: application/json
required
required
Array of objects (CartLine)

Cart lines. 1..50 entries.

required
object

Portal-side acknowledgments — both booleans MUST be true.

Responses

Request samples

Content type
application/json
{
  • "lines": [
    ],
  • "acknowledgments": {
    }
}

Response samples

Content type
application/json
{
  • "orderGroupId": "019ad4d5-8a3c-7000-8000-000000000010",
  • "orderIds": [
    ],
  • "receiptPdfUrl": null
}

Paginated list of ENTERPRISE order groups

Authorizations:
sessionCookie
query Parameters
limit
number [ 1 .. 100 ]
Default: 20

Page size (1..100). Defaults to 20.

offset
number >= 0
Default: 0

Row offset (>=0). Defaults to 0.

from
string
Example: from=2026-01-01T00:00:00Z

Inclusive lower bound on created_at (ISO 8601 datetime). Omit to leave open-ended below.

to
string
Example: to=2026-01-31T23:59:59Z

Inclusive upper bound on created_at (ISO 8601 datetime). Omit to leave open-ended above.

Responses

Response samples

Content type
application/json
{
  • "rows": [
    ],
  • "total": 42
}

Single ENTERPRISE order group with expanded line items

Authorizations:
sessionCookie
path Parameters
id
required
string

Responses

Response samples

Content type
application/json
{
  • "id": "019ad4d5-8a3c-7000-8000-000000000010",
  • "createdAt": "2026-08-06T10:00:00Z",
  • "totalMinor": "2100",
  • "currency": "EUR",
  • "receiptPdfUrl": null,
  • "statusSummary": {
    },
  • "lineItems": [
    ]
}

Rendered receipt PDF for an ENTERPRISE order group

Renders and streams the receipt PDF for the given order group. Returns 200 with application/pdf bytes. 404 for any group the caller does not own (SEC-37 non-existence-leak — the response body does not distinguish "not found" from "not owned"). A group with zero committed orders (all CONFIRM_PENDING / CONFIRM_FAILED) also 404s until at least one order commits.

Authorizations:
sessionCookie
path Parameters
id
required
string

Responses

Response samples

Content type
application/problem+json
{}

Single ENTERPRISE order — cheap polling endpoint

Authorizations:
sessionCookie
path Parameters
id
required
string

Responses

Response samples

Content type
application/json
{
  • "id": "019ad4d5-8a3c-7000-8000-000000000011",
  • "status": "CONFIRM_PENDING",
  • "planId": "019ad4d5-8a3c-7000-8000-000000000005",
  • "planName": "Turkey_1GB_7DAYs",
  • "salePrice": "10.50",
  • "provisioningFee": "0.50",
  • "iccid": null,
  • "createdAt": "2026-08-06T10:00:00Z",
  • "refundRequestedAt": "2026-08-09T12:00:00Z",
  • "esim": {
    }
}

Per-unit refund for an ENTERPRISE order

Accepts the refund and returns 202. The body reports REFUNDED if upstream termination completes inline within ~3s; otherwise REFUND_PENDING and the background worker drives the commit. Poll GET /app/orders/:id until status is REFUNDED or REFUND_FAILED. Refund credit posts to the credit-line ledger as REFUND_CREDIT with delta = -planPrice (design §3.6 — provisioning fee retained per FIN-32).

Authorizations:
sessionCookie
path Parameters
id
required
string

Responses

Response samples

Content type
application/json
{
  • "orderId": "string",
  • "status": "REFUND_PENDING",
  • "refundRequestedAt": "2026-05-13T12:00:00.000Z",
  • "refundedAt": "2026-05-13T12:00:01.234Z",
  • "refundedAmount": "14.50"
}

List the caller's ENTERPRISE eSIM inventory

Returns a paginated list of the caller's eSIMs plus a filter-independent KPI summary. Filters compose against the customer-scoped base via AND at the service (see design §5.5).

Authorizations:
sessionCookie
query Parameters
status
string
Enum: "PROVISIONED" "ACTIVE" "EXPIRED" "BLOCKED"

Filter by esims.status. When omitted, all statuses are returned.

assigned
boolean
Example: assigned=false

Tri-state filter on assignment. true returns rows with a travellerEmail set, false returns rows without one, omit for no filter.

iccidPrefix
string <= 20 characters
Example: iccidPrefix=891234

ICCID prefix search (LIKE '%'). Digits only, max 20 chars.

limit
number [ 1 .. 100 ]
Default: 20

Page size (1..100). Defaults to 20.

offset
number >= 0
Default: 0

Row offset (>=0). Defaults to 0.

Responses

Response samples

Content type
application/json
{
  • "items": [
    ],
  • "summary": {
    },
  • "total": 42
}

Get an ENTERPRISE eSIM detail — row + timeline + usage

Returns the caller's eSIM row plus its plan back-ref, order back-ref (via source_ref_kind='ENTERPRISE_ORDER'), user-facing status timeline (whitelisted actions only, ASC by createdAt), and usage summary. Cross-customer ids and non-existent ids both return an identical 404 payload (SEC-37 non-existence-leak).

Authorizations:
sessionCookie
path Parameters
id
required
string

Responses

Response samples

Content type
application/json
{
  • "esim": {
    },
  • "plan": {
    },
  • "order": {
    },
  • "timeline": [
    ],
  • "usage": {
    }
}

Assign an unassigned ENTERPRISE eSIM to a traveller

Row-locks the target eSIM, stamps traveller_email + assigned_at + assigned_by_user_id, mints a fresh install token, and dispatches the NOTIF-1 CID-inline install email best-effort. Returns 200 with the refreshed eSIM row + installUrl. Send Idempotency-Key to guarantee at-most-once semantics on retries (design §4.7).

Authorizations:
sessionCookie
path Parameters
id
required
string
Request Body schema: application/json
required
travellerEmail
required
string <= 254 characters

Traveller email that will receive the install email and be recorded on esims.traveller_email. Normalised (trim + lowercase) by the service before persist. Must be a valid RFC-5322 email; malformed → 422 via ValidationPipe.

Responses

Request samples

Content type
application/json
{}

Response samples

Content type
application/json
{}

Reassign an already-assigned ENTERPRISE eSIM to a new traveller

Row-locks the target eSIM, then atomically (single tx): overwrites traveller_email + assigned_at + assigned_by_user_id AND bumps install_token_version AND mints the fresh install-token JWT signed against the bumped version. The previous traveller's install URL falls to 410 GONE atomically at reassign commit — there is NO window where BOTH old and new URLs resolve. Concurrent reassign races serialise on the row lock. Post-commit (best-effort, never blocks the 200): dispatches the NOTIF-1 CID-inline install email, emits an esim.reassigned audit event with the old and new emails. Returns 200 with the refreshed eSIM row + installUrl. Send Idempotency-Key to guarantee at-most-once semantics on retries — the atomicity guarantee holds within a single request and the idempotency cache short-circuits replays before any DB write (design §4.4, §4.7).

Authorizations:
sessionCookie
path Parameters
id
required
string
Request Body schema: application/json
required
travellerEmail
required
string <= 254 characters

New traveller email that will overwrite esims.traveller_email and receive the fresh install email. Normalised (trim + lowercase) by the service before persist. Must be a valid RFC-5322 email; malformed → 422 via ValidationPipe.

Responses

Request samples

Content type
application/json
{}

Response samples

Content type
application/json
{}

Re-send the install email for an already-assigned ENTERPRISE eSIM

Re-sends the install email to the SAME travellerEmail currently stamped on the row (no body — callers cannot override the recipient). Refreshes installTokenExpiresAt when the current value is null / past / within 7 days from now (design §4.5 near-expiry threshold); preserves it otherwise. Does NOT bump install_token_version — the traveller's ORIGINAL install URL (and every prior resend URL) remain valid. Rate-limited to 3 sends per rolling 24h per esim_id — a 4th call inside the window returns 429 with a Retry-After header (delta-seconds) computed off the oldest prior send. Idempotency-key replay returns the cached response and does NOT double-send the email or double-count against the rate limit.

Authorizations:
sessionCookie
path Parameters
id
required
string

Responses

Response samples

Content type
application/json
{}

Revoke an ENTERPRISE eSIM (request async refund)

Flips the parent order to REFUND_PENDING inside a single row-locked tx and emits an esim.revoked audit event; the refund cron (advisory lock 100006) drives the subsequent terminate_subscription upstream call, the eSIM's PROVISIONED → BLOCKED transition, and the credit-line refund asynchronously. Returns 202 with refundStatusUrl pointing at /app/orders/:orderId for polling. Allowed while esim.status IN ('PROVISIONED', 'ACTIVE') AND the parent order is in COMPLETED; concurrent revokes on the same eSIM serialise on the row lock (the loser returns 409 NOT_PROVISIONED). Send Idempotency-Key to guarantee at-most-once semantics on retries — same-key replay returns the cached 202 without re-writing the order. Design §4.6.

Authorizations:
sessionCookie
path Parameters
id
required
string

Responses

Response samples

Content type
application/json
{
  • "refundStatusUrl": "/app/orders/019ad4d5-8a3c-7000-8000-000000000001",
  • "orderStatus": "REFUND_PENDING"
}

Admin: Customers

Create a new customer

Authorizations:
apiKeybearer
Request Body schema: application/json
required
name
required
string

Customer name

environment
required
string
Enum: "live" "sandbox"

Customer environment

kind
required
string
Enum: "WHOLESALE" "VOUCHER"

Commercial product the customer is onboarded under. Required and immutable. WHOLESALE: customer-keyed API places wallet-funded orders + activates / tops-up eSIMs. VOUCHER: admin mints code batches against this customer; end-travellers redeem at /r/:code. Pricing for both kinds resolves from customer_plan_prices.price; the kind governs which endpoints the customer can call and how invoice lines are shaped (no provisioning-fee split on voucher lines).

country
required
string

Buyer country as ISO 3166-1 alpha-2, uppercase. Required and IMMUTABLE after create. Drives the VAT/PDV rate applied at invoice generation (via country_vat_rates lookup); unlisted countries fall back to 0% + label "VAT" (matches Serbian izvoz-usluga export exemption). Common values: RS (Serbia), HR (Croatia), DE (Germany), US (United States). A relocated reseller must be created as a new customer to avoid retroactively changing the tax basis of historical invoices.

addressLine1
required
string

Buyer street address, line 1. Required on the printed invoice.

addressLine2
string

Buyer street address, line 2 (apartment / suite / floor).

city
required
string

Buyer city. Required on the printed invoice.

postalCode
required
string

Buyer postal code. Required on the printed invoice.

taxId
required
string

Buyer tax ID (PIB for Serbia, VAT number for EU, etc.). Required at create per invoice design §13.2 — Serbian law requires the buyer PIB on every valid račun, and we are B2B-only, so every legitimate customer has a tax identifier of some kind. If a buyer has no jurisdiction-level tax ID at all (rare edge case), pass a placeholder here and update the row before generating a real invoice.

registrationId
string

Buyer business registration id (MB in Serbia; equivalent elsewhere). Optional.

Responses

Request samples

Content type
application/json
{
  • "name": "Acme Corp",
  • "environment": "live",
  • "kind": "WHOLESALE",
  • "country": "RS",
  • "addressLine1": "Kneza Miloša 12",
  • "addressLine2": "Sprat 4",
  • "city": "Beograd",
  • "postalCode": "11000",
  • "taxId": "108xxxxxx",
  • "registrationId": "21xxxxxx"
}

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "environment": "live",
  • "kind": "WHOLESALE",
  • "balance": "150.00",
  • "createdAt": "2019-08-24T14:15:22Z",
  • "country": "RS",
  • "addressLine1": "Kneza Miloša 12",
  • "addressLine2": "Sprat 4",
  • "city": "Beograd",
  • "postalCode": "11000",
  • "taxId": "108xxxxxx",
  • "registrationId": "21xxxxxx",
  • "apiKey": "string"
}

List all customers

Authorizations:
apiKeybearer

Responses

Response samples

Content type
application/json
[
  • {
    }
]

Get a customer by ID

Authorizations:
apiKeybearer
path Parameters
id
required
string

Responses

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "environment": "live",
  • "kind": "WHOLESALE",
  • "balance": "150.00",
  • "createdAt": "2019-08-24T14:15:22Z",
  • "country": "RS",
  • "addressLine1": "Kneza Miloša 12",
  • "addressLine2": "Sprat 4",
  • "city": "Beograd",
  • "postalCode": "11000",
  • "taxId": "108xxxxxx",
  • "registrationId": "21xxxxxx"
}

Rotate API key for a customer

Authorizations:
apiKeybearer
path Parameters
id
required
string

Responses

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "environment": "live",
  • "kind": "WHOLESALE",
  • "balance": "150.00",
  • "createdAt": "2019-08-24T14:15:22Z",
  • "country": "RS",
  • "addressLine1": "Kneza Miloša 12",
  • "addressLine2": "Sprat 4",
  • "city": "Beograd",
  • "postalCode": "11000",
  • "taxId": "108xxxxxx",
  • "registrationId": "21xxxxxx",
  • "apiKey": "string"
}

Admin: Enterprise Customers

Create an ENTERPRISE-kind customer (admin-token only)

Creates a new customer with kind = ENTERPRISE. Idempotent on (name, taxId): repeat requests with the same pair return the same row. ENTERPRISE customers authenticate via session cookies (/auth/* + /app/*) and hold no customer-keyed API key.

Authorizations:
apiKeybearer
Request Body schema: application/json
required
name
required
string

Enterprise customer display name.

country
required
string

Buyer country as ISO 3166-1 alpha-2, uppercase. Required and IMMUTABLE after create. Drives the VAT/PDV rate applied at invoice generation (via country_vat_rates lookup).

addressLine1
required
string

Buyer street address, line 1. Required on the printed invoice.

addressLine2
string

Buyer street address, line 2 (apartment / suite / floor).

city
required
string

Buyer city. Required on the printed invoice.

postalCode
required
string

Buyer postal code. Required on the printed invoice.

taxId
required
string

Buyer tax ID (PIB for Serbia, VAT number for EU, etc.). Required per invoice design §13.2.

registrationId
string

Buyer business registration id (MB in Serbia; equivalent elsewhere). Optional.

creditLimitMinor
required
string

Credit ceiling in minor units (e.g. EUR cents) as a non-negative decimal string. Represented as a string so callers preserve full precision on ceilings above 2^53 (same convention as the /app/me credit fields — see MeResponse for the encoding rationale).

currency
required
string
Value: "EUR"

ISO 4217 currency of the credit line. Hardcoded to EUR today (mirrors the AppSessionService read path). Validated up-front so callers get a clear 422 rather than a silent accept-and-ignore if they forward a stale currency selection from a future multi-currency screen. Not persisted on the customers row — promotion to a per-customer currency column lands with the mixed-currency ledger design (Slice C or later).

environment
string
Default: "live"
Enum: "live" "sandbox"

Customer environment — live (default) or sandbox. ENTERPRISE customers currently run on live only; the field is kept for parity with WHOLESALE + VOUCHER create.

Responses

Request samples

Content type
application/json
{
  • "name": "Acme Enterprise",
  • "country": "RS",
  • "addressLine1": "Kneza Miloša 12",
  • "addressLine2": "Sprat 4",
  • "city": "Beograd",
  • "postalCode": "11000",
  • "taxId": "108xxxxxx",
  • "registrationId": "21xxxxxx",
  • "creditLimitMinor": "100000",
  • "currency": "EUR",
  • "environment": "live"
}

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "environment": "live",
  • "country": "RS",
  • "addressLine1": "Kneza Miloša 12",
  • "addressLine2": "Sprat 4",
  • "city": "Beograd",
  • "postalCode": "11000",
  • "taxId": "108xxxxxx",
  • "registrationId": "21xxxxxx",
  • "kind": "ENTERPRISE",
  • "currency": "EUR",
  • "creditLimitMinor": "100000",
  • "creditOutstandingMinor": "0",
  • "createdAt": "string",
  • "updatedAt": "string"
}

Seed the first admin user for an ENTERPRISE customer (admin-token only)

Creates the initial portal user for a freshly-created ENTERPRISE customer, links it via customer_users, mints a better-auth setup-password verification token, and sends a welcome email with the token embedded in the link. Idempotent on (customer_id, email): repeat calls return the existing user without resending the email. Returns 404 if the customer does not exist OR is not kind = ENTERPRISE (no reveal). Returns 409 with body detail EMAIL_SUPPRESSED: ... if the target email is on the SES suppression list.

Authorizations:
apiKeybearer
path Parameters
id
required
string

Enterprise customer id (UUID v7).

Request Body schema: application/json
required
email
required
string

Email address of the first admin user. Normalised to lowercase server-side before the customer_users + email_suppressions lookups.

name
required
string

Display name of the first admin user.

Responses

Request samples

Content type
application/json
{}

Response samples

Content type
application/json
{
  • "userId": "018f2b5d-a5cd-71a8-9e0e-8d7f5b0f2c73",
  • "email": "[email protected]",
  • "name": "Admin One",
  • "customerId": "string"
}

Update the credit ceiling on an ENTERPRISE customer (admin-token only)

Sets credit_limit_minor to the caller-supplied non-negative value and appends an audit_events row with action = customer.credit_limit_updated. Returns 404 if the customer does not exist OR is not kind = ENTERPRISE (no reveal). No reciprocal invariant with credit_outstanding_minor — the ledger design permits lowering the ceiling below current outstanding.

Authorizations:
apiKeybearer
path Parameters
id
required
string

Enterprise customer id (UUID v7).

Request Body schema: application/json
required
creditLimitMinor
required
string

New credit ceiling in minor units (e.g. EUR cents) as a non-negative integer decimal string.

Responses

Request samples

Content type
application/json
{
  • "creditLimitMinor": "250000"
}

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "environment": "live",
  • "country": "RS",
  • "addressLine1": "Kneza Miloša 12",
  • "addressLine2": "Sprat 4",
  • "city": "Beograd",
  • "postalCode": "11000",
  • "taxId": "108xxxxxx",
  • "registrationId": "21xxxxxx",
  • "kind": "ENTERPRISE",
  • "currency": "EUR",
  • "creditLimitMinor": "100000",
  • "creditOutstandingMinor": "0",
  • "createdAt": "string",
  • "updatedAt": "string"
}

Update identity fields on an ENTERPRISE customer (admin-token only)

Updates any subset of { name, country, addressLine1, addressLine2, city, postalCode, taxId, registrationId }. Every field is optional; undefined (omitted) leaves the column untouched. addressLine2 and registrationId accept explicit null to clear a previously-set value. Refuses creditLimitMinor (use the dedicated /credit-limit endpoint), currency (EUR-only today), kind, and environment (both immutable after create) via the whitelist validation pipe — unknown fields return 422. Writes an audit_events row with action = customer.identity_updated capturing only the changed fields (before/after). Returns 404 if the customer does not exist OR is not kind = ENTERPRISE (no reveal). Slice C preamble P1.

Authorizations:
apiKeybearer
path Parameters
id
required
string

Enterprise customer id (UUID v7).

Request Body schema: application/json
required
name
string

Enterprise customer display name.

country
string

Buyer country as ISO 3166-1 alpha-2, uppercase. Country changes affect the VAT/PDV rate applied on the NEXT invoice — historic invoices are not re-computed.

addressLine1
string

Buyer street address, line 1.

addressLine2
object or null

Buyer street address, line 2. Pass null to clear an existing value; omit to leave untouched.

city
string

Buyer city.

postalCode
string

Buyer postal code.

taxId
string

Buyer tax ID (PIB for Serbia, VAT number for EU, etc.). WARNING: the create endpoint's (name, taxId) idempotency key means a later POST with the OLD tax id + name will spawn a duplicate row. Ops should follow a tax-id change with a fresh review of any automation that stores the pair.

registrationId
object or null

Buyer business registration id (MB in Serbia; equivalent elsewhere). Pass null to clear an existing value; omit to leave untouched.

Responses

Request samples

Content type
application/json
{
  • "name": "eSIM Data Store Internal",
  • "country": "RS",
  • "addressLine1": "Kneza Miloša 12",
  • "addressLine2": "Sprat 4",
  • "city": "Beograd",
  • "postalCode": "11000",
  • "taxId": "108xxxxxx",
  • "registrationId": "21xxxxxx"
}

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "environment": "live",
  • "country": "RS",
  • "addressLine1": "Kneza Miloša 12",
  • "addressLine2": "Sprat 4",
  • "city": "Beograd",
  • "postalCode": "11000",
  • "taxId": "108xxxxxx",
  • "registrationId": "21xxxxxx",
  • "kind": "ENTERPRISE",
  • "currency": "EUR",
  • "creditLimitMinor": "100000",
  • "creditOutstandingMinor": "0",
  • "createdAt": "string",
  • "updatedAt": "string"
}

Purge an ENTERPRISE customer and its portal users (admin-token only)

Wipes the customer row, every linked customer_users row, every users row that was ONLY linked to this customer (cascades to sessions + accounts via schema FKs), and every orphan verifications row for those users. All inside one transaction — a failure at any step rolls the whole delete back. Returns 204 on success. Refuses with 409 CUSTOMER_HAS_ORDERS if the customer has any order_groups OR orders rows, CUSTOMER_HAS_LEDGER for credit_line_ledger rows, or the generic CUSTOMER_HAS_DOWNSTREAM_ROWS (with the offending pg constraint name) for any other FK-referencing table we did not explicitly guard (customer_plan_prices, invoices, esims, voucherBatches, customerWallets, …) — partially-consumed credit lines and finance-relevant history carry legal + accounting consequences that must be resolved with finance BEFORE purging. Returns 404 if the customer does not exist OR is not kind = ENTERPRISE (no reveal). Slice C preamble P1.

Authorizations:
apiKeybearer
path Parameters
id
required
string

Enterprise customer id (UUID v7).

Responses

Response samples

Content type
application/problem+json
{}

Record a manual credit-outstanding adjustment (admin-token only)

Writes an ADJUSTMENT row to credit_line_ledger and increments customers.credit_outstanding_minor by the signed deltaMinor (positive grows debt; negative shrinks it). Both writes atomic in one transaction with a FOR UPDATE lock on the customer row. Returns 422 for zero delta (no-op ledger rows are a caller bug). Returns 404 if the customer does not exist OR is not kind = ENTERPRISE (SEC-37 no-reveal).

Authorizations:
apiKeybearer
path Parameters
id
required
string

Enterprise customer id (UUID v7).

Request Body schema: application/json
required
deltaMinor
required
string

Signed decimal-string delta in minor units. -1000 shrinks outstanding by 10.00; 1000 grows it by 10.00. Zero rejected as 422. Range: up to 18 digits (positive) / 17 digits + minus sign (negative) — conservatively below the PostgreSQL BIGINT ceiling of ±9.2e18 so overflow surfaces as 422 not 500.

reason
required
string

Free-form operator context (between 5 and 500 chars). Persisted on the ledger row notes column and mirrored into the audit event.

Responses

Request samples

Content type
application/json
{
  • "deltaMinor": "-1500",
  • "reason": "Compensation for reconciler drift #4321 — root cause: retry storm"
}

Response samples

Content type
application/json
{
  • "ledgerRowId": "string",
  • "deltaMinor": "-1500",
  • "newOutstandingMinor": "3900"
}

Replay the monthly-invoice generation for a specific period (admin-token only)

Reruns the per-customer invoice-generation transaction for the supplied (periodStart, periodEnd) window. Ops uses this after a monthly cron failedCount > 0 tick, once the source-data drift (e.g. currency mismatch surfaced by the H1 fail-loud path) has been corrected. Idempotent — a second replay for a period whose invoice already exists returns outcome: 'skipped' without touching the sequence or writing a second invoice row. Emits an enterprise_invoice.admin_replay audit event on every invocation (including failed attempts, via try/finally) so ops has a forensic trail. Returns 404 if the customer does not exist OR is not kind = ENTERPRISE (SEC-37 no-reveal). Returns 422 on any of: calendar-invalid date (e.g. 2026-02-31), non-UTC-midnight, periodEnd <= periodStart, a period whose shape does not match first-of-month → first-of-next-month (codex iter-3 High-1 — keeps the replay idempotency key aligned with the natural monthly cron so admin + cron cannot double-bill overlapping activity windows), or a period that is NOT a closed prior month (periodEnd > start-of-current-month UTC) — replays for the current or future month are rejected because they would persist a partial invoice and cause the natural cron to later skip the same key (codex iter-4 High-1). Throttled to 10 per minute PER CUSTOMER — bucket keyed on the :id path param via a custom generateKey (codex iter-3 Medium-2) so IP rotation cannot storm a single customer.

Authorizations:
apiKeybearer
path Parameters
id
required
string

Enterprise customer id (UUID v7).

Request Body schema: application/json
required
periodStart
required
string

Inclusive UTC-midnight start of the billing period. MUST be the first day of a month (e.g. 2026-07-01T00:00:00.000Z). Non-first-of-month is rejected with 422 to keep the replay idempotency key aligned with the natural monthly cron.

periodEnd
required
string

Exclusive UTC-midnight end of the billing period. MUST be the first day of the month immediately following periodStart. Non-monthly spans (>1 month, <1 month, or non-first-of-month) are rejected with 422.

Responses

Request samples

Content type
application/json
{
  • "periodStart": "2026-07-01T00:00:00.000Z",
  • "periodEnd": "2026-08-01T00:00:00.000Z"
}

Response samples

Content type
application/json
{
  • "outcome": "generated",
  • "invoiceId": "01948a7d-3d5e-7a52-9c8a-42b8bcf28aae"
}

Admin: Enterprise Invoices

Record an out-of-band settlement on an ENTERPRISE invoice (admin-token only)

Writes an INVOICE_PAYMENT credit-line-ledger row, decrements customers.credit_outstanding_minor by the customer-net subtotal (re-derived from invoice_line_items — VAT is pass-through to the tax authority and stays out of the credit line), and stamps invoices.paid_at. All three writes are atomic in one transaction. Returns 409 invoice already paid if paid_at is already set (idempotency guard — no duplicate ledger row on the 409 path). Returns 409 cannot mark-paid a non-positive-subtotal invoice for a refund-heavy period (credit-memo settlement is a separate flow, not yet implemented) or a zero-subtotal invoice (data-bug surface). Returns 404 for unknown invoice ids OR invoices whose parent customer is not kind = ENTERPRISE (SEC-37 no-reveal).

Authorizations:
apiKeybearer
path Parameters
id
required
string

Invoice id (UUID v7).

Request Body schema: application/json
required
paidAt
required
string

When the payment was received (ISO 8601 date or date-time). Stamped verbatim onto invoices.paid_at.

method
required
string

Short label for the settlement channel (bank_transfer, wire, manual_adjustment, ...). Max 40 chars.

reference
required
string

Bank / wire reference or tracking id for reconciliation. Max 200 chars.

Responses

Request samples

Content type
application/json
{
  • "paidAt": "2026-08-09T14:00:00Z",
  • "method": "bank_transfer",
  • "reference": "REF-2026-08-000123"
}

Response samples

Content type
application/json
{
  • "invoiceId": "string",
  • "paidAt": "string",
  • "ledgerRowId": "string"
}

Admin: Origination

Issue an eSIM to a traveller: reserve upstream, persist, sign install token, email install link.

Authorizations:
apiKeybearer
Request Body schema: application/json
required
customerId
required
string

Customer owning the eSIM.

planId
required
string

Plan to issue.

email
required
string

Recipient email for the install link.

required
object (SourceRef)
locale
string
Enum: "en" "de"

Email + install-page locale. Defaults to 'en'. 'de' falls back to en until Phase 2 fills the DE template.

expiresInDays
number [ 1 .. 360 ]

Install token lifetime in days. Defaults to 360. Capped at 360 by InstallTokenService.sign() to track the eSIMfx upstream activate_by window.

travelerName
string

Optional first name surfaced in the email greeting.

Responses

Request samples

Content type
application/json
{
  • "customerId": "string",
  • "planId": "string",
  • "email": "string",
  • "sourceRef": {
    },
  • "locale": "en",
  • "expiresInDays": 1,
  • "travelerName": "string"
}

Response samples

Content type
application/json
{
  • "esimId": "string",
  • "installToken": "string",
  • "installUrl": "string"
}

Admin: Refunds

Force a stuck REFUND_PENDING refund to commit (credits wallet)

Bypasses the upstream check and atomically credits the wallet + flips status to REFUNDED. Use when ops has confirmed out-of-band that the upstream subscription is terminated. The reason is recorded in the audit log.

Authorizations:
apiKeybearer
path Parameters
id
required
string
Request Body schema: application/json
required
reason
required
string [ 1 .. 500 ] characters

Non-empty rationale for the manual override; recorded in the audit trail.

Responses

Request samples

Content type
application/json
{
  • "reason": "eSIMfx support confirmed termination via ticket #12345"
}

Response samples

Content type
application/json
{
  • "orderId": "string",
  • "status": "REFUND_PENDING",
  • "refundRequestedAt": "2026-05-13T12:00:00.000Z",
  • "refundedAt": "2026-05-13T12:00:01.234Z",
  • "refundedAmount": "14.50"
}

Force a stuck REFUND_PENDING refund to terminal failure

Flips status to REFUND_FAILED without crediting the wallet. Use when the refund is irrecoverable (e.g. confirmed fraud, duplicate, or upstream never had the order). The reason is recorded in the audit log.

Authorizations:
apiKeybearer
path Parameters
id
required
string
Request Body schema: application/json
required
reason
required
string [ 1 .. 500 ] characters

Non-empty rationale for the manual override; recorded in the audit trail.

Responses

Request samples

Content type
application/json
{
  • "reason": "eSIMfx support confirmed termination via ticket #12345"
}

Response samples

Content type
application/json
{
  • "orderId": "string",
  • "status": "REFUND_PENDING",
  • "refundRequestedAt": "2026-05-13T12:00:00.000Z",
  • "refundedAt": "2026-05-13T12:00:01.234Z",
  • "refundedAmount": "14.50"
}

Admin: Invoices

Generate a customer invoice (PDF binary response). Returns 200 + application/pdf body; persists invoices + invoice_line_items rows.

Authorizations:
apiKeybearer
Request Body schema: application/json
required
customerId
required
string

Customer to invoice.

periodStart
required
string

Inclusive UTC-midnight start of the invoicing window. Bare-date ISO string ('YYYY-MM-DD'). Datetimes / timezones / calendar-invalid dates rejected.

periodEnd
required
string

Exclusive UTC-midnight end of the invoicing window. Bare-date ISO string ('YYYY-MM-DD').

locale
string
Enum: "en" "sr"

PDF language. 'en' (English, default) or 'sr' (Serbian Latin script). Controls all label text, date format (ISO YYYY-MM-DD vs DD.MM.YYYY.), money decimal separator (period vs comma), and line-item description prefixes. Font (Noto Sans) is Unicode-safe for both.

object

Manual override of the USD-to-RSD exchange rate. Auto-fetches from kurs.resenje.org (NBS mirror) at invoice-generation time when omitted for RS-country buyers. When supplied it is used verbatim regardless of buyer country -- useful for audit replay, mirror-outage fallback, and adding an RSD conversion to a non-RS invoice on the operator's discretion. Provide all three subfields together.

Responses

Request samples

Content type
application/json
{
  • "customerId": "string",
  • "periodStart": "2026-05-01",
  • "periodEnd": "2026-06-01",
  • "locale": "en",
  • "exchangeRate": {
    }
}

Response samples

Content type
application/problem+json
{}

Admin: Reports

Generate financial report

Authorizations:
apiKeybearer
query Parameters
month
required
string
Example: month=2026-03

Month in YYYY-MM format

format
string
Default: "json"
Enum: "json" "csv" "pdf"

Export format

sections
string
Example: sections=revenue,profitability

Comma-separated sections to include

Responses

Response samples

Content type
{
  • "month": "string",
  • "generatedAt": "string",
  • "revenue": {
    },
  • "profitability": {
    },
  • "customers": {
    }
}

Admin: Voucher Batches

Create a voucher batch + N codes atomically. Returns batch id + code count.

Authorizations:
apiKeybearer
Request Body schema: application/json
required
customerId
required
string

Customer UUID who will be invoiced for each redemption.

planId
required
string

Plan UUID to issue against each redemption.

count
required
number [ 1 .. 2000 ]

Number of codes to generate. Hard cap 2,000 to keep the bundle in-memory; larger jobs must split or move to DO Spaces (deferred).

expiresAt
object

Batch expiry (ISO 8601, UTC). null or omitted = no expiry. Past the expiry, redemption returns 410 GONE and the public state view reports expired.

label
required
string <= 120 characters

Human-readable batch label. PUBLIC — shown to end-travellers in the /r/[code] redemption-page footer. Do NOT include PII, internal identifiers, or domain-shaped strings. Period (.) is excluded so labels cannot impersonate domain.tld; use spaces, -, or () for visual separation.

createdBy
required
string <= 120 characters

Operator identifier (email or name) for the audit trail. REQUIRED. Persisted on voucher_batches.created_by AND echoed onto every audit_events row this batch produces. Must contain at least one non-whitespace character — a whitespace-only " " would otherwise satisfy @Length(1, 120) and leave a meaningless audit-trail entry.

topupPlanId
string

UUID of the companion TOPUP plan. When set, this batch is a BUNDLE: each code generates a paired TOPUP code (PENDING_BINDING) that becomes redeemable after the paired ESIM code is redeemed. Must be in the eSIM plan's compatible_topup_product_ids cache (populated by the daily plan-sync); a 422 INVALID_REFERENCE surfaces otherwise.

Responses

Request samples

Content type
application/json
{
  • "customerId": "string",
  • "planId": "string",
  • "count": 1,
  • "expiresAt": { },
  • "label": "VisaCo Croatia 2026-06",
  • "createdBy": "string",
  • "topupPlanId": "string"
}

Response samples

Content type
application/json
{
  • "batchId": "string",
  • "codeCount": 0,
  • "kind": "STANDARD",
  • "topupPlanId": "string"
}

Paginated list of voucher batches. Filters: customerId, status. Defaults to 50 newest-first.

Authorizations:
apiKeybearer
query Parameters
customerId
string

Filter by customer UUID. Omit to list across all customers.

status
string
Enum: "ACTIVE" "REVOKED"

Filter by batch status. ACTIVE includes batches whose individual codes may have been revoked; REVOKED is set only when the WHOLE batch was revoked.

limit
number [ 1 .. 200 ]
Default: 50

Page size (1-200). Defaults to 50.

offset
number >= 0
Default: 0

Row offset (>=0). Defaults to 0.

Responses

Response samples

Content type
application/json
{
  • "items": [
    ],
  • "total": 0
}

Voucher batch detail: stored batch row + per-status code counts (issued / redeemed / revoked / expired) via one GROUP BY scan.

Authorizations:
apiKeybearer
path Parameters
id
required
string

Responses

Response samples

Content type
application/json
{
  • "batch": {
    },
  • "stats": {
    }
}

Enumerate voucher codes inside a single batch. Backlog task #31: closes the operator-triage gap surfaced by the 2026-06-23 manual voucher test — POST /admin/codes/:id/revoke takes a voucher_code.id UUID, but operators only ever see the `code` string (CSV / support ticket / bundle PDF). Without this endpoint the only workaround was direct psql against the prod DB. Sort mirrors the bundle exporter so row position maps 1:1 to the CSV: STANDARD batches sort by `code ASC`; BUNDLE batches sort by `pair_id ASC, pair_kind ASC` so each pair's ESIM half precedes its TOPUP half (matches the wide-format CSV's `esim_code` / `topup_code` column order). The `code` field is PRIVILEGED — admin-only by design and the response carries `Cache-Control: no-store` so intermediaries can't retain it.

Authorizations:
apiKeybearer
path Parameters
id
required
string
query Parameters
status
string
Enum: "ISSUED" "REDEEMING" "PROVISIONING" "REDEEMED" "REVOKED" "EXPIRED" "PENDING_BINDING" "REFUNDED" "REDEEM_FAILED"

Filter by code status. Common operator paths: ?status=ISSUED to find a still-redeemable code for single-code revoke (backlog task #31 motivator), ?status=REDEEMED for invoice/audit triage.

pairKind
string
Enum: "ESIM" "TOPUP"

Filter by bundle half. ESIM = starter half, TOPUP = companion half. STANDARD-batch rows carry pair_kind = NULL and never match either filter.

limit
number [ 1 .. 200 ]
Default: 50

Page size (1-200). Defaults to 50.

offset
number >= 0
Default: 0

Row offset (>=0). Defaults to 0.

Responses

Response samples

Content type
application/json
{
  • "items": [
    ],
  • "total": 0
}

Cascade-revoke a batch: flip the batch row + every ISSUED code in it to REVOKED. REDEEMED / EXPIRED / already-REVOKED codes are intentionally untouched (status_invariants forbids the mixed state; a redeemed code is in the user's hands and revoke cannot retroactively claw it back).

Authorizations:
apiKeybearer
path Parameters
id
required
string
Request Body schema: application/json
required
reason
object <= 500 characters

Optional revoke reason. If null / empty / whitespace, voucher_codes.revoke_reason is coerced to the sentinel "no reason provided" at the service layer (the voucher_codes_status_invariants check requires revoke_reason NOT NULL on REVOKED rows). The audit_events row preserves the raw operator input so ops can distinguish "no reason given" from "literal sentinel" downstream.

revokedBy
required
string <= 120 characters

Operator identifier (email or name) for the audit trail. REQUIRED. Stored only on the audit_events row; voucher_codes itself has no revoked_by column (revoke_reason carries the human context, audit captures the actor). Must contain at least one non-whitespace character — a whitespace-only " " would otherwise satisfy @Length(1, 120) and leave a meaningless audit entry.

Responses

Request samples

Content type
application/json
{
  • "reason": { },
  • "revokedBy": "string"
}

Response samples

Content type
application/json
{
  • "batchId": "string",
  • "revokedCodeCount": 0,
  • "failedCodeCount": 0
}

Stream the customer-facing voucher bundle as ZIP{codes.csv, codes.pdf}. Generated in-memory; capped at the batch creation hard-limit of 2,000 codes per batch. Response body is application/zip (binary).

Authorizations:
apiKeybearer
path Parameters
id
required
string

Responses

Response samples

Content type
application/problem+json
{}

Admin: Voucher Codes

Revoke a single ISSUED voucher code. REDEEMED / EXPIRED / already-REVOKED codes return 409 CONFLICT — revoking REDEEMED would require simultaneously setting revoke + redemption columns, which voucher_codes_status_invariants forbids.

Authorizations:
apiKeybearer
path Parameters
id
required
string
Request Body schema: application/json
required
reason
object <= 500 characters

Optional revoke reason. If null / empty / whitespace, voucher_codes.revoke_reason is coerced to the sentinel "no reason provided" at the service layer (the voucher_codes_status_invariants check requires revoke_reason NOT NULL on REVOKED rows). The audit_events row preserves the raw operator input so ops can distinguish "no reason given" from "literal sentinel" downstream.

revokedBy
required
string <= 120 characters

Operator identifier (email or name) for the audit trail. REQUIRED. Stored only on the audit_events row; voucher_codes itself has no revoked_by column (revoke_reason carries the human context, audit captures the actor). Must contain at least one non-whitespace character — a whitespace-only " " would otherwise satisfy @Length(1, 120) and leave a meaningless audit entry.

Responses

Request samples

Content type
application/json
{
  • "reason": { },
  • "revokedBy": "string"
}

Response samples

Content type
application/json
{
  • "codeId": "string"
}

Admin: Voucher Pairs

Resolve a voucher pair_id to a full diagnostic view: both halves + linked eSIM lifecycle + up to 10 most-recent audit_events scoped to {pairId, esimCodeId, topupCodeId, esimId}.

Authorizations:
apiKeybearer
path Parameters
pairId
required
string

Responses

Response samples

Content type
application/json
{
  • "pairId": "string",
  • "batchId": "string",
  • "customerId": "string",
  • "esim": {
    },
  • "topup": {
    },
  • "recentAuditEvents": [
    ]
}

Resolve EITHER an ESIM-half or TOPUP-half code id to the full pair view. Returns 404 when the code is standalone (NULL pair_id) or does not exist.

Authorizations:
apiKeybearer
path Parameters
codeId
required
string

Responses

Response samples

Content type
application/json
{
  • "pairId": "string",
  • "batchId": "string",
  • "customerId": "string",
  • "esim": {
    },
  • "topup": {
    },
  • "recentAuditEvents": [
    ]
}

Admin: Voucher Diagnostics

Triage diagnostics for a voucher. Accepts exactly one of email|code|codeId. Returns local row + batch + linked eSIM + upstream snapshot + paired half (for bundles) + last 10 audit events.

Authorizations:
apiKeybearer
query Parameters
email
string

Look up by traveller email (redeemed_email OR bound_email). The most-recent match is returned when an email appears on multiple codes.

code
string

Look up by human-displayable voucher code string.

codeId
string

Look up by voucher_codes.id (UUIDv7). Accepted for programmatic clients; operators normally use email or code.

fresh
boolean

Bypass the 60s upstream eSIMfx snapshot cache. Defaults to false. Set when triaging suspected upstream/local divergence.

Responses

Response samples

Content type
application/json
{
  • "voucherCode": {
    },
  • "batch": {
    },
  • "esim": {
    },
  • "upstream": {
    },
  • "upstreamOrder": {
    },
  • "esimHalf": {
    },
  • "topupHalf": {
    },
  • "recentAuditEvents": [
    ]
}

Admin: Voucher Actions

Resend the install email for a REDEEMED voucher. Supports a destination override (typo correction); does NOT overwrite the on-disk redeemed_email. SES failure on the send is NOT a 502 -- the response returns 200 with `emailDelivered=false` and the freshly-signed install token + URL still populated so the operator can hand-deliver (iter-14 M1).

Authorizations:
apiKeybearer
Request Body schema: application/json
required
code
required
string

Human-displayable voucher code string. Must be the ESIM-half on bundle codes — TOPUP-halves have no install link.

email
string

Override destination email. Defaults to the on-disk redeemed_email. Use for typo corrections; the on-disk value is NOT overwritten.

locale
string

Render locale for the install email template. Defaults to the eSIM row's stored locale, else en.

reason
required
string

Free-form operator-provided reason (recorded in the audit event). Surfaced in support tooling so the support agent can see why an admin resent.

resentBy
required
string

Operator handle (admin email / system actor). Recorded in the voucher_redemption.install_email_resent (or _resend_failed) audit event so post-hoc attribution is possible. Matches the sibling refundedBy / actor / reissuedBy fields on refund / chargeback / reissue.

Responses

Request samples

Content type
application/json
{
  • "code": "string",
  • "email": "string",
  • "locale": "string",
  • "reason": "string",
  • "resentBy": "string"
}

Response samples

Content type
application/json
{
  • "installUrl": "string",
  • "sentTo": "string",
  • "emailDelivered": true
}

Terminate the eSIM upstream + flip local lifecycle to BLOCKED (chargeback path). No credit memo — reseller absorbs the chargeback loss with their processor. ESIM-half cascades to TOPUP-half (ISSUED/PENDING_BINDING only); TOPUP-half called directly terminates only the TOPUP order.

Authorizations:
apiKeybearer
Request Body schema: application/json
required
code
required
string

Voucher code string (ESIM-half, TOPUP-half, or standalone).

disputeId
required
string

Stripe / payment-processor dispute identifier. Free-form text — recorded in audit so support can correlate the chargeback ticket.

reason
required
string

Free-form chargeback reason (fraud, friendly-fraud, etc). Recorded verbatim in the audit row.

actor
required
string

Operator handle (admin email / system actor) — recorded as actor in audit. No FK; admin users are not modelled.

Responses

Request samples

Content type
application/json
{
  • "code": "string",
  • "disputeId": "string",
  • "reason": "string",
  • "actor": "string"
}

Response samples

Content type
application/json
{
  • "code": "string",
  • "esimId": "string",
  • "terminatedAt": "2019-08-24T14:15:22Z",
  • "cascaded": true
}

Refund a REDEEMED voucher (REDEEMED → REFUNDED). Terminates the upstream subscription, stamps the refund triplet, and cascades to the paired bundle half (recursive refund for REDEEMED TOPUPs, revoke for ISSUED/PENDING_BINDING TOPUPs).

Authorizations:
apiKeybearer
Request Body schema: application/json
required
code
required
string

Voucher code string. ESIM-half refund cascades to the paired TOPUP-half.

reason
required
string

Free-form refund reason (defective device, customer dispute, etc.). Recorded verbatim on voucher_codes.refund_reason + audit.

refundedBy
required
string

Operator handle (admin email / system actor). Recorded as refunded_by on the voucher_codes row + audit.

notifyTraveller
required
boolean

Send a "your voucher has been refunded" email to the on-disk redeemed_email (bundle cascade sends one email per pair). Required — the operator must make an explicit choice per call. Set true for customer-initiated refunds where the traveller is expecting confirmation; set false for fraud/hostile-usage triage or when the reseller handles the customer-facing communication through their own channels (respect the reseller's customer relationship). See voucher-runbook.html §2.3 for the case-by-case triage.

Responses

Request samples

Content type
application/json
{
  • "code": "string",
  • "reason": "string",
  • "refundedBy": "string",
  • "notifyTraveller": true
}

Response samples

Content type
application/json
{
  • "code": "string",
  • "refundedAt": "2019-08-24T14:15:22Z",
  • "cascaded": true,
  • "cascadeKind": "refund"
}

Reissue a fresh eSIM for a REDEEMED voucher (profile-fault default). Terminates old upstream, provisions new, updates esims in place, migrates bundle TOPUP-half bound_iccid, then resends install email. Rejects TOPUP-half codes and bundle ESIM-halves where the paired TOPUP is already REDEEMED. SES failure on the post-swap email send is NOT a 502 -- the swap is durable and the response returns 200 with `emailDelivered=false`; operator can call /admin/vouchers/resend-install-email separately.

Authorizations:
apiKeybearer
Request Body schema: application/json
required
code
required
string

Voucher code string (ESIM-half or standalone). TOPUP-half codes are rejected with 422.

reason
required
string

Free-form reissue reason (profile fault, scan failure, etc). Recorded in audit.

reissuedBy
required
string

Operator handle (admin email / system actor). Recorded in audit.

locale
string

Render locale for the install email template. Defaults to the eSIM row's stored locale, else en.

email
string

Destination override for the new install email. Defaults to the on-disk redeemed_email. Use when support previously corrected the address via resend-install-email and the reissue should NOT fall back to the original (stale) one.

Responses

Request samples

Content type
application/json
{
  • "code": "string",
  • "reason": "string",
  • "reissuedBy": "string",
  • "locale": "string",
  • "email": "string"
}

Response samples

Content type
application/json
{
  • "code": "string",
  • "action": "swapped",
  • "esimId": "string",
  • "iccid": "string",
  • "installUrl": "string",
  • "sentTo": "string",
  • "emailDelivered": true
}

Admin: Email Suppressions

List suppressions with optional filters. Powers the OPS-7-style cleanup workflow: enumerate matching rows here, then feed the emails into POST /admin/email-suppressions/remove-batch or the docs/ops/unsuppress-cleanup.sh helper. Filters: `pattern` (SQL LIKE, e.g. `traveller+%@esimdatastore.com`), `reason` (hard_bounce | complaint | manual), `limit` (1..500, default 100), `offset` (default 0). Ordered by (suppressed_at DESC, email DESC) so offset pagination is stable across ties in suppressed_at. Returns full email addresses (admin-scope). PII note: `pattern` lives in the query string so it ends up in request-log envelopes; use class-of-rows patterns rather than specific customer addresses when possible.

Authorizations:
apiKeybearer

Responses

Response samples

Content type
application/json
{
  • "items": [
    ],
  • "total": 43,
  • "limit": 100,
  • "offset": 0
}

Delete a recipient from the email_suppressions table so EmailSender's pre-send guard stops blocking future sends. Idempotent; returns `deleted=false` when no row matches (already-unsuppressed or never-suppressed). Empty / whitespace-only email inputs are rejected upstream as 422. Input address is normalised (trim + lowercase) to match the storage-canonical form. Recipient lives in the request body (not the URL) so request-path logging never captures it. Emits `email_suppression.unsuppressed` audit event with domain-only recipient (SEC-45).

Authorizations:
apiKeybearer
Request Body schema: application/json
required
object (UnsuppressEmailRequest)

Responses

Request samples

Content type
application/json
{ }

Response samples

Content type
application/json
{}

Batch variant of POST /remove. Accepts 1..500 emails in one call so bulk cleanup flows (OPS-7, one-click unsubscribe rollback batches) don't pay N HTTP + N DB round-trips. One SQL DELETE ... WHERE email IN (...) RETURNING email under the hood; per-entry result preserves input order. Emits one `email_suppression.unsuppressed` audit event per DELETED row (domain-only, SEC-45). Empty / whitespace-only entries fail upstream as 422.

Authorizations:
apiKeybearer
Request Body schema: application/json
required
object (BatchUnsuppressRequest)

Responses

Request samples

Content type
application/json
{ }

Response samples

Content type
application/json
{
  • "results": [],
  • "summary": {
    }
}