Activations API
SIM activation lifecycle — validate ICCID, create activation, verify identity, track status, and expose admin dashboard records.
The customer routes (POST /activations, /activations/:id/verify-identity, /:id/status, /:id/esim-qr, /history) are end_user only; other roles get 403 AUTH_FORBIDDEN.
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 ↘ failed
↘ cancelledThe lifecycle is identical for both SIM types. The legal moves live in ACTIVATION_TRANSITIONS in @activation-sys/shared, the single source every writer checks against:
| From | Allowed targets |
|---|---|
pending | in_progress, failed, cancelled |
in_progress | activated, failed |
activated | none (terminal) |
failed | none (terminal) |
cancelled | none (terminal) |
Every status write goes through transitionActivation in packages/database/src/activation/lifecycle.ts. It locks the activation row, checks the move against the map, and writes the new status together with one row in activation_status_events in the same transaction. Each event records oldStatus, newStatus, a note, and a source from the shared TRANSITION_SOURCES list: system, operator_webhook, manual_support, or officer. The queue worker, the recovery scan, and the identity check write system. A move to the status the row already holds, or a move whose expected starting status no longer matches, returns changed: false and writes nothing. Two writers racing for the same move therefore produce one event. A move to failed must carry both failedReason and an errorCode (see Failure Codes), and a move the map forbids throws.
Creating an activation writes no status event, so the event history of an activation starts at its first transition. Moves made before every writer went through transitionActivation may have no event, so a complete per-activation history exists only for moves made through it.
Physical serial holds
Every activation creation path and every paid completion path protects a physical ICCID with the same transaction advisory lock before checking occupancy. The holder query includes paid orders (paid, activating, or completed) whose activation is pending, in_progress, or activated. A failed activation without a completion claim, a cancelled activation, any other unpaid order, and a failed activation whose code is operator_reject or officer_verified_failed release the serial for reuse, because in both cases the operator is known not to have activated the line. A failed activation on a paid order that has a completion claim and any other failure code still holds the serial, because the operator may have activated the line. eSIM codes do not use this physical serial hold.
The lock order is serial, customer, then contract where a contract is involved. This keeps customer and B2B entry points serialized consistently. The customer create path returns ACTIVATION_SERIAL_IN_USE when another activation already holds a physical ICCID. Completion uses the first-claim rule below and records serial_in_use on the later activation without calling the operator.
Every committed change is handed once to afterActivationChange in apps/api/src/services/activation-dispatch.ts, after the transaction commits. Each effect runs on its own and a failing effect is logged without hiding the others:
- A Realtime broadcast on the shared activation status channel, carrying
activationId,oldStatus,newStatus,estimatedActivationSeconds, andupdatedAt. It carries no owner id, SIM number, or catalog reference. A listener that knows the activation id reads the rest over HTTP. - On
activated, the customer'sactivation.completednotification. For an eSIM, the delivery email is sent only when the operator issued the profile with this same transition. The LPA code the customer submitted at creation is stored on the row but is never mailed back. - On
failed, the officers'activation.failednotification (see Notifications API).
Identity Status Flow (Nafath)
waiting → completed
↘ rejected
↘ expired
↘ errorThe identity check belongs to the operator. The platform asks the operator plugin to start a check, records the attempt, and reads the operator's answer for that attempt. It never decides the outcome itself.
Starting a check. POST /activations/:id/verify-identity is accepted for a pending activation whose identity status is empty, expired, rejected, or error. Every new check runs on the same activation. A check that is still waiting but past its window (the attempt's creation time plus the policy's timeoutMs, the same deadline the status read applies) is first recorded as expired through applyIdentityOutcome, and the start then proceeds as a restart. A client that stops waiting at the window can therefore start again without reading the status first. A waiting check still inside its window refuses the start as already in progress. The operator's transId and code are installed as the current attempt under the activation lock (installIdentityAttempt), so of two simultaneous starts only one wins. The API then enqueues an identity_poll job for that attempt, delayed by the poll interval. If that enqueue fails the request still succeeds, and the recovery scan schedules the missing poll.
Before the state check, operator call, and attempt installation, the shared identity service acquires a per-activation Redis lease. Its TTL is the operator call bound plus five seconds. The lease removes the common overlap in which two callers both pass the initial state check and both call the operator, for example a repeated submit. It does not promise exactly-once operator start: a slow call can outlive the lease, and the attempt installation's expected-reference check remains the arbiter; the first installer wins. Redis failure returns IDENTITY_START_UNAVAILABLE and does not start the check.
Reading the answer. Two paths read the operator's answer for the current attempt: the customer's GET /activations/:id/verify-identity and the identity_poll queue job. Both apply a final answer through applyIdentityOutcome, which checks under lock that the attempt is still the current one and still waiting. The two paths therefore cannot disagree, and an answer read for a superseded attempt changes nothing. The customer read always returns the committed state. When the attempt it read was superseded in the meantime, it returns the status of the activation's current attempt. The poll re-delays itself while the operator answers waiting, and an unrecognised answer is treated as waiting.
Timing. The poll interval, the window, and the bound on each operator call come from the operator plugin's identityCheck policy, resolved by identityPolicy() in packages/shared/src/constants/identity-check.ts. A plugin may override any value, and a missing, non-finite, or non-positive value falls back to the platform default:
| Policy field | Meaning | Default |
|---|---|---|
timeoutMs | How long a started check may stay unanswered before it is expired | 3 minutes |
pollIntervalMs | Wait between two status reads while the check is waiting | 15 seconds |
callTimeoutMs | Upper bound on one operator call before it is abandoned | 10 seconds |
Outcomes.
| Operator answer | Identity status | Activation |
|---|---|---|
completed | completed | Moves to in_progress. Payment settlement is enqueued when a completed payment already exists |
rejected | rejected | Stays pending with no status event, so the customer can start a new check |
error | error | Stays pending with no status event, so the customer can start a new check |
expired, or no answer within the window | expired | Stays pending with no status event, so the customer can start a new check |
The operator's stated reason, when it gives one, is stored as the attempt's outcome note. Because no status move follows a rejected, errored or expired check, none of them sends a Realtime broadcast or the officers' failure notification.
Unreachable operator. On the customer read, a call that fails or exceeds the call bound returns 502 OPERATOR_UNAVAILABLE and writes nothing. The error details carry a fixed summary and never the raw operator error, which is only logged. The poll retries on transport errors. When its last retry fails inside the window, the attempt is recorded as error with the classified failure summary as its note, and the activation stays pending so the customer can start a new check. When the last retry fails after the window has closed, the check is expired instead.
Failure Codes
A failed activation carries failedReason, a prose reason, and errorCode, the class of the failure from the shared ACTIVATION_ERROR_CODES list. The lifecycle refuses a move to failed that lacks either one.
| Code | Written when |
|---|---|
operator_reject | The operator answered the activation call with a failure |
identity_reject | No longer written. A rejected identity check leaves the activation pending. The code and its labels stay so records that already carry it keep their label |
identity_error | No longer written. An errored identity check leaves the activation pending. The code and its labels stay so records that already carry it keep their label |
operator_unavailable | The activation call could not reach the operator, and the job had spent its retries. Only a connection that was never made (refused, or the host not found) fails the activation, and only on the job's last attempt. Each activation gets one completion call unless the operator provably never received it. A reset or timed out call on any attempt may have reached the operator, so the job stops retrying and the activation stays in_progress for a person |
unknown | No longer written by any path. An identity poll whose last attempt fails leaves the activation pending, and a failed activation call never stores it, because such a failure may have reached the operator. The code stays so records that already carry it keep their label |
serial_in_use | Completion found another paid physical activation that already claimed the same ICCID. The operator is not called for the later activation |
officer_verified_failed | An officer recorded evidence that the operator confirmed this completion did not activate the line. The customer-facing reason is fixed; the typed evidence stays in the audit record |
errorCode appears beside failedReason on each item of GET /activations/records and is null until the activation fails. When a call fails for transport reasons, failedReason holds a fixed summary such as "The operator's service did not answer" and never the raw error. For operator_reject it holds the operator's stated details when the operator gives them.
Completion claim
The complete worker calls claimCompletion immediately before asking the operator to complete a paid activation. The claim transaction re-reads the activation under lock, requires in_progress, completed identity and a completed payment on a paid order, and sets completionAttemptedAt. A second worker that sees the claim does not call the operator, so overlapping recovery runs have a first-claim-wins fence. Physical rows also take the serial lock and compare other completion claimants; the later row moves to failed with serial_in_use without an operator call.
If the connection was refused or the host was not found, the operator was provably not reached and the claim is cleared for another worker attempt. An error that may have reached the operator leaves the claim set, leaves the activation in_progress, and sends the row to the officer fallout queue for investigation. This prevents duplicate operator calls but does not make an external operator call exactly once.
Staff can inspect the completion job state and retry only a row with no claim and no live job. The officer completion tool (Activation Officer) uses the same completion worker and claim: it retries, checks the operator directly, and records a confirmed outcome by hand.
Operator Plugin Abilities
OperatorPlugin.capabilities is an optional { repeatSafeActivation?, requestLookup?, lineLookup? }, each false when absent. repeatSafeActivation declares that sending the same activation request again, with the same reference, never activates a second line or charges twice; the officer retry action reads it to decide whether a row whose earlier request may have reached the operator can be retried. requestLookup and lineLookup declare that lookupActivationRequest(reference) and lookupLineBySerial(iccid) are implemented and safe to call; a declared ability without the matching method never counts, so the officer console never offers an action that would fail. initiateActivation receives reference, our activation id, so a repeated request is recognised by the operator and a later request can be looked up by it; a plugin may ignore the field. The mock operator declares none of these, so the manual completion path is what the real stack exercises. See Activation Officer for how the lookups are used.
Recovery
A recurring scan on the activation queue settles activations whose queue work was lost or left behind (packages/queue/src/workers/activation-recovery.ts). At startup the API queues one immediate run and a repeat every ACTIVATION_RECOVERY_SCAN_INTERVAL_MS (default 1 minute, minimum 30 seconds), short enough to replace a lost identity poll inside the check's window.
An activation is a candidate when it is not soft-deleted and either is pending, has a waiting identity check and was created more than one minute ago, or is in_progress with a paid order and was created more than ACTIVATION_STUCK_THRESHOLD_MINUTES ago (default 15). An in-progress row without a paid order is never a candidate: nothing can be recovered on it, and it would otherwise stay in the set forever and crowd out newer rows. The scan walks candidates in creation order by keyset on the database's own timestamp text, so microsecond precision is kept and no row is visited twice, ACTIVATION_RECOVERY_BATCH_SIZE per page (default 200, maximum 500), at most 20 pages per run and five candidates at a time. A run that stops at the page limit leaves its position, and the next run continues after its last row. A run that reaches the end sends the next one back to the oldest candidate. The position lives in the worker process, so a restart also begins from the oldest candidate. The same threshold drives the stuck count of GET /activations/records/counts.
Each candidate takes exactly one branch, checked in this order:
- Check past its window. A pending activation whose waiting check is past its window has the check expired. The activation stays
pending. - No runnable poll. A pending activation with no poll job, or whose latest poll job completed or failed (spent attempts, a stall limit, a write error after the answer was read), gets a replacement
identity_polljob carrying the remaining attempt budget, because reading the operator's answer again is safe. An attempt younger than 60 seconds with no poll yet is left for the request that started it, and after ten replacements the attempt is left for a person. When the operator could not be reached on the poll's last attempt, the worker already recorded theerroroutcome and the check is no longer waiting. - Settlement never ran. An
in_progressactivation with a completed identity check, a completed payment on a paid order, and either no completion job or a retained one that finished without doing work has that retained job removed and payment settlement enqueued again. Right before it asks the operator to complete the activation, the worker claims the attempt by settingcompletionAttemptedAt, and only while that column is still empty. A worker that finds it already set does not call the operator. Its job fails without retries and lands in branch 4. The worker clears the column again only when the operator provably never received the request (a refused connection or an unknown host). So each activation gets one completion call unless the operator never received it, and two overlapping scans that both replay settlement still reach the operator once. When there is no completion job but that column is set, settlement is not replayed. The operator may already have activated the line, so the row is left for a person with a warning. - Completion failed. An
in_progressactivation whose completion job is retained as failed is left alone with a warning, because the job's failure text cannot separate an operator outage from a database error and the operator may already have activated the line. The record stays visible to officers as stuck. When the operator was never reached on the job's last attempt, the worker already failed the activation itself withoperator_unavailable. A failure that may have reached the operator, on any attempt, stops the retries at once and leaves the rowin_progresswith the job failed, so it lands here. - Nothing to recover. Everything else is left alone: an unpaid checkout, a runnable job, or no evidence of lost work.
Age only selects candidates and the scan never fails an activation. The worker fails one only when the activation call could not reach the operator across its attempts, meaning the connection was never made on every attempt, because any other failure may have activated the line and leaves the record for a person. An identity poll never fails an activation. Every write goes through the lifecycle functions.
After each scan run the worker also calls replayActivationFailed in apps/api/src/services/activation-notifications.ts with a window covering the last 24 hours. It finds failed status events that have no notification under their dedupe key and triggers activation.failed for each one. A window is clamped to at most seven days ending at its upper bound, and the function also accepts a single event id. A replay failure is logged and never fails the scan.
POST /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": null,
"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.
While no identity check holds the customer's type, identityType is saved to the profile and the check runs with it. A change saved this way is audited as user.identity_type_changed with source: "identity_check". While another check for the customer is waiting, once any check has completed, and always for a company employee, the profile's stored type is used and the sent identityType is ignored. Either way idNumber is checked against the type actually used before the operator is asked. The new check is installed only while the profile still carries the type sent to the operator. If the type changed during the operator call, the start is refused with the same 422 IDENTITY_REJECTED an overlapping start gets, and a new start uses the new type.
Path Parameters
| Parameter | Type | Description |
|---|---|---|
id | string | UUID of the activation |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
identityType | string | Yes | citizen, resident, or visitor. Only used while no identity check holds the customer's type |
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": "1234567897"
}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 |
ID_NUMBER_INVALID | 400 | The number does not fit the identity type actually used |
ID_NUMBER_TYPE_MISMATCH | 400 | A valid citizen number was sent under resident, or the reverse |
IDENTITY_ERROR | 422 | Activation not in pending status, or identity verification initiation failed |
IDENTITY_REJECTED | 422 | A check is still waiting inside its window or already completed on this activation (details.identityStatus carries the current identity status), another start overlapped, or the identity type changed during the operator call |
OPERATOR_UNAVAILABLE | 502 | The operator did not accept the start or did not answer within the call bound. Nothing is written |
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 |
OPERATOR_UNAVAILABLE | 502 | The operator could not be reached or did not answer within the call bound. Nothing is written |
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",
"nextStep": "activating",
"userId": "e5f6a7b8-c9d0-1234-efab-567890123456",
"operatorId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"packageId": "9b18d6a4-3e7f-4a2b-8c5d-6f7a8b9c0d1e",
"failedReason": null,
"activatedAt": null,
"createdAt": "2026-05-05T08:00:00.000Z",
"updatedAt": "2026-05-05T08:30:00.000Z",
"estimatedActivationSeconds": 15
}nextStep is the customer's current phase. The server derives it on every read from the activation status, the identity status, and the order status, and never stores it. Terminal states are checked first, then the identity check, then the order:
nextStep | Derived when |
|---|---|
done | The activation is activated |
failed | The activation is failed |
cancelled | The activation is cancelled |
verify_identity | The activation is pending and has no identity check, or its check expired, was rejected, or ended in error |
identity_pending | The activation is pending and its identity check is waiting |
pay | The activation is in_progress, identity is completed, and the order is pending_payment |
activating | The activation is in_progress, identity is completed, and the order is paid, activating, or completed |
unknown | Any other combination |
nextStep is a screen phase, not proof that a charge is due. A completed payment can briefly coexist with an order still marked pending_payment, so pay alone must not start a charge.
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. Each item carries nextStep, derived the same way as on GET /activations/:id/status.
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",
"nextStep": "done",
"packageNameEn": "STC 50 SAR Plan",
"packageNameAr": "باقة STC ٥٠ ريال",
"operatorNameEn": "Saudi Telecom Company",
"operatorNameAr": "شركة الاتصالات السعودية",
"estimatedActivationSeconds": 0,
"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",
"nextStep": "done",
"packageNameEn": "Mobily Data 20GB",
"packageNameAr": "موبايلي بيانات ٢٠ جيجا",
"operatorNameEn": "Mobily",
"operatorNameAr": "موبايلي",
"estimatedActivationSeconds": 0,
"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 super_admin or an admin holding the activation-records area (View).
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. The response is shaped by viewer. The owner sees stored email, phone, ICCIDs, and operator references. Every other viewer sees masked email and phone, the last 4 characters of each ICCID, and a masked operator reference. Field names and types are identical for both.
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,
"errorCode": 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 | Caller is not super_admin and does not hold the activation-records area |
ACTIVATION_FAILED | 422 | Failed to retrieve activation records |
GET /activations/records/counts
Return the attention counts used by the activation records dashboard. failed counts records with status failed. stuck counts records with status pending or in_progress whose createdAt is older than stuckThresholdMinutes. The threshold is returned in the response and is configurable. It is not a hard service-level target.
Requires super_admin or an admin holding the activation-records area (View).
The route accepts the same query schema as GET /activations/records. operatorId, packageId, from, to, and includeDeleted affect the counts. The counts ignore status and apply the date window. cursor and limit are parsed but do not affect them.
Example Request
GET /activations/records/counts?status=in_progress&includeDeleted=false
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...Response
200 OK
{
"failed": 3,
"stuck": 1,
"stuckThresholdMinutes": 15
}Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | Invalid filter value or query parameter |
AUTH_REQUIRED | 401 | Missing or invalid authentication token |
AUTH_FORBIDDEN | 403 | Caller lacks the activation-records area |