B2B Administration API
The internal side of the B2B portal: company approval, editing and archiving, company portal people, quote review, contract confirmation, funding balance oversight, payment recording, and magic-link oversight. Three routers share the /admin mount — companies.ts, contracts.ts and links.ts.
For the company-facing half see B2B Portal.
Authorization
Every group applies requireRole('super_admin', 'admin') before requireArea('b2b'). The order is load-bearing: the external portal role b2b_admin also holds the b2b area, so an area check alone would admit external company staff into oversight.
| Level | Guard | Admits |
|---|---|---|
| View | requireArea('b2b') | super_admin, or an admin whose managedAreas include b2b |
| Manage | requireAreaManage('b2b') | super_admin, or an admin whose managedAreasWrite include b2b |
Area membership is resolved from the database at request time, never frozen in the JWT. See Admin Users for how areas are assigned.
| Group | Reads | Writes |
|---|---|---|
/admin/companies | Role + b2b View | Role + b2b Manage |
/admin/companies/invites | — | company-people Manage with a companyId, otherwise company-onboarding Manage |
/admin/companies/:id/users | Role + b2b View + company-people View | — |
/admin/companies/pending-admins | Role + b2b View + company-onboarding View | — |
/admin/quotes | Role + b2b View | Role + b2b Manage |
/admin/contracts | Role + b2b View | Role + b2b Manage |
/admin/links | Role + b2b View | — (read-only group) |
Inviting and listing portal users is stricter than the section's own gate: it needs the company-people or company-onboarding area on top of b2b, each with b2b as its required parent. The owner holds both implicitly. Phone and email in these lists are masked for everyone but the owner.
Archived Companies
An archived company is a closed record and is read-only for internal admins too. Until it is unarchived, every write that would change it is refused with 409 COMPANY_ARCHIVED:
- editing its details (
PATCH /admin/companies/:id) - inviting a portal user into it (
POST /admin/companies/inviteswith itscompanyId) - starting review on, countering or rejecting one of its quotes
- confirming a contract from one of its quotes
- recording a payment against one of its contracts
A company admin changing their own email never fills an archived company's empty contact email either.
Archiving is internal to staff. The company side keeps seeing the status the company was archived from (suspended or rejected): middleware/company-context.ts resolves that status for every portal gate, and GET /b2b/company returns it, so the company portal behaves exactly as it did before the archive.
The payment-document download is a read but uses Manage, because those files carry financial and personal content.
Quote State Machine
Read from quote-service.ts. A quote is versioned and immutable once submitted. countered, accepted, rejected and expired are terminal.
| From | Action | To | Who |
|---|---|---|---|
| — | POST /b2b/quotes | draft | Company admin |
draft | POST /b2b/quotes/:id/submit | submitted | Company admin |
submitted | POST /admin/quotes/:id/start-review | under_review | Internal admin |
submitted, under_review | POST /admin/quotes/:id/counter | countered (this version) + a new under_review version | Internal admin |
under_review (admin-authored) | POST /b2b/quotes/:id/accept | accepted | Company admin |
under_review (admin-authored) | POST /b2b/quotes/:id/reject | rejected | Company admin |
draft, submitted, under_review | POST /admin/quotes/:id/reject | rejected | Internal admin |
company-authored submitted/under_review | POST /admin/quotes/:id/confirm-contract | accepted (implicitly) + a new contract | Internal admin |
accepted | POST /admin/quotes/:id/confirm-contract | unchanged + a new contract | Internal admin |
A counter is one transaction: the old version flips to countered (which is exactly what "superseded" means) and a new version is created with version + 1, parentId set to the old version, and status under_review. parentId is server-set and never client input.
Because only a counter ever sets parentId, parentId === null doubles as "company-authored" and parentId !== null as "admin-authored". No extra column is needed.
expired exists in the status union and in the terminal-status list, but no code path writes it. There is no quote expiry mechanism.
Within a chain, the tip version — the one with no child — is the only actionable one. Status filters on the list endpoints match that live version, which is why a filter on countered can never match a chain.
Drafts are the company's private working state. They are excluded from the admin review queue, and GET /admin/quotes/:id returns QUOTE_NOT_FOUND for one so a direct URL cannot leak it.
GET /admin/companies
List companies, newest first.
Requires role + b2b View.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
status | string | — | pending, approved, rejected, suspended or archived. Omitted, every status is listed |
search | string | — | Case-insensitive substring match on company name or CR number |
page | number | 1 | Page number |
limit | number | 20 | 1–100 |
Response
200 OK — { "data": { "companies": [ ... ], "total": 12, "page": 1, "limit": 20 } }.
contactEmail is the registering admin's account email, and null when that admin was invited by phone only. See POST /b2b/company.
POST /admin/companies/invites
Provision a portal user with the b2b_admin role.
Requires super_admin, or an admin with Manage on company-people (when companyId is given) or on company-onboarding (when it is not). The section-wide role + b2b View guard runs first, then the area is checked from the body, so a platform admin holding only b2b is rejected with 403 ADMIN_USER_SCOPE_EXCEEDED.
Creation goes through the same provisioning path as Admin Users — duplicate checks, rollback on failure, and invitation delivery included. When companyId is given, the company membership is written in the same transaction as the profile and before the invitation is sent, so a failed link leaves no user behind.
The first admin invite has no company yet: their own registration creates it, and until then they are listed by GET /admin/companies/pending-admins. A member viewer or an additional admin joins an existing company, so companyId is required for a member. When companyId is given the company must exist and must not be archived, and the membership row is created with the user.
This invite is the only way to provision a b2b_admin: POST /admin/users accepts staff roles only, because a company person must also be linked to their company.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
phone | string | Conditional | Saudi E.164. At least one of phone or email. |
email | string | Conditional | At least one of phone or email. |
name | string | Yes | 1–255 characters |
role | string | Yes | admin or member — the company-level role |
companyId | string | Conditional | Required when role is member |
Response
201 Created
{
"data": {
"user": { "id": "...", "phone": "+966501234567", "email": null, "name": "Layla Ahmed", "role": "b2b_admin", "isActive": true, "createdAt": "2026-07-18T08:00:00.000Z" },
"companyId": null,
"companyRole": "admin"
}
}Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | Body invalid, including the member + companyId rule |
AUTH_FORBIDDEN | 403 | Caller is not super_admin or admin, or lacks b2b View |
ADMIN_USER_SCOPE_EXCEEDED | 403 | The caller lacks Manage on the area this invite needs |
COMPANY_NOT_FOUND | 404 | The given companyId does not exist |
ADMIN_USER_PHONE_EXISTS | 409 | Phone already registered |
ADMIN_USER_EMAIL_EXISTS | 409 | Email already registered |
COMPANY_ARCHIVED | 409 | The given companyId is archived |
GET /admin/companies/pending-admins
Invited company admins who have not registered their company yet: b2b_admin users with no company membership, newest first. Registering the company creates the membership, which drops the user from this list.
Requires super_admin, or an admin with company-onboarding View. Registered before /admin/companies/:id so the literal pending-admins segment never matches the id parameter.
Resend, deactivate and remove use the Admin Users routes on the user's id.
Response
200 OK — { "data": [ ... ] }, each entry an Administration User Object with role: "b2b_admin".
Error Responses
| Code | Status | Condition |
|---|---|---|
AUTH_FORBIDDEN | 403 | Caller is not super_admin or admin, or lacks company-onboarding View |
GET /admin/companies/:id
One company.
Requires role + b2b View.
Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | id is not a valid UUID |
COMPANY_NOT_FOUND | 404 | No such company |
GET /admin/companies/:id/users
The company's portal users (b2b_admin accounts linked through company_users) with their company role, newest first.
Requires super_admin, or an admin with company-people View.
Response
200 OK
{
"data": [
{
"id": "c3d4e5f6-a7b8-9012-cdef-123456789012",
"phone": "+966501234567",
"email": "layla@darmi.example",
"name": "Layla Ahmed",
"role": "b2b_admin",
"managedAreas": null,
"managedAreasWrite": null,
"isActive": true,
"createdAt": "2026-07-18T08:00:00.000Z",
"companyRole": "admin"
}
]
}Each entry is an Administration User Object plus companyRole (admin or member).
Managing company people
Resend, deactivate and remove use the Admin Users routes on the user's id. A person linked to a company needs company-people Manage, and a pending admin with no company needs company-onboarding Manage; neither is compared against the caller's own strength. Two rules protect a company there:
- Last admin. A company must keep at least one active
admin. Deactivating or removing its last activeadminis refused with409 COMPANY_LAST_ADMIN. The company's admin rows are locked inside the same transaction as the write, so two concurrent removals of different admins cannot both pass. - Locked role. A company person's app role cannot be changed:
PATCH /admin/users/:idwith anyrolereturns409 COMPANY_ADMIN_ROLE_LOCKED. Moving them to a staff role would drop them from their company's portal and grant staff access. Remove them instead.
There is no route that changes a person's company role.
Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | id is not a valid UUID |
AUTH_FORBIDDEN | 403 | Caller is not super_admin or admin, or lacks company-people View |
COMPANY_NOT_FOUND | 404 | No such company |
PATCH /admin/companies/:id
Edit a company's registration number, name and contact details. Status, payment mode and approval have their own actions.
Requires role + b2b Manage.
Only fields whose value actually changes are written. They bump updatedAt and are audited as company.updated with old and new values, the contact phone and email masked. An edit that changes nothing writes nothing and returns the company as it is.
Request Body
Every field is optional; at least one is required.
| Field | Type | Required | Description |
|---|---|---|---|
crNumber | string | No | Trimmed, 3–30 characters. Whether the company is in KSA is not stored, so the edit applies the free-format rule. Must not belong to another company |
name | string | No | Trimmed, 1–255 characters |
contactName | string | No | Trimmed, 1–255 characters |
contactEmail | string | null | No | Email address. An empty string or null clears it |
contactPhone | string | No | E.164 phone, any country |
Example Request
{ "crNumber": "1010123456", "contactEmail": "ops@darmi.example" }Response
200 OK — the company after the edit.
Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | Empty body, an invalid field, or id is not a valid UUID |
AUTH_FORBIDDEN | 403 | Caller lacks b2b Manage |
COMPANY_NOT_FOUND | 404 | No such company |
COMPANY_CR_EXISTS | 409 | The registration number belongs to another company. A pre-check gives this answer, and the unique index on cr_number catches a concurrent edit or registration the pre-check cannot see |
COMPANY_ARCHIVED | 409 | The company is archived, including one archived between the read and the write |
POST /admin/companies/:id/status
Move a company through its lifecycle.
Requires role + b2b Manage.
Lifecycle
| Action | From | To |
|---|---|---|
approve | pending | approved |
reject | pending | rejected |
suspend | approved | suspended |
reactivate | suspended | approved |
archive | suspended, rejected | archived |
unarchive | archived | The status it was archived from |
Anything else is 409 COMPANY_INVALID_TRANSITION with { from, action } in the error details, and nothing is written.
archive records the status it starts from in archived_from, and unarchive restores that status and clears the column, so a rejected company never reaches approved without a fresh review. A database CHECK constraint, companies_archived_from_check, keeps archived_from set exactly while the company is archived. While archived the company is read-only — see Archived Companies.
The write is conditional on the status that was validated, so an admin racing a concurrent transition gets the same 409 instead of a silent overwrite, and an archive never records a stale archived_from.
approve is the only action that stamps approvedBy/approvedAt, and the only one that notifies. It sends a bilingual notice to the company's admin members over both email and SMS, best-effort — a delivery failure does not fail the action. reactivate does neither: it moves the status back to approved without re-stamping the approver.
suspend revokes every company member's live sessions through the Redis blocklist and blocks new provisioning. Suspension is access-level only and never touches live SIMs.
reject, reactivate, archive and unarchive send no notification. Every action is audited as company.<action>.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
action | string | Yes | approve, reject, suspend, reactivate, archive or unarchive |
reason | string | No | 500 characters or less. Goes to the audit row only — it is not stored on the company. |
Response
200 OK — the updated company.
Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | Body or id invalid |
AUTH_FORBIDDEN | 403 | Caller lacks b2b Manage |
COMPANY_NOT_FOUND | 404 | No such company |
COMPANY_INVALID_TRANSITION | 409 | The action is not legal from the current status |
GET /admin/quotes
The review queue, grouped for display by company: company name A→Z, then latest activity first within a company. Each chain carries companyName so the client can render group headers.
Requires role + b2b View.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
status | string | — | Matches the chain's live version. draft is not accepted. |
companyId | string | — | Restrict to one company |
page | number | 1 | Page number |
limit | number | 20 | 1–100 |
Response
200 OK — { "data": { "chains": [ ... ], "total": 7, "page": 1, "limit": 20 } }. Each chain is { ref, live, history, companyName }, matching the portal list shape plus companyName.
GET /admin/quotes/catalog
The same B2B-priced catalog the portal wizard uses, for the counter-quote line-item picker. Names resolve from Accept-Language.
Requires role + b2b View.
Registered before /admin/quotes/:id so the literal catalog segment never matches the id parameter.
Response
200 OK — the array documented at GET /b2b/catalog.
GET /admin/quotes/:id
One version with its items and version chain.
The version summary carries companyNote alongside note. note is this side's — set by a counter or an admin rejection. companyNote is the company's own reason for declining a counter, written from the portal, and is read-only here: no admin route writes it. Both can be populated on the same version, since a declined counter keeps the counter note in note and the decline reason in companyNote.
Requires role + b2b View.
Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | id is not a valid UUID |
QUOTE_NOT_FOUND | 404 | No such quote, or it is a draft |
POST /admin/quotes/:id/start-review
submitted → under_review. Stamps reviewedBy/reviewedAt. No request body.
Requires role + b2b Manage.
This is an explicit action, never implicit on read.
Error Responses
| Code | Status | Condition |
|---|---|---|
QUOTE_NOT_FOUND | 404 | No such quote |
QUOTE_INVALID_STATE | 409 | The quote is not submitted |
COMPANY_ARCHIVED | 409 | The quote's company is archived |
POST /admin/quotes/:id/counter
Counter a quote. Allowed from submitted or under_review.
Requires role + b2b Manage.
One transaction: the old version becomes countered, and a new admin-authored version is created under_review with the countered items. The needs-form answers carry over unchanged so they stay visible on every version.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
items | array | Yes | 1–200 entries of { packageId, qty }. A counter with no items is not a counter. |
note | string | No | 500 characters or less |
Response
200 OK — the new version's detail.
Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | Body or id invalid |
QUOTE_PACKAGE_NOT_FOUND | 400 | Unknown or not-B2B-orderable packageId |
QUOTE_NOT_FOUND | 404 | No such quote |
QUOTE_INVALID_STATE | 409 | Not submitted or under_review |
COMPANY_ARCHIVED | 409 | The quote's company is archived |
QUOTE_COUNTER_FAILED | 502 | The new version insert returned no row |
POST /admin/quotes/:id/reject
Any non-terminal status → rejected, with an optional reviewer note.
This is the internal admin's rejection. A company declining an admin counter is the separate POST /b2b/quotes/:id/reject — same destination status, different writer, a different audit action (company.quote_decline rather than company.quote_reject), and it writes companyNote rather than note.
Requires role + b2b Manage.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
note | string | No | 500 characters or less. Replaces the existing note when given. Never touches companyNote. |
Error Responses
| Code | Status | Condition |
|---|---|---|
QUOTE_NOT_FOUND | 404 | No such quote |
QUOTE_INVALID_STATE | 409 | The quote is already in a final state |
COMPANY_ARCHIVED | 409 | The quote's company is archived |
POST /admin/quotes/:id/confirm-contract
Turn an agreed quote version into a contract, atomically.
Requires role + b2b Manage.
Eligible when the version is accepted, or it is company-authored and submitted/under_review — submitting your own numbers is accepting them. An admin-authored counter the company has not explicitly accepted is rejected: the company's consent to what it is charged for is required.
No request body. The payment mode is no longer client input — the contract inherits the company's own pre-picked companies.paymentMode (set on the portal via PATCH /b2b/company/payment-mode). Confirming before the company has picked a mode is rejected.
Prepaid and flexible compute the total differently, both server-side and both snapshotted at this moment — no client input either way:
- Prepaid: line items, unit prices and the total are computed from the quote's items, snapshotting
packages.priceSarB2b. Every quoted package must carry a non-null B2B price or the whole confirm fails — there is no partial contract. - Flexible:
totalSaris the quote's declaredneeds.fundingSar, and the contract gets zero line items, ever. A flexible contract IS the funded amount — packages are picked later from the catalog while staffing, at magic-link mint time, never on the contract itself.
The single-active-contract rule
The service refuses to create a second contract while the company has one with status active, returning 409 CONTRACT_ALREADY_EXISTS. This also blocks re-confirming a later flexible top-up request into a second contract — a top-up's funds are added by recording a payment against the existing contract instead, not by confirming a new one.
This rule is enforced at the database layer. A partial unique index, idx_contracts_one_active_per_company, is defined on contracts (company_id) WHERE status = 'active' — two concurrent confirmations for the same company serialize on it, and the loser gets a constraint violation rather than a second active contract. The application-level check above runs first only to turn that failure mode into the friendly 409 CONTRACT_ALREADY_EXISTS instead of a raw database error reaching the client.
Response
201 Created — the contract detail, shape as at GET /b2b/contract.
Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | id invalid |
CONTRACT_PACKAGE_PRICE_MISSING | 400 | Prepaid: a quoted package has no priceSarB2b (the packageId is in the details) |
QUOTE_NOT_FOUND | 404 | No such quote |
QUOTE_INVALID_STATE | 409 | The company has not accepted this version, the company has not chosen a payment mode yet, prepaid has no items to confirm, or flexible has no declared needs.fundingSar |
CONTRACT_ALREADY_EXISTS | 409 | The company already has an active contract |
COMPANY_ARCHIVED | 409 | The quote's company is archived |
CONTRACT_CREATE_FAILED | 502 | The contract insert returned no row |
GET /admin/contracts/company/:companyId
The company's active contract, or null. Same shape as the portal's read.
Requires role + b2b View.
GET /admin/contracts/company/:companyId/readiness
The three readiness gates for a company. Same shape as GET /b2b/readiness.
Requires role + b2b View.
An unknown companyId returns all-false rather than a 404 — the read never verifies the company exists.
GET /admin/contracts/company/:companyId/balance
Flexible pay's funding balance — the same read the flexible gates and both billing screens share.
Requires role + b2b View. No Manage gate, so a View-only admin sees the numbers behind an incoming payment.
Response
200 OK
{
"data": {
"paymentMode": "flexible",
"fundedSar": 50000.0,
"drawnSar": 1548.0,
"committedSar": 387.0,
"availableSar": 48452.0,
"activeLines": [
{ "packageId": "e5f6a7b8-c901-2345-efab-345678901234", "nameEn": "Business 20GB", "nameAr": "أعمال 20 جيجابايت", "count": 12, "drawnSar": 1548.0 }
]
}
}| Field | Meaning |
|---|---|
paymentMode | The active contract's mode. Defaults to prepaid when there is no active contract, from the company's own picked mode |
fundedSar | Σ recorded contract_payments on the active contract — the initial funding plus every top-up |
drawnSar | Realized charges — one per successful activation, the sum over this company's paid orders whose activation is still alive |
committedSar | Seats already consumed but with no live charge yet |
availableSar | fundedSar - drawnSar — funded minus drawn. committedSar is advisory only: the link-mint gate projects with it, the activation gate does not |
activeLines | One entry per package with a live activated line: count and drawnSar over that package |
An order whose activation is failed or cancelled is excluded from drawnSar. Its seat flows into committedSar instead, so each consumed seat contributes exactly once.
A company with no active contract reports zero for every figure, activeLines: [], and paymentMode falling back to the company's own picked mode (prepaid if unpicked) — nothing to gate. A prepaid company returns the same zeroed shape harmlessly. An unknown companyId also returns zeros rather than a 404.
POST /admin/contracts/:id/payments
Record a payment. Manual channels only — there is no online payment anywhere in this flow. The same route serves both modes: a prepaid pay-in-full and every flexible funding or top-up amount.
Requires role + b2b Manage.
- Prepaid: pay-in-full —
amountSarmust be at least the contract total. There are no partial records and no balance ledger. - Flexible: any positive amount is accepted. The initial funding and every later top-up are the same call — there is no separate "confirm this top-up" step. A company can request a top-up from its portal (
/b2b/funding, a zero-item quote request declaring the amount), but recording is never blocked on a request existing — money simply lands when the admin records it.
The document is required — every manual payment carries its proof. Bytes are stored in the database, size-capped at 5MB. The mime type and decoded size are re-checked server-side as defense in depth even though the schema already restricts both.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
method | string | Yes | bank_transfer or cheque |
amountSar | number | Yes | Positive, at most 99,999,999.99 (the numeric(10,2) column ceiling). Prepaid additionally requires at least the contract total — enforced in the service, not the schema. |
document | object | Yes | The proof file |
document.name | string | Yes | 1–255 characters |
document.mime | string | Yes | application/pdf, image/png or image/jpeg |
document.dataBase64 | string | Yes | Valid base64, decoding to 5MB or less |
idempotencyKey | string | Yes | 8–128 characters. Replay guard on a screen with no void or delete path: the payment row id is derived from this key, so replaying the same key returns the original record instead of inserting a second one. |
Response
201 Created — the payment metadata. documentBytes is never returned.
{
"data": {
"id": "c9012345-6789-abcd-ef01-789012345678",
"contractId": "a7b8c901-2345-6789-abcd-567890123456",
"method": "bank_transfer",
"amountSar": 5160.0,
"documentName": "transfer-receipt.pdf",
"documentMime": "application/pdf",
"hasDocument": true,
"confirmedBy": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"confirmedByName": null,
"confirmedAt": "2026-07-19T13:30:00.000Z",
"createdAt": "2026-07-19T13:30:00.000Z"
}
}confirmedByName is only resolved by the list endpoint's join, so it is null here.
Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | Body or id invalid. A missing document or missing idempotencyKey fails here, as a plain schema error with no business code — as do malformed base64 and the schema's own 5MB check. |
CONTRACT_PAYMENT_DOCUMENT_INVALID_MIME | 400 | Mime not in the allowed set — the service's post-decode re-check |
CONTRACT_PAYMENT_DOCUMENT_TOO_LARGE | 400 | Decoded bytes exceed 5MB — the service's post-decode re-check |
AUTH_FORBIDDEN | 403 | Caller lacks b2b Manage |
CONTRACT_NOT_FOUND | 404 | No such contract |
CONTRACT_PAYMENT_MODE_INVALID | 409 | The contract's payment mode is neither prepaid nor flexible (legacy/invalid data — every contract is one or the other today) |
CONTRACT_PAYMENT_BELOW_TOTAL | 409 | Prepaid only: amountSar is below the contract total |
CONTRACT_PAYMENT_IDEMPOTENCY_CONFLICT | 409 | The idempotencyKey was already used with a different method or amount |
COMPANY_ARCHIVED | 409 | The contract's company is archived |
CONTRACT_PAYMENT_RECORD_FAILED | 502 | The insert returned no row |
GET /admin/contracts/:id/payments
The contract's payments, newest first. Metadata only — documentBytes is not selected.
Requires role + b2b View.
The read never verifies the contract exists. An unknown id returns { "data": [] }, not a 404.
Each row adds confirmedByName, resolved by a left join onto users. It is null when confirmedBy is null or no longer resolves.
hasDocument is derived from documentName being present, since a document's three columns are always written together.
GET /admin/contracts/:id/payments/:paymentId/document
Download the uploaded proof file — the exact bytes the admin attached when recording the payment. This is the one route that ever streams documentBytes.
Requires role + b2b Manage — the same level as the payment writes, because these files carry financial and personal content.
Not the same resource as the generated receipt.
GET /documents/contract_payment/:id.pdfconcerns the same payment but returns something else entirely — a generated metadata receipt that deliberately never readsdocument_bytes. This route returns the stored upload. Swapping one for the other yields the wrong bytes. See Documents.
Response
200 OK — the raw stored bytes.
Content-Type is the stored mime, so the response is not always a PDF. It is whichever of application/pdf, image/png or image/jpeg was uploaded.
Content-Disposition: attachment; filename="transfer-receipt.pdf"; filename*=UTF-8''transfer-receipt.pdfThe filename was client-supplied at upload with no character restriction, so the header carries an ASCII-sanitized quoted fallback plus the real name in RFC 5987 filename*. Clients must prefer filename* — the name can be Arabic.
Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | id or paymentId is not a valid UUID |
AUTH_FORBIDDEN | 403 | Caller lacks b2b Manage |
CONTRACT_PAYMENT_DOCUMENT_NOT_FOUND | 404 | No such payment on that contract, or it has no stored document |
GET /admin/links
Cross-company magic-link delivery oversight: a filterable table plus per-status tiles.
Requires role + b2b View. Read-only group.
Destinations are masked server-side. Raw tokens and token hashes never appear.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
status | string | — | issued, sent, exported, used, expired, failed or revoked |
search | string | — | Matches person name or company name |
page | number | 1 | Page number |
limit | number | 20 | 1–100 |
The status filter applies the same lazy-expiry rule the rows display: filtering issued never surfaces a link already past its expiresAt, and filtering expired catches those too, not only rows already written expired.
Response
200 OK
{
"data": {
"links": [
{
"id": "e0123456-789a-bcde-f012-901234567890",
"personName": "Omar Saleh",
"companyName": "Darmi Logistics",
"channel": "email",
"destination": "o•••@darmi.example",
"status": "sent",
"expiresAt": "2026-07-21T10:00:00.000Z",
"lastUpdate": "2026-07-20T10:00:00.000Z"
}
],
"total": 1,
"page": 1,
"limit": 20,
"stats": { "sent24h": 34, "used": 12, "exported": 8, "pending": 18, "failed": 1, "expired": 3 }
}
}channel is one of email, sms, email_sms, export or null. It is derived from the raw stored status: exported is the only mode the schema still distinguishes once a link is redeemed, so used, revoked and failed rows fall back to contact-presence derivation. The schema stores neither channel nor destination.
Masking keeps the email domain in full and only masks the local part with a fixed bullet count, so the mask never leaks the real length. A phone keeps a short prefix and the last two digits. An export channel has no contact to mask and renders the literal marker company_channel.
stats is cross-company and independent of the current filters. sent24h is a rolling window and approximates "delivery, not export" via the raw status.
GET /admin/links/export
The same filtered rows as CSV, newest first, with the same masking.
Requires role + b2b View. Audited in the service.
Accepts status and search. There is no pagination — the result is capped at 5000 rows server-side.
Response
200 OK
Content-Type: text/csv; charset=utf-8
Content-Disposition: attachment; filename="links.csv"
X-Truncated: trueColumns: person,company,channel,destination,status,expires_at,last_update.
X-Truncated: true is present only when the cap clipped the result. There is no marker row in the CSV body.