Skip to content

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.roleCapability
adminRead and write everywhere in the portal
memberRead-only viewer — allowed on every read, rejected on every write

Portal Flow

  1. A super-admin invites the first portal user (see B2B Administration). That user has no company yet.
  2. The user registers the company via POST /b2b/company. The company is created pending.
  3. An internal admin approves it. Until then every data route returns 403 COMPANY_NOT_APPROVED.
  4. The company stages its beneficiaries from a CSV — POST /b2b/roster/stage.
  5. The company picks a payment mode — PATCH /b2b/company/payment-modeprepaid or flexible. Switchable freely until a contract exists.
  6. 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.
  7. 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 into totalSar, flexible snapshots needs.fundingSar as totalSar with zero line items ever.
  8. 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.
  9. GET /b2b/readiness reports the three gates. Once all three pass, the company may mint magic links for its roster.
  10. 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.
  11. GET /b2b/billing and GET /b2b/dashboard report 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 be b2b_admin and have a membership row. Passing roles restricts further ('admin' for writes). Any failure is 403 AUTH_FORBIDDEN.
  • requireApprovedCompany() — the resolved company must be approved.

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 statusCodeStatus
pendingCOMPANY_NOT_APPROVED403
suspendedCOMPANY_SUSPENDED403
rejectedCOMPANY_REJECTED403

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:

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

json
{ "data": { "company": null, "role": null } }

Error Responses

CodeStatusCondition
AUTH_REQUIRED401Missing token
AUTH_FORBIDDEN403Caller 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

FieldTypeRequiredDescription
inKsabooleanYesWhether the company is registered inside Saudi Arabia
crNumberstringYesCommercial registration number — see below
namestringYesCompany name (1–255 characters)
contactNamestringYesPrimary contact (1–255 characters)
contactEmailstringYesContact email
contactPhonestringYesE.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

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

CodeStatusCondition
VALIDATION_ERROR400Schema validation failed, including the inKsa-conditional crNumber rule
AUTH_FORBIDDEN403Caller is not b2b_admin
COMPANY_ALREADY_REGISTERED409The caller already belongs to a company
COMPANY_CR_EXISTS409That CR number is already registered
COMPANY_CREATE_FAILED502The 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

FieldTypeRequiredDescription
paymentModestringYesprepaid or flexible

Response

200 OK{ "data": { "company": { ... } } }, the company with the new paymentMode.

Error Responses

CodeStatusCondition
VALIDATION_ERROR400paymentMode missing or not one of the two values
AUTH_FORBIDDEN403Caller is a member viewer
COMPANY_NOT_APPROVED403Company not approved
COMPANY_NOT_FOUND404The resolved company no longer exists
CONTRACT_ALREADY_EXISTS409The 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

ParameterTypeDefaultDescription
statusstringstaged or enabled
linkStatusstringnone, 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.
searchstringExact phone or email match, not a substring — see below
pagenumber1Page number
limitnumber201–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

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

FieldTypeRequiredDescription
rowsarrayYes1–500 row objects. Split larger rosters across uploads.

Each row is validated individually so one malformed row rejects only itself:

FieldTypeRequiredDescription
namestringYes1–255 characters
phonestringConditionalE.164, any country. At least one of phone or email.
emailstringConditionalAt least one of phone or email.
identityTypestringYescitizen, resident or visitor
nationalitystringNo100 characters or less

The CSV deliberately carries no ID-number field.

Response

200 OK

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

CodeMeaning
ROSTER_ROW_INVALID_NAMEName missing or unusable, or the row is not an object
ROSTER_ROW_INVALID_PHONEPhone present but not E.164
ROSTER_ROW_INVALID_EMAILEmail format invalid
ROSTER_ROW_INVALID_IDENTITY_TYPENot citizen/resident/visitor
ROSTER_ROW_INVALID_NATIONALITYOver 100 characters
ROSTER_ROW_MISSING_CONTACTNeither phone nor email supplied
ROSTER_ROW_DUPLICATE_IN_BATCHThe same contact appears twice in this upload
ROSTER_ROW_ALREADY_ON_ROSTERAlready on this company's roster — makes re-uploading the same file safe
ROSTER_ROW_CONTACT_EXISTSContact registered elsewhere in the system

Error Responses

CodeStatusCondition
VALIDATION_ERROR400Batch shape invalid (empty, or over 500 rows)
AUTH_FORBIDDEN403Caller is a member viewer
COMPANY_NOT_APPROVED403Company 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

FieldTypeRequiredDescription
rosterIdsstring[]Yes1–500 roster entry UUIDs
contractItemIdstringNoBatch-default contract line (prepaid). Applied to every selected entry that does not already consume a seat.
packageIdstringNoBatch-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

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

CodeMeaning
ROSTER_ENTRY_NOT_FOUNDThe id does not resolve against this company's roster
LINK_USER_ALREADY_ACTIVEThe 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_ASSIGNEDNo contractItemId/packageId given and the row carries no existing assignment
ACTIVATION_QUOTA_EXCEEDEDThe contract line's remaining seats are exhausted (prepaid)
COMPANY_BALANCE_EXCEEDEDThe 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_UNAVAILABLEFlexible: the row's (or the batch override's) package has since had its priceSarB2b unset — nothing to snapshot
LINK_DELIVERY_FAILEDThe link was minted but email/SMS delivery failed

Error Responses

CodeStatusCondition
VALIDATION_ERROR400Body invalid
AUTH_FORBIDDEN403Caller is a member viewer
COMPANY_NOT_APPROVED403Company not approved
CONTRACT_ITEM_NOT_FOUND404contractItemId is not a line on this company's active contract
PACKAGE_NOT_FOUND404packageId is not in the live B2B catalog (flexible batch override)
COMPANY_NOT_READY409No 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

FieldTypeRequiredDescription
rosterIdsstring[]No1–500 entries. Omit for every eligible roster entry.
contractItemIdstringNoSame batch-default semantics as send
packageIdstringNoSame flexible batch-default semantics as send

Response

200 OK

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

FieldTypeRequiredDescription
rosterIdsstring[]Yes1–500 roster entry UUIDs

Response

200 OK

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

CodeMeaningEnglish MessageArabic Message
ROSTER_ENTRY_NOT_FOUNDThe id does not resolve against this company's rosterPerson not found on your rosterالشخص غير موجود في قائمتك
LINK_ALREADY_USEDThe beneficiary redeemed a link — the seat is permanently theirsThis invitation was already redeemedتم استخدام رابط الدعوة بالفعل
NO_ACTIVE_LINKNothing was open to revokeNo open invitation link to revokeلا يوجد رابط دعوة مفتوح للإلغاء

Error Responses

CodeStatusCondition
VALIDATION_ERROR400Body invalid — empty or over 500 ids
AUTH_FORBIDDEN403Caller is a member viewer
COMPANY_NOT_APPROVED403Company 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

ParameterTypeDescription
rosterIdstringRoster entry UUID

Request Body

FieldTypeRequiredDescription
packageIdstring | nullExactly oneFlexible only — a live B2B-priced catalog package, or null to clear
contractItemIdstring | nullExactly onePrepaid 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

CodeStatusCondition
VALIDATION_ERROR400Neither or both fields supplied, or an invalid id
VALIDATION_INVALID_INPUT400The wrong field for the company's payment mode
AUTH_FORBIDDEN403Caller is a member viewer
COMPANY_NOT_APPROVED403Company not approved
ROSTER_ENTRY_NOT_FOUND404Unknown entry, or one belonging to another company
PACKAGE_NOT_FOUND404packageId is not a live B2B-priced catalog package
CONTRACT_ITEM_NOT_FOUND404contractItemId is not a line on this company's active contract
ROSTER_LINK_OPEN409A live invitation still holds this seat — revoke it first
ROSTER_SEAT_CONSUMED409The 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

ParameterTypeDescription
rosterIdstringRoster entry UUID

Response

200 OK

json
{ "data": { "removed": true } }

Error Responses

CodeStatusCondition
VALIDATION_ERROR400Invalid rosterId
AUTH_FORBIDDEN403Caller is a member viewer
COMPANY_NOT_APPROVED403Company not approved
ROSTER_ENTRY_NOT_FOUND404Unknown entry, or one belonging to another company
ROSTER_LINK_OPEN409A live invitation still holds this seat — revoke it first
ROSTER_SEAT_CONSUMED409The 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 redeemed409 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/exported link409 ROSTER_LINK_OPEN. Cleared by POST /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

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

ParameterTypeDefaultDescription
statusstringMatches the chain's live version status
pagenumber1Page number
limitnumber201–100

Filtering by countered never matches: a countered version always has a successor, so it only ever appears in history.

Response

200 OK

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

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

CodeStatusCondition
VALIDATION_ERROR400id is not a valid UUID
QUOTE_NOT_FOUND404No 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

FieldTypeRequiredDescription
needsobjectNoNeeds-form answers. Skippable in full. When the trio is given, all three of userCount/preference/budgetSar are required together.
needs.userCountnumberConditionalPositive integer
needs.preferencestringConditionalminutes, data or mixed
needs.budgetSarnumberConditionalPositive number
needs.fundingSarnumberConditionalPositive number, at most 99,999,999.99. Flexible pay: the declared funding or top-up amount. May accompany the trio, or stand alone.
itemsarrayYesUp to 200 entries. May be empty.
items[].packageIdstringYesCatalog package UUID
items[].qtynumberYesPositive integer, 10000 or less

Response

201 Created — the created draft in the detail shape.

Error Responses

CodeStatusCondition
VALIDATION_ERROR400Body invalid
QUOTE_PACKAGE_NOT_FOUND400One 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_FORBIDDEN403Caller is a member viewer
QUOTE_CREATE_FAILED502The 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

CodeStatusCondition
VALIDATION_ERROR400Body or id invalid
QUOTE_PACKAGE_NOT_FOUND400Unknown or not-B2B-orderable packageId
QUOTE_NOT_FOUND404No such quote for this company
QUOTE_INVALID_STATE409The quote is no longer a draft

POST /b2b/quotes/:id/submit

draftsubmitted. 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

CodeStatusCondition
QUOTE_NOT_FOUND404No such quote for this company
QUOTE_INVALID_STATE409Not 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

CodeStatusCondition
QUOTE_NOT_FOUND404No such quote for this company
QUOTE_INVALID_STATE409Company-authored version, or not under_review
QUOTE_VERSION_SUPERSEDED409A 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

json
{
  "note": "Over our budget for this quarter"
}
FieldTypeNotes
notestring, ≤500 charsOptional. 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

CodeStatusCondition
VALIDATION_ERROR400Note longer than 500 characters, or a malformed id
QUOTE_NOT_FOUND404No such quote for this company
QUOTE_INVALID_STATE409Company-authored version, or not under_review
QUOTE_VERSION_SUPERSEDED409A 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

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

json
{
  "data": {
    "contractConfirmed": true,
    "paymentSatisfied": true,
    "rosterStaged": true,
    "canSendLinks": true
  }
}
FieldTrue when
contractConfirmedAn active contract exists for the company
paymentSatisfiedRecorded 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.
rosterStagedAt least one company_roster row exists
canSendLinksAll 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

json
{
  "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" }
    ]
  }
}
FieldMeaning
hasLaunchedThe company has ever minted a magic link. Drives the pre-launch / post-launch mode switch.
invitesSent30dLinks minted, send or export, in the last 30 days
activatedBeneficiaries whose latest link is used. A link-outcome tile, deliberately broader than the roster entry's activated boolean
pendingBeneficiaries whose latest link is still live and unredeemed
failedBeneficiaries 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

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

Internal documentation - Activation System