B2B Portal API
The /b2b routes serve external company staff — the people who run a company's own portal. Every route is mounted behind requireRole('b2b_admin'), so no internal staff role can reach them.
b2b_admin is the app-level role. Capability inside a company comes from a second, separate role on company_users:
company_users.role | Capability |
|---|---|
admin | Read and write everywhere in the portal |
member | Read-only viewer — allowed on every read, rejected on every write |
Portal Flow
- A super-admin invites the first portal user (see B2B Administration). That user has no company yet.
- The user registers the company via
POST /b2b/company. The company is createdpending. - An internal admin approves it. Until then every data route returns
403 COMPANY_NOT_APPROVED. - The company stages its beneficiaries from a CSV —
POST /b2b/roster/stage. - The company picks a payment mode —
PATCH /b2b/company/payment-mode—prepaidorflexible. Switchable freely until a contract exists. - Prepaid: the company builds a quote from the B2B catalog, submits it, and negotiates. An internal admin may counter, producing a new version the company answers — explicitly accepting it, or declining it with an optional reason, which ends the negotiation for good. Flexible: the company declares a funding amount instead — a zero-item quote request carrying
needs.fundingSar— and submits it the same way; a countered funding request is answered on the same two routes. - The internal admin confirms a contract from the agreed or declared version —
POST /admin/quotes/:id/confirm-contract, no body. The contract inherits the company's chosen mode: prepaid snapshots the quoted line items intototalSar, flexible snapshotsneeds.fundingSarastotalSarwith zero line items ever. - An internal admin records payment against the contract. Prepaid must be covered in full before readiness flips. Flexible accepts any positive amount — the initial funding and every later top-up are the same call.
GET /b2b/readinessreports the three gates. Once all three pass, the company may mint magic links for its roster.- Flexible companies pick packages from
GET /b2b/catalog— a browse-and-cart planning step that moves no money — and carry the picks into the roster as the batch default for minting. Prepaid companies mint straight against their contract lines. GET /b2b/billingandGET /b2b/dashboardreport what has been drawn and charged.
The portal (apps/admin) renders two flexible-only screens on top of this API: /b2b/funding (declare or top up the funding amount — amount only, the transfer stays offline; the pending card and every history row link through to that request's quote detail, which is where a countered funding request is accepted or declined) and /b2b/catalog (the browse-and-cart step in step 10). Both submit through the quote and billing endpoints documented below; neither is a separate API mount. The sidebar swaps Quotes for Catalog + Funding the moment a company picks the flexible mode.
Company Context
middleware/company-context.ts resolves the caller's company on every request. companyId is never accepted from the client — not as a body field, a query parameter, or a path segment. It is read from company_users by the authenticated userId at request time, never frozen into the JWT.
Two guards stack on top of that resolution:
requireCompanyMember(...roles)— the caller must beb2b_adminand have a membership row. Passing roles restricts further ('admin'for writes). Any failure is403 AUTH_FORBIDDEN.requireApprovedCompany()— the resolved company must beapproved.
requireApprovedCompany throws AppError, not AuthError, on purpose. AuthError maps non-forbidden codes to 401, which would trigger the client's auth-redirect and log the user out instead of routing them to the right screen.
| Company status | Code | Status |
|---|---|---|
pending | COMPANY_NOT_APPROVED | 403 |
suspended | COMPANY_SUSPENDED | 403 |
rejected | COMPANY_REJECTED | 403 |
GET /b2b/company deliberately carries neither guard. It resolves membership directly so a freshly invited user with no company yet gets company: null and can be routed to registration, instead of a 403.
Services still scope every query by the resolved companyId explicitly. Row-level security is inert for API traffic.
GET /b2b/company
The caller's company and their membership role. The portal's landing decision.
Requires b2b_admin. No membership or approval guard.
Response
200 OK — with a company:
{
"data": {
"company": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"crNumber": "1010101010",
"name": "Darmi Logistics",
"contactName": "Layla Ahmed",
"contactEmail": "layla@darmi.example",
"contactPhone": "+966501234567",
"status": "approved",
"paymentMode": null,
"approvedBy": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"approvedAt": "2026-07-20T07:10:00.000Z",
"createdAt": "2026-07-18T09:00:00.000Z",
"updatedAt": "2026-07-20T07:10:00.000Z"
},
"role": "admin"
}
}200 OK — invited but not yet registered:
{ "data": { "company": null, "role": null } }Error Responses
| Code | Status | Condition |
|---|---|---|
AUTH_REQUIRED | 401 | Missing token |
AUTH_FORBIDDEN | 403 | Caller is not b2b_admin |
POST /b2b/company
Register the caller's company. Creates the company pending and the caller's admin membership in one transaction. Only an internal admin can move it to approved.
Requires b2b_admin.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
inKsa | boolean | Yes | Whether the company is registered inside Saudi Arabia |
crNumber | string | Yes | Commercial registration number — see below |
name | string | Yes | Company name (1–255 characters) |
contactName | string | Yes | Primary contact (1–255 characters) |
contactEmail | string | Yes | Contact email |
contactPhone | string | Yes | E.164 phone, any country — not Saudi-only |
inKsa is validation-only and is never persisted. It selects which crNumber rule applies: true requires exactly 10 digits, false accepts a free-format registration number of 3–30 trimmed characters. CompanyService.registerCompany inserts only crNumber, name, contactName, contactEmail and contactPhone — there is no in_ksa column, and the value cannot be read back from any endpoint.
Example Request
{
"inKsa": true,
"crNumber": "1010101010",
"name": "Darmi Logistics",
"contactName": "Layla Ahmed",
"contactEmail": "layla@darmi.example",
"contactPhone": "+966501234567"
}Response
201 Created — { "data": { "company": { ... }, "role": "admin" } }, the company with status: "pending".
Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | Schema validation failed, including the inKsa-conditional crNumber rule |
AUTH_FORBIDDEN | 403 | Caller is not b2b_admin |
COMPANY_ALREADY_REGISTERED | 409 | The caller already belongs to a company |
COMPANY_CR_EXISTS | 409 | That CR number is already registered |
COMPANY_CREATE_FAILED | 502 | The insert returned no row |
PATCH /b2b/company/payment-mode
Pick or switch the company's payment mode.
Requires company admin, approved company.
Lives on the wizard's needs step in the portal. Switchable freely until a contract exists — picking flexible then confirming a contract locks the mode; picking prepaid then confirming a contract does the same. The internal admin never chooses this — the confirm-contract step only ever inherits it.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
paymentMode | string | Yes | prepaid or flexible |
Response
200 OK — { "data": { "company": { ... } } }, the company with the new paymentMode.
Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | paymentMode missing or not one of the two values |
AUTH_FORBIDDEN | 403 | Caller is a member viewer |
COMPANY_NOT_APPROVED | 403 | Company not approved |
COMPANY_NOT_FOUND | 404 | The resolved company no longer exists |
CONTRACT_ALREADY_EXISTS | 409 | The company already has an active contract — the mode is locked |
GET /b2b/roster
The company's beneficiaries, newest first.
Requires company admin or member, approved company.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
status | string | — | staged or enabled |
linkStatus | string | — | none, issued, sent, exported, used, expired, failed, revoked. none means no link was ever issued. used spans both the activated and the opened-but-unfinished — there is no filter that separates them; use the entry's activated boolean. |
search | string | — | Exact phone or email match, not a substring — see below |
page | number | 1 | Page number |
limit | number | 20 | 1–100 |
users.name, phone and email are encrypted with random IVs, so substring search over them is impossible in SQL. The search term is trimmed, SHA-256 hashed, and matched against the stored phoneHash/emailHash.
linkStatus has no SQL representation either — it is derived per row from the latest magic link and filtered in JS.
Response
200 OK
{
"data": {
"entries": [
{
"id": "c3d4e5f6-a7b8-9012-cdef-123456789012",
"companyId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"userId": "d4e5f6a7-b8c9-0123-defa-234567890123",
"nationality": "SA",
"status": "staged",
"packageId": null,
"createdBy": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"createdAt": "2026-07-19T10:00:00.000Z",
"updatedAt": "2026-07-19T10:00:00.000Z",
"name": "Omar Saleh",
"phone": "+966501112223",
"email": null,
"identityType": "citizen",
"linkStatus": null,
"linkExpiresAt": null,
"linkCreatedAt": null,
"activated": false
}
],
"total": 1,
"page": 1,
"limit": 20
}
}A link past its expiresAt reads as expired here even before its stored status column flips. Expiry is applied lazily on read.
activated is read from the person's latest activation, not from any link. linkStatus: "used" only means the token was exchanged for a session, so it cannot on its own separate someone holding a live SIM from someone who opened the link and walked away. Those two need opposite handling: the first must not be sent another link, the second must.
GET /b2b/roster/export
The whole roster and its link states as CSV. No pagination and no filters — an export is the entire roster. Audited as company.roster_export.
Requires company admin or member, approved company.
Response
200 OK
Content-Type: text/csv; charset=utf-8
Content-Disposition: attachment; filename="people.csv"Columns: name,contact,identity_type,nationality,roster_status,link_status,link_expires_at,package,created_at.
contact is the person's own unmasked email or phone — the company owns its own roster's contacts. The response carries no id, token or link column. package is the English catalog name of the package assigned at magic-link mint time, empty until a link has been minted.
POST /b2b/roster/stage
Stage a parsed CSV batch. Partial accept: the request succeeds with 200 even when rows reject. Per-row problems are data in the response body, never an HTTP error.
Requires company admin, approved company.
Staging is side-effect free — it writes a users row and a company_roster row with status staged, and nothing else. No magic links, no quota, no orders.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
rows | array | Yes | 1–500 row objects. Split larger rosters across uploads. |
Each row is validated individually so one malformed row rejects only itself:
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | 1–255 characters |
phone | string | Conditional | E.164, any country. At least one of phone or email. |
email | string | Conditional | At least one of phone or email. |
identityType | string | Yes | citizen, resident or visitor |
nationality | string | No | 100 characters or less |
The CSV deliberately carries no ID-number field.
Response
200 OK
{
"data": {
"staged": 2,
"rejected": [
{
"rowNumber": 3,
"field": "phone",
"code": "ROSTER_ROW_INVALID_PHONE",
"messageEn": "Phone must be E.164 format, e.g. +9665XXXXXXXX",
"messageAr": "يجب أن يكون رقم الهاتف بصيغة E.164، مثل +9665XXXXXXXX"
}
]
}
}rowNumber is the 1-based index in the submitted array. rejected is sorted by rowNumber. Every row error carries both messageEn and messageAr.
Row Rejection Codes
| Code | Meaning |
|---|---|
ROSTER_ROW_INVALID_NAME | Name missing or unusable, or the row is not an object |
ROSTER_ROW_INVALID_PHONE | Phone present but not E.164 |
ROSTER_ROW_INVALID_EMAIL | Email format invalid |
ROSTER_ROW_INVALID_IDENTITY_TYPE | Not citizen/resident/visitor |
ROSTER_ROW_INVALID_NATIONALITY | Over 100 characters |
ROSTER_ROW_MISSING_CONTACT | Neither phone nor email supplied |
ROSTER_ROW_DUPLICATE_IN_BATCH | The same contact appears twice in this upload |
ROSTER_ROW_ALREADY_ON_ROSTER | Already on this company's roster — makes re-uploading the same file safe |
ROSTER_ROW_CONTACT_EXISTS | Contact registered elsewhere in the system |
Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | Batch shape invalid (empty, or over 500 rows) |
AUTH_FORBIDDEN | 403 | Caller is a member viewer |
COMPANY_NOT_APPROVED | 403 | Company not approved |
POST /b2b/roster/links/send
Mint fresh magic links for the selected roster entries and attempt delivery by email/SMS.
Requires company admin, approved company.
Each mint issues a new link and revokes that person's prior active link. Only the token hash is stored, so a raw link can never be re-sent — only re-issued. Links live for 24 hours.
Selecting someone who redeemed their link but never finished activating re-mints them rather than skipping them — that is what rescues an abandoned seat. The revoke targets active links only, so the redeemed row survives, but because status is read from the newest link row that person's reported roster status returns to pending. They keep their existing contract line and do not draw a second seat.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
rosterIds | string[] | Yes | 1–500 roster entry UUIDs |
contractItemId | string | No | Batch-default contract line (prepaid). Applied to every selected entry that does not already consume a seat. |
packageId | string | No | Batch-default catalog package (flexible). A flexible contract has no lines, so this is the flexible counterpart of contractItemId — same already-consuming-seat exemption. |
Response
200 OK
{
"data": {
"sent": 12,
"failed": [
{
"rosterId": "c3d4e5f6-a7b8-9012-cdef-123456789012",
"code": "ACTIVATION_QUOTA_EXCEEDED",
"messageEn": "...",
"messageAr": "..."
}
]
}
}An entry that could not be minted and one that delivery could not reach are both "this person has no working invite", so they are merged into one failed array, sorted by rosterId.
Per-Entry Failure Codes
| Code | Meaning |
|---|---|
ROSTER_ENTRY_NOT_FOUND | The id does not resolve against this company's roster |
LINK_USER_ALREADY_ACTIVE | The person's latest activation reached activated. They cannot redeem again, so a fresh link would be dead on arrival while revoking the one they hold. Someone who redeemed but never finished activating is not in this state and is re-minted normally — that is what rescues an abandoned seat. |
LINK_PACKAGE_NOT_ASSIGNED | No contractItemId/packageId given and the row carries no existing assignment |
ACTIVATION_QUOTA_EXCEEDED | The contract line's remaining seats are exhausted (prepaid) |
COMPANY_BALANCE_EXCEEDED | The flexible balance gate rejected this seat — funded minus drawn minus committed minus accepted-so-far in this batch would go below the line's price |
LINK_PACKAGE_UNAVAILABLE | Flexible: the row's (or the batch override's) package has since had its priceSarB2b unset — nothing to snapshot |
LINK_DELIVERY_FAILED | The link was minted but email/SMS delivery failed |
Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | Body invalid |
AUTH_FORBIDDEN | 403 | Caller is a member viewer |
COMPANY_NOT_APPROVED | 403 | Company not approved |
CONTRACT_ITEM_NOT_FOUND | 404 | contractItemId is not a line on this company's active contract |
PACKAGE_NOT_FOUND | 404 | packageId is not in the live B2B catalog (flexible batch override) |
COMPANY_NOT_READY | 409 | No active contract, or payment not yet satisfied |
POST /b2b/roster/links/export
Mint links and return them raw, for the company to distribute over its own channel.
Requires company admin, approved company.
Raw links appear in only two responses, this one and reissue-expired. Only the hash is persisted, and the mint audit records counts, never tokens. Never log either response.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
rosterIds | string[] | No | 1–500 entries. Omit for every eligible roster entry. |
contractItemId | string | No | Same batch-default semantics as send |
packageId | string | No | Same flexible batch-default semantics as send |
Response
200 OK
{
"data": {
"entries": [
{
"rosterId": "c3d4e5f6-a7b8-9012-cdef-123456789012",
"name": "Omar Saleh",
"contact": "+966501112223",
"link": "https://app.skyte.sa/activate?token=...",
"expiresAt": "2026-07-21T10:00:00.000Z"
}
],
"skipped": []
}
}skipped uses the same per-entry codes as send, minus LINK_DELIVERY_FAILED (nothing is delivered here). Error responses match send.
POST /b2b/roster/links/reissue-expired
Regenerate every expired link for the company in one action. No request body.
Requires company admin, approved company.
Selection happens server-side — the company's roster entries whose latest link is effectively expired, lazy expiry included. Client-supplied ids are not accepted. Each is re-minted through the normal export pipeline, so quota, the flexible balance gate and per-entry partial accept all apply unchanged. An entry whose person is already activated is skipped with LINK_USER_ALREADY_ACTIVE rather than re-issued. History is preserved: a fresh link row is appended and the prior one revoked.
Nothing expired returns an empty result with no mint and no audit entry.
Response
200 OK — the same { entries, skipped } shape as /roster/links/export, carrying raw links. Never log it.
POST /b2b/roster/links/revoke
Cancel the open invitations of the selected beneficiaries. The explicit counterpart to sending.
Requires company admin, approved company.
An open invitation blocks changing or removing that beneficiary, and this is how the admin clears it. Revoking frees the seat immediately: quota and the flexible balance count redeemed links and still-live ones, and a revoked link is neither. No balance is drawn and none is refunded.
A beneficiary who has ever redeemed a link is never revocable. Redemption consumes the seat permanently, even once that link is past its own expiry.
A row that does have active links flips all of them, including a stale issued/sent/exported row already past its expiresAt. That only writes down what the reader already reports as expired. The end user is not notified. Audited once per call as company.links_revoke, counts only.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
rosterIds | string[] | Yes | 1–500 roster entry UUIDs |
Response
200 OK
{
"data": {
"revoked": [{ "rosterId": "c3d4e5f6-a7b8-9012-cdef-123456789012" }],
"skipped": [
{
"rosterId": "d4e5f6a7-b8c9-0123-defa-234567890123",
"code": "LINK_ALREADY_USED",
"messageEn": "This invitation was already redeemed",
"messageAr": "تم استخدام رابط الدعوة بالفعل"
}
]
}
}Partial accept, same idiom as send and export — a row is never an error, and skips carry the same bilingual shape as the mint routes' failed array.
Per-Entry Skip Codes
| Code | Meaning | English Message | Arabic Message |
|---|---|---|---|
ROSTER_ENTRY_NOT_FOUND | The id does not resolve against this company's roster | Person not found on your roster | الشخص غير موجود في قائمتك |
LINK_ALREADY_USED | The beneficiary redeemed a link — the seat is permanently theirs | This invitation was already redeemed | تم استخدام رابط الدعوة بالفعل |
NO_ACTIVE_LINK | Nothing was open to revoke | No open invitation link to revoke | لا يوجد رابط دعوة مفتوح للإلغاء |
Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | Body invalid — empty or over 500 ids |
AUTH_FORBIDDEN | 403 | Caller is a member viewer |
COMPANY_NOT_APPROVED | 403 | Company not approved |
PATCH /b2b/roster/:rosterId
Change or clear one beneficiary's package assignment. Standalone — not tied to sending them anything.
Requires company admin, approved company.
Unlike the batch defaults on send and export, this is the only way to fix an assignment after the first one. Exactly one field must be present, and which one applies depends on the company's payment mode: packageId for flexible, contractItemId for prepaid. A company that has not picked a mode yet counts as prepaid. The wrong field for the mode is a 400, not a silent no-op. Either field may be null to clear the assignment.
The write always clears the price snapshot. company_roster.unitPriceSar is only ever taken at link mint, so a repointed seat re-snapshots the current B2B price at the next mint instead of carrying the previous package's. A prepaid write also denormalizes the line's package onto the roster row, the same pairing the mint writes, so the roster view never shows a package that disagrees with the assigned line.
Two guards run before any write. The beneficiary must never have redeemed a link, and no invitation may still be open — see the shared seat guards below. Audited as company.roster_assign.
Path Parameters
| Parameter | Type | Description |
|---|---|---|
rosterId | string | Roster entry UUID |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
packageId | string | null | Exactly one | Flexible only — a live B2B-priced catalog package, or null to clear |
contractItemId | string | null | Exactly one | Prepaid only — a line on the company's active contract, or null to clear |
Response
200 OK — the updated roster entry, same shape as one entries row of GET /b2b/roster.
Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | Neither or both fields supplied, or an invalid id |
VALIDATION_INVALID_INPUT | 400 | The wrong field for the company's payment mode |
AUTH_FORBIDDEN | 403 | Caller is a member viewer |
COMPANY_NOT_APPROVED | 403 | Company not approved |
ROSTER_ENTRY_NOT_FOUND | 404 | Unknown entry, or one belonging to another company |
PACKAGE_NOT_FOUND | 404 | packageId is not a live B2B-priced catalog package |
CONTRACT_ITEM_NOT_FOUND | 404 | contractItemId is not a line on this company's active contract |
ROSTER_LINK_OPEN | 409 | A live invitation still holds this seat — revoke it first |
ROSTER_SEAT_CONSUMED | 409 | The beneficiary redeemed their invitation — the assignment is permanent |
DELETE /b2b/roster/:rosterId
Remove one beneficiary from the roster entirely. No request body.
Requires company admin, approved company.
A hard delete, not a tombstone: link history, the roster row, and the end-user account that staging eagerly created. That account only ever existed because the company uploaded them, so removal means removal. Sessions are deleted first so no live token outlives the account.
The same two guards as the assignment edit apply. A last re-check then refuses the whole removal, also as ROSTER_SEAT_CONSUMED, if the account is no longer exactly what staging created — an end_user of this company with no activation ever attached. Audited as company.roster_remove.
Path Parameters
| Parameter | Type | Description |
|---|---|---|
rosterId | string | Roster entry UUID |
Response
200 OK
{ "data": { "removed": true } }Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | Invalid rosterId |
AUTH_FORBIDDEN | 403 | Caller is a member viewer |
COMPANY_NOT_APPROVED | 403 | Company not approved |
ROSTER_ENTRY_NOT_FOUND | 404 | Unknown entry, or one belonging to another company |
ROSTER_LINK_OPEN | 409 | A live invitation still holds this seat — revoke it first |
ROSTER_SEAT_CONSUMED | 409 | The beneficiary redeemed their invitation, or already has activation history |
Roster Seat Guards
Both mutating routes above share one guard, and the boundary is deliberate:
- Ever redeemed →
409 ROSTER_SEAT_CONSUMED. Permanent, even after that link's own expiry, matching how seat consumption is counted for quota and balance. A redeemed-and-abandoned seat therefore stays held. - A live, unexpired
issued/sent/exportedlink →409 ROSTER_LINK_OPEN. Cleared byPOST /b2b/roster/links/revoke. - A stale active-status row past its expiry no longer holds the seat, so it never blocks. Same lazy-expiry rule the roster list reads by.
The portal shows this proactively — while an invitation is open the assignment picker is locked and a Revoke button sits next to it. The two 409s are the race backstop, not the primary UX.
GET /b2b/catalog
The B2B-priced catalog the quote wizard suggests from. Every active package with a non-null priceSarB2b, cheapest first.
Requires company admin or member, approved company.
The public /packages list deliberately omits priceSarB2b, which is why the B2B-priced view sits behind the company-member gate instead. Ranking against the needs form happens client-side.
name, description and operatorName are resolved from Accept-Language.
Response
200 OK
{
"data": [
{
"id": "e5f6a7b8-c901-2345-efab-345678901234",
"name": "Business 20GB",
"description": "20GB data with 500 minutes",
"dataAmountMb": 20480,
"voiceMinutes": 500,
"smsCount": 100,
"validityDays": 30,
"priceSarB2b": 129.0,
"operatorName": "STC"
}
]
}operatorName is present so the wizard can tell same-named packages from different operators apart.
GET /b2b/quotes
The company's negotiations, newest first. One entry per negotiation chain, not per version.
Requires company admin or member, approved company.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
status | string | — | Matches the chain's live version status |
page | number | 1 | Page number |
limit | number | 20 | 1–100 |
Filtering by countered never matches: a countered version always has a successor, so it only ever appears in history.
Response
200 OK
{
"data": {
"chains": [
{
"ref": "Q-a1b2c3d4e5f6",
"live": {
"id": "f6a7b8c9-0123-4567-fabc-456789012345",
"companyId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"version": 2,
"parentId": "0a1b2c3d-4e5f-6789-0abc-def123456789",
"needs": { "userCount": 40, "preference": "data", "budgetSar": 6000 },
"status": "under_review",
"note": "Adjusted to the 20GB tier",
"companyNote": null,
"reviewedBy": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"reviewedAt": "2026-07-19T12:00:00.000Z",
"createdBy": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"createdAt": "2026-07-19T12:00:00.000Z",
"updatedAt": "2026-07-19T12:00:00.000Z"
},
"history": []
}
],
"total": 1,
"page": 1,
"limit": 20
}
}ref is derived from the chain root's id (Q- plus the first 12 hex characters), so it never changes as counters add versions. It is a display identifier — nothing looks a quote up by it.
GET /b2b/quotes/:id
One version with its items and its full version chain.
Requires company admin or member, approved company.
A quote belonging to another company returns QUOTE_NOT_FOUND, never a 403 — existence is not leaked.
Response
200 OK — the version summary plus:
{
"data": {
"items": [{ "id": "...", "packageId": "...", "qty": 40 }],
"versions": [
{ "id": "...", "version": 1, "status": "countered", "createdAt": "2026-07-19T11:00:00.000Z" },
{ "id": "...", "version": 2, "status": "under_review", "createdAt": "2026-07-19T12:00:00.000Z" }
]
}
}versions is oldest first.
Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | id is not a valid UUID |
QUOTE_NOT_FOUND | 404 | No such quote, or it belongs to another company |
POST /b2b/quotes
Create a draft. Items may be empty — the admin browses the suggested catalog first, or (flexible) the draft is a funding request and never gets line items.
Requires company admin, approved company.
The portal never sends prices. Line items are package references and quantities only.
needs is a union, not one shape with optional fields: the userCount/preference/budgetSar trio (with fundingSar optionally alongside it), OR fundingSar alone. A company that skipped the needs form and only wants to declare a funding amount (the /b2b/funding screen) sends the second shape.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
needs | object | No | Needs-form answers. Skippable in full. When the trio is given, all three of userCount/preference/budgetSar are required together. |
needs.userCount | number | Conditional | Positive integer |
needs.preference | string | Conditional | minutes, data or mixed |
needs.budgetSar | number | Conditional | Positive number |
needs.fundingSar | number | Conditional | Positive number, at most 99,999,999.99. Flexible pay: the declared funding or top-up amount. May accompany the trio, or stand alone. |
items | array | Yes | Up to 200 entries. May be empty. |
items[].packageId | string | Yes | Catalog package UUID |
items[].qty | number | Yes | Positive integer, 10000 or less |
Response
201 Created — the created draft in the detail shape.
Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | Body invalid |
QUOTE_PACKAGE_NOT_FOUND | 400 | One or more packageIds do not exist or are not B2B-orderable — inactive or no B2B price (the offending ids are in the error details) |
AUTH_FORBIDDEN | 403 | Caller is a member viewer |
QUOTE_CREATE_FAILED | 502 | The insert returned no row |
PATCH /b2b/quotes/:id
Replace a draft's needs and items wholesale. Allowed on draft status only — a quote is immutable once submitted.
Requires company admin, approved company.
Body matches POST /b2b/quotes.
Response
200 OK — the updated draft.
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 for this company |
QUOTE_INVALID_STATE | 409 | The quote is no longer a draft |
POST /b2b/quotes/:id/submit
draft → submitted. No request body.
Requires company admin, approved company.
The service validates the already-persisted items, not client input, and requires at least one — unless needs.fundingSar is set, in which case a zero-item draft submits as a funding/top-up request. This is the last write a company makes to its own version.
Response
200 OK — the submitted quote.
Error Responses
| Code | Status | Condition |
|---|---|---|
QUOTE_NOT_FOUND | 404 | No such quote for this company |
QUOTE_INVALID_STATE | 409 | Not a draft, or the draft has no items and no declared fundingSar |
POST /b2b/quotes/:id/accept
Accept an admin-countered version. No request body.
Requires company admin, approved company.
Only valid on an admin-authored version — one with a non-null parentId, produced by a counter — that is still under_review. Submitting your own numbers already counts as accepting them, so a company-authored version is never accepted this way.
Response
200 OK — the accepted version.
Error Responses
| Code | Status | Condition |
|---|---|---|
QUOTE_NOT_FOUND | 404 | No such quote for this company |
QUOTE_INVALID_STATE | 409 | Company-authored version, or not under_review |
QUOTE_VERSION_SUPERSEDED | 409 | A later counter has superseded this exact version |
POST /b2b/quotes/:id/reject
Decline an admin-countered version — the other answer to the question accept answers.
Requires company admin, approved company.
Eligibility is identical to accept: an admin-authored version (non-null parentId) still under_review. Declining your own draft or submission is a different act (withdraw) and is not offered here.
rejected is terminal and cannot be countered again, so this ends the negotiation. The company's next move is a fresh quote.
Request
{
"note": "Over our budget for this quarter"
}| Field | Type | Notes |
|---|---|---|
note | string, ≤500 chars | Optional. Send {} to decline without a reason. |
The reason is stored in companyNote, never in note. note holds the reviewer's counter note — the very offer being declined — so writing a decline reason there would erase it and misattribute the company's words to the admin. Omitting note leaves any existing company note untouched.
Audited as company.quote_decline, distinct from the internal admin's company.quote_reject, so the trail records who ended the chain.
Response
200 OK — the declined version, status: "rejected".
Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | Note longer than 500 characters, or a malformed id |
QUOTE_NOT_FOUND | 404 | No such quote for this company |
QUOTE_INVALID_STATE | 409 | Company-authored version, or not under_review |
QUOTE_VERSION_SUPERSEDED | 409 | A later counter has superseded this exact version |
GET /b2b/contract
The company's active contract with its line items, or null when none is confirmed yet.
Requires company admin or member, approved company.
Read-only in the portal. Payment is admin-only, and payment documents never appear here.
Response
200 OK
{
"data": {
"id": "a7b8c901-2345-6789-abcd-567890123456",
"ref": "CTR-a7b8c9012345",
"companyId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"quoteRequestId": "f6a7b8c9-0123-4567-fabc-456789012345",
"paymentMode": "prepaid",
"status": "active",
"totalSar": 5160.0,
"confirmedBy": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"confirmedAt": "2026-07-19T13:00:00.000Z",
"createdAt": "2026-07-19T13:00:00.000Z",
"updatedAt": "2026-07-19T13:00:00.000Z",
"items": [
{
"id": "b8c90123-4567-89ab-cdef-678901234567",
"packageId": "e5f6a7b8-c901-2345-efab-345678901234",
"qty": 40,
"unitPriceSar": 129.0,
"consumedQty": 12,
"remainingQty": 28
}
],
"totalQty": 40,
"rosterCount": 45,
"driftExcess": 5
}
}unitPriceSar is a snapshot of packages.priceSarB2b taken at confirmation. Later catalog price changes affect new contracts only.
A seat is consumed by a link that is either used, or still active and not past its expiresAt. driftExcess is max(0, rosterCount - totalQty) — roster rows beyond what the contract currently covers.
Flexible contracts always report items: [], totalQty: 0 and driftExcess: 0. A flexible contract IS the declared funding amount — totalSar — and never holds line items. Packages are picked from the catalog and assigned at magic-link mint time instead, priced from company_roster.unitPriceSar (see GET /b2b/catalog and the /roster/links/send batch-default packageId), not from anything on this contract.
GET /b2b/readiness
The three server-computed gates that must all pass before links may be minted.
Requires company admin or member, approved company.
Response
200 OK
{
"data": {
"contractConfirmed": true,
"paymentSatisfied": true,
"rosterStaged": true,
"canSendLinks": true
}
}| Field | True when |
|---|---|
contractConfirmed | An active contract exists for the company |
paymentSatisfied | Recorded payments sum to at least the contract total — same rule in both modes. For flexible, totalSar IS the declared funding, so this reads as "the declared amount landed." No active contract: always false. |
rosterStaged | At least one company_roster row exists |
canSendLinks | All three above |
canSendLinks is computed server-side and is not re-derived client-side. When it flips from false to true, the company's portal admins get a best-effort bilingual notice by email and SMS. It fires on the flip only, so a second payment never re-notifies.
GET /b2b/dashboard
Counts and a recent activity feed for the portal home screen.
Requires company admin or member, approved company.
Every figure is company-tenancy only — counts, the company's own roster names, link statuses and timestamps. No end-user identity data, and no token column is selected.
Response
200 OK
{
"data": {
"hasLaunched": true,
"stats": { "invitesSent30d": 34, "activated": 12, "pending": 18, "failed": 1 },
"activity": [
{ "kind": "used", "personName": "Omar Saleh", "at": "2026-07-19T14:20:00.000Z" }
]
}
}| Field | Meaning |
|---|---|
hasLaunched | The company has ever minted a magic link. Drives the pre-launch / post-launch mode switch. |
invitesSent30d | Links minted, send or export, in the last 30 days |
activated | Beneficiaries whose latest link is used. A link-outcome tile, deliberately broader than the roster entry's activated boolean |
pending | Beneficiaries whose latest link is still live and unredeemed |
failed | Beneficiaries whose latest link delivery failed |
The outcome tiles bucket the latest link per person. activity is the 8 most recent link events. A person mid-activation counts in this activated tile from the company's vantage — the operator, not Skyte, owns what happens after redemption. Do not confuse it with GET /b2b/roster's per-entry activated, which reads the activation record and excludes exactly those people.
The three tiles do not sum to the roster size. A person whose latest link was revoked or has expired matches none of them, so they are absent from all three counts while still appearing in the activity feed.
GET /b2b/billing
The billing screen. Read-only for both roles — payment is offline and the portal never charges.
Requires company admin or member, approved company.
Package names in charges are resolved from Accept-Language.
A company with no active contract gets the zeroed shape below, never a 404 — a company mid-registration may open Billing.
Response
200 OK
{
"data": {
"mode": "flexible",
"outstandingSar": 1548.0,
"accruedThisMonthSar": 645.0,
"contractTotalSar": 5160.0,
"paidSar": 5160.0,
"availableSar": 3612.0,
"activeLines": [
{ "packageId": "e5f6a7b8-c901-2345-efab-345678901234", "nameEn": "Business 20GB", "nameAr": "أعمال 20 جيجابايت", "count": 12, "drawnSar": 1548.0 }
],
"latestPayment": {
"id": "c9012345-6789-abcd-ef01-789012345678",
"documentName": "transfer-receipt.pdf",
"amountSar": 5160.0,
"recordedByName": "Platform Admin",
"recordedAt": "2026-07-19T13:30:00.000Z"
},
"payments": [
{
"id": "c9012345-6789-abcd-ef01-789012345678",
"documentName": "transfer-receipt.pdf",
"amountSar": 5160.0,
"recordedByName": "Platform Admin",
"recordedAt": "2026-07-19T13:30:00.000Z"
}
],
"linesDrawn": { "consumed": 12, "total": 40 },
"charges": [
{
"date": "2026-07-19T14:20:00.000Z",
"personName": "Omar Saleh",
"packageName": "Business 20GB",
"amountSar": 129.0,
"paymentId": "d0123456-789a-bcde-f012-890123456789"
}
]
}
}charges and outstandingSar derive from the same live-order set, so the table always sums to outstandingSar exactly. For flexible, outstandingSar is the drawn total and paidSar is the funded total — the same field names as prepaid, reused rather than renamed. accruedThisMonthSar is the subset dated in the current calendar month (UTC).
availableSar (flexible only, null for prepaid) is paidSar - outstandingSar — funded minus drawn. activeLines (flexible only, empty for prepaid) is one entry per package with a live activated line: count and drawnSar over that package. linesDrawn stays the prepaid consumed/total pair — it is { consumed: 0, total: 0 } for flexible, where activeLines is the meaningful figure instead.
payments lists every contract payment, newest first. latestPayment is payments[0]. Both carry the document name only — bytes are never exposed here.
charges[].paymentId and payments[].id are the entity ids for downloading the corresponding receipt PDF — see Documents. paymentId is null on the rare charge order with no matching payment row, and clients must not assume it is present.
GET /b2b/billing/export
The same charge rows as CSV, in the same order. Audited as company.billing_export — financial data leaving the portal.
Requires company admin or member, approved company.
Response
200 OK
Content-Type: text/csv; charset=utf-8
Content-Disposition: attachment; filename="charges.csv"Columns: date,person,package,amount_sar.