Customers API
The staff view of one customer: exact-match search, a six-part record, an identity check detail, and a per-customer audit history, mounted under /admin/customers. Beside them sit the officer actions under /admin/officer, which act on a customer's records.
Only an end_user is a customer. Administration accounts (owner, admin, officer, support, b2b_admin) are staff and have no record — a staff id returns the same 404 as an unknown id.
Authorization
Gated per route, not by one blanket guard.
| Level | Guard | Admits |
|---|---|---|
customers View | requireArea('customers') | super_admin, activation_officer, support, or an admin whose managedAreas include customers |
identity-detail View | requireArea('identity-detail') | super_admin, or an admin whose managedAreas include identity-detail |
officer Manage | requireAreaManage('officer') for resend actions | super_admin, activation_officer, or an admin whose managedAreasWrite include officer |
Area membership is resolved at request time, never frozen in the JWT. See Admin Users for how areas are assigned.
Reading a customer is the customers area. Acting on one is the officer area, so support can look at a record and refer it without being able to send anything from it. The identity panel needs identity-detail on top of customers — both guards run — so an admin can hold the record without the identity check behind it, and never the reverse.
Viewer Shaping
Contact values are shaped by who is asking. The owner (super_admin) sees the stored phone and email. Every other viewer sees them masked: the phone reduced to a prefix plus its last two digits behind fixed bullets, the email reduced to its first character plus the full domain. Field names and types are identical either way. Search results are masked for every viewer, the owner included — the full values live only on the record itself. See Viewer Shaping.
POST /admin/customers/search
Send the term in a JSON body, for example { "q": "customer@example.com" }. Search values never belong in query parameters. The portal preserves searches for up to ten minutes in encrypted HttpOnly cookies bound to the current login; URLs carry no contact or SIM values.
Find the customers one term points at. Every branch is an equality match on the whole value, so a partial phone, a partial email, or a name never returns anything. At most 20 customers come back, newest first.
Requires the customers area (View).
Search Classification
The server classifies the term, in this order:
| Term shape | Classified as | Looked up against |
|---|---|---|
| E.164 phone (normalized) | phone | The deterministic phone hash — exact match only |
| Email address | email | The deterministic email hash. Matches the spelling used at sign-up, and its lower-case form. |
| 19 or 20 digits | iccid | Store order ICCIDs first, then activation ICCIDs |
RCPT- prefix | receipt | Payment receipt numbers, normalized to the stored upper-case prefix and lower-case hex |
| UUID | orderId | Orders first, then activations, then store orders |
| Anything else | providerOrderId | The store order's provider-side reference |
Phone and email are stored encrypted with a random IV per write, so they are looked up through their hash columns exactly as OTP login does. There is no partial or prefix search over either. Phone and ICCID cannot collide: E.164 caps at 15 digits and an ICCID is 19 or 20.
JSON Body
| Parameter | Type | Required | Description |
|---|---|---|---|
q | string | Yes | The search term, 3 to 120 characters. Classified server-side |
Response
200 OK
Each row carries masked contact details, what the term matched on, and the customer's activation and store order counts.
{
"items": [
{
"id": "e5f6a7b8-c9d0-1234-efab-567890123456",
"name": "Ahmed Al-Rashid",
"maskedPhone": "+9665•••••67",
"maskedEmail": "a•••@example.com",
"role": "end_user",
"companyName": null,
"createdAt": "2026-05-02T09:14:00.000Z",
"activationCount": 2,
"storeOrderCount": 1,
"matchedOn": "phone"
}
]
}matchedOn is one of phone, email, iccid, receipt, orderId, providerOrderId.
Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | The term is missing, shorter than 3 characters, or over 120 |
AUTH_REQUIRED | 401 | Missing token (an invalid or expired token returns AUTH_SESSION_EXPIRED) |
AUTH_FORBIDDEN | 403 | Caller does not hold the customers area |
GET /admin/customers/:id
The whole record for one customer: profile, activations, orders with their payment, eSIM store orders, and notifications across every delivery state, plus a capabilities block telling the caller which actions this viewer may take from the record.
Requires the customers area (View).
Each section carries the latest 20 rows and a total beside it, so the page can say "20 of 137". No section ever carries an eSIM activation code, matching id, SM-DP+ address, or identity number. operatorName and packageName follow the request Accept-Language, falling back to English when an Arabic name is unavailable.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes | Customer ID |
Response
200 OK
{
"profile": {
"id": "e5f6a7b8-c9d0-1234-efab-567890123456",
"name": "Ahmed Al-Rashid",
"phone": "+9665•••••67",
"email": "a•••@example.com",
"locale": "ar",
"role": "end_user",
"company": null,
"createdAt": "2026-05-02T09:14:00.000Z"
},
"activations": {
"items": [
{
"id": "c3d4e5f6-a7b8-9012-cdef-345678901234",
"createdAt": "2026-06-01T10:00:00.000Z",
"operatorName": "Saudi Telecom Company",
"packageName": "STC 99 SAR Plan",
"simType": "esim",
"status": "activated",
"identityStatus": "completed",
"issued": true,
"orderPaid": true,
"liveRefundRequest": false
}
],
"total": 2
},
"orders": {
"items": [
{
"orderId": "a7b8c9d0-e1f2-3456-abcd-789012345678",
"orderStatus": "completed",
"createdAt": "2026-06-01T10:00:00.000Z",
"amount": 99,
"currency": "SAR",
"receiptNumber": "RCPT-c4d5e6f7a8b9",
"method": "mada",
"paymentStatus": "completed",
"paidAt": "2026-06-01T10:01:00.000Z"
}
],
"total": 1
},
"storeOrders": {
"items": [
{
"storeOrderId": "c8c9d0e1-f2a3-4567-bcde-890123456789",
"orderId": "b8c9d0e1-f2a3-4567-bcde-890123456789",
"createdAt": "2026-06-03T08:00:00.000Z",
"productNameEn": "Saudi Arabia 3 GB",
"productNameAr": "السعودية 3 جيجابايت",
"providerSlug": "mobimatter",
"issuanceState": "issued",
"issuedAt": "2026-06-03T08:01:00.000Z",
"needsAttention": false,
"externalOrderId": "MM-99213",
"orderPaid": true,
"liveRefundRequest": false
}
],
"total": 1
},
"notifications": {
"items": [
{
"id": "d0e1f2a3-b4c5-6789-def0-012345678901",
"createdAt": "2026-06-03T08:01:05.000Z",
"eventKey": "esim.issued",
"channel": "email",
"deliveryStatus": "delivered"
}
],
"total": 6
},
"tickets": {
"items": [
{
"id": "f1a2b3c4-d5e6-7890-fabc-123456789012",
"reference": "TKT-123456789012",
"type": "customer_request",
"status": "in_progress",
"subject": "Activation did not finish",
"createdAt": "2026-06-01T10:00:00.000Z",
"dueAt": "2026-06-06T10:00:00.000Z",
"resolvedAt": null,
"decision": null
}
],
"total": 1
},
"capabilities": {
"canResend": true,
"canViewIdentity": false,
"canOpenStoreOrders": false,
"canOpenTicket": true,
"canRequestRefund": true
}
}profile.phone and profile.email carry the stored values for the owner and the masked forms for every other viewer. Each order row carries its latest payment when one exists — a draft or unpaid order has null payment fields. Each activation and store order row also carries orderPaid (its order is paid, activating, or completed, the set the refund request routes accept, and for an activation the order belongs to no company, since a company activation returns its funds to the company and the refund routes refuse it) and liveRefundRequest (a refund request for the row is open or approved; a rejected one does not count), so the record can show a refund action only where one could actually be opened. The notifications section reads the notifications table directly (one row per delivery channel), so archived, expired and soft-deleted rows are all visible. capabilities maps areas to actions: canResend from officer, canViewIdentity from identity-detail, canOpenStoreOrders from store, canOpenTicket from Manage on support, and canRequestRefund from Manage on support for a viewer who does not hold the refund decider role.
The customer record also includes a tickets section with the latest 20 tickets and a total. Each row contains id, generated reference, type, status, subject, createdAt, dueAt, resolvedAt, and nullable decision. Staff can open the linked support page only when canOpenTicket is true. Ticket details and conversation rules are documented in Support Tickets.
Store order rows use storeOrderId as the store-order primary identifier and retain their parent orderId; both are available to record actions and links. An officer refund request on an activation uses its current fallout claim (assignedOfficerId) to establish scope.
Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | id is not a valid UUID |
AUTH_REQUIRED | 401 | Missing token (an invalid or expired token returns AUTH_SESSION_EXPIRED) |
AUTH_FORBIDDEN | 403 | Caller does not hold the customers area |
NOT_FOUND | 404 | No such customer, or the id belongs to a staff account |
GET /admin/customers/:id/identity
The identity check state of each of the customer's activations: what was checked, how it ended, when it was verified, and the operator's transaction reference. The reference is returned in full only to the owner. Every other viewer receives a masked value that keeps only the last four characters. The identity number itself is never stored and is never returned.
Requires the customers and identity-detail areas (View).
Opening someone's identity check detail is itself an audited event: every call writes a customer.identity_viewed audit entry before the response is sent, so a read that never reaches the client is still a read.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes | Customer ID |
Response
200 OK — one entry per activation that has an identity check.
{
"items": [
{
"activationId": "c3d4e5f6-a7b8-9012-cdef-345678901234",
"identityType": "citizen",
"identityStatus": "completed",
"verifiedAt": "2026-06-01T10:01:30.000Z",
"operatorReference": "nafath-trans-abc123"
}
]
}Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | id is not a valid UUID |
AUTH_REQUIRED | 401 | Missing token (an invalid or expired token returns AUTH_SESSION_EXPIRED) |
AUTH_FORBIDDEN | 403 | Caller does not hold both the customers and identity-detail areas |
NOT_FOUND | 404 | No such customer, or the id belongs to a staff account |
GET /admin/customers/:id/history
The customer's audit history (entityType: 'user'), newest first.
Requires the customers area (View).
Every viewer sees changedFields. oldValues and newValues are included only for the owner. See Audit History for the shared entry shape and cursor format.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes | Customer ID |
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
cursor | string | (none) | createdAt|id cursor from the previous page |
limit | integer | 50 | Page size from 1 to 100 |
Response
200 OK
{
"items": [
{
"id": "f6a7b8c9-d0e1-2345-fabc-678901234567",
"createdAt": "2026-06-10T12:00:00.000Z",
"actor": { "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "name": "Sara Officer" },
"action": "officer.delivery_email_resent",
"entityType": "user",
"entityId": "e5f6a7b8-c9d0-1234-efab-567890123456",
"changedFields": ["customerId", "sequence"]
}
],
"nextCursor": "2026-06-10T12:00:00.000Z|f6a7b8c9-d0e1-2345-fabc-678901234567",
"hasMore": true
}Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | id is not a valid UUID, or the cursor or limit is invalid |
AUTH_REQUIRED | 401 | Missing token (an invalid or expired token returns AUTH_SESSION_EXPIRED) |
AUTH_FORBIDDEN | 403 | Caller does not hold the customers area |
NOT_FOUND | 404 | No such customer, or the id belongs to a staff account |
POST /admin/officer/activations/:activationId/resend-delivery
Send the customer the eSIM delivery email for this activation again. Nothing is re-issued: the same profile already on the record is rendered again, so the message only helps a customer who has not installed it yet. Each call is audited as officer.delivery_email_resent.
Requires the officer area (Manage).
The resend reuses the esim.issued template the issuance lane already sends. The dedupe key carries an occurrence number, so each call really sends. The response carries that number as sequence.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
activationId | string (uuid) | Yes | Activation ID |
Response
202 Accepted — the delivery email was handed to the notification pipeline.
{
"sent": true,
"sequence": 1
}Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | activationId is not a valid UUID |
AUTH_REQUIRED | 401 | Missing token (an invalid or expired token returns AUTH_SESSION_EXPIRED) |
AUTH_FORBIDDEN | 403 | Caller does not hold Manage on the officer area |
ACTIVATION_NOT_FOUND | 404 | No such activation |
OFFICER_RESEND_RECORD_DELETED | 409 | The activation is soft-deleted |
OFFICER_RESEND_NOT_ISSUED | 409 | The activation is not an eSIM, or it is not activated yet. A pending eSIM already holds the code the customer submitted, which is not an issued profile |
OFFICER_RESEND_NO_EMAIL | 409 | The customer has no email address |
POST /admin/officer/store-orders/:orderId/resend-delivery
Send the customer the eSIM delivery email for this store order again. Nothing is re-issued: the same profile already on the order is rendered again. Each call is audited as officer.delivery_email_resent.
Requires the officer area (Manage).
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
orderId | string (uuid) | Yes | The parent order ID of the store order |
Response
202 Accepted — the delivery email was handed to the notification pipeline.
{
"sent": true,
"sequence": 2
}Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | orderId is not a valid UUID |
AUTH_REQUIRED | 401 | Missing token (an invalid or expired token returns AUTH_SESSION_EXPIRED) |
AUTH_FORBIDDEN | 403 | Caller does not hold Manage on the officer area |
STORE_ORDER_NOT_FOUND | 404 | No store order for that order id |
OFFICER_RESEND_RECORD_DELETED | 409 | The store order is soft-deleted |
OFFICER_RESEND_NOT_ISSUED | 409 | Nothing has been issued yet (issuedAt is null) |
OFFICER_RESEND_CREDENTIALS_CLEARED | 409 | The order was issued but no longer holds an eSIM profile to send |
OFFICER_RESEND_NO_EMAIL | 409 | The customer has no email address |
History pagination failures keep the current rows and cursor, show localized retry feedback, and leave Load more available. Ticket screens offer customer navigation only when the viewer holds the customers area.