Skip to content

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.

LevelGuardAdmits
customers ViewrequireArea('customers')super_admin, activation_officer, support, or an admin whose managedAreas include customers
identity-detail ViewrequireArea('identity-detail')super_admin, or an admin whose managedAreas include identity-detail
officer ManagerequireAreaManage('officer') for resend actionssuper_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 shapeClassified asLooked up against
E.164 phone (normalized)phoneThe deterministic phone hash — exact match only
Email addressemailThe deterministic email hash. Matches the spelling used at sign-up, and its lower-case form.
19 or 20 digitsiccidStore order ICCIDs first, then activation ICCIDs
RCPT- prefixreceiptPayment receipt numbers, normalized to the stored upper-case prefix and lower-case hex
UUIDorderIdOrders first, then activations, then store orders
Anything elseproviderOrderIdThe 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 ​

ParameterTypeRequiredDescription
qstringYesThe 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.

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

CodeStatusCondition
VALIDATION_ERROR400The term is missing, shorter than 3 characters, or over 120
AUTH_REQUIRED401Missing token (an invalid or expired token returns AUTH_SESSION_EXPIRED)
AUTH_FORBIDDEN403Caller 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 ​

ParameterTypeRequiredDescription
idstring (uuid)YesCustomer ID

Response ​

200 OK

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

CodeStatusCondition
VALIDATION_ERROR400id is not a valid UUID
AUTH_REQUIRED401Missing token (an invalid or expired token returns AUTH_SESSION_EXPIRED)
AUTH_FORBIDDEN403Caller does not hold the customers area
NOT_FOUND404No 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 ​

ParameterTypeRequiredDescription
idstring (uuid)YesCustomer ID

Response ​

200 OK — one entry per activation that has an identity check.

json
{
  "items": [
    {
      "activationId": "c3d4e5f6-a7b8-9012-cdef-345678901234",
      "identityType": "citizen",
      "identityStatus": "completed",
      "verifiedAt": "2026-06-01T10:01:30.000Z",
      "operatorReference": "nafath-trans-abc123"
    }
  ]
}

Error Responses ​

CodeStatusCondition
VALIDATION_ERROR400id is not a valid UUID
AUTH_REQUIRED401Missing token (an invalid or expired token returns AUTH_SESSION_EXPIRED)
AUTH_FORBIDDEN403Caller does not hold both the customers and identity-detail areas
NOT_FOUND404No 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 ​

ParameterTypeRequiredDescription
idstring (uuid)YesCustomer ID

Query Parameters ​

ParameterTypeDefaultDescription
cursorstring(none)createdAt|id cursor from the previous page
limitinteger50Page size from 1 to 100

Response ​

200 OK

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

CodeStatusCondition
VALIDATION_ERROR400id is not a valid UUID, or the cursor or limit is invalid
AUTH_REQUIRED401Missing token (an invalid or expired token returns AUTH_SESSION_EXPIRED)
AUTH_FORBIDDEN403Caller does not hold the customers area
NOT_FOUND404No 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 ​

ParameterTypeRequiredDescription
activationIdstring (uuid)YesActivation ID

Response ​

202 Accepted — the delivery email was handed to the notification pipeline.

json
{
  "sent": true,
  "sequence": 1
}

Error Responses ​

CodeStatusCondition
VALIDATION_ERROR400activationId is not a valid UUID
AUTH_REQUIRED401Missing token (an invalid or expired token returns AUTH_SESSION_EXPIRED)
AUTH_FORBIDDEN403Caller does not hold Manage on the officer area
ACTIVATION_NOT_FOUND404No such activation
OFFICER_RESEND_RECORD_DELETED409The activation is soft-deleted
OFFICER_RESEND_NOT_ISSUED409The 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_EMAIL409The 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 ​

ParameterTypeRequiredDescription
orderIdstring (uuid)YesThe parent order ID of the store order

Response ​

202 Accepted — the delivery email was handed to the notification pipeline.

json
{
  "sent": true,
  "sequence": 2
}

Error Responses ​

CodeStatusCondition
VALIDATION_ERROR400orderId is not a valid UUID
AUTH_REQUIRED401Missing token (an invalid or expired token returns AUTH_SESSION_EXPIRED)
AUTH_FORBIDDEN403Caller does not hold Manage on the officer area
STORE_ORDER_NOT_FOUND404No store order for that order id
OFFICER_RESEND_RECORD_DELETED409The store order is soft-deleted
OFFICER_RESEND_NOT_ISSUED409Nothing has been issued yet (issuedAt is null)
OFFICER_RESEND_CREDENTIALS_CLEARED409The order was issued but no longer holds an eSIM profile to send
OFFICER_RESEND_NO_EMAIL409The 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.

Internal documentation - Activation System