Activations API
SIM activation lifecycle — validate ICCID, create activation, verify identity, track status, and expose admin dashboard records.
SIM Types
Every activation carries a simType (physical | esim). It discriminates what the iccid field holds:
| simType | The iccid field carries | Format rule |
|---|---|---|
physical (default) | 19-digit ICCID | ^\d{19}$ per CITC standard |
esim | LPA activation code | GSMA SGP.22 §4.1 — LPA: prefix optional, positional $-separated fields (3–6 segments), max 255 chars, e.g. LPA:1$SMDP.EXAMPLE.COM$04386-AGYFT-A74Y8 |
Requests without simType default to physical, so pre-eSIM clients are unaffected.
eSIM semantics (reseller model): activated means the profile was issued and delivered — the QR email is sent and the payload is available on the owner-scoped endpoints. Installing the profile is the user's step with the device OS and is not tracked. Once issued, the operator's payload is stored on the activation (esimProfile), and an operator-assigned profile ICCID replaces the stored serial when provided. The activation lane's esimProfile never carries an iccidSuffix — that field appears only on store-issued profiles (see docs/api/store.md). esimProfile stays null and the QR endpoint returns 404 until the activation reaches activated — the payload is never delivered while payment or processing is pending, even though the submitted code is stored earlier. The whole eSIM surface is gated by the FEATURE_ESIM env flag — when off, esim-typed requests fail with ACTIVATION_ESIM_DISABLED (422). Operator plugins must also declare supportsEsim: true; otherwise eSIM validation and B2C create fail with ACTIVATION_ESIM_UNSUPPORTED_OPERATOR (422). B2B seats are physical-only for now (same disabled code, see POST /activations).
Activation Status Flow
pending → in_progress → activated
↘ failed
pending → cancelledThe lifecycle is identical for both SIM types.
Identity Status Flow (Nafath)
waiting → completed
↘ rejected
↘ expired
↘ errorPOST /activations/validate-iccid
Validate a SIM serial. The operator plugin is the truth source for validity.
This endpoint does not require authentication — ICCID validation is a pre-check before creating an activation.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
iccid | string | Yes | 19-digit ICCID for physical, LPA activation code for esim (see SIM Types) |
simType | string | No | physical (default) or esim |
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
operator | string | mock | Operator slug to validate against (stc, mobily, zain, mock) |
Example Request
POST /activations/validate-iccid?operator=stc
Content-Type: application/json{
"iccid": "8996601212345678901"
}eSIM variant:
{
"iccid": "LPA:1$SMDP.EXAMPLE.COM$04386-AGYFT-A74Y8",
"simType": "esim"
}Response
200 OK — Valid ICCID:
{
"valid": true,
"operatorId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"operatorSlug": "stc",
"simType": "physical"
}200 OK — Invalid ICCID:
{
"valid": false,
"simType": "physical",
"error": "ICCID not recognized by operator"
}Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | Serial fails the simType's format rule (19 digits for physical, LPA grammar for esim) |
ACTIVATION_ESIM_DISABLED | 422 | simType: esim while the FEATURE_ESIM gate is off |
ACTIVATION_ESIM_UNSUPPORTED_OPERATOR | 422 | simType: esim requested against an operator plugin that did not explicitly declare supportsEsim: true |
OPERATOR_NOT_FOUND | 404 | Requested operator slug is not registered — operator not supported (use a different operator) |
ACTIVATION_ICCID_INVALID | 422 | ICCID validation failed against the operator plugin |
ACTIVATION_FAILED | 422 | General activation failure |
OPERATOR_UNAVAILABLE | 502 | Operator reachable but the validation call failed — a retry may help |
OPERATOR_TIMEOUT | 504 | Operator API timed out |
POST /activations
Create a new SIM activation order. Creates order + order item + activation in a single transaction.
Requires authentication.
Per T-04-06:
userIdis extracted from the JWT context — never from the request body.
B2B branch: when the JWT user belongs to a company, the same endpoint activates their assigned seat instead. Prepaid resolves the package and price from the roster's assigned contract line; flexible resolves them from the roster's own
packageId+unitPriceSarsnapshot instead (a flexible contract holds no lines). Either way the snapshot price is charged to the company — the order landspaidwith a synthetic corporate payment — gated on a redeemed invite link, one-seat-one-SIM, and — for flexible contracts — the company's available balance. See the 409 codes below.
eSIM: with
simType: esimtheiccidfield carries the LPA activation code. The code is decomposed and stored at create time; the operator refreshes the payload at issuance (settlement). The resolved operator plugin must declaresupportsEsim: true, otherwise the request fails withACTIVATION_ESIM_UNSUPPORTED_OPERATOR(422). Corporate (B2B) users cannot create esim activations yet —ACTIVATION_ESIM_DISABLED(422).
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
iccid | string | Yes | 19-digit ICCID for physical, LPA activation code for esim (see SIM Types) |
packageId | string | Yes | UUID of the selected package |
simType | string | No | physical (default) or esim |
Example Request
POST /activations
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json{
"iccid": "8996601212345678901",
"packageId": "9b18d6a4-3e7f-4a2b-8c5d-6f7a8b9c0d1e"
}Response
201 Created
{
"activationId": "c3d4e5f6-a7b8-9012-cdef-345678901234",
"orderId": "d4e5f6a7-b8c9-0123-defa-456789012345",
"status": "pending",
"identityStatus": "waiting",
"simType": "physical"
}Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | Serial fails the simType's format rule, or missing packageId |
ACTIVATION_CODE_INVALID | 422 | eSIM: the operator rejected the activation code |
ACTIVATION_ESIM_DISABLED | 422 | eSIM while the FEATURE_ESIM gate is off, or an esim attempt by a corporate (B2B) user |
ACTIVATION_ESIM_UNSUPPORTED_OPERATOR | 422 | eSIM requested against an operator plugin that did not explicitly declare supportsEsim: true |
ACTIVATION_FAILED | 422 | Failed to create activation (e.g., SIM limit exceeded) |
NOT_FOUND | 404 | Package not found |
AUTH_REQUIRED | 401 | Missing or invalid authentication token |
COMPANY_NOT_APPROVED | 403 | B2B: the seat's company is not currently approved. Re-checked here because the redeem-time gate cannot cover a seat redeemed before a suspension — suspension must block new provisioning, and this is the draw moment on flexible pay |
ACTIVATION_PACKAGE_NOT_ON_CONTRACT | 409 | B2B: no roster entry, no assigned contract line, requested package ≠ assigned line, or the contract is not active |
ACTIVATION_LINK_NOT_REDEEMED | 409 | B2B: the roster entry's invite link was never redeemed — OTP login alone cannot activate a staged seat |
ACTIVATION_SEAT_ALREADY_USED | 409 | B2B: the seat already has a pending/in-progress/activated SIM (failed/cancelled allow retry) |
COMPANY_BALANCE_EXCEEDED | 409 | B2B flexible: this seat's charge would exceed the company's available balance (funded minus drawn) |
POST /activations/:id/verify-identity
Initiate Nafath identity verification for a pending activation. The operator plugin handles the Nafath integration.
Requires authentication.
Per T-04-09: Returns an error if the activation is not in
pendingstatus (hard gate). Per T-04-07: Users can only verify identity for their own activations.
Path Parameters
| Parameter | Type | Description |
|---|---|---|
id | string | UUID of the activation |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
identityType | string | Yes | citizen, resident, or visitor |
idNumber | string | Yes | National ID or Iqama number (5–20 characters) |
Example Request
POST /activations/c3d4e5f6-a7b8-9012-cdef-345678901234/verify-identity
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json{
"identityType": "citizen",
"idNumber": "1234567890"
}Response
200 OK
{
"transId": "nafath-trans-abc123",
"randomCode": "4",
"status": "waiting"
}The randomCode is the Nafath verification code the user must confirm in the Nafath app. The status indicates the current verification state.
Error Responses
| Code | Status | Condition |
|---|---|---|
ACTIVATION_NOT_FOUND | 404 | Activation ID not found or not owned by user |
IDENTITY_ERROR | 422 | Activation not in pending status, or identity verification initiation failed |
AUTH_REQUIRED | 401 | Missing or invalid authentication token |
GET /activations/:id/verify-identity
Check the current status of an identity verification request. Handles all 5 Nafath states: waiting, completed, rejected, expired, error.
Requires authentication.
Per T-04-07: Users can only check their own activations.
Path Parameters
| Parameter | Type | Description |
|---|---|---|
id | string | UUID of the activation |
Example Request
GET /activations/c3d4e5f6-a7b8-9012-cdef-345678901234/verify-identity
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...Response
200 OK — Verification waiting:
{
"status": "waiting",
"verifiedAt": null
}200 OK — Verification completed:
{
"status": "completed",
"verifiedAt": "2026-05-05T08:30:00.000Z"
}200 OK — Verification rejected:
{
"status": "rejected",
"verifiedAt": null
}200 OK — Verification expired:
{
"status": "expired",
"verifiedAt": null
}200 OK — Verification error:
{
"status": "error",
"verifiedAt": null
}Error Responses
| Code | Status | Condition |
|---|---|---|
ACTIVATION_NOT_FOUND | 404 | Activation ID not found or not owned by user |
IDENTITY_ERROR | 422 | Identity verification status check failed |
AUTH_REQUIRED | 401 | Missing or invalid authentication token |
GET /activations/:id/status
Get the activation status combined with identity verification status and estimated activation timing.
Requires authentication.
Per STAT-05: Includes
estimatedActivationSecondshint. Per T-04-07: Users can only see their own activations.
Path Parameters
| Parameter | Type | Description |
|---|---|---|
id | string | UUID of the activation |
Example Request
GET /activations/c3d4e5f6-a7b8-9012-cdef-345678901234/status
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...Response
200 OK
{
"id": "c3d4e5f6-a7b8-9012-cdef-345678901234",
"iccid": "8996601212345678901",
"simType": "physical",
"esimProfile": null,
"status": "in_progress",
"identityStatus": "completed",
"estimatedActivationSeconds": 120,
"createdAt": "2026-05-05T08:00:00.000Z",
"updatedAt": "2026-05-05T08:30:00.000Z"
}For an activated eSIM, esimProfile carries the delivery payload (null for physical rows and for any eSIM activation that has not reached activated):
{
"simType": "esim",
"esimProfile": {
"smdpAddress": "SMDP.DEV.SKYTE.SA",
"matchingId": "SKYTE-DEV-0001",
"activationCodeRaw": "LPA:1$SMDP.DEV.SKYTE.SA$SKYTE-DEV-0001",
"qrUrl": "/activations/c3d4e5f6-a7b8-9012-cdef-345678901234/esim-qr"
}
}Error Responses
| Code | Status | Condition |
|---|---|---|
ACTIVATION_NOT_FOUND | 404 | Activation ID not found or not owned by user |
ACTIVATION_FAILED | 422 | Failed to retrieve activation status |
AUTH_REQUIRED | 401 | Missing or invalid authentication token |
GET /activations/:id/esim-qr
Serves the install QR PNG for an activated eSIM, generated on demand from the stored activation code (LPA:-prefixed per GSMA SGP.22 §4.1). Never stored — Cache-Control: private, no-store. Returns 404 until the activation reaches activated — the payload is never delivered while payment or processing is pending.
Requires authentication. Owner-scoped through the same lookup as /:id/status (T-04-07).
Path Parameters
| Parameter | Type | Description |
|---|---|---|
id | string | UUID of the activation |
Example Request
GET /activations/c3d4e5f6-a7b8-9012-cdef-345678901234/esim-qr
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...Response
200 OK — image/png binary body.
Error Responses
| Code | Status | Condition |
|---|---|---|
ACTIVATION_NOT_FOUND | 404 | Activation not found, not owned by user, physical, or no issued profile yet |
ACTIVATION_ESIM_DISABLED | 422 | FEATURE_ESIM gate is off |
AUTH_REQUIRED | 401 | Missing or invalid authentication token |
GET /activations/history
Paginated activation history for the authenticated user. Uses keyset (cursor-based) pagination for deterministic ordering.
Requires authentication.
Per T-06-04: History query scoped by JWT
userId(ownership guard). No cross-user lookup.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
cursor | string | (none) | Omit for first page; use nextCursor from previous response |
limit | integer | 20 | Number of records per page (1–100) |
status | string | (none) | Filter by activation status: pending, in_progress, activated, failed, cancelled |
Example Requests
First page (default limit):
GET /activations/history
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...With limit and status filter:
GET /activations/history?limit=10&status=activated
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...Next page with cursor:
GET /activations/history?cursor=2026-04-28T10:30:00.000Z|a1b2c3d4-e5f6-7890-abcd-ef1234567890&limit=10
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...Response
200 OK
{
"items": [
{
"activationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"iccid": "8996601212345678901",
"simType": "physical",
"esimProfile": null,
"status": "activated",
"packageNameEn": "STC 50 SAR Plan",
"packageNameAr": "باقة STC ٥٠ ريال",
"operatorNameEn": "Saudi Telecom Company",
"operatorNameAr": "شركة الاتصالات السعودية",
"estimatedActivationSeconds": null,
"createdAt": "2026-04-28T10:30:00.000Z",
"updatedAt": "2026-04-28T10:35:00.000Z"
},
{
"activationId": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"iccid": "8996600000000000123",
"simType": "esim",
"esimProfile": {
"smdpAddress": "SMDP.DEV.SKYTE.SA",
"matchingId": "SKYTE-DEV-0001",
"activationCodeRaw": "LPA:1$SMDP.DEV.SKYTE.SA$SKYTE-DEV-0001",
"qrUrl": "/activations/b2c3d4e5-f6a7-8901-bcde-f12345678901/esim-qr"
},
"status": "activated",
"packageNameEn": "Mobily Data 20GB",
"packageNameAr": "موبايلي بيانات ٢٠ جيجا",
"operatorNameEn": "Mobily",
"operatorNameAr": "موبايلي",
"estimatedActivationSeconds": null,
"createdAt": "2026-04-27T14:00:00.000Z",
"updatedAt": "2026-04-27T14:01:00.000Z"
}
],
"nextCursor": "2026-04-27T14:00:00.000Z|b2c3d4e5-f6a7-8901-bcde-f12345678901",
"hasMore": true
}When no more results:
{
"items": [],
"nextCursor": null,
"hasMore": false
}Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | Invalid cursor format or limit value |
AUTH_REQUIRED | 401 | Missing or invalid authentication token |
GET /activations/records
Paginated activation records for the admin activation dashboard and history views. This endpoint is admin-scoped only and does not expose user-owned history semantics from GET /activations/history.
Requires authentication and appRole === "super_admin".
Ownership is derived from the authenticated admin context. Do not send
userId, ownership, or body parameters. Unknown query parameters are rejected withVALIDATION_ERROR. Soft-deleted activations (deletedAtset) are excluded by default. PassincludeDeleted=trueto include them. Excluding them at the source keeps cursor paging and dashboard counts accurate. Operator secrets are not exposed:operator.apiConfigandoperator.apiBaseUrlare intentionally omitted. User sensitive fields are not exposed:user.idNumber,user.phoneHash, anduser.emailHashare intentionally omitted.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
cursor | string | (none) | Omit for first page; use nextCursor from previous response. Format: `createdAt |
limit | integer | 20 | Number of records per page (1-100) |
status | string | (none) | Filter by activation status: pending, in_progress, activated, failed, cancelled |
operatorId | uuid | (none) | Filter by activation operator ID |
packageId | uuid | (none) | Filter by activation package ID |
from | ISO datetime | (none) | Inclusive lower bound for activations.createdAt |
to | ISO datetime | (none) | Inclusive upper bound for activations.createdAt; must be greater than or equal to from |
includeDeleted | boolean | false | Whether to include soft-deleted activations (deletedAt set). When false, deleted rows are excluded from the page and the pagination cursor, so they do not shrink pages or skew counts. |
Rejected Parameters
| Parameter | Reason |
|---|---|
userId | User ownership is not accepted from clients for this admin endpoint |
| Any unknown query parameter | The query schema is strict to prevent accidental ownership or privilege semantics |
| Request body | This is a GET endpoint; filters are query parameters only |
Example Requests
First page:
GET /activations/records
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...Filtered dashboard query:
GET /activations/records?limit=10&status=activated&operatorId=f47ac10b-58cc-4372-a567-0e02b2c3d479&from=2026-06-01T00:00:00.000Z&to=2026-06-02T00:00:00.000Z
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...Next page with cursor:
GET /activations/records?cursor=2026-06-01T10:00:00.000Z|c3d4e5f6-a7b8-9012-cdef-345678901234&limit=10
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...Include soft-deleted records (e.g. for an audit view):
GET /activations/records?includeDeleted=true&limit=10
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...Response
200 OK
{
"items": [
{
"activationId": "c3d4e5f6-a7b8-9012-cdef-345678901234",
"orderItemId": "d4e5f6a7-b8c9-0123-defa-456789012345",
"userId": "e5f6a7b8-c9d0-1234-efab-567890123456",
"operatorId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"iccid": "8996601212345678901",
"simType": "physical",
"packageId": "9b18d6a4-3e7f-4a2b-8c5d-6f7a8b9c0d1e",
"status": "activated",
"identityStatus": "completed",
"identityRef": "nafath-trans-abc123",
"assignedOfficerId": null,
"activatedAt": "2026-06-01T10:02:00.000Z",
"failedReason": null,
"retainUntil": null,
"deletionRequestedAt": null,
"deletedAt": null,
"createdAt": "2026-06-01T10:00:00.000Z",
"updatedAt": "2026-06-01T10:01:00.000Z",
"order": {
"id": "a7b8c9d0-e1f2-3456-abcd-789012345678",
"userId": "e5f6a7b8-c9d0-1234-efab-567890123456",
"companyId": null,
"status": "completed",
"totalAmountSar": "99.00",
"createdAt": "2026-06-01T10:00:00.000Z",
"updatedAt": "2026-06-01T10:01:00.000Z"
},
"orderItem": {
"id": "d4e5f6a7-b8c9-0123-defa-456789012345",
"orderId": "a7b8c9d0-e1f2-3456-abcd-789012345678",
"packageId": "9b18d6a4-3e7f-4a2b-8c5d-6f7a8b9c0d1e",
"iccid": "8996601212345678901",
"quantity": 1,
"unitPriceSar": "99.00"
},
"user": {
"id": "e5f6a7b8-c9d0-1234-efab-567890123456",
"name": "Ahmed Al-Rashid",
"email": "ahmed@example.com",
"phone": "+966501234567"
},
"package": {
"id": "9b18d6a4-3e7f-4a2b-8c5d-6f7a8b9c0d1e",
"operatorId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"nameEn": "STC 99 SAR Plan",
"nameAr": "باقة STC ٩٩ ريال",
"descriptionEn": "Monthly data and voice package",
"descriptionAr": "باقة شهرية للبيانات والمكالمات",
"dataAmountMb": 20480,
"voiceMinutes": 500,
"smsCount": 100,
"validityDays": 30,
"priceSar": "99.00",
"priceSarB2b": null,
"isActive": true,
"isPromotional": false,
"isNameCustomized": false,
"isPriceCustomized": false,
"createdAt": "2026-05-01T08:00:00.000Z",
"updatedAt": "2026-05-01T08:05:00.000Z"
},
"operator": {
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"slug": "stc",
"nameEn": "Saudi Telecom Company",
"nameAr": "شركة الاتصالات السعودية",
"isActive": true,
"logoUrl": "https://example.com/stc.png",
"createdAt": "2026-04-01T08:00:00.000Z",
"updatedAt": "2026-04-01T08:05:00.000Z"
}
}
],
"nextCursor": null,
"hasMore": false
}Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | Invalid limit, status, UUID, datetime range, cursor format, or unknown/ownership query parameter |
AUTH_REQUIRED | 401 | Missing or invalid authentication token |
AUTH_FORBIDDEN | 403 | Authenticated user is not super_admin |
ACTIVATION_FAILED | 422 | Failed to retrieve activation records |