Support API

Support embed S1: the API core

This page is the reference for the console UI lane and for integrators. Machine truth lives in GET /openapi.json, and the zod sources are in packages/types/src/support-embed.ts,…

This page is the reference for the console UI lane and for integrators. Machine truth lives in GET /openapi.json, and the zod sources are in packages/types/src/support-embed.ts, support-customer-sync.ts, support-public-api.ts and api-keys.ts. The customer-sync guide is in customer-sync.md.

Migrations: 0227 (chat embed columns), 0228 (customer status deactivated, metadata, updated_at cursor, support_customer_sync_runs), 0229 (api_keys.project_ids).

1. Project chat and voice enablement (S0 finding 0.1)

Before S1, nothing wrote chat_enabled or chat_connection_id. Now both doors write them through one writer, SupportProjectsService.update, under the K-8 version:

DoorRouteAuth
consolePATCH /support/projects/:id (If-Match: <version>)session, support:edit
consolePOST /support/projects/:id/chat/secret/revealsupport:edit
consolePOST /support/projects/:id/chat/secret/rotate {overlapHours: 0|24}support:edit
APIGET / PATCH /v1/support/projects/:id (version in body)support:read / support:rw
APIPOST /v1/support/projects/:id/chat/secret/reveal · /rotatesupport:rw

PATCH fields (shared, SupportProjectEmbedPatchFields): chatEnabled, voiceEnabled, identityMode, chatConnection, chatAllowedOrigins ([] clears), chatIdentityTsRequired.

chatConnection is an act, not a column write:

  • {mode:"provision"} mints a webchat connection (routing.provisionedBy="support") with a fresh 32-byte secret, sealed AES-256-GCM (@pulse/db channel-credentials), and binds it.
  • {mode:"bind",connectionId} adopts a live webchat connection of the same workspace that no other project holds. Another tenant's id returns 404, a non-webchat line 422, a line already held 409. A line without a secret gets one minted.
  • {mode:"unbind"} detaches the line and switches chat off. It soft-deletes the connection only if support provisioned it.

The act runs in the same transaction as the version-checked UPDATE, so a stale version rolls back a provision. chatEnabled:true with no line returns 409 Chat needs a chat line first. A second provision returns 409.

Project DTO additions (console SupportProjectDto, all optional on the wire; public SupportPublicProjectDto, all present): chatEnabled, voiceEnabled, chatConnectionId, chatAllowedOrigins, chatIdentityTsRequired, chatSecretPendingReveal, and on the console only chatPreviousSecretExpiresAt. The secret is never on a read.

Reveal-once. revealSecret claims the window with one conditional UPDATE (revealed_at IS NULL OR < set_at), so a second or racing reveal returns 409. It's audited as support.project.chat_secret_revealed. The response is SupportChatSecretDto {projectId, connectionId, secret, previousSecretExpiresAt} with Cache-Control: no-store, and it's never stored for Idempotency-Key replay.

Rotate. This mints a new secret. The response is its reveal. With overlapHours: 24, the old secret stays sealed in support_projects.chat_previous_secret and keeps verifying until it expires. 0 kills it immediately. Audited as support.project.chat_secret_rotated.

Console UI notes: show a "Reveal secret" button only while chatSecretPendingReveal. After a reveal, offer "Rotate" (24 h overlap by default; "0 h" labelled the secret leaked). Show chatPreviousSecretExpiresAt as "old secret still accepted until …".

Widget identity: identityHash v2 (default) and v1

The hash always travels in the x-pulse-identity header; the visitor id travels as externalId in the POST /support/chat/session body.

  • v2 (the documented default): hash "v2\n" + externalId + "\n" + identityTs and send identityTs (epoch seconds) in the session body. It's accepted within ±10 min, so a leaked hash cannot be replayed later. The hosted widget sends it when the page passes identityTs to PulseWebchat.init (and loadWidget in @gauge/support/browser); the SDK helpers are signIdentity / sign_identity. A project with chatIdentityTsRequired: true refuses v1.
  • v1 (legacy): hex(HMAC-SHA256(secret, externalId)), no identityTs. Still accepted while chatIdentityTsRequired is off, so existing embeds keep working.
  • During a rotation overlap, a hash under the previous secret verifies too (v1 or v2).

Origin allowlist

chatAllowedOrigins holds up to 50 entries of https://host[:port] or https://*.host. http is allowed only for localhost. A wildcard matches subdomains and never the apex. It's checked at POST /support/chat/session and /offline before any write. A non-empty list refuses a missing Origin too. A refusal is 403 {error:{code:"forbidden",message:"origin not allowed"}}. An empty list means unrestricted (the pre-S1 behaviour).

The sandbox project (Support DX)

Every workspace has exactly one sandbox support project (isSandbox: true on both project DTOs; support_projects.is_sandbox, one per workspace by 0237's partial unique index). It is created on the first pulse_k_test_ request to /v1/support or the first visit to /developers/support (POST /support/projects/sandbox, console, support:create).

  • Key modes. A test key (pulse_k_test_) acts only on the sandbox: the server pins it there whatever it was minted with. A live key never reaches the sandbox. A named project of the wrong mode — route projects/:id, :projectId, a body projectId / projectIds / customers[].projectIds, a query projectId — is 403 {code:"forbidden", reason} with reason test_key_live_project or live_key_sandbox_project; a test key gets that 403 for any non-sandbox id, existing or not. A ticket or article of the wrong mode is the usual 404. Minting a test key pinned to a live project, or a live key pinned to the sandbox, is a 400.
  • Customers. A customer whose every grant is on the sandbox is sandbox data: a live key cannot see, list, reconcile, claim (by ref: 404; by email: 422 email_unavailable) or adopt it. A customer shared with a live project stays live data; a live key never sees its sandbox grant.
  • Free. It never takes a support-project slot, and its conversations never count toward conversationsPerMonth, billing meters or analytics (overview, SLA, conversation reports).
  • Nothing leaves the building. Emails are recorded sent with provider_ref: "sandbox" and a support_sandbox_email_suppressed log line, never handed to the transport (magic links too: sign sandbox customers in with session links). No voice (refused by name and by a CHECK), no phone number binding, no AI agent turns and no Pulse scoring (so no AI credits).
  • Webhooks carry livemode: false for sandbox events (see below).
  • Reset (POST /support/projects/:id/sandbox/reset, console, support:delete, body {confirm: <project key>}) deletes the sandbox's customers, tickets, chats and articles in one transaction, audited as support.project.sandbox_reset with the counts. The project, its id, its chat line and its portal host survive. Refused on a live project (422 not_sandbox).

Webhook envelope: livemode

Every delivery body carries livemode (additive): false when the event is about the sandbox project — its projectId, a project/ticket/article entity in it, a conversation on its chat line, or a sandbox-only customer — and true otherwise (including console test events). A receiver that ignores it sees the same body as before; one that serves production should drop livemode: false.

2. Customer lifecycle

RouteScopeNotes
GET /v1/support/customerssupport:readfilters email, externalRef, status, projectId; updatedSince reconcile mode
GET /v1/support/customers/:idsupport:readSupportCustomerRecordDto
PUT /v1/support/customers/by-ref/:externalRefsupport:rwupsert, wraps findOrCreateByEmail (unedited)
PATCH /v1/support/customers/:idsupport:rwname, locale, metadata, status
POST / DELETE /v1/support/customers/:id/projects/:projectIdsupport:rwdelegates to SupportProjectCustomersService (audited, evented)
POST /v1/support/customers/:id/sessions/revokesupport:rw{revoked: n}; REST and live sockets
POST /v1/support/customers/:id/erase {reason}support:rw + support-erase:rwdelegates to CustomerErasureService
POST /v1/support/customers/syncsupport:rw≤500, Idempotency-Key, per-item results
POST /v1/support/customers/statussupport:rwbatch status move

SupportCustomerRecordDto: id, externalRef, email, name, locale, status, metadata, projectIds (filtered to the key's pin), emailVerifiedAt, createdAt, updatedAt. The console's SupportCustomerDto gained optional metadata and updatedAt. The console PATCH /support/customers/:id accepts status: deactivated and routes every status through SupportCustomersService.changeStatus.

Console lane: render metadata as text, never as HTML. metadata is written by the tenant's integration, so on our side it is untrusted input. Agents will see it. As of S1, no console, portal or widget path renders it (checked 2026-10-10: apps/web, apps/portal, apps/widget have no reader). The first surface that shows it must:

  • render every key and value as a React text node. Don't use dangerouslySetInnerHTML, Markdown or linkification, and don't use the value in an href/src or a style.
  • truncate long values visibly, not silently.
  • ship with a component test proving that a value like <img src=x onerror=alert(1)> renders as literal text.

If metadata ever reaches an AI agent's prompt, the same rule applies there: quote it as data, never as instructions. (Security review #10.)

Status semantics (founder decision):

  • blocked is a full lockout. Every session row is revoked and customer_session_revoked is published, so sockets drop immediately.
  • deactivated is read-only. The session resolver admits it as readOnly, and CustomerSessionGuard refuses every non-GET/HEAD with 403 This account is read-only. That covers portal replies, attachments, CSAT, new tickets, voice and meetings.
  • Project-pinned keys (security review). A customer's status applies to every project. A pinned key changes it only for a customer served exclusively by its pinned projects. If another project also serves the customer, blocked/deactivated lands at project level instead: the customer's grants on the key's projects are revoked (access_revoked, audited), along with the portal sessions bound to those projects. The customer's own status, its other grants and its other sessions are untouched. Project level is a full revoke of that project's access, not a read-only mode. The answer marks it:
    • PATCH returns {scope:"project", customerId, status, revokedProjectIds} instead of the record.
    • PUT by-ref adds scope:"project".
    • Batch and sync results add scope:"project".
  • A pinned key cannot lift a shared customer's workspace-wide block or deactivation: 403 forbidden. Restoring that customer is for a workspace-wide key or the console.
  • POST /customers/:id/sessions/revoke with a pinned key revokes only the sessions bound to the key's projects.

Events (new):

  • support.customer.unblocked, support.customer.deactivated: {version, customerId, projectId}, one per granted project.
  • support.customer.updated: {version, customerId, projectId, fields: ("name"|"locale"|"email"|"metadata")[]}.

All carry ids only. A no-op change emits nothing.

3. Enforcement: blocked and deactivated open nothing new

Every door uses one predicate, customers/customer-status.ts customerCanOpenNew:

DoorRefusal
POST /v1/support/projects/:id/tickets (resolveRequester, by ref or email)403 reason: customer_inactive, before any grant
widget chat open, when the hmac externalId equals a customer's externalRef, or there's a portal session403 at session open, before ingest
chat escalate and offline capture403, no ticket, no grant
inbound email, new threadbridge outcome skipped, no ticket
portal writes (deactivated)guard 403; reads still work
request a callback: portal, widget, POST /v1/support/callbacks (also a grant blocked on that project)403 reason: customer_inactive, before any ticket (callbacks.md)

Each refusal is mutation-proven: deleting the check turns its test red.

Callbacks (callbacks.md). callback_enabled (0238) turns "Request a callback" on with or without voiceEnabled. The API surface is POST/GET /v1/support/callbacks, GET /v1/support/callbacks/:id and POST /v1/support/callbacks/:id/cancel, under the same pins, test-key rule and Idempotency-Key as the rest of /v1/support.

4. Security hardening

  • Project-pinned keys. Mint with projectIds: [...] (MintApiKeyBody; validated against the workspace). On /v1/support, every other project, its tickets, and any customer reachable only through it is a 404, the same answer as cross-tenant. A pinned key can't create projects (403). Customers it creates default into its pin. Outside /v1/support: POST /v1/agents/events with a pinned key accepts only its projects (any other projectId is the unknown-project 404) and GET /v1/agents/events lists only their events; the /v1 contacts / conversations / deals surface has no project notion and refuses a pinned key with 403 {reason: "key_pinned_to_projects"} (use a workspace-wide key). Every new api-key controller must declare how it treats pins (@ApiKeyPins, census-tested); an undeclared one refuses pinned keys. ApiKeyDto.projectIds exposes the pin for the console.
  • Capability scopes support-sessions:rw (handoff, session link and meeting mints) and support-erase:rw (GDPR erase). ApiKeyGuard checks them on top of CASL; *:rw never implies them. They're exported as API_KEY_CAPABILITY_SCOPES and deliberately kept off the permission-grid catalogue, which round-trips them via custom. Console lane: add two explicit checkboxes. Migration note: existing keys that mint sessions need the new scope. Prod had 0 support keys on 2026-10-10.
  • API session links: ttlSeconds ≤ 900, single-use only.
  • Webhooks: each body carries id (= outbox id, stable across retries and resends). The documented replay window is ±5 min on x-pulse-timestamp.
  • D254: webhook.* filters are refused at create and update.
  • D248: a {project} template is never a link origin.
  • Handoff: both exchange routes answer Referrer-Policy: no-referrer. Portal lane: add the same header to /auth/handoff, the page whose URL holds the token. apps/portal/next.config.mjs currently sends strict-origin-when-cross-origin.

5. Assumptions recorded (no questions asked, per the brief)

  1. support-sessions is a capability scope, not a new RBAC PermissionArea. A new area would touch rbac.ts and role seeds outside this lane. The guard-level check gives the same isolation.
  2. GDPR erase over the API requires a second explicit capability, because keys never receive delete.
  3. The audit trail is a dedicated table, so integrators and the console can list runs. Per-item rows hold ids and codes only.
  4. Deactivate leaves sessions alive (read-only). Block revokes them.
  5. An hmac visitor is mapped to a support customer only when its verified externalId equals that customer's external identity. No new identity rows are written at chat open.
  6. Unbind soft-deletes only connections support provisioned. A bound Channels connection is left intact.
  7. The Referrer-Policy on the token page itself belongs to the portal lane (see §4).

6. Native Help & Support screens: live status and the help centre

For a native app's Help & Support screen (Salonvira is the first consumer). The founder rule: hours, staffing and "who answers" are configured in Gauge only. The app reads them here and stores none of it. Call these from your backend with the server key. Never ship the key in the app. Zod sources: packages/types/src/support-native.ts. Guide: native-screens.md.

RouteScopeNotes
GET /v1/support/projects/:id/status?integration=<key>support:readlive status, cached ≤ 10 s, 600/min per key
GET /v1/support/projects/:id/articles/search?q=&locale=en|ar&integration=&limit=support:readpublished + public only, highlights, 120/min per key
GET /v1/support/projects/:id/articles/:article?locale=&integration=support:read:article is the id or the slug, 600/min per key

All three: a pinned key gets 404 outside its projects. A test key acts only on the sandbox, and a live project is 403 test_key_live_project. integration is an integration key of the App. An unknown key is 404 with message unknown_integration: …, the same as the session mints. Over budget answers 429 rate_limited (budgets: public-api/native-api-limits.ts, keyed by API key id).

Status (SupportProjectStatusDto)

FieldSource (all live)
answeringdecide's effectiveMode for a new chat on the integration, after the day/night switch (after_hours_mode). null only for an App with no integration row.
aiActivethe Decision's aiAllowed. Always false in the sandbox, because it never runs AI turns.
teamOnline, availableCount.chatthe integration's roster (its Team, else the App's members), online in agent presence or (under apps.v2.routing) Available in the one agent state
availableCount.callthe in-app call pool (availableAgentIds: Available, not DND, taking_calls 0259) ∩ the integration's Team
hoursthe clock decide reads: the Team calendar, else the App's SLA clock (project calendar with holidays, team, workspace). openNow is decide's inHours. todayRanges is walked with the same evaluator, so holidays and special hours apply. configured: false means nothing is set anywhere, so the desk is always open (openNow: true, no ranges).
typicalFirstReplymedian of support_chat_sessions.first_response_secs (time to the first reply from a person), chats queued in the last 30 days, scoped to the integration (an unstamped chat counts for the App's default integration). Below 10 conversations it is {seconds: null, reason: "not enough data", sampleSize, minimumSample: 10, windowDays: 30}.
channels.chatnot_offered (integration offers), project_paused, integration_paused, chat_disabled, else the widget's own resolveChatAvailability (outside_business_hours / no_agents_online)
channels.callthe portal call probe (SupportVoiceCapabilityService.readForServer): voice_disabled, queue_closed, wallet_floor, …; not_configured when voice isn't mounted
channels.ticketnot_offered, project_paused or integration_paused, else available

Cache. The answer is cached in-process per (workspace, project, integration) for 10 s (SUPPORT_STATUS_CACHE_SECONDS) and sent with Cache-Control: private, max-age=10. A console change shows within 10 s.

Help centre

These routes use the portal's KbPublicService, so results match the portal exactly. It runs the hybrid search (folded full-text with Arabic alef/ta-marbuta/diacritic folding, title trigram, and the pgvector leg when words find too little). It serves only published, public live revisions, with related articles and language variants. Integration scope follows the P3 rules. A shelf limited to no integration is shared. A shelf limited to salon is returned only for integration=salon, never for customer and never without an integration. The key asserts the integration, the same way the session mints take integration. With apps.v2.knowledge off, or a paused or draft integration, only shared shelves are returned (fail closed). Search hits carry highlights: {title, excerpt} as [start, end) UTF-16 offsets into the original strings. They are data, never markup.

Static search wins over :article, so an article slugged search has to be read by id.

Mutation-proven (each turns its test red when removed): pinned key 404 (status and articles), drafts and internal never returned (search, slug, id), salon-only article never returned for customer, first-reply integration isolation.

Assumptions recorded (status lane). (1) typicalFirstReply measures the first reply from a person, the moment markAnswered records. AI replies are instant and would make the number meaningless. (2) With ?integration omitted, typicalFirstReply covers the whole App while answering uses the App's default integration. (3) channels.chat passes the Decision to the widget rule only when apps.v2.decide enforces, as the widget does, so the API never claims chat is open where the widget would say offline. (4) The help-centre offers.helpCentre switch is not applied to these reads. It governs the widget's display, and a server read is the tenant's own choice. (5) No migration was needed.

7. Native Help & Support screens: requests, threads and unread

The customer's side of their requests, driven by your backend AS one signed-in user, named by externalRef (your user id). Zod sources: packages/types/src/support-native-threads.ts (+ SupportTicketContext in support.ts). Guide: native-screens.md.

RouteScopeNotes
GET /v1/support/projects/:id/tickets?externalRef=&statusGroup=&cursor=&limit=support-tickets:readthe customer's requests (§7.1)
GET /v1/support/projects/:id/topics?integration=support-tickets:readthe App's topics for a native screen (§7.2a)
POST /v1/support/projects/:id/ticketssupport-tickets:rwcreate; takes context, integration, topicId, details (§7.2, §7.2a)
GET /v1/support/projects/:id/tickets/:ticketId/messages?externalRef=&cursor=&limit=support-tickets:readthe thread (§7.3)
POST /v1/support/projects/:id/tickets/:ticketId/messagessupport-tickets:rwreply AS the requester, Idempotency-Key
POST /v1/support/projects/:id/tickets/:ticketId/attachmentssupport-tickets:rwupload AS the requester, Idempotency-Key
GET /v1/support/projects/:id/customers/by-ref/:externalRef/unreadsupport-tickets:readunread counts (§7.4)
POST /v1/support/projects/:id/customers/by-ref/:externalRef/readsupport-tickets:rwmark read (§7.4), Idempotency-Key

All of them: a pinned key gets 404 outside its projects; a test key works only on the sandbox (403 test_key_live_project / live_key_sandbox_project); another tenant's ids are 404.

Idempotency-Key (409 rule). On every POST here, and on POST /projects/:id/tickets and POST /tickets/:id/replies|notes, the key's fingerprint is the body plus the route's resource ids (project id, ticket id, or project id + externalRef; a create's requester is in its body). The same key with the same ids and body replays the first answer; the same key with a different body or a different id (another ticket, project or customer) is 409 conflict, never another resource's stored answer.

Rate limits (per customer per project, on top of the per-key budgets): reply as the customer 30 / minute; upload as the customer spends the portal's own upload bucket (10 / hour, shared with the portal). Over budget: 429 rate_limited with Retry-After (seconds) and error.details.retryAfterSecs; nothing is written and an Idempotency-Key claim is released, so retry the same key after the wait.

7.1 List items

SupportTicketListItem = the public ticket + statusGroup (open = new/open/on_hold, waiting_on_customer = pending, resolved = solved/closed), lastActivityAt, lastReply ({messageId, authorType: customer|agent|ai|system, preview ≤140 chars, truncated, createdAt}, never an internal note, or null), sla ({state: none|on_track|at_risk|breached, clock, nextDueAt}), integration ({id, key} or null), context ({type, id} or null) and unread (team messages the requester has not read). Newest first; the cursor is the last row's id. An unknown requester is an empty page.

7.2 Create with context and integration

context: {type: booking|transaction|invoice|voucher|payout, id}: id is YOUR record id ([A-Za-z0-9][A-Za-z0-9._:-]*, ≤128). Gauge never dereferences it. You verify the customer owns the record before sending it. It is stored in support_tickets.custom_fields.context, shown to agents on the ticket ("About: Booking BK-1042"), and returned as context on the ticket (GET /tickets/:id, the create answer), the list items and the thread header. integration (a key) stamps the ticket's write-once integration_id: unknown on this App → 404 unknown_integration: … (checked before any write); with apps.v2.identity on, a requester not granted it → 403 integration_not_granted: …, also checked before any write (no project grant, no find-or-created customer, no ticket).

7.2a Topics: list them, file a request under one (0269)

GET /v1/support/projects/:id/topics?integration=<key> (support-tickets:read) → PublicTopicsResponse {topics: [{id, name, description, starter, detailFields}]}: the App's active topics, this integration's starter buttons first (starter: true, in their order), then the rest by position. integration absent = the App's default integration; unknown → 404 unknown_integration. detailFields (≤3): {key, label, type: text|choice|date, required, options? (choice), fromAttr? (chat fills it from that app attribute)}. The built-in "Other" is never listed. Zod: packages/types/src/support-topics-ai.ts.

POST /v1/support/projects/:id/tickets takes optional topicId (an active topic of THIS App, else 422 invalid_topic, nothing written) and details (key → value, needs topicId; checked against the topic's detailFields: an unknown key, a choice off its list, an unparseable date or a missing required value → 422 invalid_details: “<label>” …). The topic lands in support_tickets.topic_id (0269) and the details in custom_fields.topic_details, in the creation transaction. Ticket rows (GET …/tickets, GET /tickets/:id, the create answer) and thread headers carry topic: {id, name} or null. GET …/status carries topics (this integration's starter topics, {id, name}, in order).

7.3 Thread, reply and upload

The rules are in native-threads.service.ts's header: externalRef must be the ticket's requester, else the same 404 as an unknown id; customer-visible messages only (inbound / outbound, never internal notes or system lines) from the ticket's creation on; the reply goes through the portal's own TicketsService.addPublicReply (customer actor, no member) so status, events, SLA and notifications match the portal; blocked / deactivated / ungranted requesters read (readOnly: true) but every write is 403 reason: customer_read_only. SupportThreadTicketDto carries context and createdAt.

Attachments carry downloadState: scanning / refused (infected) / unavailable / ready (+ download: {url, expiresAt}). A URL is only ever minted by the SAME object-store presigner the portal's download uses; no deployment binds one yet (the store refuses, portal.module.ts), so today every clean file reads unavailable with download: null, on the portal and here alike. Gauge never returns a URL that cannot work. When a presigner is bound, ready URLs are short-lived (its TTL; expiresAt says when).

7.4 Unread and mark-read

The customer's read state is one table (support_customer_read_marks, migration 0260, RLS quad): one row per (customer, thread) holding the newest message read, (read_through_at, read_through_message_id) in the thread's own order. It is shared with the portal: opening a ticket's timeline there moves the same mark (markTicketSeenSafely), so a badge cleared on the web is cleared in the app.

Unread = customer-visible team messages (outbound: agent, AI, API sends) after the mark, over the customer's tickets as requester (from the ticket's creation) and their chats in the project (up to the handoff, if the chat became a ticket, so nothing counts twice). The customer's own words, notes and system lines never count.

GET …/unread → SupportCustomerUnreadDto {externalRef, customerId|null, total, ticketsUnread, chatsUnread, tickets: [{ticketId, unread, lastUnreadAt}], chats: [{chatId, unread, lastUnreadAt}]} (only threads with something unread, newest first, lists ≤100, total always complete). Unknown externalRef → zeros with customerId: null. Unknown project → 404. Cache-Control: no-store.

POST …/read body SupportMarkReadBody: {scope: "ticket", ticketId, throughMessageId?} | {scope: "chat", chatId, throughMessageId?} | {scope: "all"}. Without throughMessageId the mark moves to the thread's newest visible message. A thread or message that is not this customer's → 404. Marks only move forward (ON CONFLICT … WHERE old < new); an older id answers 200 and changes nothing. Allowed for read-only customers (it writes no message). Answers the new summary.

Webhook support.customer.unread_changed (ids + counts only): {version, projectId, customerId, externalRef|null, cause: read|team_message, total, ticketsUnread, chatsUnread, tickets: [{ticketId, unread}], chats: [{chatId, unread}]}. Emitted in the same transaction as the write that moved the count: a mark-read that moved something (cause: read, also from the portal), an agent's public ticket reply and a team (agent / AI / system) chat line for a known customer (cause: team_message). The team-message emits run inside a SAVEPOINT, so a failure there is logged and never rolls the message back. version is commit-ordered (a per-customer lock is taken before the counts are read and the version drawn, in the writer's transaction), so the highest version always carries the latest counts: keep the highest per customer and ignore lower ones. It is an increasing integer, not a time. On any doubt (a gap, a restart, an out-of-order delivery) re-fetch GET …/unread: the webhook is a hint, the GET is the truth.

7.5 Security, mutation-proven

native-tickets.integration.test.ts, native-threads.integration.test.ts, native-unread.integration.test.ts, native-rate-limit.integration.test.ts, plus the [review] cases in support-public.integration.test.ts and session-mint.integration.test.ts (★ cases). Each must turn red when its guard is removed:

GuardMutationTest that must fail
requester in the thread WHERE (loadRequesterTicket)drop AND t.requester_customer_id = …threads ★ cross-customer 404, ★ impersonation
visibility predicate (read-state/visibility.ts)IN ('inbound','outbound','internal')threads ★ note never appears; unread ★ note never counted; tickets ★ preview
unread ownership (customerUnread / markPoint)drop requester_customer_id = ${customerId} / s.customer_id = …unread ★ cross-customer, ★ mark another's ticket/chat 404
project pin (projectOutOfScope)return falsethreads/unread ★ pinned key 404
read-only (readOnlyRefusal)skip the checkthreads ★ deactivated / blocked
portal writer requester-onlymark on any visible ticketunread ★ non-requester no-op
path-bound Idempotency-Key (boundRequest)pass rawBody alonethreads ★ reuse on another ticket; unread ★ another customer; support-public ★ another project / ticket
grant check before writes (resolveRequester)check after ensureProjectGrantsession-mint ★ no project grant / no customer on 403
commit-ordered version (emitUnreadChanged)version = Date.now(), no lockunread ★ racing replies and mark-reads
per-customer write budgets (spend)skip spendnative-rate-limit ★ 429 + Retry-After

Assumptions recorded (requests lane). (1) The requester is named by requesterExternalRef on create (the existing field) and externalRef everywhere else; there is no second alias. (2) A mark-read is allowed for blocked/deactivated customers: it is a read position, not a message. (3) An unknown externalRef on GET …/unread is zeros (the list's "matched no one" rule), never a 404 that would reveal which refs exist. (4) context is stored in custom_fields.context (an existing JSONB column), so no ticket migration was needed; 0260 is only the read marks. (5) The portal moves the mark when the customer opens the ticket's timeline; the portal UI itself shows no unread state yet.

On this page