Generated from the zod contracts in packages/contracts and the NestJS routes in apps/api — never hand-written, and CI fails when the committed artifact differs from a fresh generation.
Scoping. Every workspace-scoped route requires the X-Workspace-Id header. It is asserted before any repository is touched, and Postgres row-level security is the second belt behind it. GET /workspaces is what produces a valid value.
Authorization. Workspace roles form a strict ladder — viewer < approver < owner. Each route documents its MINIMUM role, read directly off the @RequireRole decorator on the handler.
Errors. Every error is application/problem+json (RFC 7807). The type is a stable URI under https://visiblebrand.ai/problems/, so clients should switch on type, not on the human-readable title or detail. Structured evidence (lint violations, policy excerpts, retryAfterSeconds) rides as extension members.
CSRF. State-changing requests that carry the session cookie must also send the X-VB-Csrf header matching the vb_csrf cookie. Callers that authenticate with an explicit header instead of a cookie (the internal surfaces) carry no ambient authority and are therefore not CSRF-gated.
Approvals are the safety boundary. No agent can reach a production write without a human approving a gate. Approval tokens are single-use and bound to a hash of the exact arguments, and they are validated at the Gatekeeper hook AND again at the server layer. No approval token is ever returned over HTTP.
Non-REST boundaries. The live board is a WebSocket channel and the two agent surfaces are SSE streams. All three are documented from the same zod contracts, under the x-vb-channels extension.
Machine-readable spec
The same document this page renders, served as JSON so you can generate a client from it: /docs/openapi.json.
Four authorities exist and each route documents exactly which of them it accepts. Two are customer-facing, two are internal service secrets and are labelled as such wherever they appear.
sessionCookiecookie vb_session
The signed, session-bound cookie issued by POST /auth/login, POST /auth/signup or the Google OIDC callback. Rotated on every privilege change; the durable sessions row is the liveness authority, so a logout applies across replicas and survives a restart.
csrfTokenheader X-VB-Csrf
Signed, session-bound double-submit token. Send the value returned by GET /auth/csrf; the matching vb_csrf cookie must accompany it. Required on every state-changing request that carries the session cookie (a bare double-submit is forgeable by anyone who can write a cookie, so the token is bound to the session and a rotated session invalidates its predecessor's token).
ingestTokenheader x-vb-ingest-token
INTERNAL SERVICE SECRET (VB_INGEST_TOKEN), compared in constant time. Fail-closed ladder: unset on the server means the whole surface is OFF (503 ingest_disabled), a missing or wrong header is 401. Not a customer credential — these routes are the agent-runtime's.
adminTokenheader x-vb-admin-token
PLATFORM-ADMIN SECRET (VB_ADMIN_TOKEN), compared in constant time. Unset means the surface is OFF (503 admin_disabled), never open. Used by the KYB review queue, which spans workspaces and therefore cannot be workspace-scoped.
X-Workspace-Idheader
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
A first call, end to end
VB_API=https://app.visiblebrand.ai
# 1. sign in — the session cookie and a CSRF pair come back together
curl -sS -c jar.txt "$VB_API/api/v1/auth/csrf" >/dev/null
VB_CSRF=$(grep vb_csrf jar.txt | awk '{print $7}')
curl -sS -b jar.txt -c jar.txt -X POST "$VB_API/api/v1/auth/login" \
-H "Content-Type: application/json" -H "X-VB-Csrf: $VB_CSRF" \
-d '{"email":"you@example.com","password":"…"}'
# 2. find the workspace you are a member of
curl -sS -b jar.txt "$VB_API/api/v1/workspaces"
# 3. every scoped call carries the workspace header
curl -sS -b jar.txt -H "X-Workspace-Id: $VB_WORKSPACE" \
"$VB_API/api/v1/metrics/visibility?engine=chatgpt"
Authorization
Workspace roles are a strict ladder: viewer < approver < owner. A route requiring `approver` therefore admits approvers and owners.
The role on each endpoint below is read off the @RequireRole decorator on the handler that serves it, so this reference cannot describe a permission the server does not enforce. Two policies sit deliberately outside the ladder and are worth knowing before you build against the write path:
Approval is the only path to a production write. No role, no plan and no autonomy setting lets an agent write to a customer’s site or publish content without a human approving a gate. The approval token is single-use and bound to a hash of the exact arguments, it is validated at the agent hook and again server-side, and it is never returned over HTTP.
Trust tier is enforced at token mint. A workspace below the required tier gets 403 tier_requirement and the gate stays pending — it is not silently downgraded, and the check is not duplicated (and therefore cannot disagree with itself) anywhere else.
Entitlements fail closed. Trial and manual-assist plans are refused the write path with 403 entitlement_writes_refused before any pipeline starts, so no gate and no staged change is created.
Errors
Every error is RFC 7807 application/problem+json. The type is a stable URI; title and detail are for humans and may be reworded at any time, so client logic should switch on type (or the machine code at the end of it) and nothing else.
{
"type": "https://visiblebrand.ai/problems/tier-requirement",
"title": "Tier requirement",
"status": 403,
"detail": "workspace trust tier 't1' is below 't2' required to approve a placement purchase"
}
Structured evidence rides as extension members rather than being flattened into the detail string: prompt-bank lint violations, content-policy violations with their exact draft excerpts, and retryAfterSeconds on a throttled credential endpoint (which also sets Retry-After).
The full code list is in the problem code reference — 72 codes, read out of the api’s own registry.
Realtime channels
The board is live over WebSocket and both agent surfaces stream over SSE. All three are documented from the same zod contracts as the REST surface — a reference that stopped at REST would leave out how the product actually behaves once it is open.
Board channel
websocket/ws?workspace={workspaceId}
The live workspace board. The server pushes every board event with its log-assigned `event_id`; clients send optimistic operations carrying a `client_op_id` and receive an `ack` frame echoing it, which is what lets the UI reconcile an optimistic move against the authoritative result. After a disconnect the client sends `{resume_from: <last event_id>}` and receives the gap, then live frames — replay is de-duplicated by `event_id`, so a reconnect is never visible as duplicated work (AC-06-02).
Authentication
PER-CONNECTION, exactly like a REST request: the vb_session cookie travels on the upgrade, its signature is verified, the durable sessions row is the liveness authority, and the caller must hold a membership in the requested workspace. Holding a workspace uuid alone subscribes to nothing. Unknown-workspace and not-a-member answer the SAME code (`workspace_access_denied`) so the channel cannot be used to probe which workspaces exist. Mutating frames re-check >= approver PER OPERATION, so a mid-connection role revocation stops writes on the next frame.
Frames
server → client Board event · WsServerEvent
A discriminated union on `t`: ticket.created, ticket.updated, comment.created, agent.status, gate.created, gate.resolved, blocker.raised, standup.posted, connector.suggested, presence. Every event carries `event_id`, and the board state is reconstructable from the event log alone (AC-04-04).
Echoes the `client_op_id` of a client operation with `ok` plus, on failure, the use-case error code and message. This is the frame an optimistic UI reconciles against — a rejected move is rolled back in the client rather than left looking applied.
{
"t": "ack",
"client_op_id": "op-7",
"ok": false,
"error": "invalid_transition",
"message": "in_review -> backlog is not a legal move"
}
A protocol- or authorization-level fault: invalid_json, invalid_frame, workspace_query_invalid, unauthenticated, workspace_access_denied. The connection is closed with 1008 after an authorization fault.
client → server Client operation · WsClientMessage
ticket.move, comment.create or typing — each with a `client_op_id` the server echoes in its ack. Board writes over WebSocket enforce the same RBAC ladder as their REST twins.
Unknown properties are stripped rather than rejected.
Notes
The channel is mounted on the same HTTP server as the REST API but is NOT under /api/v1 — connect to /ws directly.
Heartbeats every 15s; a peer that misses a full cycle is dropped.
The board is fan-out, never the record: the durable event log is the authority a client resumes against.
Ticket activity stream
sse/api/v1/tickets/{id}/stream
What an agent is doing on one ticket, as Server-Sent Events: session start, curated thought summaries, tool calls and results, text deltas, artifacts, gate requests and the session end. Replays the recent ring, then follows live. The `id:` line carries the ring seq.
Authentication
The same session cookie and workspace header as any REST read (>= viewer). Workspace scope is asserted BEFORE any stream bytes are written, so a cross-workspace ticket id never opens a stream.
Frames
server → client Ticket stream event · TicketStreamEvent
A discriminated union on `t`. `thought.summary` is CURATED progress, never raw chain-of-thought (DECISION-06-02) — only curated frames ever enter the buffer, so there is no configuration in which a client can read the model's unfiltered reasoning.
Heartbeat comment (`: hb`) every 15s. `X-Accel-Buffering: no` is set so proxies do not buffer frames.
EventSource cannot set headers; browser clients rely on the cookie and the same-origin proxy for X-Workspace-Id.
Chat stream
sse/api/v1/chat/{chatSessionId}/stream
The agent conversation, live. Replays the DURABLE transcript from the resume cursor and only then follows the in-process ring — the ring is fan-out, never the record. Replay-then-live is gap-free: a buffering listener attaches synchronously, so frames produced while the transcript read is in flight are captured and then de-duplicated by durable seq.
Authentication
Session cookie + X-Workspace-Id, >= viewer. Unknown and cross-workspace sessions answer the identical 404.
Frames
server → client Chat stream event · ChatStreamEvent
Text deltas, tool lines and message cards (the editable profile-draft card, credential requests, plan proposals). Human turns have NO frame in the grammar and none is invented — dressing a human turn as agent output would put words in the agent's mouth — so they advance the resume cursor with an `id:` line and a comment naming where to read them.
{
"t": "text.delta",
"text": "I found your site and read the homepage. "
}
`id:` carries the durable chat_messages.seq. Frames with no durable row carry NO `id:`, so Last-Event-ID never advances past something the transcript could not replay.
Resume with the browser's automatic Last-Event-ID header, or explicitly with ?afterSeq=. The header wins — it is what the client actually received.
Stability & versioning
Where the version comes from
info.version is 0.1.0, taken from apps/api/package.json — the api’s own version, the same value GET /health advertises. Generation fails if those two ever disagree, so a running server and the document describing it always claim the same number. There is deliberately no separate “spec version” to keep in step, and no repo version (the workspace root is private and unversioned).
The /api/v1 path prefix is the wire version and moves only for a breaking change. info.version moves with the api release.
How a breaking wire change is made
Every boundary schema lives in packages/contracts, and the binding architecture decisions that govern them (DECISION-xx-nn) change only through an ADR in docs/adr/. So a breaking wire change is a four-step act, in this order:
Write the ADR. It records what breaks, who is affected, and the migration. A contracts change without one is a review failure, not a merge conflict to resolve later.
Expand, then migrate, then contract — the same discipline the database migrations use. Add the new field as optional, ship clients onto it, and only then remove the old one. A field going from optional to required, or disappearing, is a break even when no test notices.
Regenerate.pnpm openapi:generate, and commit docs/api/openapi.json in the same pull request. This is what makes the wire change visible in the diff to a reviewer.
Bump the prefix only if it truly breaks. A removal or a semantic change of an existing field needs /api/v2 alongside /api/v1; additive change does not.
Additive and therefore safe: a new optional request field, a new response field, a new enum member on a value the server produces, a new endpoint. Breaking: removing or renaming anything, tightening validation, adding a required request field, adding an enum member to a value the server accepts (old clients will not know to handle the new one coming back), or changing a status code.
Why this page cannot go stale
The document is generated, never written: the route table is reflected off the NestJS controllers and the schemas are the zod objects the server validates with. CI runs pnpm openapi:check, which regenerates and fails when the committed artifact differs, and a reconciliation test asserts in both directions that every registered route is documented and every documented route exists. Adding a controller route without documenting it breaks the build.
apps/api/src/docs/generate.ts + packages/contracts/src/openapi — regenerate with `pnpm openapi:generate`; CI fails when the committed artifact differs (`pnpm openapi:check`).
One honest gap
38 of the 122 schemas below are marked mirror. Those shapes are declared inside apps/api — controller-local request schemas and use-case DTOs that have not been promoted into packages/contracts yet — so they are described here rather than derived, and they are the only part of this document that a code change could contradict without CI noticing. Each one names the file it mirrors. Promoting them into contracts is what closes the gap; the endpoints themselves cannot drift either way.
Problem code reference
Every code the api can answer with, read out of its own registry. The type URI is the stable identifier.
Code
Status
Meaning
admin_disabled
503
Platform-admin surface (x-vb-admin-token; ingest-token guard precedent): VB_ADMIN_TOKEN unset — the admin surface is off, never open
admin_token_invalid
401
header missing or mismatched — fail closed
agent_not_available
501
addon grants the seat but the agent has no v1 roster row (v1.5/v2 keys)
args_hash_mismatch
403
approve change A, attempt payload B (DECISION-04-06)
artifact_path_outside_root
400
Market-brief artifact loading (spec 02 §2 surface): the stored content_path must resolve UNDER the configured artifacts root (VB_ARTIFACTS_ROOT) — a row pointing outside it is refused, never read.
bank_not_found
404
Bank not found
brief_not_found
404
market-brief surface: no brief versions yet / unknown version
change_not_found
404
spec 05 §6 site_changes lookup by row id / change_id
chat_event_invalid
422
internal ingest: api re-validates each event against ChatStreamEvent
chat_session_not_found
404
spec 06 §7 chat channel: unknown session or wrong workspace
csrf_token_invalid
403
Csrf token invalid
csrf_token_missing
403
CSRF (spec 13 §3) — auth/csrf.guard.ts.
destination_unsupported
422
only client_site has a v0 connector publish path
draft_not_found
404
unknown content draft id / wrong workspace
email_taken
409
Signup on an address that already exists (@vb/app RegisterUser). 409, not 422: the body is well-formed and semantically valid, it conflicts with existing server state (RFC 9110 §15.5.10). The disclosure is deliberate and bounded — the honest alternative (pretend success) either strands the caller or mails a stranger, the detail never says whether the existing account signs in with a password or with Google, and the route carries the same per-email+IP / per-IP throttle as login so it cannot be driven as a bulk address oracle.
empty_bank
422
Empty bank
entitlement_writes_refused
403
Billing & entitlements (E10, spec 15; AC-15-02 server-side enforcement): trial/manual-assist plans never execute production writes (DECISION-15-02)
VB_INGEST_TOKEN unset — the ingest surface is off, never open
ingest_token_invalid
401
Internal event ingest (packages/contracts ingest.ts; spec 04 §5 dev transport): x-vb-ingest-token missing or mismatched — fail closed
integration_not_connected
409
refresh needs a connected row with a vault ref
integration_not_found
404
spec 06 §5: unknown integration id / wrong workspace
invalid_credentials
401
Password auth (spec 11 §2). ONE code for "unknown email" and "wrong password" on purpose — a distinct code would turn the login endpoint into a user-enumeration oracle (see auth/auth.controller.ts).
spec 26 §2 inbox: unknown id or another user's row
oauth_callback_invalid
400
Google OIDC callback taxonomy (spec 11 §2 OAuth primary, spec 13 §3): Google returned error=... or code/state missing
oauth_email_unverified
403
accounts bind by email; unverified is refused
oauth_exchange_failed
502
Google token endpoint rejected the exchange
oauth_id_token_invalid
401
signature/iss/aud/exp/nonce check failed
oauth_refresh_invalid_grant
409
GSC/GA4 consent + token-refresh taxonomy (spec 07 §7): refresh token expired/revoked per Google docs — re-auth required
oauth_state_mismatch
400
unknown/expired/replayed state — CSRF posture
oauth_upstream_unavailable
502
discovery/JWKS fetch failed or malformed
placement_kind_unavailable
422
the property does not offer this placement kind (null price)
placement_not_found
404
unknown placement id / wrong workspace
policy_failed
422
Content publish pipeline (E09, spec 02 §5 / spec 24): DECISION-24-01: draft failed policy; violations ride the body
policy_pass_required
409
gated draft without a passing stored verdict (corrupt state)
profile_approved_immutable
409
IMPLEMENTATION DECISION (spec 06 §1 is silent past "draft edits"): PATCH /profile on an APPROVED profile conflicts with the domain rule "approved exactly once, then read-only" (spec 05 §2) — 409, never a silent flip back to draft; re-onboarding creates a new draft instead.
spec 06 §5 connect: provider exists in the enum but has no v1 connect flow yet (spec 07 §2-§4 are v1.5/v2) — honest 501, never an invented flow.
provider_verification_failed
422
spec 06 §5 credentials: the vendor itself refused the submitted credentials during the pre-store verification probe (integrations/provider-probe.ts). 422 — the request is well-formed, the values do not work against the vendor.
queue_unavailable
503
REDIS_URL unset or enqueue failed — nothing would pick the job up
Sets the vb_csrf cookie and returns the matching token to send as X-VB-Csrf. The token is BOUND to the current session, so it must be re-fetched after login or logout (both of those responses already ship a replacement cookie). GET /auth/me also refreshes a stale pair.
Safe method, so the CSRF guard does not gate it — this is the bootstrap every state-changing call depends on.
no workspace rolenot workspace-scopedunauthenticatedAuthController.csrf
Request
curl -sS -X GET "$VB_API/api/v1/auth/csrf"
Response 200 OK
A fresh token plus the header and cookie names to use.
Convenience entry for local development: finds or creates a user by email and sets the session cookie. HARD-DISABLED when NODE_ENV=production, where it answers 404 — password login and Google OIDC are the only production entries.
Never available in production.
no workspace rolenot workspace-scopedCSRF token requiredheader X-VB-CsrfAuthController.devLogin
302 to the Google consent screen with PKCE S256 and a bound single-use state. Answers 501 problem+json while GOOGLE_CLIENT_ID/SECRET are unset — the stub adapter throws before any redirect happens.
no workspace rolenot workspace-scopedunauthenticatedAuthController.googleStart
Validates state, PKCE and the id_token (local JWKS RS256 verification), maps the verified email to a user and issues the standard session cookie — OAuth is a way to OBTAIN a session, not a parallel auth system. Then 302 to the web app.
No CSRF token and none needed: it is a GET, and its anti-forgery control is the bound single-use state parameter.
no workspace rolenot workspace-scopedunauthenticatedAuthController.googleCallback
Request
curl -sS -X GET "$VB_API/api/v1/auth/google/callback"
Parameters
Name
In
Type
Description
code
query
string
Google authorization code.
state
query
string
The bound single-use state issued at /auth/google.
An unknown address, an address with no password (SSO-only account) and a wrong password all answer the same 401 invalid_credentials and spend the same Argon2id cost getting there, so the endpoint cannot be timed to enumerate accounts. The throttle is checked BEFORE the hasher.
CSRF required — login-CSRF (silently signing a victim into an attacker-controlled account) is a real attack.
no workspace rolenot workspace-scopedCSRF token requiredheader X-VB-CsrfAuthController.login
Revokes the durable session row (so the logout survives a restart and applies to every replica) and clears both the session and CSRF cookies. Idempotent: unknown, expired and already-revoked handles all succeed.
no workspace rolenot workspace-scopedunauthenticatedAuthController.logout
Request
curl -sS -X POST "$VB_API/api/v1/auth/logout"
Response 204 No Content
Session revoked; both cookies cleared.
Errors — 0 problem codes across 1 status
Status
Code
type
Every error body is application/problem+json. Switch on type, never on title or detail.
Registers the address, starts a session and returns the principal. Shares the login throttle buckets: without that, this route would be a fast "does this address exist" oracle.
CSRF IS required here even though the caller has no session yet: signup creates ambient authority, and a forged signup plants an attacker-controlled account in the victim's browser.
no workspace rolenot workspace-scopedCSRF token requiredheader X-VB-CsrfAuthController.signup
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Partial DRAFT edits — absent fields stay untouched (competitors never implicitly clear). An APPROVED profile answers 409 profile_approved_immutable: approved once, then read-only. Re-onboarding creates a new draft instead. `primaryDomain` is stripped from the request.
role >= approverX-Workspace-Id requiredCSRF token requiredcookie vb_sessionheader X-VB-CsrfProfileController.patchProfile
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Resolves the profile.approved gate. The only path from draft to approved is a human calling this. `activation` reports what the approval STARTED, step by step: the draft question bank (with its id, version and prompt count), the first site audit and the first measurement — each with a real status, and for anything that could not start the named precondition in `waitingOn`. When no bank could be drafted, `missing` names the profile inputs responsible (`offerings`, `category`, `brand_free_terms`) instead of leaving the customer with silence. Absent on an already-approved replay: nothing was re-run.
role >= approverX-Workspace-Id requiredCSRF token requiredcookie vb_sessionheader X-VB-CsrfProfileController.approveProfile
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Response 200 OK
The approved profile id, status and what the approval kicked off.
Answers with the authenticated actor's memberships and nothing else — there is no global workspace listing on this surface. This is the route that PRODUCES the scope the X-Workspace-Id header carries everywhere else.
no workspace rolenot workspace-scopedcookie vb_sessionWorkspacesController.listWorkspaces
Request
curl -sS -X GET "$VB_API/api/v1/workspaces" \
-b "vb_session=$VB_SESSION"
Response 200 OK
Every workspace the caller is a member of, with their role.
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
The resource id. A uuid on every route except the site-change and content-draft routes, where it may also be the client-chosen change id.
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Column moves go through the domain entity, so an illegal transition is a 409 invalid_transition rather than a silent write. Only the transitions a human is allowed to make are accepted.
role >= approverX-Workspace-Id requiredCSRF token requiredcookie vb_sessionheader X-VB-CsrfBoardController.patchTicket
The resource id. A uuid on every route except the site-change and content-draft routes, where it may also be the client-chosen change id.
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
The resource id. A uuid on every route except the site-change and content-draft routes, where it may also be the client-chosen change id.
cursor
query
string(uuid)
Keyset cursor: the id of the last comment from the previous page. Absent starts at the newest row.
limit
query
integer
Page size, 1–100. Defaults to the server page size.
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
The resource id. A uuid on every route except the site-change and content-draft routes, where it may also be the client-chosen change id.
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Every error body is application/problem+json. Switch on type, never on title or detail.
Approvals & trust
The approval inbox. Approving a gate is the ONLY path to a production write; the single-use args-hashed approval token never crosses HTTP (spec 04 §6).
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
The resource id. A uuid on every route except the site-change and content-draft routes, where it may also be the client-chosen change id.
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
The single most important write in the product. Idempotent: a double-submit returns the current state with 200. Trust-tier preconditions are enforced HERE at token mint (403 tier_requirement leaves the gate pending). The minted single-use, args-hashed approval token NEVER crosses HTTP — it flows only to the Temporal signal / Gatekeeper path.
Approval is the ONLY path to a production write (invariant #1). The token is validated again at the Gatekeeper hook AND at the server layer (DECISION-04-06).
role >= approverX-Workspace-Id requiredCSRF token requiredcookie vb_sessionheader X-VB-CsrfGatesController.approve
The resource id. A uuid on every route except the site-change and content-draft routes, where it may also be the client-chosen change id.
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
The resource id. A uuid on every route except the site-change and content-draft routes, where it may also be the client-chosen change id.
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Every error body is application/problem+json. Switch on type, never on title or detail.
Measurement
Prompt banks, metric series and evidence. Every metric carries a confidence band and its measurement regime — there are no naked numbers on this surface (DECISION-03-04).
The workspace-wide per-prompt summary: for every prompt with scored samples, one state per engine × measurement regime — mention rate with its Wilson CI, `mentions`/`runs` (the exact numerator/denominator behind the per-question "You: 0/3" column), average rank with a band, and the modal sentiment (`mixed` on a tie, never resolved by fiat). A COMPUTED read model: recomputed from stored prompt samples on every request, backed by no table, so every figure is reproducible from the evidence drawer. States from different regimes are served separately, never blended (AC-17-02).
role >= viewerX-Workspace-Id requiredcookie vb_sessionScoresController.promptState
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Response 200 OK
Per-prompt states grouped by prompt id, plus the tracked-prompt count.
The share of cited sources that belong to the brand's own properties. Every point carries a confidence interval, the sample size n and the measurement regime (runner_mode + model_version). Points from different regimes are never merged into one aggregate (AC-17-02).
role >= viewerX-Workspace-Id requiredcookie vb_sessionMetricsController.citationShare
Restrict to one answer engine. Absent returns one series per engine.
from
query
string(date)
Inclusive start date (YYYY-MM-DD).
to
query
string(date)
Inclusive end date (YYYY-MM-DD).
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Reciprocal-rank position of the brand inside the answer. Every point carries a confidence interval, the sample size n and the measurement regime (runner_mode + model_version). Points from different regimes are never merged into one aggregate (AC-17-02).
role >= viewerX-Workspace-Id requiredcookie vb_sessionMetricsController.position
Restrict to one answer engine. Absent returns one series per engine.
from
query
string(date)
Inclusive start date (YYYY-MM-DD).
to
query
string(date)
Inclusive end date (YYYY-MM-DD).
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Brand mentions as a share of all brand mentions in the answer set. Every point carries a confidence interval, the sample size n and the measurement regime (runner_mode + model_version). Points from different regimes are never merged into one aggregate (AC-17-02).
role >= viewerX-Workspace-Id requiredcookie vb_sessionMetricsController.sov
Restrict to one answer engine. Absent returns one series per engine.
from
query
string(date)
Inclusive start date (YYYY-MM-DD).
to
query
string(date)
Inclusive end date (YYYY-MM-DD).
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
How often the brand appears in answers to unbranded questions. Every point carries a confidence interval, the sample size n and the measurement regime (runner_mode + model_version). Points from different regimes are never merged into one aggregate (AC-17-02).
role >= viewerX-Workspace-Id requiredcookie vb_sessionMetricsController.visibility
Restrict to one answer engine. Absent returns one series per engine.
from
query
string(date)
Inclusive start date (YYYY-MM-DD).
to
query
string(date)
Inclusive end date (YYYY-MM-DD).
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Draft and frozen banks for the workspace, optionally filtered by locale. AR and EN banks are siblings, never merged. Each card carries `promptCount` so a review page needs no per-bank round trip to show its size.
role >= viewerX-Workspace-Id requiredcookie vb_sessionPromptBanksController.list
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Lint precondition then freeze. A brand leak in an unbranded prompt answers 422 prompt_bank_lint_failed with the offending prompts as RFC-7807 extension members (AC-02-03) — the bank never freezes with a leak in it. FREEZING STARTS THE WORK: on the draft → frozen transition the first measurement over that version is enqueued through the same producer POST /runs uses, and `measurement` reports the queue receipt (or, when it could not start, the named precondition it waits on). The enqueue is contained (a queue outage cannot un-freeze the bank) and idempotent (a deterministic version-scoped jobId), so `measurement` is absent when an already-frozen bank is re-approved — nothing was started.
The 422 `prompt_bank_lint_failed` problem carries the EVIDENCE as RFC-7807 extension members, so a review page can highlight the offending prompts instead of restating the message: `brandLeaks` (integer — how many findings are brand leaks, the DECISION-03-01 violations) and `violations` (array of `{promptId, text, violations: [{code, message, alias?}]}`, where `code` is one of `brand_leak`, `over_length`, `empty_text`, `control_class_missing_brand` and `alias` is present on a brand leak, naming the alias that matched).
The measurement kicked off by the freeze is enqueued with a DETERMINISTIC jobId scoped to workspace + locale + bank version, so replaying the approval cannot buy a second vendor-billed cycle, while freezing a NEW version does get its own baseline run.
role >= approverX-Workspace-Id requiredCSRF token requiredcookie vb_sessionheader X-VB-CsrfPromptBanksController.approve
The resource id. A uuid on every route except the site-change and content-draft routes, where it may also be the client-chosen change id.
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Response 200 OK
The frozen bank id, version, status and what the freeze kicked off.
The bank's prompts grouped by prompt class, with its version and freeze status, its counts (total/enabled/per class/per language) and its STRATIFY coverage: target, actual and deficit per class × locale (spec 03 §3 step 4). The targets are allocated by the same domain quota matrix the bank BUILD ran, against the plan's prompt-bank capacity — `coverage.basis` says whether that size came from the workspace entitlement or the conservative Starter fallback, so no client has to invent a target. `coverage.totalActual` counts stratum members only: an AR prompt inside an EN bank fills no EN stratum and is reported through `counts.byLanguage` instead. ADDITIVE `tracking` per prompt: one state per engine × measurement regime the prompt has scored samples under (empty until the first measurement), each carrying `mentions`/`runs` with the Wilson CI band — this is what makes the per-question "You: 0/3" column buildable directly from this read. States from different regimes are never merged (AC-17-02); everything is recomputed from stored samples, no table backs it.
role >= viewerX-Workspace-Id requiredcookie vb_sessionPromptBanksController.prompts
The resource id. A uuid on every route except the site-change and content-draft routes, where it may also be the client-chosen change id.
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Response 200 OK
The bank, its grouped prompts (with per-prompt tracking), counts and coverage.
Draft banks only — an edit against a frozen bank is a 409 frozen_bank, because a frozen bank is what makes a measurement comparable over time. Text is capped at 500 characters, mirroring the runner hard cap (AC-17-04).
role >= approverX-Workspace-Id requiredCSRF token requiredcookie vb_sessionheader X-VB-CsrfPromptBanksController.patchPrompt
The resource id. A uuid on every route except the site-change and content-draft routes, where it may also be the client-chosen change id.
pid req
path
string
Prompt id (uuid).
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
The throughput verb the review page needs at 400 prompts — enable or disable up to 500 prompts in one call, instead of one PATCH each. Semantics mirror the single-prompt PATCH exactly: DRAFT banks only (a frozen bank answers 409 frozen_bank — the frozen version is a measurement regime and must not shift under it), and the whole batch validates BEFORE anything applies: an unknown prompt id fails the entire request with the offending ids named, never a half-applied batch. Idempotent in effect — `updated` counts only prompts whose state actually changed, so a double-submit reports 0.
role >= approverX-Workspace-Id requiredCSRF token requiredcookie vb_sessionheader X-VB-CsrfPromptBanksController.bulk
The resource id. A uuid on every route except the site-change and content-draft routes, where it may also be the client-chosen change id.
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Runs candidates through the deterministic AR-aware linter, dedupe and quota stratification. The response reports what was KEPT and what was DROPPED with the reason, so nothing disappears silently.
role >= approverX-Workspace-Id requiredCSRF token requiredcookie vb_sessionheader X-VB-CsrfPromptBanksController.build
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Keyset cursor from the previous page's nextCursor (a run id, or `demo` in in-memory mode).
limit
query
integer
Page size, 1–100. Defaults to the server page size.
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
The individual engine answers behind an aggregate — the receipts. Each sample carries its verbatim answer text, the classified citations and the regime it was collected under.
role >= viewerX-Workspace-Id requiredcookie vb_sessionRunsController.samples
Measurement run id (uuid), or the literal `demo` in in-memory mode.
prompt_id
query
string(uuid)
Restrict to samples for one prompt.
cursor
query
string(uuid)
Keyset cursor: the id of the last sample from the previous page. Absent starts at the newest row.
limit
query
integer
Page size, 1–200. Defaults to the server page size.
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
The ADR 0004 composites: AVS (Σ PVS × mention_rate / Σ PVS) and Share of Model per engine × measurement regime — never cross-engine averaged (AC-17-02) — each with a CI band propagated from the per-prompt Wilson bands, and CRS = AccessGate × (0.45 OnSite + 0.55 OffSite) over the findings feed, where AccessGate is 0.2 exactly when an open access-gate marker finding (a T2 crawler block) exists. Nothing is stored: every number is recomputed on read and the response carries the full drill-down — which prompts carry which PVS (with the versioned weight set and the NAMED neutral-prior components) and which findings cost which CRS points (AC-8) — so every figure is recomputable by hand.
role >= viewerX-Workspace-Id requiredcookie vb_sessionScoresController.list
Restrict AVS/SoM to one answer engine (CRS is workspace-level).
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Response 200 OK
One AVS/SoM entry per engine × regime, plus the workspace CRS.
Deterministic detector output, newest first. Detectors are code, never model judgement (DECISION-17-03), and each finding links the playbook ticket it created.
role >= viewerX-Workspace-Id requiredcookie vb_sessionFindingsController.list
Keyset cursor: the id of the last finding from the previous page. Absent starts at the newest row.
limit
query
integer
Page size, 1–100. Defaults to the server page size.
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Every error body is application/problem+json. Switch on type, never on title or detail.
Demand seeds
Mined demand signals (autocomplete suggestions, People-Also-Ask questions) with checkable evidence, and per-prompt provenance — no prompt without a source (AC-2); template-derived prompts say so instead of inventing one.
The stored demand signals mined at profile approval, newest first, each carrying its source (Google Autocomplete per market, DataForSEO People-Also-Ask / related searches), quote-level evidence and a deterministic confidence rung. `metricVolume` is absent for every implemented source — no volume vendor is wired, and absent means unknown, never 0. The reserved `gsc` source stays empty until the Search Console data reader lands (the GSC adapter is consent plumbing only today).
role >= viewerX-Workspace-Id requiredcookie vb_sessionSeedsController.list
Keyset cursor: the id of the last seed from the previous page. Absent starts at the newest row.
limit
query
integer
Page size, 1–100. Defaults to the server page size.
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Where one prompt came from (AC-2: no prompt without a source). `derivation: 'seeds'` lists the stored demand seeds the prompt was built from; `derivation: 'template'` is the honest answer for prompts built by the deterministic template fallback — an empty lineage, never an invented one.
role >= viewerX-Workspace-Id requiredcookie vb_sessionSeedsController.promptProvenance
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
The current ISO week's digest, composed on demand and cached per (workspace, week). Metric lines are copied VERBATIM from the same MetricsService instance /metrics uses, so the digest and the dashboard can never disagree about a number (AC-02-08).
role >= viewerX-Workspace-Id requiredcookie vb_sessionReportsController.latest
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Market-brief version number (1-based, append-only).
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
The resource id. A uuid on every route except the site-change and content-draft routes, where it may also be the client-chosen change id.
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
The resource id. A uuid on every route except the site-change and content-draft routes, where it may also be the client-chosen change id.
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Exercises the stored refresh token. A Google `invalid_grant` (expired or revoked per their docs) flips the row to `error` and answers 409 oauth_refresh_invalid_grant with a re-auth notification — never a silent retry loop.
role >= ownerX-Workspace-Id requiredCSRF token requiredcookie vb_sessionheader X-VB-CsrfIntegrationRefreshController.refresh
The resource id. A uuid on every route except the site-change and content-draft routes, where it may also be the client-chosen change id.
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Answers either `{kind:"oauth", oauthUrl}` for the guided Google consent flows or `{kind:"credentials", formSchema, reason}` for scoped-token providers. A provider that exists in the enum but has no v1 flow answers an honest 501 provider_not_connectable rather than an invented URL.
role >= approverX-Workspace-Id requiredCSRF token requiredcookie vb_sessionheader X-VB-CsrfIntegrationsController.connect
Integration provider key, e.g. `cloudflare`, `gsc`, `ga4`.
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Values travel TLS → here → vault and are NEVER echoed, logged, stored in Postgres or attached to an event (DECISION-06-01). Hence 204 with an empty body, always.
role >= approverX-Workspace-Id requiredCSRF token requiredcookie vb_sessionheader X-VB-CsrfIntegrationsController.submitCredentials
Integration provider key, e.g. `cloudflare`, `gsc`, `ga4`.
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
The CONSENT flow's own callback, distinct from the login callback. `state` binds {workspaceId, provider} server-side, so this route carries no workspace header and no session requirement — it is a browser redirect from accounts.google.com. Every post-state outcome answers 302 back to the web settings page.
no workspace rolenot workspace-scopedunauthenticatedGoogleConsentCallbackController.callback
Request
curl -sS -X GET "$VB_API/api/v1/integrations/oauth/google/callback"
Generates the per-workspace shared HMAC secret the WordPress pairing steps reference: the user pastes it into the plugin under Settings → VisibleBrand AND into the `pairingCode` credential field; the live handshake then proves both sides hold the same secret. The value is returned ONCE in this response (the shown-once posture of an Application Password) and stored in the vault; it is a platform-generated secret delivered TO the user, not an echoed submission, so DECISION-06-01 is untouched. Re-minting rotates: the superseded vault entry dies immediately.
The handshake — not a comparison against the stored copy — is what proves possession, so a secret minted elsewhere (e.g. WP-CLI) still pairs.
role >= approverX-Workspace-Id requiredCSRF token requiredcookie vb_sessionheader X-VB-CsrfWordPressPluginController.mintPairingSecret
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Response 200 OK
The freshly minted secret and its mint time. Shown once.
The `visiblebrand-connector` plugin zip, built at request time from the checked-in plugin source that ships with the deploy — no separate artifact store, so the download can never lag the code. Deterministic: identical source yields a byte-identical zip (store method, fixed timestamps), and the version in the filename is parsed from the plugin header — the single source of truth. The zip contains only what a WordPress install needs (main file, src/, readme.txt, license.txt), rooted so wp-admin Upload Plugin extracts it correctly.
Step 1 of the connect flow the wordpress connect descriptor describes (its `downloadPath` points here); the full guide is docs/integrations/wordpress.md.
role >= viewerX-Workspace-Id requiredcookie vb_sessionWordPressPluginController.downloadPlugin
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Response 200 OK
The plugin as `application/zip`, served as an attachment named `visiblebrand-connector-{version}.zip`.
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Exhaustive by contract — every type × every channel. A partial body is a 400, never a silent merge, so a client can never half-configure someone's routing. The in-app row is never suppressed, quiet hours included.
role >= viewerX-Workspace-Id requiredCSRF token requiredcookie vb_sessionheader X-VB-CsrfNotificationPrefsController.put
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Keyset cursor from the previous page's nextCursor (a notification cursor).
limit
query
integer
Page size, 1–100. Defaults to the server page size.
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
The resource id. A uuid on every route except the site-change and content-draft routes, where it may also be the client-chosen change id.
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Owner-only — hiring changes what the org pays for. The response distinguishes the four AC-15-02 outcomes: already included, purchasable as an add-on (with the hold), walled by the plan, or refused because a trial plan may never execute production writes.
role >= ownerX-Workspace-Id requiredCSRF token requiredcookie vb_sessionheader X-VB-CsrfHireAgentController.hire
Agent registry key, e.g. `technical`, `content`, `researcher`.
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
The plan card, the entitlements snapshot the server enforces (AC-15-02) and the usage meters. Viewer-readable: the team panel and every capability wall render from this one response.
role >= viewerX-Workspace-Id requiredcookie vb_sessionPlanController.plan
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Every error body is application/problem+json. Switch on type, never on title or detail.
Measurement costs
What measurement spends (PM AC-12): a pre-run cost preview priced only from vendor-cited figures (unknown stays unknown — never guessed) and the workspace month-to-date agent_costs sums, integer minor units per currency.
The workspace's current-UTC-calendar-month agent_costs sums — the same ledger the workers' cost meter writes (spec 17 §5) and the monthly budget ceiling is checked against (PM AC-12). Integer minor units, one total per currency, never blended.
role >= viewerX-Workspace-Id requiredcookie vb_sessionCostsController.month
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Estimated vendor calls (promptCount × samples per engine) and a cost band from a versioned unit-price table. Prices come ONLY from the vendor manifest's cited figures (Docs-First Protocol, spec 18); an engine without a citable price answers null with the reason, and the band's upper bound is null while any requested engine is unpriced — unknown is served as unknown, never as a guess.
role >= viewerX-Workspace-Id requiredcookie vb_sessionCostsController.preview
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Placement inventory across the Mawdoo3 network, filterable by vertical and locale. Prices are integer minor units with an ISO-4217 currency; `null` means the property does not offer that placement kind.
role >= viewerX-Workspace-Id requiredcookie vb_sessionNetworkPropertiesController.listProperties
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Requests the placement.purchase gate on the placement's ticket — it does NOT spend money. The human then decides in the approval inbox; the T2 tier requirement is enforced at token mint, and a fresh approval places the billing hold via the gate observer. Idempotent: an existing pending gate answers `replayed: true`.
role >= approverX-Workspace-Id requiredCSRF token requiredcookie vb_sessionheader X-VB-CsrfPlacementsController.approve
The resource id. A uuid on every route except the site-change and content-draft routes, where it may also be the client-chosen change id.
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Creates the board ticket and the placement row plus the complete proposal card (property, audience, rationale, disclosure, cost, expected impact with a CI — AC-02-06, populated by construction). NO gate and NO money yet.
role >= approverX-Workspace-Id requiredCSRF token requiredcookie vb_sessionheader X-VB-CsrfPlacementsController.propose
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
200 EITHER WAY: `{verified:false, failure}` is a flow answer (AC-23-03), not an error — RFC-7807 is reserved for request faults, so a not-yet-propagated DNS record is not dressed up as a 4xx. 409 only when there is no pending claim to check.
role >= ownerX-Workspace-Id requiredCSRF token requiredcookie vb_sessionheader X-VB-CsrfVerificationController.check
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Approve flips the workspace t1 → t2 with events and an audit entry; reject records the event. The reviewer must always justify the decision — the note is required. The body names its target workspace because this route is not scoped by header.
no workspace rolenot workspace-scopedheader x-vb-admin-tokenKybController.decide
PLATFORM-ADMIN route, deliberately NOT workspace-scoped: the reviewer works across workspaces. Authority is the x-vb-admin-token shared secret, compared in constant time. Unset VB_ADMIN_TOKEN means the surface is OFF (503), never open.
no workspace rolenot workspace-scopedheader x-vb-admin-tokenKybController.queue
Request
curl -sS -X GET "$VB_API/api/v1/verification/kyb/queue" \
-H "x-vb-admin-token: $VB_ADMIN_TOKEN"
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Every error body is application/problem+json. Switch on type, never on title or detail.
Verify-it-worked
The J12 loop: a done ticket that blamed specific prompts gets a baseline at done-time, a scoped re-run of exactly those prompts after the window, and a statistical verdict — moved, no_change, worsened, or an honest inconclusive. Same-regime comparisons only (AC-17-02).
The J12 loop's two halves for one ticket: the plan written when the ticket was marked done (blocked prompts, window, baseline band — or the honest 'no_baseline'), and the re-run verdict once the window has produced one. Both are null until their moment arrives; nothing here is ever a naked number (DECISION-03-04).
role >= viewerX-Workspace-Id requiredcookie vb_sessionVerificationLoopController.getVerification
The resource id. A uuid on every route except the site-change and content-draft routes, where it may also be the client-chosen change id.
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Response 200 OK
The ticket's verification plan and result, each null until written.
Applies the change to STAGING, writes a diff artifact and opens a site.write gate whose meta binds tool=`cms.execute_change` plus the args-hash of exactly this change (spec 04 §6). Nothing touches production here. The entitlement check runs BEFORE the pipeline: a trial or manual-assist workspace is refused with 403 and no gate, no diff and no staged change are created.
`changeId` is a client idempotency key; a replay answers the existing change with `replayed: true`.
role >= approverX-Workspace-Id requiredCSRF token requiredcookie vb_sessionheader X-VB-CsrfSiteChangesController.propose
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
The resource id. A uuid on every route except the site-change and content-draft routes, where it may also be the client-chosen change id.
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
NOT the primary path — approving the gate auto-executes the bound change. This exists for retries after a crash or outage: it re-checks that the gate is human-approved and the tier is sufficient, then mints a FRESH single-use token server-side. No token ever crosses HTTP.
role >= ownerX-Workspace-Id requiredCSRF token requiredcookie vb_sessionheader X-VB-CsrfSiteChangesController.execute
The resource id. A uuid on every route except the site-change and content-draft routes, where it may also be the client-chosen change id.
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Restores the byte-identical prior state from the stored snapshot (AC-02-04). A failed rollback keeps the token durable so a manual retry stays possible.
role >= approverX-Workspace-Id requiredCSRF token requiredcookie vb_sessionheader X-VB-CsrfSiteChangesController.rollback
The resource id. A uuid on every route except the site-change and content-draft routes, where it may also be the client-chosen change id.
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Every error body is application/problem+json. Switch on type, never on title or detail.
Content publish
Draft submission through the deterministic policy engine. A failing verdict is a 422 carrying every violation with its exact excerpt, and NO gate is created (DECISION-24-01).
The deterministic policy engine runs FIRST. On a failing verdict the draft persists as `policy_failed` and the response is a 422 problem+json carrying every violation WITH its exact excerpt — the rewrite-loop feed — and NO gate exists (DECISION-24-01). On a passing verdict: staging preview, diff and a content.publish gate bound to tool + args-hash. The entitlement check precedes all of it.
role >= approverX-Workspace-Id requiredCSRF token requiredcookie vb_sessionheader X-VB-CsrfContentController.submit
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
The resource id. A uuid on every route except the site-change and content-draft routes, where it may also be the client-chosen change id.
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
The retry twin of the site-change manual execute: re-checks the human approval and tier, then mints a fresh single-use token server-side. A gated draft without a stored passing verdict is refused (409 policy_pass_required) rather than published.
role >= ownerX-Workspace-Id requiredCSRF token requiredcookie vb_sessionheader X-VB-CsrfContentController.republish
The resource id. A uuid on every route except the site-change and content-draft routes, where it may also be the client-chosen change id.
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Resume cursor — return only messages with a higher chat_messages.seq. Absent replays the whole transcript (seq 0 is a real message, so absence and 0 differ).
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
202, not 200: the message is durably recorded and a job is queued, but the AGENT'S reply is produced asynchronously by the runtime and arrives over the SSE stream. Claiming 200/complete would be a lie.
role >= approverX-Workspace-Id requiredCSRF token requiredcookie vb_sessionheader X-VB-CsrfChatController.send
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Replays the durable transcript from the resume cursor, then follows live — gap-free: a buffering listener attaches synchronously so frames produced during the transcript read are captured, then deduped by durable seq. `id:` carries chat_messages.seq, and frames with no durable row (mid-turn deltas, tool lines) carry NO `id:`, so Last-Event-ID never advances past something the transcript could not replay. The browser's automatic Last-Event-ID header wins over ?afterSeq=.
role >= viewerX-Workspace-Id requiredcookie vb_sessionChatController.stream
Resume cursor — return only messages with a higher chat_messages.seq. Absent replays the whole transcript (seq 0 is a real message, so absence and 0 differ).
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Last-Event-ID
header
string
Set automatically by EventSource on reconnect. A malformed value degrades to ?afterSeq=, then to a full replay.
Response 200 OK
An open event stream whose `data:` payloads are ChatStreamEvent objects. The schema describes ONE `data:` payload, not the whole body.
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
The REST read over the durable workspace event log the WS board channel replays (spec 04 §5): every retained board-channel event, oldest first, keyset paged on the opaque event_id. The log is a retained window (in-memory ring in dev; Redis Streams MAXLEN in production), not an archive — page one simply starts at the oldest event still retained. Per-connection presence hello frames never appear: they are snapshots, not history.
`head` is the log's current head event_id — the same baseline the WS hello frame carries, so a client can read history here and then resume live on the board channel without a gap.
role >= viewerX-Workspace-Id requiredcookie vb_sessionEventHistoryController.history
Keyset cursor: the event_id of the last event from the previous page (the previous response's nextCursor). Absent (or '0') starts at the oldest retained event.
limit
query
integer
Page size, 1–500. Defaults to the server page size.
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Response 200 OK
A page of events. `at` is the append instant when the log recorded one (Redis Streams entry ids carry it); null from the dev in-memory ring, which records order only — clients must not invent times for those.
Server-Sent Events of the agent working this ticket: replays the recent ring, then follows live until the client disconnects. `id:` carries the ring seq. Heartbeat comment every 15s. Curated frames only — never raw chain-of-thought (DECISION-06-02).
Workspace scope is asserted before any stream bytes are written.
role >= viewerX-Workspace-Id requiredcookie vb_sessionTicketStreamController.stream
The resource id. A uuid on every route except the site-change and content-draft routes, where it may also be the client-chosen change id.
X-Workspace-Id req
header
string(uuid)
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Response 200 OK
An open event stream whose `data:` payloads are TicketStreamEvent objects. The schema describes ONE `data:` payload, not the whole body.
Enqueues one audit cycle against the workspace's primary_domain. NO BODY on purpose: the URL is never caller-chosen, so this route cannot be turned into a crawler for arbitrary sites. 422 when the workspace has no domain on file.
role >= approverX-Workspace-Id requiredCSRF token requiredcookie vb_sessionheader X-VB-CsrfAuditTriggersController.start
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Enqueues one full measurement cycle. 202 — the work happens on the BullMQ fleet, so claiming a completed run would be a lie. Requires a FROZEN bank: a run against a draft bank would not be comparable (409 no_frozen_bank).
role >= approverX-Workspace-Id requiredCSRF token requiredcookie vb_sessionheader X-VB-CsrfRunTriggersController.start
The workspace this request acts inside. Required on every workspace-scoped route: the guard rejects a missing (400 workspace_header_missing), malformed (400 workspace_header_invalid) or unknown (404 workspace_not_found) value before any handler runs, and the value is pinned into the Postgres session so row-level security scopes every query behind it. Obtain one from GET /workspaces.
Can THIS process reach its own database and Redis? Liveness cannot answer that, and an api serving /health while Postgres is unreachable is the classic false green. ALWAYS answers 200, even when degraded — a non-2xx here would make a naive monitor or load balancer pull a degraded-but-serving instance out of rotation, turning a partial failure into a total one. The verdict is in the body, per dependency.
Sends `cache-control: no-store`. Unauthenticated, like /health.
no workspace rolenot workspace-scopedunauthenticatedHealthController.ready
Request
curl -sS -X GET "$VB_API/api/v1/health/ready"
Response 200 OK
The verdict plus one row per dependency. `status` is `ok` or `degraded`.
The whole status document in one response: overall state with the reason it was reached, per-component history, and open incidents. Unauthenticated and unscoped on purpose — a status page that requires a login is worthless during the outage that broke logins. Safe because the data is platform-scoped by construction: no query takes a workspace, the tables carry no workspace_id, and every probe detail string is sanitised before it is stored.
Sends `cache-control: no-store` rather than a short cache: a cached status page reports the state of the world as it was without saying so.
no workspace rolenot workspace-scopedunauthenticatedHealthController.statusPage
Request
curl -sS -X GET "$VB_API/api/v1/health/status"
Response 200 OK
Components, incidents, and an honest `source` field saying whether this was read live from the probe store or served degraded.
Adds a human-written, bilingual update to an incident. ADDITIVE ONLY: it cannot edit or delete what the prober observed — an operator adds context, they do not get to rewrite measurements. Authority is the existing x-vb-admin-token shared secret (the KYB-queue precedent), not a new scheme. An api with no status store at all answers 501 rather than pretending to have recorded it.
no workspace rolenot workspace-scopedheader x-vb-admin-tokenHealthController.appendIncidentUpdate
Every error body is application/problem+json. Switch on type, never on title or detail.
Internal — event ingest internal only
Service-to-service only. Authenticated by the shared X-VB-Ingest-Token secret, never by a session; the payload carries the workspace and every handler re-checks resource scope.
INTERNAL. Write-back, not just fan-out: agent output is PERSISTED as chat_messages rows before it is streamed, so an agent reply survives a restart exactly like the human turn that prompted it. Every event is re-validated against ChatStreamEvent BEFORE any write, and the whole batch is rejected with the index of the first invalid event, so a bad batch cannot half-apply to a transcript.
no workspace rolenot workspace-scopedheader x-vb-ingest-tokenIngestController.ingestChatEvents
INTERNAL. The bridge the agent-runtime posts through so its session events surface on the ticket SSE stream. The dev/v0 transport for the Redis Streams pipeline — same contract-shaped events, so swapping the transport never changes payloads. The ticket must exist in the workspace the payload claims; unknown and cross-workspace both 404.
no workspace rolenot workspace-scopedheader x-vb-ingest-tokenIngestController.ingestTicketEvents
Every error body is application/problem+json. Switch on type, never on title or detail.
Internal — agent runtime internal only
Service-to-service only. The agent-runtime reads transcripts and writes profile DRAFTS here; scope rides the path and is resource-checked, so a cross-workspace call 404s exactly like an unknown one.
INTERNAL. The runtime reads the user's turn from the durable transcript (its queue job carries ids only). Scope comes from the PATH and is resource-checked: unknown and cross-workspace sessions answer the identical 404, so there is no existence oracle.
no workspace rolenot workspace-scopedheader x-vb-ingest-tokenInternalAgentController.messages
Request
curl -sS -X GET "$VB_API/api/v1/internal/agent/workspaces/{workspaceId}/chat/{chatSessionId}/messages" \
-H "x-vb-ingest-token: $VB_INGEST_TOKEN"
Parameters
Name
In
Type
Description
workspaceId req
path
string
Workspace uuid. On the internal surfaces the scope rides the PATH (never a caller-claimed identity) and is resource-checked, so a cross-workspace call 404s exactly like an unknown one.
chatSessionId req
path
string
Chat session uuid.
afterSeq
query
integer
Resume cursor — return only messages with a higher chat_messages.seq. Absent replays the whole transcript (seq 0 is a real message, so absence and 0 differ).
INTERNAL. The stored Claude Agent SDK session id. This is what lets a turn CONTINUE the model's own session instead of starting a stranger — the fix for the production bug where a user typed "approve" and the agent re-crawled and re-presented.
no workspace rolenot workspace-scopedheader x-vb-ingest-tokenInternalAgentController.sessionMeta
Request
curl -sS -X GET "$VB_API/api/v1/internal/agent/workspaces/{workspaceId}/chat/{chatSessionId}/session-meta" \
-H "x-vb-ingest-token: $VB_INGEST_TOKEN"
Parameters
Name
In
Type
Description
workspaceId req
path
string
Workspace uuid. On the internal surfaces the scope rides the PATH (never a caller-claimed identity) and is resource-checked, so a cross-workspace call 404s exactly like an unknown one.
INTERNAL. Persists the SDK-assigned session id after a turn. Last write wins: a resume-miss fallback overwrites the stale pointer so the NEXT turn resumes the session that actually ran.
no workspace rolenot workspace-scopedheader x-vb-ingest-tokenInternalAgentController.patchSessionMeta
Workspace uuid. On the internal surfaces the scope rides the PATH (never a caller-claimed identity) and is resource-checked, so a cross-workspace call 404s exactly like an unknown one.
INTERNAL. One row per (model, session) usage entry of a turn: token counts are exact, costUsd is the SDK's per-model estimate. `chatSessionId` is REQUIRED as the scope proof — every named session must resolve inside the path workspace before anything is written, otherwise a caller could meter spend into any workspace uuid it invents. Real mode only; metering mock turns would fabricate spend.
no workspace rolenot workspace-scopedheader x-vb-ingest-tokenInternalAgentController.recordCosts
Workspace uuid. On the internal surfaces the scope rides the PATH (never a caller-claimed identity) and is resource-checked, so a cross-workspace call 404s exactly like an unknown one.
INTERNAL. The same DTO the public GET /profile serves, primaryDomain included, so the two surfaces cannot disagree about what is on file.
no workspace rolenot workspace-scopedheader x-vb-ingest-tokenInternalAgentController.getProfile
Request
curl -sS -X GET "$VB_API/api/v1/internal/agent/workspaces/{workspaceId}/profile" \
-H "x-vb-ingest-token: $VB_INGEST_TOKEN"
Parameters
Name
In
Type
Description
workspaceId req
path
string
Workspace uuid. On the internal surfaces the scope rides the PATH (never a caller-claimed identity) and is resource-checked, so a cross-workspace call 404s exactly like an unknown one.
INTERNAL. The runtime writes the DRAFT a human then approves — draft-only by construction (an approved profile answers 409), so this is NOT a Gatekeeper bypass: a draft's only route to `approved` remains a human clicking approve.
no workspace rolenot workspace-scopedheader x-vb-ingest-tokenInternalAgentController.patchProfile
Workspace uuid. On the internal surfaces the scope rides the PATH (never a caller-claimed identity) and is resource-checked, so a cross-workspace call 404s exactly like an unknown one.
INTERNAL. PROPOSE only: staging apply + diff artifact + the site.write gate bound to this exact args-hash (spec 02 §4 steps 2-3). NOT a write — production is touched only after a human approves and the Gatekeeper mints a single-use token server-side (DECISION-04-06), and nothing on this route can shorten that path. It reuses the SAME SiteChangesService.propose the public route calls, and the AC-15-02 / DECISION-15-02 entitlement check runs FIRST: a trial or manual-assist workspace is refused before anything is staged. Being service-authenticated says who is calling, not what the plan bought.
no workspace rolenot workspace-scopedheader x-vb-ingest-tokenInternalAgentController.proposeSiteChange
Workspace uuid. On the internal surfaces the scope rides the PATH (never a caller-claimed identity) and is resource-checked, so a cross-workspace call 404s exactly like an unknown one.
INTERNAL. The read half of the vb:agent-ticket seam — that queue job carries ids only, deliberately, so a re-delivery can never replay a superseded description. Answers the ticket plus, inside its opaque payload, the findings it was opened from (DECISION-17-03 makes those the agent's mandate: no finding, no work), the scan window that produced them, and the business profile including the target domain. It cannot be the PUBLIC GET /tickets/{id}: that route needs a user identity the runtime has none of and must not impersonate. Read-only by construction — nothing here moves the ticket, comments on it or touches a gate. Unknown and cross-workspace tickets answer the identical 404.
no workspace rolenot workspace-scopedheader x-vb-ingest-tokenInternalAgentController.ticketView
Request
curl -sS -X GET "$VB_API/api/v1/internal/agent/workspaces/{workspaceId}/tickets/{ticketId}" \
-H "x-vb-ingest-token: $VB_INGEST_TOKEN"
Parameters
Name
In
Type
Description
workspaceId req
path
string
Workspace uuid. On the internal surfaces the scope rides the PATH (never a caller-claimed identity) and is resource-checked, so a cross-workspace call 404s exactly like an unknown one.
INTERNAL. The agent's structured progress / blocker comment (spec 02 §0). The public POST /tickets/{id}/comments stamps the CurrentUser as a HUMAN author by design, which would put a person's byline on machine work in the audit trail and on the card — so the author here is the AGENT. Mentions are still extracted deterministically server-side; nothing the agent writes chooses its own author kind or forges a mention list.
no workspace rolenot workspace-scopedheader x-vb-ingest-tokenInternalAgentController.postComment
Workspace uuid. On the internal surfaces the scope rides the PATH (never a caller-claimed identity) and is resource-checked, so a cross-workspace call 404s exactly like an unknown one.
Every error body is application/problem+json. Switch on type, never on title or detail.
Schemas
122 named components. Unmarked schemas are the zod objects in @vb/contracts that the server validates with; the mirror ones are described from a shape that still lives in apps/api (see Stability). Names ending in Input are the request-direction conversion of a schema that also appears in responses: a zod object strips unknown keys on parse, so the two directions are genuinely not the same shape.
AgentCommentRequestmirror
Mirrors apps/api/src/internal-agent/internal-agent.controller.ts. Not yet promoted into @vb/contracts, so this description is maintained rather than derived.
Unknown properties are stripped rather than rejected.
AgentCommentResponsemirror
Mirrors apps/api/src/internal-agent/internal-agent.controller.ts. Not yet promoted into @vb/contracts, so this description is maintained rather than derived.
Field
Type
Notes
id req
string(uuid)
AgentProposeSiteChangeRequestmirror
Mirrors apps/api/src/internal-agent/internal-agent.controller.ts. Not yet promoted into @vb/contracts, so this description is maintained rather than derived.
Field
Type
Notes
ticketId req
string(uuid)
changeId
string
changeType req
string
targetUrl req
string
payload req
map
summary
string
Unknown properties are stripped rather than rejected.
AgentTicketViewmirror
Mirrors apps/api/src/internal-agent/internal-agent.controller.ts. Not yet promoted into @vb/contracts, so this description is maintained rather than derived.
Field
Type
Notes
id req
string
title req
string
status req
string
agentKey
string
severity
string
payload req
map
targetUrl
string
ApprovePlacementResponsemirror
Mirrors apps/api/src/placements/placements.service.ts. Not yet promoted into @vb/contracts, so this description is maintained rather than derived.
Mirrors apps/api/src/measurement/prompt-banks.controller.ts. Not yet promoted into @vb/contracts, so this description is maintained rather than derived.
Field
Type
Notes
bankId req
string(uuid)
version req
integer
<= 9007199254740991
status req
string
measurement
object
BankPromptsResponsemirror
Mirrors apps/api/src/measurement/prompt-bank-ops.ts (BankPromptsDto). Not yet promoted into @vb/contracts, so this description is maintained rather than derived.
Field
Type
Notes
bankId req
string(uuid)
version req
integer
<= 9007199254740991
status req
"draft" | "approved" | "frozen" | "superseded"
locale req
"ar" | "en"
groups req
object[]
counts req
object
coverage req
object
BilingualText
Field
Type
Notes
en req
string
ar req
string
BilingualTextInput
Field
Type
Notes
en req
string
ar req
string
Unknown properties are stripped rather than rejected.
BoardResponsemirror
Mirrors apps/api/src/board/board.service.ts (BoardDto). Not yet promoted into @vb/contracts, so this description is maintained rather than derived.
Field
Type
Notes
columns req
object[]
min 8 items · max 8 items
BuildBankRequestmirror
Mirrors @vb/app BuildPromptBankInput minus workspaceId. Not yet promoted into @vb/contracts, so this description is maintained rather than derived.
Field
Type
Notes
locale req
"ar" | "en"
planSize req
integer
<= 9007199254740991
classShares
map
candidates req
object[]
min 1 items
brandAliases req
string[]
min 1 items
competitorAliases
string[]
Unknown properties are stripped rather than rejected.
BuildPromptBankResponsemirror
Mirrors @vb/app BuildPromptBankResult (ok branch). Not yet promoted into @vb/contracts, so this description is maintained rather than derived.
Field
Type
Notes
ok req
"true"
bankId req
string(uuid)
version req
integer
<= 9007199254740991
kept req
object[]
dropped req
object
coverage req
object[]
shareViolations req
object[]
BulkPromptsRequest
Field
Type
Notes
promptIds req
string(uuid)[]
min 1 items · max 500 items
action req
"enable" | "disable"
Unknown properties are stripped rather than rejected.
BulkPromptsResponse
Field
Type
Notes
bankId req
string(uuid)
action req
"enable" | "disable"
requested req
integer
<= 9007199254740991
updated req
integer
>= 0 · <= 9007199254740991
BusinessProfile
Field
Type
Notes
id req
string(uuid)
workspaceId req
string(uuid)
name req
string
min length 1
primaryDomain
string
min length 1
nameAr
string
min length 1
categoryPath
string
description
string
descriptionAr
string
offerings req
object[]
serviceAreas req
object[]
languages req
string[]
personas req
object[]
socials req
object[]
brandAliases req
string[]
competitors req
object[]
default []
techStack
object
fieldProvenance
map
status req
"draft" | "approved"
approvedBy
string(uuid)
approvedAt
string(date-time)
ChatEventIngestRequest
Field
Type
Notes
workspaceId req
string(uuid)
chatSessionId req
string(uuid)
turnId
string
min length 1 · max length 128
events req
map[]
min 1 items · max 200 items
Unknown properties are stripped rather than rejected.
Mirrors packages/app/src/use-cases/content-draft-support.ts (ContentDraftDto). Not yet promoted into @vb/contracts, so this description is maintained rather than derived.
Unknown properties are stripped rather than rejected.
CredentialsSubmitRequestmirror
Mirrors apps/api/src/integrations/provider-catalog.ts (CredentialsSubmitRequest). Not yet promoted into @vb/contracts, so this description is maintained rather than derived.
Field
Type
Notes
values req
map
Unknown properties are stripped rather than rejected.
CsrfTokenResponsemirror
Mirrors apps/api/src/auth/auth.controller.ts (CsrfTokenResponse). Not yet promoted into @vb/contracts, so this description is maintained rather than derived.
Field
Type
Notes
token req
string
header req
string
cookie req
string
DevLoginRequestmirror
Mirrors apps/api/src/auth/auth.controller.ts (DevLoginRequest). Not yet promoted into @vb/contracts, so this description is maintained rather than derived.
Field
Type
Notes
email req
string(email)
Unknown properties are stripped rather than rejected.
EventHistoryPagemirror
Mirrors apps/api/src/events/event-history.controller.ts (EventHistoryPage). Not yet promoted into @vb/contracts, so this description is maintained rather than derived.
Field
Type
Notes
items req
object[]
nextCursor req
string | null
head req
string
Finding
Field
Type
Notes
id req
string(uuid)
kind req
string
min length 1
severity req
"info" | "low" | "med" | "high"
evidence req
map
ticketId
string(uuid)
FindingsPagemirror
Mirrors apps/api/src/findings/findings.service.ts (FindingsPage). Not yet promoted into @vb/contracts, so this description is maintained rather than derived.
Mirrors apps/api/src/health/health.controller.ts. Not yet promoted into @vb/contracts, so this description is maintained rather than derived.
Field
Type
Notes
ok req
"true"
IntegrationHealthmirror
Mirrors apps/api/src/integrations/integrations.service.ts (HealthDto). Not yet promoted into @vb/contracts, so this description is maintained rather than derived.
Mirrors apps/api/src/verification/kyb.controller.ts (KybDecideRequest). Not yet promoted into @vb/contracts, so this description is maintained rather than derived.
Field
Type
Notes
workspaceId req
string(uuid)
decision req
"approve" | "reject"
note req
string
min length 1
Unknown properties are stripped rather than rejected.
KybQueueResponsemirror
Mirrors apps/api/src/verification/kyb.service.ts (KybQueueItem). Not yet promoted into @vb/contracts, so this description is maintained rather than derived.
Field
Type
Notes
items req
object[]
LoginRequest
Field
Type
Notes
email req
string(email)
password req
string
min length 1
Unknown properties are stripped rather than rejected.
Mirrors packages/adapters/src/repos/drizzle-network-property.repository.ts (CatalogNetworkProperty). Not yet promoted into @vb/contracts, so this description is maintained rather than derived.
Mirrors apps/api/src/notifications/notification.service.ts (InboxPage). Not yet promoted into @vb/contracts, so this description is maintained rather than derived.
Unknown properties are stripped rather than rejected.
PatchProfileRequest
Field
Type
Notes
name
string
min length 1
nameAr
string
min length 1
categoryPath
string
description
string
descriptionAr
string
offerings
object[]
serviceAreas
object[]
languages
string[]
personas
object[]
socials
object[]
brandAliases
string[]
competitors
object[]
techStack
object
fieldProvenance
map
Unknown properties are stripped rather than rejected.
PatchPromptRequestmirror
Mirrors apps/api/src/measurement/prompt-banks.controller.ts (PatchPromptRequest). Not yet promoted into @vb/contracts, so this description is maintained rather than derived.
Field
Type
Notes
text
string
min length 1 · max length 500
enabled
boolean
Unknown properties are stripped rather than rejected.
PatchSessionMetaRequestmirror
Mirrors apps/api/src/internal-agent/internal-agent.controller.ts. Not yet promoted into @vb/contracts, so this description is maintained rather than derived.
Field
Type
Notes
agentSessionId req
string
min length 1 · max length 256
Unknown properties are stripped rather than rejected.
Mirrors apps/api/src/site-changes/site-changes.controller.ts. Not yet promoted into @vb/contracts, so this description is maintained rather than derived.
Unknown properties are stripped rather than rejected.
ProposeSiteChangeResponsemirror
Mirrors apps/api/src/site-changes/site-changes.controller.ts. Not yet promoted into @vb/contracts, so this description is maintained rather than derived.
Mirrors apps/api/src/internal-agent/internal-agent.controller.ts. Not yet promoted into @vb/contracts, so this description is maintained rather than derived.
Field
Type
Notes
rows req
object[]
min 1 items · max 50 items
Unknown properties are stripped rather than rejected.
RecordCostsResponsemirror
Mirrors apps/api/src/internal-agent/internal-agent.controller.ts. Not yet promoted into @vb/contracts, so this description is maintained rather than derived.
Unknown properties are stripped rather than rejected.
SendChatMessageResponse
Field
Type
Notes
messageId req
string(uuid)
seq req
integer
>= 0 · <= 9007199254740991
SessionMetaResponsemirror
Mirrors apps/api/src/internal-agent/internal-agent.controller.ts. Not yet promoted into @vb/contracts, so this description is maintained rather than derived.
Unknown properties are stripped rather than rejected.
SiteChangemirror
Mirrors packages/app/src/use-cases/site-change-support.ts (SiteChangeDto). Not yet promoted into @vb/contracts, so this description is maintained rather than derived.
Mirrors packages/app/src/ports/cms-connector.ts (StagingDiff). Not yet promoted into @vb/contracts, so this description is maintained rather than derived.
Mirrors apps/api/src/integrations/wordpress-pairing-secret.ts (WordPressPairingSecretService.mint). Not yet promoted into @vb/contracts, so this description is maintained rather than derived.