Documentation menu

API reference

28 operations in portal. Raw spec: openapi.json.

GET/api/v1/portal/appointment-requests

GET /api/portal/appointment-requests — the caller's OWN requests, newest first.

Responses

StatusDescription
200

Security: bearer

POST/api/v1/portal/appointment-requests

POST /api/portal/appointment-requests — ask for a slot on a staff member's calendar. The slot must be FULLY inside one free interval of that staff member's PUBLISHED availability (windows minus time-off minus bookings) — requests are intents, so several clients may hold overlapping pending requests; the staff approval is the single point that books the calendar (and re-validates everything).

Request body

PortalCreateAppointmentRequest

Responses

StatusDescription
200
404requested staff member not found
409idempotency key reused
422validation failure (span, lead time, or slot outside published availability)

Security: bearer

POST/api/v1/portal/appointment-requests/{id}/cancel

POST /api/portal/appointment-requests/{id}/cancel — the requester withdraws their own still-pending request. Terminal; decided requests cannot be cancelled here (staff owns the calendar once decided).

Parameters

NameInTypeRequiredDescription
idpathstringYes

Request body

PortalCancelRequest

Responses

StatusDescription
200
404request not owned by this portal identity
409request is not pending, version conflict, or idempotency key reused

Security: bearer

GET/api/v1/portal/csat/{token}

GET /api/portal/csat/{token} — validity check for the in-app response view. Scoped TWICE: the survey row must belong to the caller's client, and the token must be live (sent, unexpired). Read-only — rendering the form never burns the single-use token.

Parameters

NameInTypeRequiredDescription
tokenpathstringYes

Responses

StatusDescription
200
404

Security: bearer

POST/api/v1/portal/csat/{token}

POST /api/portal/csat/{token} — the single-use consume + score record. The consume UPDATE wins exactly once per token; the event (and the audit row) ride the same transaction, so `csat.response_recorded` never fires for a replayed or expired link.

Parameters

NameInTypeRequiredDescription
tokenpathstringYes

Request body

PortalCsatSubmit

Responses

StatusDescription
200
404

Security: bearer

GET/api/v1/portal/invoices

GET /api/portal/invoices — the caller's client's invoices (issued and paid only), newest issued first.

Responses

StatusDescription
200

Security: bearer

GET/api/v1/portal/invoices/{id}

GET /api/portal/invoices/{id} — one of the caller's client's invoices with lines and payment receipts. Another client's document (or a voided one) is a 404, indistinguishable from absent.

Parameters

NameInTypeRequiredDescription
idpathstringYes

Responses

StatusDescription
200
404

Security: bearer

GET/api/v1/portal/invoices/{id}/document

GET /api/portal/invoices/{id}/document — the SAME print-ready HTML document staff print (shared renderer), client-scoped: another client's (or a void) invoice is a plain 404.

Parameters

NameInTypeRequiredDescription
idpathstringYes

Responses

StatusDescription
200
404

Security: bearer

POST/api/v1/portal/invoices/{id}/pay

POST /api/portal/invoices/{id}/pay — record a payment through the `PaymentProvider` seam from the portal. v1 resolves the built-in none-mode provider: NOTHING is charged externally; the receipt ("ledger-only") records that the client settled the amount — the UI presents it exactly that way. Provenance: the payment row keeps `created_by = NULL` (portal identities are not staff rows), the audit row and the write trail carry the caller's portal actor kind.

Parameters

NameInTypeRequiredDescription
idpathstringYes

Request body

PortalPayRequest

Responses

StatusDescription
200
404invoice not visible to this client
409invoice not payable, overpayment, version conflict, or idempotency key reused
422validation failure

Security: bearer

GET/api/v1/portal/kb/articles

GET /api/portal/kb/articles — published client-facing articles (optionally narrowed to one visible category), gated by the effective client-facing rule in the query.

Parameters

NameInTypeRequiredDescription
category_idquery—No
limitqueryintegerNo
offsetqueryintegerNo

Responses

StatusDescription
200

Security: bearer

GET/api/v1/portal/kb/articles/{id}

GET /api/portal/kb/articles/{id} — one client-gated article; a draft, archived, unflagged, or foreign article is a plain 404.

Parameters

NameInTypeRequiredDescription
idpathstringYes

Responses

StatusDescription
200
404

Security: bearer

GET/api/v1/portal/kb/categories

GET /api/portal/kb/categories — the client-visible category tree.

Responses

StatusDescription
200

Security: bearer

POST/api/v1/portal/login

POST /api/portal/login — argon2 verify against `portal_users`. No MFA in portal v1 (documented deferral). Disabled accounts resolve to nothing → uniform 401, never revealing which half failed.

Request body

PortalLoginRequest

Responses

StatusDescription
200
401bad credentials
GET/api/v1/portal/me

GET /api/portal/me — the signed-in portal identity.

Responses

StatusDescription
200

Security: bearer

GET/api/v1/portal/quotes

GET /api/portal/quotes — the caller's client's quotes (non-draft only), newest first.

Responses

StatusDescription
200

Security: bearer

GET/api/v1/portal/quotes/{id}

GET /api/portal/quotes/{id} — one quote of the caller's client with its lines. A foreign quote or a draft is a bare 404 (no existence leak).

Parameters

NameInTypeRequiredDescription
idpathstringYes

Responses

StatusDescription
200
404quote not visible to this client

Security: bearer

POST/api/v1/portal/quotes/{id}/approve

POST /api/portal/quotes/{id}/approve — the client accepts a sent quote. Same `sent → accepted` leg as the staff flow; the provider can then run the conversion-to-invoice seam.

Parameters

NameInTypeRequiredDescription
idpathstringYes

Request body

PortalQuoteDecisionRequest

Responses

StatusDescription
200
404quote not visible to this client
409quote not decidable, version conflict, or idempotency key reused

Security: bearer

POST/api/v1/portal/quotes/{id}/decline

POST /api/portal/quotes/{id}/decline — the client declines a sent quote. Same `sent → declined` leg as the staff flow; the optional reason is audit-only, never shown back as validation.

Parameters

NameInTypeRequiredDescription
idpathstringYes

Request body

PortalQuoteDecisionRequest

Responses

StatusDescription
200
404quote not visible to this client
409quote not decidable, version conflict, or idempotency key reused

Security: bearer

POST/api/v1/portal/refresh

POST /api/portal/refresh — rotation within the portal session family. A presented token resolves ONLY via its live session row; rotation revokes that row, so replaying an old refresh token fails.

Request body

PortalRefreshRequest

Responses

StatusDescription
200
GET/api/v1/portal/schedule/slots

GET /api/portal/schedule/slots — published availability as FREE intervals per staff member: weekly windows minus time-off minus already-booked time (appointments + on-call). Slots that already started are dropped; anything shorter than 30 minutes is noise.

Parameters

NameInTypeRequiredDescription
fromquerystringYesRange start (inclusive), ISO date.
toquerystringYesRange end (inclusive), ISO date.
user_idquery—NoOptional: narrow to one staff member.

Responses

StatusDescription
200
422range exceeds 31 days

Security: bearer

GET/api/v1/portal/tickets

GET /api/portal/tickets — the caller's client's tickets across EXPOSED boards only. Unexposed boards are invisible, not empty-filtered.

Parameters

NameInTypeRequiredDescription
board_idquery—No
categoryquerynull | TicketStatusCategoryNo
limitqueryintegerNo
offsetqueryintegerNo

Responses

StatusDescription
200

Security: bearer

GET/api/v1/portal/tickets/{id}

GET /api/portal/tickets/{id} — detail plus comments through the notes-taxonomy whitelist for the caller's tier. A ticket from another client (or an unexposed board) is a 404, indistinguishable from absent.

Parameters

NameInTypeRequiredDescription
idpathstringYes

Responses

StatusDescription
200
404

Security: bearer

GET/api/v1/portal/tickets/{id}/attachments

GET /api/portal/tickets/{id}/attachments — attachments the portal caller may see: ticket-level files always; comment files only when the owning note kind is visible to this tier.

Parameters

NameInTypeRequiredDescription
idpathstringYes

Responses

StatusDescription
200
404

Security: bearer

GET/api/v1/portal/tickets/{id}/attachments/{attachment_id}/download

GET /api/portal/tickets/{id}/attachments/{attachment_id}/download — streams one visible attachment through the API. Hidden or foreign attachments are plain 404s (no existence leak).

Parameters

NameInTypeRequiredDescription
idpathstringYes
attachment_idpathstringYes

Responses

StatusDescription
200
404
409the attachment has no admission verdict, so it is not served

Security: bearer

POST/api/v1/portal/tickets/{id}/reply

POST /api/portal/tickets/{id}/reply — ALWAYS creates a `client_visible` comment regardless of tier, authored under portal audit provenance (`actor_kind = portal_contact | portal_approver | portal_it_manager`; `author_id` stays NULL because portal identities are not staff rows). Emits the SAME `ticket.comment_added` event as staff replies so every downstream notification path works unchanged.

Parameters

NameInTypeRequiredDescription
idpathstringYes

Request body

PortalReplyRequest

Responses

StatusDescription
200
404

Security: bearer