eSIM Store API
Global eSIM store endpoints let users browse destination products, buy a profile, pay on the existing payment rail, and receive the issued profile.
The store is an additive lane beside the local Saudi activation flow. It does not use Nafath. It does not ask for an ICCID. It does not enforce CITC SIM quotas. Local SIM activation keeps its own identity and operator flow.
The store uses reseller semantics. An order is fulfilled when the provider profile is issued and delivered. Installing the profile on the device is the user's step.
GET /store/status
Public health check for the eSIM storefront. Returns whether FEATURE_ESIM_STORE is enabled.
This endpoint is intentionally public.
GET /store/status
{
"enabled": true
}Response cache headers:
Cache-Control: public, max-age=60
When the flag is disabled, this endpoint still returns 200 with enabled: false.
The FEATURE_ESIM_STORE flag gates the store browse and purchase surface (GET /store/destinations, GET /store/products, GET /store/products/:id, and POST /store/orders); when the flag is off, those routes return STORE_DISABLED with status 422. GET /store/status remains public and returns enabled: false. Owned-order reads and the QR endpoint stay available regardless of the flag, so already-delivered eSIM codes are never locked out by a feature toggle: GET /store/orders, GET /store/orders/:id, and GET /store/orders/:id/esim-qr keep serving the authenticated owner. The catalog sync stays scheduled-off while the flag is off, so the provider catalog API is never called for a feature nothing can browse or buy. The flag defaults on in development and test. In production it defaults off and must be set to true explicitly — the store fails closed until a deploy deliberately enables it.
GET /store/device-compatibility
Advisory list of eSIM-capable phone models, served from a hard-coded, versioned constant.
This endpoint is intentionally public AND intentionally ungated by FEATURE_ESIM_STORE — it works even while the store is disabled, so any surface (mobile app, future web) can use it.
GET /store/device-compatibility
{
"version": "2026-08-11",
"devices": [
{ "platform": "ios", "model": "iPhone15,2", "name": "iPhone 14 Pro", "status": "compatible", "match": "exact" },
{ "platform": "android", "model": "SM-S91", "name": "Galaxy S23/S24/S25 lines", "status": "compatible", "match": "prefix" },
{ "platform": "android", "model": "SM-S9110", "name": "Galaxy S23 (China/HK variant)", "status": "incompatible", "match": "exact" }
]
}Response cache headers:
Cache-Control: public, max-age=3600
Matching contract — the server does no matching; the client judges its own device:
| rule | meaning |
|---|---|
match: exact | Compare the full device identifier (utsname machine on iOS, Build.MODEL on Android) for equality |
match: prefix | The entry matches when the device identifier starts with model |
| exact beats prefix | A matching exact entry always overrides a matching prefix entry (how China variants override their family) |
| no match | Unknown device → show the advisory warning, never block the purchase |
Client rules: run the native OS capability check first where available — it stays the primary signal. This list is advisory; enforcement belongs to the OS eSIM installer. The purchase flow never blocks on it.
TypeScript clients can use resolveDeviceCompatibility(platform, model) from @activation-sys/shared — the canonical implementation of this table. Ports to other languages must replicate its exact-beats-prefix rule: the dataset encodes carrier variants as exact rows inside compatible prefix families, so first-match-in-array resolution misclassifies them.
Regional caveats carried by the list (documented here, not encoded per-row):
- iPhones bought in mainland China share these identifiers but have no eSIM — except iPhone Air and iPhone 17e. Undetectable from the identifier; users can dial
*#06#and look for an EID as a manual check. - iPhone 17-line phones sold in Saudi Arabia are eSIM-only (no SIM tray).
- Some Galaxy A-series units lack eSIM depending on the country of purchase, even when the model appears listed. US/Canada Galaxy S20-series-and-newer variants support eSIM per Samsung's own support pages.
The list ships inside the code and changes only by deploy: a test pins the version literal, the total count, per-group counts, and spot entries. A small content edit can still pass without a version bump, so reviewers should check the version stamp on every list change.
GET /store/destinations
Return destination aggregates for active products.
This endpoint is public.
Only products whose owning provider (operators row) is active are counted. Every arm of the aggregate carries the same operator-active rule as the product list and detail queries, so disabling a provider removes its destinations and "from" prices in the same request.
data is grouped and sorted:
- all
countryrows in ascending country code order - all
regionrows in ascending region slug order - one
globalrow if any global products are active
Each row carries:
scopecountry(present for country rows)region(present for region rows)planCount(how many active products map to this destination)fromPrice(minimum active product retail price for this destination, in selected presentment currency)
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
currency | USD | SAR | AED | EUR | GBP | EGP | account preference, then USD | Presentment currency override |
Example
GET /store/destinations?currency=USD{
"data": [
{ "scope": "country", "country": "AE", "planCount": 2, "fromPrice": { "amount": 5.5, "currency": "USD" } },
{ "scope": "country", "country": "EG", "planCount": 1, "fromPrice": { "amount": 5.5, "currency": "USD" } },
{ "scope": "global", "planCount": 1, "fromPrice": { "amount": 6, "currency": "USD" } }
],
"currency": "USD"
}Currency Model
Store catalog prices are stored in base USD. The API can present prices in USD, SAR, AED, EUR, GBP, or EGP.
Currency resolution order is:
- Explicit
currencyquery or body value. - The account
preferredCurrency, when the user is authenticated and no explicit value was sent. USD.
Client-side detection should choose a currency and send it as the explicit value. The API does not geo-detect currency.
The store-order record snapshots the presentment currency, presentment amount, FX rate, and retail USD price at buy time. The base order records the USD charge amount and its currency for the payment rail.
FX rates are fetched daily from an external provider and stored in the fx_rates table. They are display-only (converted prices reach the client, never raw rates). When a supported currency has no fetched rate yet, the API returns STORE_CURRENCY_UNSUPPORTED with a detail that rates have not been fetched. SAR always has a value: when the table is empty it falls back to the SAMA official peg of 3.75 SAR per USD.
Lifecycle
draft → pending_payment → paid → activating → completed
↘ cancelledStoreOrderView.status uses the shared order status enum exactly: draft, pending_payment, paid, activating, completed, cancelled.
pending_payment means the user has created a store order but has not paid yet.
paid means the payment rail settled the order and queued fulfillment.
activating is available in the shared order status enum for activation-style fulfillment transitions. The current store worker normally moves paid store orders directly to completed after provider issuance.
completed means the provider issued the eSIM profile and the API stored its payload.
There is no failed or processing order status. A payment failure keeps the order at pending_payment. A fulfillment failure keeps the order at paid while the job retries, and the failure reason is recorded on the store order record.
esimProfile is null until issuance. After issuance it contains smdpAddress, matchingId, activationCodeRaw, qrUrl, and iccidSuffix (the last 4 ICCID digits, store-lane only — omitted on issued rows without a populated ICCID).
esimProfile returns to null for good once the PDPL retention sweep scrubs the order. That happens after the owner requests account deletion and the retention window passes — the payload columns are nulled while the order row is kept as a financial record. See Compliance API.
Clients should poll GET /store/orders/:id for store order state. Store orders do not ride the activation realtime channel.
Issuance Lifecycle
Beside the order status, each store order carries a provider-agnostic issuance_state:
pending → in_flight → issued
├──→ pending_verification ──(reconcile)──→ issued
├──→ failed
└──→ reconciliation_required ──(reconcile)──→ issuedThe fulfillment worker stamps in_flight BEFORE calling the provider and persists the provider's own order reference (external_order_id) as soon as it is known. A retry that finds in_flight, reconciliation_required, or pending_verification goes through the plugin's reconcile capability and never blind-reissues. That is the double-billing guard for providers without idempotency keys: a worker crash mid-call can no longer produce a second billable provider order on retry.
Provider failures are classified by the plugin into a shared taxonomy:
| Class | Worker behavior |
|---|---|
TRANSIENT_UNAVAILABLE | State resets to pending and the job retries with backoff |
OUT_OF_STOCK / TERMINAL_FAILURE | State becomes failed, no automatic retry — the admin requeue is the recovery path |
AMBIGUOUS_RESULT | State becomes reconciliation_required with the external reference persisted — recovery is requeue-driven through reconcile |
| Unclassified throw | State stays in_flight, so the retry reconciles instead of reissuing |
A transient or unclassified failure records the order's failedReason only on the job's final attempt. The owner-facing needsAttention flag keys on that field, so a self-healing retry window never flashes a failure at the customer — an order surfaces one only after its retry budget is spent.
A provider ICCID that collides with one already issued to another order is refused by a database unique index. The order parks as reconciliation_required with the collision recorded — the issuing transaction rolls back entirely, so a requeue must reconcile with the provider rather than fresh-issue.
pending_verification is a healthy wait, not a failure: the customer must complete provider-side identity verification at the order's verificationUrl before the profile can be issued. It never sets needsAttention while reconciliation is still retrying. Once a reconcile exhausts its retry budget the failure is recorded and the flag fires. Until provider event ingestion ships, completion is reconciled through the admin requeue path. The worker accepts a verification URL from the provider only when it is a valid https link. Anything else is treated as a provider defect: the outcome still proves a provider-side order exists, so the order parks as reconciliation_required for reconciliation with no URL stored, rather than failing outright.
The user order view exposes verificationUrl only. The admin order view additionally exposes issuanceState and externalOrderId.
GET /store/products
Browse active eSIM store products.
This endpoint is public.
Envelope: total is the length of the returned page, not the collection size — never use it as a collection counter. Page through with nextCursor to discover whether more rows exist (hasMore is the hasNext signal).
data.throttleNote and data.throttleNoteAr carry the Arabic fair-use / throttle wording for "unlimited" SKUs (null when the provider supplies none). throttleNoteAr is sync-owned: the catalog sync overwrites it unconditionally on every run and it is not admin-editable. When throttleNoteAr is null, clients fall back to throttleNote on the client side — the API never coalesces the pair (same convention as nameEn / nameAr).
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
scope | country | region | global | none | Destination scope filter |
country | string | none | ISO-3166 alpha-2 country code. Matches any product whose explicit coverage list contains the country — including region and global products. Global is never treated as worldwide: a global plan matches only the countries its provider explicitly covers |
region | string | none | Region slug filter |
search | string | none | Case-insensitive search over English and Arabic names |
currency | USD | SAR | AED | EUR | GBP | EGP | account preference, then USD | Presentment currency override |
cursor | string | none | retailUsd|productId cursor from the previous page |
limit | integer | 50 | Page size from 1 to 100 |
SAR Example
GET /store/products?currency=SAR&country=SA&limit=2{
"data": [
{
"id": "550e8400-e29b-41d4-a716-44665544e101",
"nameEn": "Saudi Arabia 3 GB",
"nameAr": "السعودية 3 جيجابايت",
"descriptionEn": "3 GB data for Saudi Arabia.",
"descriptionAr": "3 جيجابايت بيانات داخل السعودية.",
"destination": {
"scope": "country",
"countries": ["SA"],
"region": null
},
"data": {
"amountMb": 3072,
"isUnlimited": false,
"throttleNote": null,
"throttleNoteAr": null
},
"validity": {
"value": 7,
"unit": "days",
"activationPolicy": "first_use"
},
"kycRequired": false,
"canTopUp": true,
"price": {
"amount": 18.71,
"currency": "SAR"
},
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z"
}
],
"total": 1,
"nextCursor": null,
"hasMore": false,
"currency": "SAR"
}USD Example
GET /store/products?currency=USD&scope=global{
"data": [
{
"id": "550e8400-e29b-41d4-a716-44665544e201",
"nameEn": "Global 1 GB",
"nameAr": "عالمي 1 جيجابايت",
"descriptionEn": "1 GB data for supported global destinations.",
"descriptionAr": "1 جيجابايت بيانات للوجهات العالمية المدعومة.",
"destination": {
"scope": "global",
"countries": ["SA", "AE", "US"],
"region": null
},
"data": {
"amountMb": 1024,
"isUnlimited": false,
"throttleNote": null,
"throttleNoteAr": null
},
"validity": {
"value": 7,
"unit": "days",
"activationPolicy": "first_use"
},
"kycRequired": false,
"canTopUp": false,
"price": {
"amount": 6,
"currency": "USD"
},
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z"
}
],
"total": 1,
"nextCursor": null,
"hasMore": false,
"currency": "USD"
}GET /store/products/:id
Return one active store product. Inactive products are hidden from this public endpoint.
This endpoint is public.
Example
GET /store/products/550e8400-e29b-41d4-a716-44665544e101?currency=USD{
"product": {
"id": "550e8400-e29b-41d4-a716-44665544e101",
"nameEn": "Saudi Arabia 3 GB",
"nameAr": "السعودية 3 جيجابايت",
"descriptionEn": "3 GB data for Saudi Arabia.",
"descriptionAr": "3 جيجابايت بيانات داخل السعودية.",
"destination": {
"scope": "country",
"countries": ["SA"],
"region": null
},
"data": {
"amountMb": 3072,
"isUnlimited": false,
"throttleNote": null,
"throttleNoteAr": null
},
"validity": {
"value": 7,
"unit": "days",
"activationPolicy": "first_use"
},
"kycRequired": false,
"canTopUp": true,
"price": {
"amount": 4.99,
"currency": "USD"
},
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z"
},
"currency": "USD"
}POST /store/orders
Create a pending store order for one product.
Requires authentication.
Payment is not collected by this endpoint. The client calls POST /payments/create-intent with the returned orderId.
Store orders are charged in USD through the card payment rail. mada and STC Pay are not available for store orders — only cards (visa, mastercard, stripe) and Apple Pay are supported. Attempting an unsupported method returns 422 PAYMENT_METHOD_NOT_SUPPORTED_FOR_ORDER.
KYC-required SKUs are purchasable when their provider supports the asynchronous verification flow. Issuance then answers with a customer verification URL instead of an immediate profile — see Issuance lifecycle. When the provider lacks that capability (or is not registered), the buy endpoint refuses with STORE_KYC_REQUIRED. Browse always shows the SKU either way.
A circuit breaker guards checkout: when the product's provider is unregistered in this process, its latest health reading is unhealthy, or the health check itself fails, order creation refuses with 422 STORE_PROVIDER_UNAVAILABLE before any insert. Fail closed — the store never takes money for a provider that may not fulfill. Health readings refresh with the periodic catalog sync and on demand behind a short cache.
expectedPrice is an optional optimistic-concurrency guard that protects the presentment price — the exact price.amount (in the RESOLVED presentment currency) the client rendered from the browse or detail response. When present, the server recomputes the presentment price at request time and compares it with strict equality: both sides come from the same convertFromUsd minor-unit rounding, so a matching value is byte-identical. A mismatch fails closed with 409 STORE_PRICE_CHANGED and details.currentPrice: { amount, currency } carrying the current price, so the client can re-render the product without a refetch and retry. Omit the field to keep today's no-guard behavior byte-identical.
One honest caveat: in USD presentment mode the price.amount is stable (it is the product retailUsd rounded to the cent), so the guard is exact. The charge amount recorded on the order is always in USD and follows the same retail price — there is no SAR conversion at charge time.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
productId | string | Yes | Store product UUID |
currency | USD | SAR | AED | EUR | GBP | EGP | No | Presentment currency recorded on the order. Resolution when omitted: the stored profile preference (preferredCurrency), then USD. An explicitly supplied unsupported value fails validation — it never silently falls back. expectedPrice is denominated in whichever currency this resolves to |
expectedPrice | number | No | The exact price.amount (in the request's currency) the client displayed from the browse/detail response. When present and not strictly equal to the freshly computed presentment price, the request fails with 409 STORE_PRICE_CHANGED (see Error Responses). Positive decimal in major units. |
Example Request
POST /store/orders
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json{
"productId": "550e8400-e29b-41d4-a716-44665544e101",
"currency": "SAR",
"expectedPrice": 18.71
}Response
201 Created
{
"orderId": "550e8400-e29b-41d4-a716-44665544e401",
"status": "pending_payment",
"price": {
"amount": 18.71,
"currency": "SAR"
},
"chargeAmount": {
"amount": 4.99,
"currency": "USD"
},
"product": {
"id": "550e8400-e29b-41d4-a716-44665544e101",
"nameEn": "Saudi Arabia 3 GB",
"nameAr": "السعودية 3 جيجابايت"
}
}Breaking change: The
chargeAmountSarfield has been replaced bychargeAmount: { amount, currency }. Store charges are always denominated in USD. The oldchargeAmountSarnumber field no longer exists.
POST /store/device-acks
Record that the authenticated user chose to continue after a store device warning.
Requires authentication.
This endpoint is advisory only. The warning never blocks the journey. It records evidence when the client shows either a device-readiness warning or a carrier-lock warning and the user proceeds anyway.
The record is append-only and is not exposed on any user-facing read endpoint in this batch.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
type | device_readiness | carrier_lock | Yes | Which warning the user acknowledged |
version | string | Yes | Warning-copy version shown to the user (1-64 chars) |
stateAtAck | not_sure | not_compatible | Conditional | Required when type = device_readiness. Forbidden when type = carrier_lock |
Example Request
POST /store/device-acks
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json{
"type": "device_readiness",
"version": "device-warning-v1",
"stateAtAck": "not_sure"
}Response
201 Created
{
"id": "550e8400-e29b-41d4-a716-44665544e501",
"givenAt": "2026-08-07T10:00:00.000Z"
}GET /store/orders
List the caller's store orders, newest first.
Requires authentication.
Because each order row can include the issued eSIM profile, this authenticated JSON response is returned with Cache-Control: private, no-store. Clients and intermediaries must not cache it.
Each row carries a paymentId — the id of the latest completed payments row for the order (null until a payment completes). After a reinstall or any fresh client, fetch the receipt with GET /payments/:id/receipt using this id; no separate payment lookup is needed.
paidAt is the ISO timestamp of the completed payment's paid_at — the same payments row whose id is in paymentId. It is null until a payment completes.
needsAttention is an owner-safe boolean: true when the order is paid, has not been issued yet (esimProfile is null), and a fulfillment failure is recorded for it. It is the user-side projection of the admin "stuck" signal — the raw failure reason is admin-only and is never exposed on this view. When needsAttention is true, the app should offer the user a support hand-off (e.g. "We hit a snag with your eSIM — start a support chat"). When false the order is either still pending payment, paid-and-awaiting-issuance with no failure, or already issued/completed. true means paid + unissued + failure recorded.
verificationUrl is non-null only while issuance awaits provider-side identity verification — a healthy wait that does not set needsAttention on its own. The flag fires only if reconciliation later exhausts its retries and records a failure. The app should send the customer to that URL to complete verification. See Issuance Lifecycle.
Envelope: total is the length of the returned page, not the collection size — never use it as a collection counter. Page through with nextCursor to discover whether more rows exist (hasMore is the hasNext signal).
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
cursor | string | none | Full-precision timestamptz|orderId cursor from the previous page, e.g. 2026-01-01 00:00:00.123456+00|550e8400-e29b-41d4-a716-44665544e401 |
limit | integer | 20 | Page size from 1 to 100 |
Example
GET /store/orders?limit=10
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...{
"data": [
{
"orderId": "550e8400-e29b-41d4-a716-44665544e401",
"status": "completed",
"price": {
"amount": 18.71,
"currency": "SAR"
},
"product": {
"id": "550e8400-e29b-41d4-a716-44665544e101",
"nameEn": "Saudi Arabia 3 GB",
"nameAr": "السعودية 3 جيجابايت"
},
"esimProfile": {
"smdpAddress": "SMDP.STORE.DEV.SKYTE.SA",
"matchingId": "STORE-8F3A1C2B",
"activationCodeRaw": "1$SMDP.STORE.DEV.SKYTE.SA$STORE-8F3A1C2B",
"qrUrl": "/store/orders/550e8400-e29b-41d4-a716-44665544e401/esim-qr",
"iccidSuffix": "6789"
},
"paymentId": "c4d5e6f7-a8b9-0123-cdef-234567890123",
"paidAt": "2026-01-01T00:00:30.000Z",
"needsAttention": false,
"verificationUrl": null,
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:02:00.000Z"
}
],
"total": 1,
"nextCursor": null,
"hasMore": false
}GET /store/orders/:id
Return one owner-scoped store order.
Requires authentication.
Because the order can include the issued eSIM profile, this authenticated JSON response is returned with Cache-Control: private, no-store. Clients and intermediaries must not cache it.
Foreign or missing orders return STORE_ORDER_NOT_FOUND.
Before Issuance
{
"orderId": "550e8400-e29b-41d4-a716-44665544e401",
"status": "paid",
"price": {
"amount": 18.71,
"currency": "SAR"
},
"product": {
"id": "550e8400-e29b-41d4-a716-44665544e101",
"nameEn": "Saudi Arabia 3 GB",
"nameAr": "السعودية 3 جيجابايت"
},
"esimProfile": null,
"paymentId": "c4d5e6f7-a8b9-0123-cdef-234567890123",
"paidAt": "2026-01-01T00:00:30.000Z",
"needsAttention": false,
"verificationUrl": null,
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:01:00.000Z"
}After Issuance
{
"orderId": "550e8400-e29b-41d4-a716-44665544e401",
"status": "completed",
"price": {
"amount": 18.71,
"currency": "SAR"
},
"product": {
"id": "550e8400-e29b-41d4-a716-44665544e101",
"nameEn": "Saudi Arabia 3 GB",
"nameAr": "السعودية 3 جيجابايت"
},
"esimProfile": {
"smdpAddress": "SMDP.STORE.DEV.SKYTE.SA",
"matchingId": "STORE-8F3A1C2B",
"activationCodeRaw": "1$SMDP.STORE.DEV.SKYTE.SA$STORE-8F3A1C2B",
"qrUrl": "/store/orders/550e8400-e29b-41d4-a716-44665544e401/esim-qr",
"iccidSuffix": "6789"
},
"paymentId": "c4d5e6f7-a8b9-0123-cdef-234567890123",
"paidAt": "2026-01-01T00:00:30.000Z",
"needsAttention": false,
"verificationUrl": null,
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:02:00.000Z"
}GET /store/orders/:id/esim-qr
Return the owner-scoped QR PNG for an issued profile.
Requires authentication.
The response is image/png. It is returned with Cache-Control: private, no-store.
The QR payload is LPA: plus activationCodeRaw. The endpoint returns STORE_ORDER_NOT_FOUND when the order is missing, foreign, or not issued yet.
Example
GET /store/orders/550e8400-e29b-41d4-a716-44665544e401/esim-qr
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...Admin Store Surface
All admin store routes require super_admin. They also require FEATURE_ESIM_STORE.
Store Provider Model
Store providers are rows in the shared operators table with service_type = 'esim'. The provider slug matches the registered EsimProviderPlugin slug and is the natural key for the catalog sync to join esim_products.provider_slug to operators.slug. Each provider carries a payment_mode — currently 'internal' (the Skyte payment rail charges the customer). The 'external' mode (checkout happens on the provider side) is a stub that blocks order creation with STORE_PROVIDER_PAYMENT_EXTERNAL.
GET /admin/store/providers
Return all eSIM store providers, ordered by slug. Each row carries its latest health reading — null when the provider's plugin is not registered in this process (an orphaned row).
GET /admin/store/providers
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...{
"data": [
{
"id": "550e8400-e29b-41d4-a716-44665544e001",
"slug": "mock-esim",
"nameEn": "Mock eSIM Provider",
"nameAr": "مزوّد eSIM التجريبي",
"isNameArFallback": true,
"isActive": true,
"paymentMode": "internal",
"health": {
"healthy": true,
"balance": { "amount": 1000, "currency": "USD" },
"detail": null,
"checkedAt": "2026-08-23T12:00:00.000Z"
}
}
]
}PATCH /admin/store/providers/:id
Enable or disable a store provider. This is the audited provider toggle — the only writer of operators.is_active.
The route matches service_type = 'esim' rows only. Any other id returns 404 STORE_PROVIDER_NOT_FOUND, so the store surface can never disable a telecom operator. The change takes effect immediately: the destinations aggregate, product list, product detail, checkout, and payment initiation all enforce the flag. It also survives catalog syncs — the sync operator upsert never touches is_active, so no customization flag is needed (and none should be added).
Every call writes an audit row (store.provider_toggled, entityType: 'operator') with the old and new state.
PATCH /admin/store/providers/550e8400-e29b-41d4-a716-44665544e001
Content-Type: application/json
{ "isActive": false }The response returns the updated provider row in the same shape as the list, including the health reading.
GET /admin/store/products
List all store products. Inactive products are included unless isActive filters them out.
The response uses base USD prices. It includes provider linkage and customization flags.
Envelope: total is the length of the returned page, not the collection size — never use it as a collection counter. Page through with nextCursor to discover whether more rows exist (hasMore is the hasNext signal).
GET /admin/store/products?isActive=false&limit=20
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...{
"data": [
{
"id": "550e8400-e29b-41d4-a716-44665544e101",
"providerSlug": "mock-esim",
"providerSku": "SA-3GB-7D",
"nameEn": "Saudi Arabia 3 GB",
"nameAr": "السعودية 3 جيجابايت",
"descriptionEn": "3 GB data for Saudi Arabia.",
"descriptionAr": "3 جيجابايت بيانات داخل السعودية.",
"destination": {
"scope": "country",
"countries": ["SA"],
"region": null
},
"data": {
"amountMb": 3072,
"isUnlimited": false,
"throttleNote": null,
"throttleNoteAr": null
},
"validity": {
"value": 7,
"unit": "days",
"activationPolicy": "first_use"
},
"kycRequired": false,
"canTopUp": true,
"wholesaleUsd": 3.25,
"retailUsd": 4.99,
"isActive": true,
"isNameCustomized": false,
"isPriceCustomized": false,
"isActiveCustomized": false,
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z"
}
],
"total": 1,
"nextCursor": null,
"hasMore": false
}PATCH /admin/store/products/:id
Update store product overrides.
Text fields are nameEn, nameAr, descriptionEn, and descriptionAr. Editing any of them sets isNameCustomized to true.
Editing retailUsd sets isPriceCustomized to true.
Editing isActive (either direction) sets isActiveCustomized to true. While that flag is set, the catalog sync may not reactivate the product — an admin who pulls a SKU from sale via PATCH {"isActive": false} keeps it off the public store even when the provider still offers it. Catalog-absence deactivation is unconditional: a SKU the provider no longer offers is deactivated regardless of the admin flag, because a withdrawn product cannot be sold.
Sending isNameCustomized: false clears text customization ownership. Sending isPriceCustomized: false clears price customization ownership. Sending isActiveCustomized: false clears active-state ownership and returns control to sync — the next catalog sync may reactivate the product (its current isActive value is not restored by the PATCH). The live value is not restored in the PATCH response for any of the three clear fields.
You cannot edit text fields and clear isNameCustomized in the same request. You cannot edit retailUsd and clear isPriceCustomized in the same request. You cannot edit isActive and clear isActiveCustomized in the same request.
{
"nameEn": "Saudi Arabia Traveler 3 GB",
"retailUsd": 5.49,
"isActive": true
}Clear active-state ownership (next sync may reactivate):
{
"isActiveCustomized": false
}GET /admin/settings/fx-rates
Return all stored FX rates from the daily refresh job. Rates are display-only and never exposed on public endpoints — only converted prices reach the client surface. The super_admin endpoint is the ceiling for operational visibility.
{
"base": "USD",
"rates": [
{ "currency": "AED", "ratePerUsd": 3.6725, "fetchedAt": "2026-01-01T00:00:00.000Z", "source": "exchangerate-api" },
{ "currency": "SAR", "ratePerUsd": 3.75, "fetchedAt": "2026-01-01T00:00:00.000Z", "source": "exchangerate-api" }
]
}Rates are ordered by currency ascending.
POST /admin/store/sync
Queue a manual provider catalog sync. Success returns 202. If Redis is unavailable and the sync cannot be enqueued, the route returns 503 STORE_SYNC_UNAVAILABLE.
{
"queued": true
}GET /admin/store/orders
Super-admin store order list, newest-first strict descending on (createdAt, orderId). Returns the same fields as the owner-side list plus the owning userId, the last fulfillment failedReason, and the issuedAt timestamp.
Envelope: total is the length of the returned page, not the collection size — never use it as a collection counter. Page through with nextCursor to discover whether more rows exist (hasMore is the hasNext signal).
Optional query parameters:
| Parameter | Type | Default | Notes |
|---|---|---|---|
stuck | boolean | none | stuck=true restricts the page to paid orders that have never been issued (status = paid AND issuedAt IS NULL) — the failed-fulfillment recovery surface. stuck=false is the same as omitting it. |
cursor | string | none | Full-precision timestamptz|orderId cursor from the previous page, e.g. 2026-01-01 00:00:00.123456+00|550e8400-e29b-41d4-a716-44665544e401 |
limit | integer | 50 | Page size from 1 to 100 |
{
"data": [
{
"orderId": "550e8400-e29b-41d4-a716-44665544e401",
"status": "paid",
"userId": "a3186a1d-7f0f-4d2c-9b5c-3c16c5927216",
"price": {
"amount": 18.71,
"currency": "SAR"
},
"product": {
"id": "550e8400-e29b-41d4-a716-44665544e101",
"nameEn": "Saudi Arabia 3 GB",
"nameAr": "السعودية 3 جيجابايت"
},
"esimProfile": null,
"paymentId": "c4d5e6f7-a8b9-0123-cdef-234567890123",
"paidAt": "2026-01-01T00:00:30.000Z",
"needsAttention": true,
"verificationUrl": null,
"failedReason": "Provider issuance failed: upstream timeout",
"issuedAt": null,
"issuanceState": "pending",
"externalOrderId": null,
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:01:00.000Z"
}
],
"total": 1,
"nextCursor": null,
"hasMore": false
}failedReason is the last recorded fulfillment failure reason — the "stuck" signal that stuck=true filters on. An order with status = paid, failedReason = null, and issuedAt = null is still considered stuck (a stuck row has simply not surfaced a failure reason yet). Note that the filter also matches healthy pending_verification rows — paid and deliberately unissued while the customer completes verification. Check issuanceState to tell them apart. Use POST /admin/store/orders/:id/requeue to re-enqueue fulfilment for one of these rows — for a pending_verification row that requeue IS the completion path (it runs the provider's reconcile).
paidAt and needsAttention are inherited from the owner view: paidAt is the completed payment's paid_at; needsAttention is the owner-safe boolean that mirrors the same status = paid + unissued + failedReason-set condition. The admin row surfaces the raw failedReason for triage; the owner view does not — needsAttention is the owner-facing projection of the same signal.
issuanceState and externalOrderId are admin-only projections of the issuance lifecycle (see Issuance Lifecycle). A reconciliation_required row needs manual provider review: the requeue route routes it through the plugin's reconcile capability instead of reissuing.
GET /admin/store/orders/:id
Super-admin read of one store order. Returns the same shape as the list row. Unknown id → 404 STORE_ORDER_NOT_FOUND.
POST /admin/store/orders/:id/requeue
Re-enqueue fulfilment for a stuck store order. The order must be status = paid AND issuedAt IS NULL; any other state returns 409 STORE_ORDER_NOT_REQUEUEABLE.
The endpoint calls addStoreJob({ orderId, userId }) exactly once. The queue restarts a previously-failed job for the same deterministic id (${orderId}-fulfill) and removes a previously-completed one before re-adding (a pending_verification order's first fulfillment job completed successfully, and its reconcile pass needs a new job), so requeue is safe to call repeatedly — it produces one in-flight job per order, not duplicates.
The route is audited as store.order_requeued against entityType: 'esim_store_order'. The catalog sync trigger is NOT audited, but a requeue targets one customer order, so it gets an audit row (mirroring the PATCH product audit sites).
Success returns 202 { "queued": true }. If Redis is unavailable and the queue cannot accept the job, the route returns 503 STORE_REQUEUE_UNAVAILABLE. If the order id is unknown, the route returns 404 STORE_ORDER_NOT_FOUND.
{
"queued": true
}Catalog Sync
Provider plugins supply catalog items. The sync worker validates each item before writing it to esim_products.
Provider registration is configuration-based: ESIM_STORE_PROVIDERS names the plugin keys to register at boot (see Environment). Store and queue code carry no provider branches — onboarding a provider is one adapter file, one registry entry, and its own credentials.
A global catalog item must carry its explicit country coverage — an item without one fails validation and is dropped (unsellable). Global is never treated as worldwide.
Each item may carry an opaque providerPayload (adapter metadata such as an order-call category). Sync persists it on the product unconditionally, and fulfillment hands it back to the plugin at issuance — an adapter never depends on in-process catalog state.
After each run the worker refreshes every registered provider's health reading (the same data the admin providers list and the checkout circuit breaker consume) and logs a warning for any unhealthy provider.
Sync runs every 5 minutes by default. It also runs once at API startup. A super admin can trigger it with POST /admin/store/sync. The scheduler only runs when FEATURE_ESIM_STORE is enabled — with the store disabled, no sync job is scheduled or enqueued, so the provider catalog API is never called for a feature nothing can read.
Sync always updates provider source mirror fields. Customized text, retail price, and active-state fields are guarded by isNameCustomized, isPriceCustomized, and isActiveCustomized:
- Text fields: when
isNameCustomizedis set, sync keeps the admin's name/description; otherwise it takes the provider value. - Retail price: when
isPriceCustomizedis set, sync keeps the admin'sretailUsd; otherwise it takes the provider value. - Active state: when
isActiveCustomizedis set, sync keeps the admin'sisActivechoice; otherwise it reactivates the product (isActivebecomestrue). This stops a catalog sync from silently re-listing a SKU a super admin pulled from sale.
Clearing a flag lets the next sync restore the provider source value (for text/price) or take ownership of active state (for isActiveCustomized).
Provider SKUs missing from the latest catalog are marked inactive. This catalog-absence deactivation is unconditional — a SKU the provider no longer offers cannot be sold, whatever the admin wished.
If a provider returns an empty catalog (likely an upstream partial outage rather than a full delisting), sync skips both the upsert and the deactivation pass for that provider, logs a warning, and reports zero counts — so a transient empty response cannot wipe the provider's whole storefront. The same skip applies when the response is non-empty but contains no usable SKUs (every item fails parse with no recoverable string sku), since an empty providerSkus set would otherwise match and deactivate every existing row.
Delivery
When the provider issues a profile, the worker stores the eSIM payload and marks the order completed.
The API sends a bilingual email with a QR attachment when the user has an email address. Email delivery is best effort. A failed email does not fail fulfillment.
Phone-only accounts are normal. They receive in-app delivery only through GET /store/orders/:id and the QR endpoint.
Error Responses
| Code | Status | Condition |
|---|---|---|
STORE_DISABLED | 422 | FEATURE_ESIM_STORE is off. Returned by browse (GET /store/destinations, GET /store/products, GET /store/products/:id) and purchase/write routes (POST /store/orders, POST /store/device-acks) only — GET /store/status and owned-order reads plus the QR endpoint keep serving when the flag is off |
STORE_PRODUCT_NOT_FOUND | 404 | Product is missing, or hidden from public detail because inactive |
STORE_PRODUCT_INACTIVE | 422 | Buy request targets an inactive product |
STORE_CURRENCY_UNSUPPORTED | 400 | Currency is outside the supported set (USD, SAR, AED, EUR, GBP, EGP). Also returned when a supported currency's FX rate has not been fetched yet (details include the currency and a note). The list and buy routes reject unsupported currencies as standard schema validation errors instead |
STORE_KYC_REQUIRED | 422 | Buy request targets a KYC-required SKU whose provider does not support the async verification flow (or is not registered). With a supporting provider the purchase proceeds |
STORE_CHARGE_BELOW_MINIMUM | 422 | Order charge amount is below the minimum ($0.50 USD) |
STORE_PRICE_CHANGED | 409 | Optional expectedPrice guard: the request carried expectedPrice and it is not strictly equal to the freshly computed presentment price.amount. details.currentPrice: { amount, currency } carries the current price so the client can re-render without a refetch |
STORE_ORDER_NOT_FOUND | 404 | Store order is missing, foreign, not issued for QR, or not visible to the caller |
STORE_SYNC_UNAVAILABLE | 503 | Manual catalog sync could not be enqueued because Redis is unavailable |
STORE_ORDER_NOT_REQUEUEABLE | 409 | Admin requeue targeted an order that is not paid-without-an-issued-profile (already issued, completed, cancelled, or still pending payment) |
STORE_REQUEUE_UNAVAILABLE | 503 | Admin requeue could not enqueue a store fulfillment job because Redis is unavailable |
STORE_PROVIDER_PAYMENT_EXTERNAL | 422 | The store product belongs to a provider with payment_mode = 'external'. External provider checkout is not supported yet — order creation and card/Apple Pay initiation both reject external providers with this code (fail-closed, no default-to-internal branch) |
STORE_PROVIDER_UNAVAILABLE | 422 | Checkout circuit breaker: the product's provider is unregistered in this process, unhealthy, or its health check failed (fail-closed) |
STORE_PROVIDER_NOT_FOUND | 404 | Admin provider toggle targeted an id with no eSIM operator row |
All errors use the standard bilingual error envelope.