Activation System API Reference
The Activation System API is a RESTful service built with Hono on Node.js, providing SIM card activation services for Saudi Arabian telecom operators. All endpoints return JSON and support bilingual error messages (Arabic/English) via the Accept-Language header.
Base URL
| Environment | URL |
|---|---|
| Development | http://localhost:3001 |
| Production | https://<api-production-origin> |
Interactive Docs
Explore the API interactively with Scalar UI at:
| Environment | URL |
|---|---|
| Development | http://localhost:3001/docs |
| Production | https://<api-production-origin>/docs |
OpenAPI 3.1 spec available at /openapi.json.
Authentication
The API uses JWT-based authentication with a two-phase flow:
- Register or Login — triggers OTP delivery to phone/email
- Verify OTP — creates a session and returns credentials
Session Strategies
| Client Type | Detection | Token Delivery | Subsequent Requests |
|---|---|---|---|
| Mobile | X-Client-Type: mobile or Flutter/Dart/Android/iPhone User-Agent patterns | JWT in response body → session.token | Authorization: Bearer <token> |
| Web | X-Client-Type: web or default | httpOnly cookie activation-sys-session | Cookie sent automatically |
Public Endpoints (No Auth Required)
GET /healthPOST /auth/registerPOST /auth/loginPOST /auth/verify-otpPOST /auth/magic-linkGET /store/productsGET /store/destinationsGET /store/products/:idGET /store/statusGET /store/device-compatibilityPOST /activations/validate-iccidPOST /webhooks/stripePOST /webhooks/mobimatter
Protected Endpoints
All other endpoints require a valid JWT. The middleware extracts userId, appRole, and identityType from the token — these are never accepted from the request body (security: T-04-06, T-05-10, T-06-07).
Common Headers
| Header | Required | Values | Description |
|---|---|---|---|
Authorization | Protected routes | Bearer <jwt> | JWT token from /auth/verify-otp |
Accept-Language | Optional | en (default), ar | Language for error messages and display fields |
Content-Type | POST/PUT/PATCH | application/json | Request body format |
X-Client-Type | Optional | web, mobile | Overrides automatic client type detection |
X-Forwarded-For | Optional | IP address | Client IP (for consent audit trail). The first entry is used, and a value that is not a plain IPv4 or IPv6 address is recorded as empty rather than rejecting the request |
User-Agent | Optional | String | Client user-agent (for consent audit trail) |
Error Response Format
All errors follow a consistent bilingual structure:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": [
{ "path": "phone", "message": "Phone number must be at least 10 digits" }
]
}
}The message field is automatically localized based on Accept-Language. See Error Codes Reference for the complete error catalog.
Rate Limiting
| Environment | Backend | Limit |
|---|---|---|
| All | In-memory, per process | 100 requests / 15 min / IP (configurable via RATE_LIMIT_*) |
The counters live in process memory — Redis-backed counters for multi-instance deploys are a known deferred hardening item, not current behaviour.
Response headers:
| Header | Description |
|---|---|
X-RateLimit-Limit | Maximum requests per window |
X-RateLimit-Remaining | Requests remaining in current window |
X-RateLimit-Reset | Window reset timestamp (Unix epoch) |
When rate limit is exceeded, the API returns 429 with:
{
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Too many requests, please try again later"
}
}Bilingual Responses
All error messages and display fields are localized via Accept-Language.
English (Accept-Language: en):
{
"error": {
"code": "AUTH_INVALID_OTP",
"message": "Invalid OTP code"
}
}Arabic (Accept-Language: ar):
{
"error": {
"code": "AUTH_INVALID_OTP",
"message": "رمز التحقق غير صحيح"
}
}Package and operator listings return name and description in the requested language instead of both nameEn/nameAr pairs.
Idempotency
All payment endpoints require an idempotencyKey field for safe retries without duplicate charges:
| Rule | Behavior |
|---|---|
| Same key + same parameters | Returns existing payment (idempotent) |
| Same key + different parameters | Returns 402 PAYMENT_IDEMPOTENCY_CONFLICT |
Example:
{
"orderId": "b3c4d5e6-f7a8-9012-bcde-f12345678901",
"method": "mada",
"idempotencyKey": "pay-2026-0430-ahmed-001"
}The
amountfield is never accepted from the request body — it is derived from the order's storedtotalAmountandcurrencyin the database per security requirement T-05-04.
Pagination
Activation history and admin activation records use keyset (cursor-based) pagination for deterministic ordering.
| Parameter | Type | Default | Description |
|---|---|---|---|
cursor | string | (none) | Omit for first page; use nextCursor from previous response |
limit | integer | 20 | Records per page (max 100) |
First page:
GET /activations/history?limit=10Admin dashboard records use the same pagination pattern:
GET /activations/records?limit=10&status=activatedResponse:
{
"items": [
{
"activationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"iccid": "8996601212345678901",
"status": "activated",
"packageNameEn": "STC 50 SAR Plan",
"packageNameAr": "باقة STC ٥٠ ريال",
"operatorNameEn": "Saudi Telecom Company",
"operatorNameAr": "شركة الاتصالات السعودية",
"estimatedActivationSeconds": null,
"createdAt": "2026-04-28T10:30:00Z",
"updatedAt": "2026-04-28T10:35:00Z"
}
],
"nextCursor": "2026-04-28T10:30:00.000Z|a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"hasMore": true
}Next page:
GET /activations/history?cursor=2026-04-28T10:30:00.000Z|a1b2c3d4-e5f6-7890-abcd-ef1234567890&limit=10API Endpoints Overview
Health
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /health | No | API, database, and Redis connectivity check |
fxRatesAgeHours is included only when FEATURE_ESIM_STORE is enabled. When present, it is the finite age in hours of the latest FX batch, or null when no batch has been fetched yet; when the feature is disabled, the property is omitted.
Auth
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /auth/register | No | Register new user with phone/email, name, and consents; sends OTP |
| POST | /auth/login | No | Send OTP to existing user's phone/email |
| POST | /auth/verify-otp | No | Verify OTP and create session (returns JWT) |
| POST | /auth/magic-link | No | Redeem a B2B roster invite token and create a session |
| POST | /auth/consents | Yes | Record the 3 required PDPL consents for a magic-link-redeemed user |
| POST | /auth/logout | Yes | Invalidate session (clear cookie or blocklist JWT) |
| GET | /auth/session | Yes | Get current user session details |
| GET | /auth/me | Yes | Get authenticated user profile with decrypted PII |
| PUT | /auth/me | Yes | Update user profile (name, email) |
| POST | /auth/change-phone | Yes | Send OTP to new phone number |
Operators
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /operators | No | List all active telecom operators |
Packages
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /packages | No | List packages with optional filters |
| GET | /packages/search | No | Full-text search with PostgreSQL tsquery + trigram fallback |
| GET | /packages/compare | No | Side-by-side comparison of 2–5 packages |
| GET | /packages/:id | No | Single package detail with B2B pricing and operator info |
| GET | /packages/:id/availability | No | Real-time availability check via operator plugin (30s cache) |
eSIM Store
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /store/destinations | No | Public destination aggregates with scope, optional country/region, planCount, and minimum fromPrice converted to presentment currency |
| GET | /store/products | No | Browse active global eSIM store products with destination filters, presentment pricing, retailUsd|productId cursor pagination, and { data, total, nextCursor, hasMore, currency } response envelope |
| GET | /store/status | No | Report FEATURE_ESIM_STORE status for public UX gating; returns { enabled: boolean } and Cache-Control: public, max-age=60 |
| GET | /store/device-compatibility | No | Advisory eSIM device list from a versioned hard-coded constant; public, ungated by the store flag, client-side matching (exact beats prefix, unknown warns, never blocks), Cache-Control: public, max-age=3600 |
| GET | /store/products/:id | No | Get one active store product with presentment pricing |
| POST | /store/device-acks | Yes | Record an advisory device-readiness or carrier-lock acknowledgement for the authenticated user; warning-only evidence, no user-facing read surface |
| POST | /store/orders | Yes | Create a pending store order for an eSIM product. Pay with /payments/create-intent using the returned orderId |
| GET | /store/orders | Yes | List the caller's store orders with issued eSIM payloads when available, { data, total, nextCursor, hasMore }, and full-precision timestamptz|orderId cursors |
| GET | /store/orders/:id | Yes | Get one owner-scoped store order and its issued eSIM payload when available |
| GET | /store/orders/:id/esim-qr | Yes | Owner-scoped QR PNG for an issued store eSIM profile |
Activations
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /activations/validate-iccid | No | Validate a SIM serial via operator plugin — 19-digit ICCID (physical) or LPA activation code (esim) per simType |
| POST | /activations | Yes | Create activation order with package selection (simType discriminates physical vs esim) |
| POST | /activations/:id/verify-identity | Yes | Initiate Nafath identity verification |
| GET | /activations/:id/verify-identity | Yes | Check identity verification status |
| GET | /activations/:id/status | Yes | Get activation + identity status with estimated timing (carries simType + esimProfile) |
| GET | /activations/:id/esim-qr | Yes | Owner-scoped install QR PNG for an issued eSIM profile |
| GET | /activations/history | Yes | Paginated activation history for authenticated user (items carry simType + esimProfile) |
| GET | /activations/records | Yes (super_admin) | Admin dashboard activation records with filters and related order/package/operator/user contact data |
Payments
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /payments/create-intent | Yes | Create payment intent (Mada, Visa, Mastercard, Stripe) |
| POST | /payments/apple-pay | Yes | Create Apple Pay payment |
| POST | /payments/stcpay | Yes | Initiate STC Pay direct payment |
| GET | /payments/:id/receipt | Yes | Get owner-scoped payment receipt with bilingual messages |
Profile
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /profile | Yes | Get authenticated user profile aggregate |
| PUT | /profile | Yes | Update allowed profile fields (name, email, preferredCurrency) |
| GET | /profile/export | Yes | PDPL data portability export, including activation eSIM fields and eSIM store orders |
| DELETE | /profile | Yes | Request account deletion (soft delete with retention) |
Admin Users
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /admin/users | Yes (super_admin) | List manageable administration users |
| POST | /admin/users | Yes (super_admin) | Provision an administration user with role and assigned areas |
| PATCH | /admin/users/:id | Yes (super_admin) | Update a user's role, areas, or active state |
| DELETE | /admin/users/:id | Yes (super_admin) | Permanently remove an administration user |
| POST | /admin/users/:id/resend-invite | Yes (super_admin) | Re-send the sign-in invitation |
B2B Portal
Company-facing routes, mounted at /b2b. All require b2b_admin; roster/catalog/quote/contract/dashboard/billing routes additionally require an approved company (admin for writes, admin or member for reads).
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /b2b/company | Yes (b2b_admin) | Resolve the caller's own company (null if not yet registered) |
| POST | /b2b/company | Yes (b2b_admin) | Register a company for the caller (→ pending) |
| PATCH | /b2b/company/payment-mode | Yes (company admin) | Pick or switch the company's payment mode (prepaid/flexible), locked once a contract exists |
| GET | /b2b/roster | Yes (company member) | List the company's beneficiaries |
| GET | /b2b/roster/export | Yes (company member) | CSV export of the roster + link status |
| POST | /b2b/roster/stage | Yes (company admin) | Stage CSV roster rows (partial accept per row) |
| PATCH | /b2b/roster/:rosterId | Yes (company admin) | Change or clear one beneficiary's package assignment |
| DELETE | /b2b/roster/:rosterId | Yes (company admin) | Remove one beneficiary — roster row, link history and their account |
| POST | /b2b/roster/links/send | Yes (company admin) | Mint and deliver activation invite links |
| POST | /b2b/roster/links/export | Yes (company admin) | Mint invite links and return the raw links for self-distribution |
| POST | /b2b/roster/links/reissue-expired | Yes (company admin) | Re-mint every expired link for the company |
| POST | /b2b/roster/links/revoke | Yes (company admin) | Revoke the selected beneficiaries' open invitations (partial accept per row) |
| GET | /b2b/catalog | Yes (company member) | B2B-priced package catalog for the quote wizard |
| GET | /b2b/quotes | Yes (company member) | List the company's quotes |
| GET | /b2b/quotes/:id | Yes (company member) | Quote detail |
| POST | /b2b/quotes | Yes (company admin) | Create a quote draft |
| PATCH | /b2b/quotes/:id | Yes (company admin) | Update a draft quote |
| POST | /b2b/quotes/:id/submit | Yes (company admin) | Submit a draft for review |
| POST | /b2b/quotes/:id/accept | Yes (company admin) | Accept a countered quote version |
| POST | /b2b/quotes/:id/reject | Yes (company admin) | Decline a countered quote version, with an optional reason |
| GET | /b2b/contract | Yes (company member) | The company's active contract |
| GET | /b2b/readiness | Yes (company member) | Contract/payment/roster readiness gate |
| GET | /b2b/dashboard | Yes (company member) | Portal dashboard overview |
| GET | /b2b/billing | Yes (company member) | Billing/charges summary |
| GET | /b2b/billing/export | Yes (company member) | CSV export of charges |
B2B Admin
Internal oversight routes, mounted at /admin. Reads require super_admin or an admin with View on the b2b area; writes require Manage on b2b. Inviting a portal user is super_admin-only.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /admin/companies | Yes (b2b area) | List companies |
| POST | /admin/companies/invites | Yes (super_admin) | Invite a portal user (company admin or viewer) |
| GET | /admin/companies/:id | Yes (b2b area) | Company detail |
| POST | /admin/companies/:id/status | Yes (b2b area, manage) | Approve, reject, suspend, or reactivate a company |
| GET | /admin/quotes | Yes (b2b area) | List quotes across all companies |
| GET | /admin/quotes/catalog | Yes (b2b area) | Catalog for the counter-quote line-item picker |
| GET | /admin/quotes/:id | Yes (b2b area) | Quote detail |
| POST | /admin/quotes/:id/start-review | Yes (b2b area, manage) | Move a submitted quote into review |
| POST | /admin/quotes/:id/counter | Yes (b2b area, manage) | Counter a quote with a new linked version |
| POST | /admin/quotes/:id/reject | Yes (b2b area, manage) | Reject a quote |
| POST | /admin/quotes/:id/confirm-contract | Yes (b2b area, manage) | Accept the quote and create its contract |
| GET | /admin/contracts/company/:companyId | Yes (b2b area) | Company's active contract |
| GET | /admin/contracts/company/:companyId/readiness | Yes (b2b area) | Contract/payment/roster readiness gate |
| GET | /admin/contracts/company/:companyId/balance | Yes (b2b area) | Flexible funding balance (funded/drawn/committed/available + active lines) |
| POST | /admin/contracts/:id/payments | Yes (b2b area, manage) | Record a payment (with document) — prepaid pay-in-full or a flexible funding/top-up amount |
| GET | /admin/contracts/:id/payments | Yes (b2b area) | List payment records (metadata only) |
| GET | /admin/contracts/:id/payments/:paymentId/document | Yes (b2b area, manage) | Download a payment's supporting document |
| GET | /admin/links | Yes (b2b area) | Cross-company magic-link delivery oversight |
| GET | /admin/links/export | Yes (b2b area) | CSV export of magic-link oversight rows |
Settings
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /admin/settings/organization | Yes (super_admin) | Get the seller-of-record settings used on generated PDFs |
| PATCH | /admin/settings/organization | Yes (super_admin) | Update the seller-of-record settings |
| GET | /admin/settings/fx-rates | Yes (super_admin) | Read the daily-fetched FX rates ({ base: 'USD', rates: [{currency, ratePerUsd, fetchedAt, source}] }); read-only — rates are never set manually |
Admin eSIM Store
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /admin/store/providers | Yes (super_admin) | List store providers (eSIM operators rows) with each provider's latest health reading (null when its plugin is not registered) |
| PATCH | /admin/store/providers/:id | Yes (super_admin) | Audited provider enable/disable toggle (store.provider_toggled) with immediate storefront effect. eSIM rows only — 404 STORE_PROVIDER_NOT_FOUND otherwise |
| GET | /admin/store/products | Yes (super_admin) | List all store products, including inactive rows, provider linkage, isActiveCustomized, and { data, total, nextCursor, hasMore } pagination |
| PATCH | /admin/store/products/:id | Yes (super_admin) | Update store product overrides, active state, and customization flags |
| GET | /admin/store/orders | Yes (super_admin) | List all store orders, newest-first on (createdAt, orderId); adds owner userId, failedReason, and issuedAt; accepts stuck=true (status = paid AND issued_at IS NULL) and the same { data, total, nextCursor, hasMore } envelope |
| GET | /admin/store/orders/:id | Yes (super_admin) | One store order with the same shape as the list row; 404 STORE_ORDER_NOT_FOUND when unknown |
| POST | /admin/store/orders/:id/requeue | Yes (super_admin) | Re-enqueue a stuck store order fulfillment (202 { queued: true }, audited as store.order_requeued; 409 STORE_ORDER_NOT_REQUEUEABLE when not stuck, 503 STORE_REQUEUE_UNAVAILABLE when Redis is unavailable) |
| POST | /admin/store/sync | Yes (super_admin) | Queue a manual eSIM store catalog sync (202 queued, 503 STORE_SYNC_UNAVAILABLE when enqueue fails) |
Documents
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /documents/:type/:entityId.pdf | Yes | Render and download a generated PDF (receipt or contract-payment document); access is authorized per document type |
Compliance
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /compliance/retention/sweep | Yes (super_admin) | Execute PDPL data retention sweep |
| POST | /compliance/breach-report | Yes (super_admin) | Generate SDAIA breach notification report |
Webhooks
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /webhooks/stripe | No (signature verified) | Handle Stripe payment webhook events |
Detailed Endpoint Documentation
| Section | Document |
|---|---|
| Authentication | auth.md |
| Activations | activations.md |
| Payments | payments.md |
| Packages | packages.md |
| eSIM Store | store.md |
| Operators | operators.md |
| Profile | profile.md |
| Admin Users | admin-users.md |
| B2B Portal | b2b.md |
| B2B Admin | b2b-admin.md |
| Settings | settings.md |
| Documents | documents.md |
| Compliance | compliance.md |
| Webhooks | webhooks.md |
| Error Codes | errors.md |
SIM Limits by Identity Type (CITC)
Per CITC regulations, users are limited by identity type:
| Identity Type | Max SIMs |
|---|---|
citizen | 10 |
resident | 2 |
visitor | 1 |
Web Client Surfaces
The web client type applies to browser clients such as apps/admin and apps/web-b2c. Web sessions use the activation-sys-session httpOnly cookie. Mobile clients continue to request JWT delivery in the response body with X-Client-Type: mobile or mobile user-agent detection.
The public landing site (apps/site) should remain unauthenticated unless a future feature explicitly requires API access. If it calls the API, add the deployed origin to CORS_ORIGINS and keep all secrets server-side.