Activation Officer API
Search values are sent in POST JSON bodies. The portal keeps searches in encrypted HttpOnly cookies for at most ten minutes, bound to the current login. Search, detail and return URLs do not contain phone numbers or SIM serials. Queue tab selection remains in the URL.
The officer console is mounted under /admin/officer. All routes require Manage on the officer area. The fixed activation_officer role has this area; super_admin passes the area guard. An admin must be granted Manage explicitly. Customer ownership, claim assignment and company state are derived from database rows.
Completion Scope
The completion tool works on any paid activation that is not active: in_progress with the identity check completed, at any age, or failed with no live refund request. This is a superset of the fallout queue predicate below, which additionally requires an in-progress row to be past the stuck threshold. A row that is in scope, or that the caller's claim still covers after it left scope, is visible to GET /admin/officer/queue/activations/:id; a super admin sees every row.
Both check-operator's active answer and resolve's activated outcome move a row straight to activated without an operator call that would issue an eSIM profile, so a line activated by hand this way never gets a profile from this transition and no eSIM delivery email is sent for it. The customer still receives the normal activated notice either way.
POST /admin/officer/completions
Finds a customer at the counter by phone or by SIM serial and lists their paid activations that are not active. Send exactly phone or serial, never both or neither.
POST /admin/officer/completions
Content-Type: application/json
{ "phone": "0512345678" }
POST /admin/officer/completions
Content-Type: application/json
{ "serial": "8996601212345678901" }A phone is normalised to E.164 the way a counter entry reads it: a leading trunk 0 is read as a Saudi national number, anything else must already be international. It is looked up through its hash, as sign-in does, against an active end_user. The matching customer is returned even with nothing to complete, so the screen can say so. A serial is 19 or 20 digits matched exactly against a physical activation's stored serial; every active customer who owns an in-scope row on that serial is returned, each with all of their in-scope rows, not only the one on the searched serial, because the design shows "3 paid, not active" for the whole customer.
Response OfficerCompletionSearchResponse:
{
"matchedOn": "phone",
"customers": [
{
"customerId": "11111111-1111-4111-8111-111111111111",
"name": "Ahmed Al-Rashid",
"maskedPhone": "+9665•••••67",
"identityType": "citizen",
"companyCustomer": false,
"activations": [
{
"activationId": "22222222-2222-4222-8222-222222222222",
"orderId": "33333333-3333-4333-8333-333333333333",
"packageName": "STC 99 SAR Plan",
"packageNameAr": "باقة STC بقيمة 99 ريال",
"operatorName": "Saudi Telecom Company",
"operatorNameAr": "شركة الاتصالات السعودية",
"simType": "physical",
"serialLast4": "8901",
"amountSar": 99,
"paymentMethod": "mada",
"paidAt": "2026-06-01T10:01:00.000Z",
"status": "in_progress",
"errorCode": null,
"stale": false,
"answerLost": false,
"claimedBy": null,
"companyOrder": false,
"refundRequestId": null,
"updatedAt": "2026-06-01T10:02:00.000Z",
"ageSeconds": 120
}
]
}
]
}stale marks an in-progress row that has crossed the fallout queue's stuck threshold. answerLost marks an in-progress row with a completion claim set: the operator may have received the request and its answer never came back. serialLast4 is the last four digits of a physical serial and null for an eSIM. At most 50 activations per customer, newest change first. Company rows are included and marked companyOrder: true; every write on them is refused with OFFICER_COMPANY_READ_ONLY.
Fallout Queue
POST /admin/officer/queue
Returns either the activation fallout queue or the read-only store queue. JSON body fields are kind=activations|store, cursor, limit (1 to 100, default 20), and at most one of phone, serial, or orderId.
Activation rows match either predicate:
failedactivation on a paid order with no live refund request; orin_progressactivation whose identity iscompleted, whose order is paid, and whoseupdatedAtis older thanACTIVATION_STUCK_THRESHOLD_MINUTES.
A super admin, or an admin holding Manage on the officer area, sees the whole queue, plus the caller's own claims on fresher rows of the completion scope, so a claim taken at the counter stays in sight before the row would reach the queue; those claims count in claimedByMe. An activation officer sees only rows linked to a ticket assigned to them that is still open or in_progress, including their own claims on fresher rows of the completion scope, so a row they are working does not vanish when a retry puts it back in progress. Work reaches an officer by assigning them a ticket, which moves the claim on the linked activation to them, and a row leaves their queue once its ticket is resolved or closed. Deleted activations are excluded. Rows are newest by updatedAt; the response item includes activationId, orderId, customerId, customerName, status, errorCode, simType, operatorName, operatorNameAr, ageSeconds, claimedBy, claimedAt, companyOrder, refundRequestId, and updatedAt.
Store rows match paid store orders in reconciliation_required or failed, scoped the same way: the whole tab for a super admin or an admin with Manage, or only rows linked to the officer's own open tickets otherwise. They include storeOrderId, orderId, customer fields, product names, issuanceState, errorCode, reason, refundRequestId, and createdAt. This tab is read-only: it does not call the store provider or requeue work.
phone searches either tab, normalised to E.164 the way the completion tool reads one and matched through the phone hash. serial (19 or 20 digits, a physical SIM serial) searches the activations tab only. orderId (a UUID, matching the store order's payment order id or its own id) searches the store tab only. Sending a serial on the store tab, an order id on the activations tab, more than one of the three, or a malformed value answers 400. A search stays within the viewer's scope above.
GET /admin/officer/queue/counts
Returns attention counts. Counts identify rows needing attention, not rows guaranteed to be actionable, and follow the same viewer scope as the queue list above — the whole queue for a super admin or an admin with Manage, or only the caller's own open-ticket rows for an activation officer:
{ "failed": 3, "stuck": 2, "claimedByMe": 1, "store": 4 }GET /admin/officer/queue/activations/:id
Returns one paid, not-active activation with its status events. Visibility is the completion scope above, or claimed by the caller; a super admin may read any. Fields beyond the queue list item:
| Field | Meaning |
|---|---|
completionAttemptedAt | When the completion claim was set, or null |
failedReason | The failure reason, or null |
heldState | See the table below |
packageName, packageNameAr | The activation's package name |
serialLast4 | Last four digits of a physical serial, null for an eSIM |
payment | { amountSar, method, paidAt }, or null while the order has no completed payment |
operatorCapabilities | { repeatSafeActivation, requestLookup, lineLookup }, each false when the operator plugin does not declare it, or declares it without implementing the matching method |
events | Status history: id, fromStatus, toStatus, source, note, createdAt |
It remains readable to its claimant even after it leaves scope, so its status may be any activation status, while queue list items are always failed or in_progress. heldState is:
| State | Meaning |
|---|---|
in_flight | Completion job is waiting, active, delayed, or its state cannot be proved absent |
needs_investigation | Row is in_progress and completion was claimed, but no live job remains |
retryable | Row is in_progress, identity completed, no claim is set, and completion job is absent or failed |
none | None of the above |
Company rows are displayed but read-only. The companyOrder flag is true when the order belongs to a company.
POST /admin/officer/queue/activations/:id/claim
Claims an unclaimed paid, not-active activation with one conditional update, setting assignedOfficerId and assignedAt. Any row in the completion scope is eligible, so an officer at the counter can claim a fresh in-progress row too, not only one already in the fallout queue. A company row is never claimed. The first officer wins. Response: { "activationId": "...", "claimedAt": "..." }.
POST /admin/officer/queue/activations/:id/release
Clears the current officer's own claim. It cannot release another officer's claim. Response: { "activationId": "..." }.
POST /admin/officer/queue/activations/:id/retry
Retries only a claimed activation and only when completion is provably eligible: status in_progress, identity completed, paid order, no live completion job, and by default no completionAttemptedAt. When the row's operator declares repeated activation requests safe (repeatSafeActivation), a row whose earlier request may have reached the operator can be retried too: once the job reads as not live, its completion claim is released before the replay is enqueued, and only while the caller still holds the row (a super admin excepted), otherwise the retry is refused as claim_set. A retained failed or completed completion job is removed and payment settlement is re-enqueued with a unique retry job id. The queue's own eligibility read requires the caller to still hold the row (a super admin excepted), so a claim that moved to another officer after the claim check is refused with OFFICER_CLAIM_NOT_HELD and nothing is enqueued. The worker's atomic completion claim is the final race guard. The successful officer.activation_retried audit entry carries repeatSafe. Before releasing an attempt, a separate officer.activation_retry_requested entry is committed with the actor and previous attempt timestamp. A queue failure therefore retains request attribution; a refused or failed enqueue does not produce a successful retry entry. Response: { "activationId": "...", "retried": true }.
POST /admin/officer/queue/activations/:id/check-operator
Asks the operator directly what became of an in-progress activation, when its operator plugin declares a lookup usable for the row. The request lookup, by our activation id, is preferred and works for either SIM type; the line lookup, by the physical SIM serial, is the fallback and only applies to a physical SIM. Refused with OFFICER_ACTION_NOT_ALLOWED ({ reason: 'lookup_not_supported' }) when neither lookup is usable, or ({ reason: 'not_in_progress' }) when the row is not in_progress with identity completed. Refused with OFFICER_JOB_IN_FLIGHT while the completion job is live.
The claim and state are checked under a short transaction first, the operator is called with no database lock held, then the row is re-locked and re-checked before the answer is applied:
| Answer | Outcome | Effect |
|---|---|---|
active | activated | The row moves in_progress → activated and the customer receives the normal activated notice. For a physical SIM, the serial lock is taken before the row lock, and when another activation has already claimed the same serial the check is refused with OFFICER_ACTION_NOT_ALLOWED ({ reason: 'serial_in_use' }) and nothing is written, since the line lookup asks about the serial and cannot tell the two activations apart |
not_found (request lookup) | retry_allowed | The operator never received the request: the completion claim is released so the row can be retried. The release applies only while the claim is still the one read before the call and no completion job is live, otherwise nothing changes and the outcome is check_again |
not_found (line lookup) | not_active | The line is not active, but this does not prove the request never arrived, so nothing changes |
in_progress | check_again | Nothing changes |
An operator call that throws or times out returns 502 OFFICER_LOOKUP_FAILED. Audited as officer.operator_checked with { lookup, state, outcome }. Response OfficerOperatorCheckResponse:
{ "activationId": "...", "lookup": "request", "state": "active", "outcome": "activated" }POST /admin/officer/queue/activations/:id/resolve
Records the outcome of a completion attempt the caller confirmed with the operator directly, outside check-operator. The caller must hold the claim (unless super admin), the row must be in_progress with a completion claim, and no live completion job may remain. The job state is checked before the row is locked and again once it is locked, since a retry can enqueue a new job in between. Body:
{
"outcome": "not_activated",
"evidence": "Operator case 123 confirmed no activation",
"reason": "Customer should be refunded",
"completionAttemptedAt": "2026-09-20T09:00:00.000Z"
}completionAttemptedAt is the attempt the officer investigated, as the queue activation detail reported it. When the row now carries a different attempt, because it was retried and attempted again since, the answer is refused with OFFICER_ACTION_NOT_ALLOWED ({ reason: 'attempt_changed' }).
outcome is not_activated or activated; both evidence and reason are required strings limited to 500 characters and kept in the audit entry only, never shown to the customer. not_activated transitions the activation to failed with errorCode: officer_verified_failed and a fixed customer-facing failed reason and status note. activated transitions the activation to activated the same way an operator-confirmed check does, and the customer receives the normal activated notice. Neither outcome opens a refund request automatically; the officer opens one by hand with POST /admin/officer/refund-requests when it is warranted. Response OfficerResolveResponse:
{ "activationId": "...", "status": "failed" }A line an officer marks activated by hand this way never received an operator eSIM profile through this transition, so no eSIM delivery email is sent for it.
Officer Tickets and Refund Requests
POST /admin/officer/activations/:activationId/resend-delivery
Manage access. Re-sends the already-issued eSIM delivery email for an activated eSIM activation. It does not issue a new profile. The notification uses the existing esim.issued template with an occurrence number and the call is audited as officer.delivery_email_resent. Response 202: { "sent": true, "sequence": 1 }.
The activation must not be deleted, must be activated, and must still hold an issued eSIM profile. The customer must have an email address. Physical activations and pending eSIMs are not issued profiles.
Concurrent resends retry notification-key collisions until a fresh row is created. A request that cannot create a fresh notification returns 409 OFFICER_ACTION_NOT_ALLOWED and does not record a successful resend. The sequence counts persisted resend keys, including notifications created before a later audit failure.
POST /admin/officer/store-orders/:orderId/resend-delivery
Manage access. Re-sends the already-issued eSIM delivery email for a store order, without re-issuing it. The response and audit behavior match the activation resend route; the notification parameters identify the store order. The store order must be issued, not deleted, and still hold its profile credentials, and the customer must have an email address.
Both resend routes use the existing customer record action and are also exposed from the customer record when the viewer has Manage on officer.
GET /admin/officer/tickets
Lists only tickets assigned to the caller. Refund requests the caller opened are not listed here; each is read by id through GET /admin/officer/tickets/:id or GET /admin/officer/refund-requests/:id. There is no super-admin bypass. It accepts the same cursor, limit, status, type, marker, and assigneeId query parameters as the support list, although the caller's assigned scope remains in force.
GET /admin/officer/tickets/:id
Reads a ticket assigned to the caller, or a refund request the caller opened, with full comments, internal notes, audit history and linked record state. Reading a refund request opened by the officer does not permit commenting unless the ticket is also assigned to that officer.
GET /admin/officer/tickets/:id/store-order-events
Requires the officer area (Manage). Returns { events }, the issuance timeline of the store order linked to the ticket, oldest first, each event { id, fromState, toState, errorCode, source, note, createdAt }. An activation officer reads it only while the ticket is assigned to them and its status is open or in_progress, the same rule that puts the linked row on their store tab. A super_admin, or an admin holding officer Manage, is not limited by assignment or status. Anyone else gets 404 TICKET_NOT_FOUND, the answer for a missing ticket. A ticket with no linked store order returns an empty list. Read only.
POST /admin/officer/tickets/:id/comments
Comments only on a ticket assigned to the caller. Body is { content, isInternal }; refund request comments must be internal. The first public customer-request reply sets firstResponseAt. Closed tickets refuse comments. Response 201 is the staff comment.
POST /admin/officer/tickets/:id/status
Body must be { "status": "resolved" }. Only an assigned customer request can be resolved; officers cannot close tickets or choose another status. Response is the updated ticket.
POST /admin/officer/tickets
Opens a customer request ticket on behalf of the owner of an activation the completion tool can see (in scope, or claimed by the caller; a super admin may act on any), for example when a customer at the counter reports the line still does not work after the operator confirmed it active. Company rows are refused. Body:
{
"activationId": "22222222-2222-4222-8222-222222222222",
"subject": "Line still shows no service",
"description": "Customer says the line the operator confirmed active still shows no service on their phone."
}subject is 1 to 200 characters, description is 1 to 5000 characters. Created with source: staff and openedBy the officer, linked to the activation, with the usual due date. Unlike a ticket opened from the support console, this one notifies support with the same ticket.opened notice a customer-opened ticket sends, and it has no firstResponseAt, so it counts as awaiting a first response. Audited as ticket.opened. Response 201 is the Ticket.
POST /admin/officer/refund-requests
Opens or reuses a refund request for exactly one activation or store order. The caller must not hold the refund decider role, checked before any read. For an activation, the officer must hold its fallout claim (activations.assignedOfficerId). For a store order, the issuance state must be reconciliation_required or failed. The linked order must be paid. Company activations are read-only and cannot be refunded from this console. A refund request is never opened automatically by resolve or check-operator; this route is the only way one is opened for an activation. Response 201 is { ticket, created }. When a live request already existed, created is false and ticket carries only its id and reference.
GET /admin/officer/refund-requests/:id
Reads only a refund request opened by the caller. Even a super admin has no general bypass on this route. The response is the Ticket shape.
Serial and Completion Guarantees
Every physical activation creation path and completion path takes the same transaction advisory lock for its ICCID before checking occupancy. Paid pending, in-progress and activated rows hold the serial. Cancelled rows, other unpaid rows, failed rows without a completion claim, and failed rows whose code is operator_reject or officer_verified_failed release it. During completion, the first claimant for a physical serial wins. A later activation fails without an operator call using activation failure code serial_in_use.
claimCompletion locks the physical serial, re-reads the activation, requires a paid order and completed identity, and sets completionAttemptedAt atomically. If already set, the worker does not call the operator. A connection refusal or unknown host clears the claim for a retry; an error that may have reached the operator leaves the claim set for investigation. This is a safety fence, not an exactly-once provider guarantee.
Plugin Abilities
An operator plugin optionally declares capabilities: { repeatSafeActivation?, requestLookup?, lineLookup? }. A lookup ability counts only when the plugin also implements the matching method (lookupActivationRequest for requestLookup, lookupLineBySerial for lineLookup); a declaration without the method never offers an action that would fail. ActivationParams.reference carries our activation id on every activation request, so a repeated send is recognised and the request can later be looked up by it. See Activations for the plugin contract.
Errors
| Code | Status | Meaning |
|---|---|---|
OFFICER_CLAIM_TAKEN | 409 | Queue row was claimed or no longer in the completion scope |
OFFICER_CLAIM_NOT_HELD | 409 | Caller does not hold the row claim |
OFFICER_ACTION_NOT_ALLOWED | 409 | State, scope, outcome, target, or lookup support makes the action invalid |
OFFICER_JOB_IN_FLIGHT | 409 | Completion job is still live; retry, check-operator, or resolve must wait |
OFFICER_COMPANY_READ_ONLY | 409 | Company activation is visible but cannot be acted on here |
OFFICER_LOOKUP_FAILED | 502 | The operator lookup call failed or timed out |
IDENTITY_START_UNAVAILABLE | 503 | Redis identity-start lease could not be acquired safely |
ACTIVATION_NOT_FOUND | 404 | Activation is missing, deleted, or outside the caller's scope/claim |
TICKET_NOT_FOUND | 404 | Ticket is missing or outside the caller's officer scope |
TICKET_CLOSED | 409 | Closed ticket cannot receive comments or change status |
TICKET_ACTION_NOT_ALLOWED | 409 | Ticket action is invalid for its type, assignment, or state |
TICKET_LINK_INVALID | 422 | Refund link is missing or invalid |
REFUND_ORDER_NOT_PAID | 422 | Linked order is not paid |
REFUND_DECIDER_CANNOT_OPEN | 403 | Caller holds the refund decider role, which never opens a refund request |
AUTH_REQUIRED | 401 | Missing or invalid authentication |
AUTH_FORBIDDEN | 403 | Caller lacks officer Manage access or a required super-admin role |
VALIDATION_ERROR | 400 | Invalid body, UUID, enum, cursor, limit, phone, or serial |