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:
| Door | Route | Auth |
|---|---|---|
| console | PATCH /support/projects/:id (If-Match: <version>) | session, support:edit |
| console | POST /support/projects/:id/chat/secret/reveal | support:edit |
| console | POST /support/projects/:id/chat/secret/rotate {overlapHours: 0|24} | support:edit |
| API | GET / PATCH /v1/support/projects/:id (version in body) | support:read / support:rw |
| API | POST /v1/support/projects/:id/chat/secret/reveal · /rotate | support:rw |
PATCH fields (shared, SupportProjectEmbedPatchFields): chatEnabled, voiceEnabled, identityMode, chatConnection, chatAllowedOrigins ([] clears), chatIdentityTsRequired.
chatConnection is an act, not a column write:
{mode:"provision"}mints awebchatconnection (routing.provisionedBy="support") with a fresh 32-byte secret, sealed AES-256-GCM (@pulse/dbchannel-credentials), and binds it.{mode:"bind",connectionId}adopts a livewebchatconnection 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" + identityTsand sendidentityTs(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 passesidentityTstoPulseWebchat.init(andloadWidgetin@gauge/support/browser); the SDK helpers aresignIdentity/sign_identity. A project withchatIdentityTsRequired: truerefuses v1. - v1 (legacy):
hex(HMAC-SHA256(secret, externalId)), noidentityTs. Still accepted whilechatIdentityTsRequiredis 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 — routeprojects/:id,:projectId, a bodyprojectId/projectIds/customers[].projectIds, a queryprojectId— is403 {code:"forbidden", reason}withreasontest_key_live_projectorlive_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
sentwithprovider_ref: "sandbox"and asupport_sandbox_email_suppressedlog 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: falsefor 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 assupport.project.sandbox_resetwith the counts. The project, its id, its chat line and its portal host survive. Refused on a live project (422not_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
| Route | Scope | Notes |
|---|---|---|
GET /v1/support/customers | support:read | filters email, externalRef, status, projectId; updatedSince reconcile mode |
GET /v1/support/customers/:id | support:read | SupportCustomerRecordDto |
PUT /v1/support/customers/by-ref/:externalRef | support:rw | upsert, wraps findOrCreateByEmail (unedited) |
PATCH /v1/support/customers/:id | support:rw | name, locale, metadata, status |
POST / DELETE /v1/support/customers/:id/projects/:projectId | support:rw | delegates to SupportProjectCustomersService (audited, evented) |
POST /v1/support/customers/:id/sessions/revoke | support:rw | {revoked: n}; REST and live sockets |
POST /v1/support/customers/:id/erase {reason} | support:rw + support-erase:rw | delegates to CustomerErasureService |
POST /v1/support/customers/sync | support:rw | ≤500, Idempotency-Key, per-item results |
POST /v1/support/customers/status | support:rw | batch 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 anhref/srcor astyle. - 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):
blockedis a full lockout. Every session row is revoked andcustomer_session_revokedis published, so sockets drop immediately.deactivatedis read-only. The session resolver admits it asreadOnly, andCustomerSessionGuardrefuses every non-GET/HEAD with403 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/deactivatedlands 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:PATCHreturns{scope:"project", customerId, status, revokedProjectIds}instead of the record.PUTby-ref addsscope:"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/revokewith 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:
| Door | Refusal |
|---|---|
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 session | 403 at session open, before ingest |
| chat escalate and offline capture | 403, no ticket, no grant |
| inbound email, new thread | bridge 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/eventswith a pinned key accepts only its projects (any otherprojectIdis the unknown-project 404) andGET /v1/agents/eventslists only their events; the/v1contacts / conversations / deals surface has no project notion and refuses a pinned key with403 {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.projectIdsexposes the pin for the console. - Capability scopes
support-sessions:rw(handoff, session link and meeting mints) andsupport-erase:rw(GDPR erase).ApiKeyGuardchecks them on top of CASL;*:rwnever implies them. They're exported asAPI_KEY_CAPABILITY_SCOPESand deliberately kept off the permission-grid catalogue, which round-trips them viacustom. 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 onx-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.mjscurrently sendsstrict-origin-when-cross-origin.
5. Assumptions recorded (no questions asked, per the brief)
support-sessionsis a capability scope, not a new RBACPermissionArea. A new area would touchrbac.tsand role seeds outside this lane. The guard-level check gives the same isolation.- GDPR erase over the API requires a second explicit capability, because keys never receive
delete. - The audit trail is a dedicated table, so integrators and the console can list runs. Per-item rows hold ids and codes only.
- Deactivate leaves sessions alive (read-only). Block revokes them.
- An hmac visitor is mapped to a support customer only when its verified externalId equals that customer's
externalidentity. No new identity rows are written at chat open. - Unbind soft-deletes only connections support provisioned. A bound Channels connection is left intact.
- 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.
| Route | Scope | Notes |
|---|---|---|
GET /v1/support/projects/:id/status?integration=<key> | support:read | live status, cached ≤ 10 s, 600/min per key |
GET /v1/support/projects/:id/articles/search?q=&locale=en|ar&integration=&limit= | support:read | published + 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)
| Field | Source (all live) |
|---|---|
answering | decide'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. |
aiActive | the Decision's aiAllowed. Always false in the sandbox, because it never runs AI turns. |
teamOnline, availableCount.chat | the 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.call | the in-app call pool (availableAgentIds: Available, not DND, taking_calls 0259) ∩ the integration's Team |
hours | the 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). |
typicalFirstReply | median 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.chat | not_offered (integration offers), project_paused, integration_paused, chat_disabled, else the widget's own resolveChatAvailability (outside_business_hours / no_agents_online) |
channels.call | the portal call probe (SupportVoiceCapabilityService.readForServer): voice_disabled, queue_closed, wallet_floor, …; not_configured when voice isn't mounted |
channels.ticket | not_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.
| Route | Scope | Notes |
|---|---|---|
GET /v1/support/projects/:id/tickets?externalRef=&statusGroup=&cursor=&limit= | support-tickets:read | the customer's requests (§7.1) |
GET /v1/support/projects/:id/topics?integration= | support-tickets:read | the App's topics for a native screen (§7.2a) |
POST /v1/support/projects/:id/tickets | support-tickets:rw | create; takes context, integration, topicId, details (§7.2, §7.2a) |
GET /v1/support/projects/:id/tickets/:ticketId/messages?externalRef=&cursor=&limit= | support-tickets:read | the thread (§7.3) |
POST /v1/support/projects/:id/tickets/:ticketId/messages | support-tickets:rw | reply AS the requester, Idempotency-Key |
POST /v1/support/projects/:id/tickets/:ticketId/attachments | support-tickets:rw | upload AS the requester, Idempotency-Key |
GET /v1/support/projects/:id/customers/by-ref/:externalRef/unread | support-tickets:read | unread counts (§7.4) |
POST /v1/support/projects/:id/customers/by-ref/:externalRef/read | support-tickets:rw | mark 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:
| Guard | Mutation | Test 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 false | threads/unread ★ pinned key 404 |
read-only (readOnlyRefusal) | skip the check | threads ★ deactivated / blocked |
| portal writer requester-only | mark on any visible ticket | unread ★ non-requester no-op |
path-bound Idempotency-Key (boundRequest) | pass rawBody alone | threads ★ reuse on another ticket; unread ★ another customer; support-public ★ another project / ticket |
grant check before writes (resolveRequester) | check after ensureProjectGrant | session-mint ★ no project grant / no customer on 403 |
commit-ordered version (emitUnreadChanged) | version = Date.now(), no lock | unread ★ racing replies and mark-reads |
per-customer write budgets (spend) | skip spend | native-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.