GET/api/v1/portal/appointment-requests
GET /api/portal/appointment-requests — the caller's OWN requests,
newest first.
Responses
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
| Status | Description |
|---|
200 | |
404 | requested staff member not found |
409 | idempotency key reused |
422 | validation 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
| Name | In | Type | Required | Description |
|---|
id | path | string | Yes | |
Request body
PortalCancelRequest
Responses
| Status | Description |
|---|
200 | |
404 | request not owned by this portal identity |
409 | request 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
| Name | In | Type | Required | Description |
|---|
token | path | string | Yes | |
Responses
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
| Name | In | Type | Required | Description |
|---|
token | path | string | Yes | |
Request body
PortalCsatSubmit
Responses
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
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
| Name | In | Type | Required | Description |
|---|
id | path | string | Yes | |
Responses
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
| Name | In | Type | Required | Description |
|---|
id | path | string | Yes | |
Responses
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
| Name | In | Type | Required | Description |
|---|
id | path | string | Yes | |
Request body
PortalPayRequest
Responses
| Status | Description |
|---|
200 | |
404 | invoice not visible to this client |
409 | invoice not payable, overpayment, version conflict, or idempotency key reused |
422 | validation 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
| Name | In | Type | Required | Description |
|---|
category_id | query | — | No | |
limit | query | integer | No | |
offset | query | integer | No | |
Responses
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
| Name | In | Type | Required | Description |
|---|
id | path | string | Yes | |
Responses
Security: bearer
GET/api/v1/portal/kb/categories
GET /api/portal/kb/categories — the client-visible category tree.
Responses
Security: bearer
GET/api/v1/portal/kb/search
GET /api/portal/kb/search — FTS over client-visible published
articles only (websearch query + trigram fallback, ranked the same
way the unified search ranks).
Parameters
| Name | In | Type | Required | Description |
|---|
q | query | string | Yes | Websearch query (plain words, quoted phrases, `OR`, `-`
exclusions — Postgres websearch syntax). |
limit | query | integer | No | |
offset | query | integer | No | |
Responses
| Status | Description |
|---|
200 | |
422 | empty query |
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
| Status | Description |
|---|
200 | |
401 | bad credentials |
POST/api/v1/portal/magic-link/confirm
POST /api/portal/magic-link/confirm — spend the link, get portal tokens.
One 401 for unknown, already-used, expired, and disabled alike. The claim
itself enforces the standing check (the UPDATE joins `portal_users`), so
there is no window between "may this identity sign in?" and "spend it".
Request body
PortalMagicLinkConfirm
Responses
| Status | Description |
|---|
200 | |
401 | the link is unknown, used, expired, or the account is disabled |
429 | too many attempts |
POST/api/v1/portal/magic-link/request
POST /api/portal/magic-link/request — mail a single-use sign-in link.
Throttled per address and per IP on the portal's OWN gate kind, so a run
against portal addresses never locks a staff member out of `/api/login`.
Request body
PortalMagicLinkRequest
Responses
| Status | Description |
|---|
200 | always ok — the answer never depends on whether the address matches |
422 | validation error |
429 | too many requests |
GET/api/v1/portal/me
GET /api/portal/me — the signed-in portal identity.
Responses
Security: bearer
GET/api/v1/portal/quotes
GET /api/portal/quotes — the caller's client's quotes (non-draft
only), newest first.
Responses
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
| Name | In | Type | Required | Description |
|---|
id | path | string | Yes | |
Responses
| Status | Description |
|---|
200 | |
404 | quote 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
| Name | In | Type | Required | Description |
|---|
id | path | string | Yes | |
Request body
PortalQuoteDecisionRequest
Responses
| Status | Description |
|---|
200 | |
404 | quote not visible to this client |
409 | quote 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
| Name | In | Type | Required | Description |
|---|
id | path | string | Yes | |
Request body
PortalQuoteDecisionRequest
Responses
| Status | Description |
|---|
200 | |
404 | quote not visible to this client |
409 | quote 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
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
| Name | In | Type | Required | Description |
|---|
from | query | string | Yes | Range start (inclusive), ISO date. |
to | query | string | Yes | Range end (inclusive), ISO date. |
user_id | query | — | No | Optional: narrow to one staff member. |
Responses
| Status | Description |
|---|
200 | |
422 | range 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
| Name | In | Type | Required | Description |
|---|
board_id | query | — | No | |
category | query | null | TicketStatusCategory | No | |
limit | query | integer | No | |
offset | query | integer | No | |
Responses
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
| Name | In | Type | Required | Description |
|---|
id | path | string | Yes | |
Responses
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
| Name | In | Type | Required | Description |
|---|
id | path | string | Yes | |
Responses
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
| Name | In | Type | Required | Description |
|---|
id | path | string | Yes | |
attachment_id | path | string | Yes | |
Responses
| Status | Description |
|---|
200 | |
404 | |
409 | the 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
| Name | In | Type | Required | Description |
|---|
id | path | string | Yes | |
Request body
PortalReplyRequest
Responses
Security: bearer