Skip to content

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:

simTypeThe iccid field carriesFormat rule
physical (default)19-digit ICCID^\d{19}$ per CITC standard
esimLPA activation codeGSMA 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
        ↘ cancelled

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

FromAllowed targets
pendingin_progress, failed, cancelled
in_progressactivated, failed
activatednone (terminal)
failednone (terminal)
cancellednone (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, and updatedAt. 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's activation.completed notification. 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.failed notification (see Notifications API).

Identity Status Flow (Nafath) ​

waiting → completed
        ↘ rejected
        ↘ expired
        ↘ error

The 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 fieldMeaningDefault
timeoutMsHow long a started check may stay unanswered before it is expired3 minutes
pollIntervalMsWait between two status reads while the check is waiting15 seconds
callTimeoutMsUpper bound on one operator call before it is abandoned10 seconds

Outcomes.

Operator answerIdentity statusActivation
completedcompletedMoves to in_progress. Payment settlement is enqueued when a completed payment already exists
rejectedrejectedStays pending with no status event, so the customer can start a new check
errorerrorStays pending with no status event, so the customer can start a new check
expired, or no answer within the windowexpiredStays 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.

CodeWritten when
operator_rejectThe operator answered the activation call with a failure
identity_rejectNo 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_errorNo 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_unavailableThe 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
unknownNo 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_useCompletion found another paid physical activation that already claimed the same ICCID. The operator is not called for the later activation
officer_verified_failedAn 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:

  1. Check past its window. A pending activation whose waiting check is past its window has the check expired. The activation stays pending.
  2. 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_poll job 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 the error outcome and the check is no longer waiting.
  3. Settlement never ran. An in_progress activation 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 setting completionAttemptedAt, 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.
  4. Completion failed. An in_progress activation 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 with operator_unavailable. A failure that may have reached the operator, on any attempt, stops the retries at once and leaves the row in_progress with the job failed, so it lands here.
  5. 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 ​

FieldTypeRequiredDescription
iccidstringYes19-digit ICCID for physical, LPA activation code for esim (see SIM Types)
simTypestringNophysical (default) or esim

Query Parameters ​

ParameterTypeDefaultDescription
operatorstringmockOperator slug to validate against (stc, mobily, zain, mock)

Example Request ​

POST /activations/validate-iccid?operator=stc
Content-Type: application/json
json
{
  "iccid": "8996601212345678901"
}

eSIM variant:

json
{
  "iccid": "LPA:1$SMDP.EXAMPLE.COM$04386-AGYFT-A74Y8",
  "simType": "esim"
}

Response ​

200 OK — Valid ICCID:

json
{
  "valid": true,
  "operatorId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "operatorSlug": "stc",
  "simType": "physical"
}

200 OK — Invalid ICCID:

json
{
  "valid": false,
  "simType": "physical",
  "error": "ICCID not recognized by operator"
}

Error Responses ​

CodeStatusCondition
VALIDATION_ERROR400Serial fails the simType's format rule (19 digits for physical, LPA grammar for esim)
ACTIVATION_ESIM_DISABLED422simType: esim while the FEATURE_ESIM gate is off
ACTIVATION_ESIM_UNSUPPORTED_OPERATOR422simType: esim requested against an operator plugin that did not explicitly declare supportsEsim: true
OPERATOR_NOT_FOUND404Requested operator slug is not registered — operator not supported (use a different operator)
ACTIVATION_ICCID_INVALID422ICCID validation failed against the operator plugin
ACTIVATION_FAILED422General activation failure
OPERATOR_UNAVAILABLE502Operator reachable but the validation call failed — a retry may help
OPERATOR_TIMEOUT504Operator 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: userId is 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 + unitPriceSar snapshot instead (a flexible contract holds no lines). Either way the snapshot price is charged to the company — the order lands paid with 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: esim the iccid field 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 declare supportsEsim: true, otherwise the request fails with ACTIVATION_ESIM_UNSUPPORTED_OPERATOR (422). Corporate (B2B) users cannot create esim activations yet — ACTIVATION_ESIM_DISABLED (422).

Request Body ​

FieldTypeRequiredDescription
iccidstringYes19-digit ICCID for physical, LPA activation code for esim (see SIM Types)
packageIdstringYesUUID of the selected package
simTypestringNophysical (default) or esim

Example Request ​

POST /activations
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
json
{
  "iccid": "8996601212345678901",
  "packageId": "9b18d6a4-3e7f-4a2b-8c5d-6f7a8b9c0d1e"
}

Response ​

201 Created

json
{
  "activationId": "c3d4e5f6-a7b8-9012-cdef-345678901234",
  "orderId": "d4e5f6a7-b8c9-0123-defa-456789012345",
  "status": "pending",
  "identityStatus": null,
  "simType": "physical"
}

Error Responses ​

CodeStatusCondition
VALIDATION_ERROR400Serial fails the simType's format rule, or missing packageId
ACTIVATION_CODE_INVALID422eSIM: the operator rejected the activation code
ACTIVATION_ESIM_DISABLED422eSIM while the FEATURE_ESIM gate is off, or an esim attempt by a corporate (B2B) user
ACTIVATION_ESIM_UNSUPPORTED_OPERATOR422eSIM requested against an operator plugin that did not explicitly declare supportsEsim: true
ACTIVATION_FAILED422Failed to create activation (e.g., SIM limit exceeded)
NOT_FOUND404Package not found
AUTH_REQUIRED401Missing or invalid authentication token
COMPANY_NOT_APPROVED403B2B: 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_CONTRACT409B2B: no roster entry, no assigned contract line, requested package ≠ assigned line, or the contract is not active
ACTIVATION_LINK_NOT_REDEEMED409B2B: the roster entry's invite link was never redeemed — OTP login alone cannot activate a staged seat
ACTIVATION_SEAT_ALREADY_USED409B2B: the seat already has a pending/in-progress/activated SIM (failed/cancelled allow retry)
COMPANY_BALANCE_EXCEEDED409B2B 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 pending status (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 ​

ParameterTypeDescription
idstringUUID of the activation

Request Body ​

FieldTypeRequiredDescription
identityTypestringYescitizen, resident, or visitor. Only used while no identity check holds the customer's type
idNumberstringYesNational 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
json
{
  "identityType": "citizen",
  "idNumber": "1234567897"
}

Response ​

200 OK

json
{
  "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 ​

CodeStatusCondition
ACTIVATION_NOT_FOUND404Activation ID not found or not owned by user
ID_NUMBER_INVALID400The number does not fit the identity type actually used
ID_NUMBER_TYPE_MISMATCH400A valid citizen number was sent under resident, or the reverse
IDENTITY_ERROR422Activation not in pending status, or identity verification initiation failed
IDENTITY_REJECTED422A 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_UNAVAILABLE502The operator did not accept the start or did not answer within the call bound. Nothing is written
AUTH_REQUIRED401Missing 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 ​

ParameterTypeDescription
idstringUUID of the activation

Example Request ​

GET /activations/c3d4e5f6-a7b8-9012-cdef-345678901234/verify-identity
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Response ​

200 OK — Verification waiting:

json
{
  "status": "waiting",
  "verifiedAt": null
}

200 OK — Verification completed:

json
{
  "status": "completed",
  "verifiedAt": "2026-05-05T08:30:00.000Z"
}

200 OK — Verification rejected:

json
{
  "status": "rejected",
  "verifiedAt": null
}

200 OK — Verification expired:

json
{
  "status": "expired",
  "verifiedAt": null
}

200 OK — Verification error:

json
{
  "status": "error",
  "verifiedAt": null
}

Error Responses ​

CodeStatusCondition
ACTIVATION_NOT_FOUND404Activation ID not found or not owned by user
IDENTITY_ERROR422Identity verification status check failed
OPERATOR_UNAVAILABLE502The operator could not be reached or did not answer within the call bound. Nothing is written
AUTH_REQUIRED401Missing 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 estimatedActivationSeconds hint. Per T-04-07: Users can only see their own activations.

Path Parameters ​

ParameterTypeDescription
idstringUUID of the activation

Example Request ​

GET /activations/c3d4e5f6-a7b8-9012-cdef-345678901234/status
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Response ​

200 OK

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

nextStepDerived when
doneThe activation is activated
failedThe activation is failed
cancelledThe activation is cancelled
verify_identityThe activation is pending and has no identity check, or its check expired, was rejected, or ended in error
identity_pendingThe activation is pending and its identity check is waiting
payThe activation is in_progress, identity is completed, and the order is pending_payment
activatingThe activation is in_progress, identity is completed, and the order is paid, activating, or completed
unknownAny 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):

json
{
  "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 ​

CodeStatusCondition
ACTIVATION_NOT_FOUND404Activation ID not found or not owned by user
ACTIVATION_FAILED422Failed to retrieve activation status
AUTH_REQUIRED401Missing 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 ​

ParameterTypeDescription
idstringUUID 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 ​

CodeStatusCondition
ACTIVATION_NOT_FOUND404Activation not found, not owned by user, physical, or no issued profile yet
ACTIVATION_ESIM_DISABLED422FEATURE_ESIM gate is off
AUTH_REQUIRED401Missing 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 ​

ParameterTypeDefaultDescription
cursorstring(none)Omit for first page; use nextCursor from previous response
limitinteger20Number of records per page (1–100)
statusstring(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

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

json
{
  "items": [],
  "nextCursor": null,
  "hasMore": false
}

Error Responses ​

CodeStatusCondition
VALIDATION_ERROR400Invalid cursor format or limit value
AUTH_REQUIRED401Missing 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 with VALIDATION_ERROR. Soft-deleted activations (deletedAt set) are excluded by default. Pass includeDeleted=true to include them. Excluding them at the source keeps cursor paging and dashboard counts accurate. Operator secrets are not exposed: operator.apiConfig and operator.apiBaseUrl are intentionally omitted. User sensitive fields are not exposed: user.idNumber, user.phoneHash, and user.emailHash are 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 ​

ParameterTypeDefaultDescription
cursorstring(none)Omit for first page; use nextCursor from previous response. Format: `createdAt
limitinteger20Number of records per page (1-100)
statusstring(none)Filter by activation status: pending, in_progress, activated, failed, cancelled
operatorIduuid(none)Filter by activation operator ID
packageIduuid(none)Filter by activation package ID
fromISO datetime(none)Inclusive lower bound for activations.createdAt
toISO datetime(none)Inclusive upper bound for activations.createdAt; must be greater than or equal to from
includeDeletedbooleanfalseWhether 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 ​

ParameterReason
userIdUser ownership is not accepted from clients for this admin endpoint
Any unknown query parameterThe query schema is strict to prevent accidental ownership or privilege semantics
Request bodyThis 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

json
{
  "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 ​

CodeStatusCondition
VALIDATION_ERROR400Invalid limit, status, UUID, datetime range, cursor format, or unknown/ownership query parameter
AUTH_REQUIRED401Missing or invalid authentication token
AUTH_FORBIDDEN403Caller is not super_admin and does not hold the activation-records area
ACTIVATION_FAILED422Failed 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

json
{
  "failed": 3,
  "stuck": 1,
  "stuckThresholdMinutes": 15
}

Error Responses ​

CodeStatusCondition
VALIDATION_ERROR400Invalid filter value or query parameter
AUTH_REQUIRED401Missing or invalid authentication token
AUTH_FORBIDDEN403Caller lacks the activation-records area

Internal documentation - Activation System