API (1.0)

Download OpenAPI specification:

API documentation

This is the auto-generated endpoint reference. For narrative sections — Authentication, Idempotency, Error taxonomy, Throttling policy, Side-effect index — see the hand-maintained companion at /api/docs.

Install

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
}

Webhook

WebhookController_handleEsimfxWebhook

Responses

WebhookController_handleSesWebhook

Responses

Admin: Customers

Create a new customer

Authorizations:
NoneNone
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:
NoneNone

Responses

Response samples

Content type
application/json
[
  • {
    }
]

Get a customer by ID

Authorizations:
NoneNone
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:
NoneNone
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

Create a new customer

Authorizations:
NoneNone
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:
NoneNone

Responses

Response samples

Content type
application/json
[
  • {
    }
]

Get a customer by ID

Authorizations:
NoneNone
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:
NoneNone
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"
}

Credit a customer wallet

Authorizations:
NoneNone
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:
NoneNone

Responses

Response samples

Content type
application/json
[
  • {
    }
]

Start async plan sync from eSIMfx

Authorizations:
NoneNone

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:
NoneNone

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:
NoneNone
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:
NoneNone
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"
}

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:
NoneNone
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:
NoneNone
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:
NoneNone
path Parameters
iccid
required
string

Responses

Response samples

Content type
application/json
[
  • {
    }
]

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

Authorizations:
NoneNone
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"
}

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

Authorizations:
NoneNone
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:
NoneNone
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:
NoneNone
path Parameters
customerId
required
string

Responses

Response samples

Content type
application/json
[
  • {
    }
]

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

Authorizations:
NoneNone
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:
NoneNone
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:
NoneNone
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:
NoneNone
path Parameters
id
required
string
query Parameters
status
string
Enum: "ISSUED" "REDEEMING" "REDEEMED" "REVOKED" "EXPIRED" "PENDING_BINDING" "REFUNDED"

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:
NoneNone
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
}

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:
NoneNone
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:
NoneNone
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:
NoneNone
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:
NoneNone
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:
NoneNone
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": {
    },
  • "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:
NoneNone
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:
NoneNone
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:
NoneNone
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:
NoneNone
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",
  • "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:
NoneNone

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:
NoneNone
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:
NoneNone
Request Body schema: application/json
required
object (BatchUnsuppressRequest)

Responses

Request samples

Content type
application/json
{ }

Response samples

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

Wallet

Get current wallet balance

Authorizations:
None

Responses

Response samples

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

Credit a customer wallet

Authorizations:
NoneNone
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
}

Plans

List active eSIM plans

Authorizations:
None

Responses

Response samples

Content type
application/json
[
  • {
    }
]

List all plans (including inactive)

Authorizations:
NoneNone

Responses

Response samples

Content type
application/json
[
  • {
    }
]

Start async plan sync from eSIMfx

Authorizations:
NoneNone

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:
NoneNone

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:
NoneNone
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:
NoneNone
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"
}

Orders

Purchase an eSIM plan

Sandbox customers receive dummy eSIMs with no real provisioning.

Authorizations:
None
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:
None

Responses

Response samples

Content type
application/json
[
  • {
    }
]

Get order details

Authorizations:
None
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"
}

Refunds

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:
None
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"
}

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:
NoneNone
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:
NoneNone
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"
}

eSIMs

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

Authorizations:
NoneNone
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:
None
path Parameters
iccid
required
string

Responses

Response samples

Content type
application/problem+json
{}

List eSIMs

Authorizations:
None

Responses

Response samples

Content type
application/json
[
  • {
    }
]

Get eSIM details

Sandbox eSIMs always show ACTIVE status.

Authorizations:
None
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.

Authorizations:
None
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:
None
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:
None
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"
}

Admin: Origination

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

Authorizations:
NoneNone
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: Invoices

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

Authorizations:
NoneNone
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
{}

Customer Prices

Upload customer price list (CSV)

Authorizations:
NoneNone
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:
NoneNone
path Parameters
customerId
required
string

Responses

Response samples

Content type
application/json
[
  • {
    }
]

Admin: Reports

Generate financial report

Authorizations:
NoneNone
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": {
    }
}

Reports

Generate financial report

Authorizations:
NoneNone
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": {
    }
}

Public: Voucher Redemption

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": {
    }
}

Redeem a voucher code: provision eSIM, send install email, return install token.

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
}

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",
  • "data": {
    },
  • "expiresAt": "string",
  • "usageUpdatedAt": "string"
}

Admin: Voucher Batches

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

Authorizations:
NoneNone
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:
NoneNone
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:
NoneNone
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:
NoneNone
path Parameters
id
required
string
query Parameters
status
string
Enum: "ISSUED" "REDEEMING" "REDEEMED" "REVOKED" "EXPIRED" "PENDING_BINDING" "REFUNDED"

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:
NoneNone
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
}

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:
NoneNone
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:
NoneNone
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:
NoneNone
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:
NoneNone
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:
NoneNone
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": {
    },
  • "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:
NoneNone
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:
NoneNone
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:
NoneNone
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:
NoneNone
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",
  • "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:
NoneNone

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:
NoneNone
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:
NoneNone
Request Body schema: application/json
required
object (BatchUnsuppressRequest)

Responses

Request samples

Content type
application/json
{ }

Response samples

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