Testimonial & Social Proof Platform
Collect, moderate, and publish written and video testimonials as embeddable social proof, anywhere.
32,345 lines355,123 words31 sectionsgenerated in 2h 3mAug 19, 2026
Vouch — Testimonial & Social Proof Platform #
Version: 1.0 — Final Document type: Product & engineering specification, written to be executed cold by an AI coding agent or a small engineering team.
Overview #
Vouch is a testimonial and social-proof platform. It collects written and video testimonials through branded, shareable request pages and one-click email and SMS invites; records video directly in the browser with no app install; auto-transcribes and captions that video, with automatic detection across five launch languages; moderates and tags every submission in an accountable inbox; and publishes the results anywhere through embeddable walls of love, carousels, single-quote widgets, and a public showcase page. It also imports existing reviews from Google, G2, Capterra, and X, turns approved quotes into shareable social image cards, and measures widget impressions and click-through without setting a single cookie.
The product is organised around one loop — Collect → Moderate → Publish → Measure — and every section of this document belongs to one of those four stages or to the platform foundations beneath them.
Four commitments shape almost every technical decision that follows, and they are worth stating before the detail begins:
The embed is the front door, and it is the hardest constraint in the system. Widgets are pasted into other people's websites. They ship as a tiny asynchronous script that never blocks render, carries no framework runtime, isolates itself in a shadow root so host CSS cannot break it and it cannot break the host, lazy-loads video, and adds effectively nothing to the host page's Core Web Vitals. Section 13.3 states that budget as measurable numbers with a hard CI gate behind each one.
Nothing already live ever breaks. Reaching a plan limit, downgrading, falling into dunning, or cancelling outright never degrades a widget that is already serving on a customer's homepage. Limits block new collection; they never touch published output. Section 19 states this as a numbered invariant.
Published customer quotes are reputational, so every action on them is attributable. Approvals, edits, publishes, and unpublishes all write to an append-only audit trail that answers "who put that live, and when?" in two clicks. Section 11.6 owns it.
The AI selects; it never rewrites. The highlight assistant proposes which of the respondent's own words to feature and which seconds of their video to clip. It never edits, paraphrases, corrects, or smooths what a customer actually said — and the platform enforces that in code, not by policy alone. Section 12.1 and Section 12.4 own the boundary and its enforcement.
Version 1 is single-workspace: each customer manages one brand. Multi-workspace and agency/white-label operation are deliberately deferred, but the tenancy seam is built from day one — every row is scoped by workspace, with no implicit "current organisation" anywhere in the codebase — so serving agencies later is additive rather than a rewrite. Section 6.3 describes exactly what is already in place and what a later version adds.
Each concern in this document has exactly one canonical section. Every other section references it by number rather than restating it. Where the specification was silent, a decision has been made and recorded rather than deferred; no open questions and no unresolved placeholders remain. An executing agent should be able to read this cold and begin work without needing to ask anything about the document itself.
Table of Contents #
- Before You Start — Customization Decisions
- Product Overview, Vision & Success Criteria
- Technology Stack & System Architecture
- Conventions, Code Standards & Project Structure
- Data Model & Database Schema
- Multi-Tenancy, Authentication, Authorization & Team Management
- API Design — Internal Contract & Public REST API
- Collection — Request Pages, Custom Fields & Invitations
- Video Capture, Chunked Upload & Media Pipeline
- Transcription, Captions & the Transcript Editor
- Moderation Inbox, Tagging, Lifecycle & Audit Trail
- AI Highlight Assistant
- Widgets & the Embed SDK
- Public Showcase Pages & Custom Domains
- Social Image Card Generator
- Review Import — Google, G2, Capterra, X & CSV
- Analytics — Cookie-Free Impression & Click Measurement
- Integrations — Webhooks, Zapier, Slack, HubSpot
- Plans, Billing & Limit Enforcement
- Notifications & Transactional Messaging
- Accessibility — WCAG 2.2 Level AA
- Security, Privacy, Consent & Compliance
- Performance Budgets & Non-Functional Requirements
- Observability, Operations & Runbooks
- Testing Strategy & Quality Gates
- Deployment, Environments & Configuration
- Milestones & Execution Plan
- Executor Instructions
- Appendices
1. Before You Start — Customization Decisions #
This document is written to be executed cold, by an agent or a developer with no access to a product manager. Every question that could block execution has already been answered. This section exists so that the small number of answers you may legitimately want to change before writing code are collected in one place, each with a working default that is already wired through the rest of the document.
The rule that governs this entire section: never block. If you have no opinion on a decision below, take the default and move on. Every default is internally consistent with every other default and with the whole specification. If you do change one, this section tells you exactly what else moves.
Record whatever you change — and your reason — in docs/DECISIONS.md in your repository,
one Architecture Decision Record per changed item, before you write the first line of code.
That file is yours to maintain; this document will not track your deviations for you.
1.1 Decision Register #
| ID | Decision | Working default | Reversibility after build starts |
|---|---|---|---|
| D1 | Product name | Vouch | Moderate — mechanical rename, ~1 hour, procedure in 1.2.1 |
| D2 | Domain names | vouch.app + app. / v. / cdn. / edge. subdomains |
Easy before launch, hard after embeds ship |
| D3 | Cloud / hosting model | Containers on a managed platform; managed Postgres; S3-compatible object storage | Moderate — infrastructure-only |
| D4 | Object storage provider | Cloudflare R2 | Easy — one adapter, S3 API |
| D5 | Video host / transcode / delivery | Mux | Hard — pipeline, webhooks, and player all assume it |
| D6 | Transcription provider | Deepgram | Easy — provider interface, one adapter |
| D7 | AI provider | Anthropic Claude | Easy — AiProvider interface, one adapter |
| D8 | Transactional email provider | Resend | Easy — one adapter |
| D9 | SMS provider | Twilio | Easy — one adapter |
| D10 | Payment provider | Stripe | Hard — billing state machine, webhooks, portal |
| D11 | Pricing numbers | $0 / $19 / $49 / $149 per month | Easy — config + Stripe price IDs |
| D12 | Free-tier collected-testimonial cap | 10 lifetime | Easy — one plan config value |
| D13 | Maximum video length | 5 minutes (2 minutes on Free) | Easy — plan config, recorder honours it |
| D14 | Launch transcription languages | English, Spanish, French, German, Portuguese | Easy — config list |
| D15 | Primary data region + EU option | Primary us-east; EU deployment path documented |
Hard after customer data exists |
| D16 | Ship Popup / Slide-in widget in v1 | Yes | Easy — feature flag |
| D17 | Brand palette and dashboard type scale | Neutral slate + indigo accent; 1.200 minor-third type scale | Easy — token file |
| D18 | Support channel addresses | support@, security@, privacy@, status. |
Easy — config + templates |
| D19 | Log / metrics / error vendor | Sentry for errors and traces; platform-native log sink; OpenTelemetry protocol between them | Easy — exporters are swappable |
| D20 | Enable Stripe Tax | Yes | Easy — one Stripe setting + config flag |
1.2 The Decisions in Detail #
1.2.1 D1 — Product Name #
Default: Vouch. Short, pronounceable, semantically exact ("to vouch for someone" is
literally what a testimonial is), and available as an npm scope shape (@vouch/*).
Why this default: the name appears in a lot of load-bearing places — an npm scope, a cookie name, a CSS custom-property prefix, a webhook signature header, an API key prefix, a global browser variable — and a short lowercase word with no punctuation keeps all of them legal without escaping.
What changes if you pick differently: nothing architectural. The rename is mechanical but it touches identifiers that are hard to change later, so do it before you write code, not after.
Rename procedure — perform all eleven steps, in order, before first commit:
| # | Target | Change from → to | Notes |
|---|---|---|---|
| 1 | Repo root folder and Git remote name | vouch/ → <name>/ |
Cosmetic but do it first so nothing else contradicts it |
| 2 | npm scope in every package.json |
@vouch/db, @vouch/core, @vouch/validation, @vouch/ui, @vouch/config → @<name>/* |
Also update every import specifier and the pnpm-workspace.yaml glob if it names the scope |
| 3 | CSS custom-property prefix | --vch- → --<abbr>- |
Used by the dashboard token file, the request-page theme, and every declaration inside the widget's adopted stylesheet. Choose a 2–4 character abbreviation; it is emitted into third-party pages so it must be collision-resistant |
| 4 | Widget global | window.Vouch → window.<Name> |
Non-enumerable single property; see Section 13 |
| 5 | Session cookie name | __Host-vouch_session → __Host-<name>_session |
Keep the __Host- prefix; it is a security control, not branding (Section 6) |
| 6 | API key prefixes | vch_live_ / vch_test_ → <abbr>_live_ / <abbr>_test_ |
Section 7 owns key format; prefixes are stored in api_keys.display_prefix |
| 7 | Webhook signature header | Vouch-Signature → <Name>-Signature |
Section 7 owns the signing scheme; the secret prefix whsec_ is deliberately generic and does not change |
| 8 | Postgres roles | vouch_app, vouch_migrator → <name>_app, <name>_migrator |
Section 6 owns the role model; update the RLS policy owner and the connection strings together |
| 9 | Valkey key namespace | vch:{env}:... → <abbr>:{env}:... |
Section 3.8 owns the key scheme. Changing it invalidates every cached key, which is safe: all entries are regenerable |
| 10 | Database seed rows and email templates | Plan display names, the "Powered by Vouch" badge string, the consent text template, every transactional email template catalogued in Section 20.4, the respondent portal copy | The badge string is rendered server-side into widget payloads (Section 19), so it lives in one constant, not in the widget bundle |
| 11 | Public copy surfaces | Marketing domain, docs domain (docs.vouch.app), docsUrl base in the error envelope (Section 7.3), OG/social card templates (Section 15), Sunset header docs link |
Set the docs base URL once in configuration; the error envelope reads it |
A repository-wide case-sensitive search for vouch, Vouch, VOUCH, and vch after the
rename must return zero results outside docs/DECISIONS.md. Add that search as a one-line
CI check if you want the guarantee to hold.
Where it takes effect: Sections 3 (repo layout, Valkey keys), 5 (roles), 6 (cookie,
roles), 7 (key prefixes, signature header, docsUrl), 13 (global, CSS prefix), 19 (badge),
20 (email templates), 29.2 (env var names are concern-prefixed, not brand-prefixed, so they
do not change).
1.2.2 D2 — Domain Names #
Default:
| Host | Purpose |
|---|---|
vouch.app |
Marketing site (out of scope for this build; reserved) |
app.vouch.app |
Dashboard — authenticated, Node runtime, no public caching |
v.vouch.app |
Public request pages, public showcase pages, respondent portal — short host because respondents type or tap it |
cdn.vouch.app |
Widget loader e.js, widget runtime chunks, widget JSON data API, analytics beacon endpoint, media derivatives (posters, social cards, VTT) |
edge.vouch.app |
CNAME target customers point their custom domains at (Section 14) |
docs.vouch.app |
Error-code and API documentation; the docsUrl in every error envelope resolves here |
{workspace-slug}.vouch.app |
Free-tier shared showcase subdomain, served by the same edge application as v. |
Why this default: the loader host must be permanent and platform-owned, because the
embed snippet a customer pastes into their site must never need to be re-pasted (Section
13). Splitting the public respondent surface (v.) from the cached asset surface (cdn.)
lets cdn. run a long-lived, aggressively cached, cookie-free policy while v. can set
short-lived caches and serve dynamic HTML.
What changes if you pick differently: substitute your hosts in configuration and DNS.
The shape — a permanent CDN host, a short public host, an app host, a stable CNAME target
for customer domains — must be preserved. Collapsing cdn. into v. is the one change that
is genuinely harmful: it makes it impossible to serve widget assets under a cookie-free,
credential-free origin, which the analytics design in Section 17 depends on.
Where it takes effect: Sections 3.4, 13, 14, 17, 26.
1.2.3 D3 — Cloud and Hosting Model #
Default: stateless application containers on a managed container platform with
autoscaling and zero-downtime rolling deploys; managed PostgreSQL with point-in-time
recovery; managed Valkey/Redis-protocol cache; S3-compatible object storage; a CDN in front
of cdn. and edge..
Why this default: every component is commodity. Nothing in this specification requires a specific cloud. The application is a Next.js app plus a Node worker plus three stateful dependencies (Postgres, a Redis-protocol cache, object storage) and a CDN. Any platform that runs containers and offers those is sufficient.
What changes if you pick differently:
- Serverless functions instead of long-lived containers: the worker (
apps/worker) needs long-running processes for BullMQ consumers and for chunked-upload session handling. If your platform has no long-lived compute, run the worker on a small always-on instance and keep the web tier serverless. - Self-managed Postgres: you own backups, failover, and the extension set. Section 26 lists required extensions and settings.
- A single VM running everything: acceptable for a first deploy; Section 26 documents the minimum viable single-host topology and its limits.
Where it takes effect: Sections 3.4, 24, 26.
1.2.4 D4 — Object Storage Provider #
Default: Cloudflare R2, accessed through the S3-compatible API.
Why this default: R2 charges no egress fee. This product stores original video uploads and serves posters, social cards, and caption files from storage through a CDN; egress is the dominant cost driver and eliminating it changes the unit economics of the Free tier.
What changes if you pick differently: AWS S3 is a drop-in — the same SDK client, the same
multipart upload API, the same pre-signed URL flow. Set the endpoint, region, and credentials
in configuration (Section 29.2). Two behaviours to verify on any non-R2 provider: (a)
multipart upload part size minimums, and (b) AbortMultipartUpload actually reclaiming
storage, which the re-record flow in Section 9 depends on.
Where it takes effect: Sections 3, 9, 15, 22.
1.2.5 D5 — Video Host, Transcode, and Delivery #
Default: Mux.
Why this default: browser-recorded video arrives in whatever codec the respondent's device produced — H.264 in an MP4 on Safari, VP9 or VP8 in a WebM on Chrome and Firefox. Normalizing that into adaptive HLS that plays everywhere is a solved problem you should not solve. Mux additionally provides signed playback, per-asset thumbnails, and delivery analytics, which removes three subsystems from scope.
What changes if you pick differently: the media pipeline in Section 9 is expressed as five provider operations — create upload, get asset, get playback id, get thumbnail, delete asset — plus three webhooks. Any provider offering those fits. Self-hosted FFmpeg transcoding is explicitly out of scope for v1 (Section 2.7); choosing it means building transcode orchestration, an HLS packager, storage lifecycle, and a player, which is a multi-month project on its own.
Where it takes effect: Sections 3, 9, 13, 22, 24.
1.2.6 D6 — Transcription Provider #
Default: Deepgram, nova-3, of the nova model family. The model string is a
configuration value read from the environment (Section 29.2), not a dependency version line,
so a model upgrade is a configuration change rather than a deploy.
Why this default: word-level timings with per-word confidence are mandatory. The caption editor highlights low-confidence words (Section 10), and the AI highlight assistant clips video by segment boundaries (Section 12). A provider that returns only sentence-level text cannot support either feature.
Alternative: AssemblyAI, documented as a drop-in. It returns the same shape of data. Both
adapters implement one interface: transcribe(mediaUrl, opts) → CanonicalTranscript, where
CanonicalTranscript is the single JSONB structure defined in Section 10.3. Nothing
downstream of that function knows which provider ran.
What changes if you pick differently: the adapter, one environment variable set, and the per-minute cost model behind the transcription quota in Section 19.
Where it takes effect: Sections 3, 10, 12, 19.
1.2.7 D7 — AI Provider #
Default: Anthropic Claude, via the AiProvider interface.
Why this default: the highlight assistant's outputs are constrained and validated server-side (Section 12.4), so provider choice is a quality-and-cost decision, not an architectural one. The default is a strong instruction-following model with reliable structured output.
Alternative: OpenAI, documented as a second adapter. The interface is three methods —
suggestHighlights, suggestTags, draftAltText — and every prompt is versioned in
packages/core/ai/prompts with the version recorded on each suggestion row.
What changes if you pick differently: one adapter file, one credential, and the prompt templates (which are provider-tuned and versioned). The verbatim-only boundary in Section 12.1 is enforced by server-side substring validation and is provider-independent — it holds no matter which model you use.
Where it takes effect: Sections 3, 12, 19, 22.
1.2.8 D8 — Transactional Email Provider #
Default: Resend.
Why this default: simple API, good deliverability defaults, native support for the two things this product needs beyond sending — per-message tagging (so bounces and complaints can be attributed to a workspace) and inbound webhooks for delivery events.
What changes if you pick differently: Postmark and SendGrid are equivalent. The adapter
sends a template id plus a typed payload and returns a provider message id, which is stored
on the notifications row so delivery-status webhooks can be reconciled (Section 20).
Where it takes effect: Sections 3, 20, 22.
1.2.9 D9 — SMS Provider #
Default: Twilio. SMS is used only for testimonial request invitations where the workspace supplies a mobile number and the respondent has opted in.
Why this default: the broadest country coverage and the clearest compliance tooling for
opt-out handling (STOP/HELP keywords are handled by the provider and surfaced as
webhooks).
What changes if you pick differently: any provider with a send API and a delivery webhook works. Section 20 owns the invitation channel logic; SMS is optional per workspace and disabled by default, so a build that omits SMS entirely is a valid, complete build — the feature degrades to email-only invitations with no other behaviour change.
Where it takes effect: Sections 3, 8, 20.
1.2.10 D10 — Payment Provider #
Default: Stripe — Checkout for the first purchase, Billing Portal for all subsequent changes, webhooks as the authoritative source of subscription state, Stripe Tax for tax calculation.
Why this default: hosted Checkout and Portal remove card handling, SCA, dunning UI, proration UI, and invoice delivery from scope. The application never renders a card field.
What changes if you pick differently: Section 19 defines the subscription state machine
in provider-neutral terms — a billing_state of trialing, active, past_due, or
canceled, with capacity tracked orthogonally as limit_state — with a provider event table
mapping onto it. Substituting a provider means rewriting that mapping
and the two hosted-page redirects. Everything about quota enforcement is internal and
provider-independent.
Where it takes effect: Sections 3, 19, 24, 29.4.
1.2.11 D11 — Pricing Numbers #
Default: Free $0 · Starter $19/month · Growth $49/month · Scale $149/month. Annual billing is priced at ten times the monthly rate (two months free). Custom-domain add-on $10 per domain per month on Starter and Growth, $8 on Scale.
Why this default: it maps to the entitlement table in Section 19 with clear jumps in the two metered dimensions customers actually feel — collected testimonials and seats — and it places the first paid tier below the threshold at which a marketing team needs approval to buy.
What changes if you pick differently: prices live in exactly two places: the plan
configuration in packages/core/plans (display prices, used for UI only) and Stripe price
IDs in environment configuration (Section 29.2). Entitlement values are separate from
prices; you can reprice without touching limits and vice versa. Never read a price from
Stripe at render time on a public page — cache the plan config and reconcile on webhook.
Where it takes effect: Sections 2, 19.
1.2.12 D12 — Free-Tier Collected-Testimonial Cap #
Default: 10 lifetime collected testimonials.
Why this default: ten is enough to build a real wall of love and feel the product work end to end, and small enough that a growing business hits it within its first campaign. The cap is on collection, never on serving: a Free workspace's ten testimonials are served forever, at full quality, with no view limit (Section 19, rule 3).
What changes if you pick differently: one integer in the plan configuration. Raising it increases storage and transcription cost per free workspace roughly linearly; the storage and transcription quotas in Section 19 are the actual cost guards and should move with it.
Where it takes effect: Sections 2, 8, 19.
1.2.13 D13 — Maximum Video Length #
Default: 5 minutes on paid plans, 2 minutes on Free.
Why this default: usable video testimonials are 30–90 seconds. Five minutes is generous headroom; beyond it, chunked-upload session state, transcription cost, and moderator review time all grow without improving the output. The two-minute Free limit keeps the free tier's storage and transcription cost bounded.
What changes if you pick differently: the recorder's hard stop, the 15-second warning, the upload-session part budget, and the transcription minute accounting all read the same plan value. Raising it above roughly 10 minutes requires revisiting the chunked-upload part count and the single-request transcription timeout in Section 10.6.
Where it takes effect: Sections 9, 10, 19, 21.
1.2.14 D14 — Launch Transcription Languages #
Default: English (default), Spanish, French, German, Portuguese, with automatic language detection. Detection confidence below 0.6 falls back to the workspace's default language and flags the transcript for human review.
Why this default: these five cover the large majority of the target market's customer base and are all high-accuracy in the chosen model family. Automatic detection means the respondent is never asked to declare a language.
What changes if you pick differently: the language list is configuration. Adding a language requires: adding it to the allowed set, verifying the provider's model supports it at comparable accuracy, and adding its display name to the caption-editor language selector. Note that this is transcription language only — the product UI is English-only in v1 and that is stated as out of scope in Section 2.7.
Where it takes effect: Sections 10, 12.
1.2.15 D15 — Primary Data Region and EU Option #
Default: primary region us-east. All of Postgres, Valkey, object storage, and the
worker run in one region. The CDN is global. An EU-region deployment path is documented in
Section 26.11: a second, fully independent stack (its own database, cache, bucket, and worker)
with workspace-level routing at sign-up, rather than cross-region replication.
Why this default: a single-region primary is dramatically simpler to operate, and the data that actually needs to be near the user — widget payloads, posters, and video — is already global because it is served from the CDN and from the video provider's edge.
What changes if you pick differently: if you launch EU-primary, change the region for all four stateful components together and confirm your video and transcription providers' regional processing options. If you launch both, the workspace's region is chosen at creation, stored on the workspace row, and is immutable; there is no cross-region workspace migration in v1.
Where it takes effect: Sections 22, 26.
1.2.16 D16 — Ship the Popup / Slide-in Widget in v1 #
Default: yes.
Why this default: it is a preset over the same rendering core as the other widget types,
not a separate codebase, so its marginal cost is small. It is also the widget type most
likely to be misused, which is why its behaviour is tightly constrained in Section 13: never
within the first 5 seconds of page load, dismissible with both a visible control and the
Escape key, frequency-capped in memory per page view, never focus-trapping, never
covering the host page's primary content area, and fully suppressed under
prefers-reduced-motion except for a static, non-animated appearance.
What changes if you pick differently: disable it with the flag_widget_popup feature
flag. The widget type stays in the enum and the editor hides it. No schema change.
Where it takes effect: Sections 13, 21.
1.2.17 D17 — Brand Palette and Dashboard Type Scale #
Default: a neutral slate grey scale with a single indigo accent, plus semantic colours for success, warning, danger, and info. Type scale is a 1.200 minor third from a 16 px base: 12.8 / 16 / 19.2 / 23 / 27.6 / 33.2 / 39.8 px, rounded to whole pixels. Base line height 1.5, headings 1.25. System font stack throughout, in both the dashboard and the widget, so zero web fonts are downloaded on a host page (a hard requirement in Section 13).
Why this default: it is legible, neutral enough not to fight a customer's own brand in screenshots, and every foreground/background pair in the token file passes 4.5:1 for body text and 3:1 for large text and UI components, so the dashboard starts compliant rather than being remediated.
What changes if you pick differently: replace the token values in
packages/config/tokens. Two constraints are non-negotiable regardless of palette: (a) every
token pair used for text must pass the contrast ratios in Section 21, verified by the
contrast unit test that ships with the token file; (b) no colour may be the sole carrier of
meaning anywhere in the product.
Note that the widget's accent colour is a per-workspace customer setting, not a brand decision, and it is contrast-checked in the widget editor at configuration time (Section 21).
Where it takes effect: Sections 4.8, 13, 21.
1.2.18 D18 — Support Channel Addresses #
Default: support@vouch.app (product support, referenced in dunning and quota emails),
security@vouch.app (vulnerability disclosure, referenced in the security policy in Section
22), privacy@vouch.app (data subject requests, referenced in the respondent portal and the
consent text), status.vouch.app (public status page, linked from the 503 error page and
from dependency-degradation banners).
Why this default: three of the four are referenced by name in user-facing copy and in compliance text, so they must exist before launch rather than being invented during an incident.
What changes if you pick differently: all four are configuration values read by the email templates and by the error pages. Support tiers (community / email / 1 business day / 4 business hours) are entitlements in Section 19 and are independent of the addresses.
Where it takes effect: Sections 19, 20, 22, 24.
1.2.19 D19 — Log, Metrics, and Error Vendor #
Default: structured JSON logs to the platform's native log sink; errors and distributed traces to Sentry; metrics exported over the OpenTelemetry protocol to whatever collector the platform provides.
Why this default: the application emits OpenTelemetry-shaped telemetry and never calls a vendor SDK directly outside a single instrumentation module, so the vendor is an exporter configuration rather than a dependency threaded through the code.
What changes if you pick differently: swap the exporter endpoints in configuration. The non-negotiable parts are vendor-independent and are owned by Section 24: every log line carries the request id, the workspace id, and the actor id; no log line ever contains testimonial body text, a respondent's email or phone number, an API key, a session token, or a webhook secret; and every error surfaced to a user carries the request id so a support conversation can find the corresponding trace.
Where it takes effect: Sections 3, 22, 24, 29.2.
1.2.20 D20 — Stripe Tax #
Default: enabled.
Why this default: the product sells globally to businesses from day one. Calculating VAT, GST, and US sales tax by hand is a compliance liability, and retrofitting tax onto existing subscriptions is materially harder than starting with it on. With it enabled, Checkout collects and validates the customer's address and tax ID, and invoices are tax-correct from the first charge.
What changes if you pick differently: disabling it means every price is treated as tax-inclusive with no jurisdiction handling, you must remove the tax-ID field from the billing settings screen, and you accept the registration obligations yourself. The subscription state machine in Section 19 does not change either way.
Where it takes effect: Section 19.
1.3 How to Use This Document #
Reading order. Read Sections 1–7 in sequence before writing any code; they are the foundation and every later section assumes them. After that, Sections 8–20 are feature sections and can be read in build order rather than document order. Sections 21–26 are cross-cutting requirements that apply to everything you build and must be read before you consider any feature finished. Section 27 is the milestone plan, Section 28 is the execution procedure, and Section 29 is reference material you will return to constantly.
One concern, one owning section. Every fact in this document has exactly one home. The HTTP envelope is defined once in Section 7 and referenced everywhere else. The plan limits are defined once in Section 19. The testimonial state machine is defined once in Section 11. When two sections need the same fact, the non-owning one names the owner by number and does not restate it. If you ever find yourself with two plausible answers to the same question, the owning section wins — and if you change something, change it in the owning section and let the references follow.
Version lines. Every dependency version line appears exactly once, in Section 3.1. No other section states a version number: a section may name the technology and cite Section 3.1, and that is the only permitted form. This is deliberate: it makes a stack upgrade a single-table edit rather than an archaeology exercise. Section 3.1 also tells you what to do when the current stable release has moved past the line stated there.
Execution sequencing. Do not invent your own build order. Section 28 gives the exact sequence — environment first, then schema, then the tenancy and authorization core, then collection, then media, then moderation, then publishing, then the long tail — along with the verification gate that closes each step. Section 27 gives the milestone grouping and the definition of done for each milestone. Follow Section 28 literally; the ordering exists because each step's verification depends on the previous step being real rather than stubbed.
When you disagree with a decision. Change it, write the ADR in docs/DECISIONS.md, and
update the owning section of your own copy of the specification so the document and the code
never drift. What you must not do is leave the document saying one thing while the code does
another.
2. Product Overview, Vision & Success Criteria #
2.1 The Problem #
Social proof is the single highest-leverage asset a growing company has, and almost every company collects it badly.
The typical sequence is: someone on the marketing team remembers that a customer said something nice on a call. They email that customer asking for a quote. Two weeks later, after a reminder, a paragraph arrives in an email reply. Someone pastes it into a spreadsheet. Months later a designer hard-codes it into a landing page as static HTML. Nobody has written permission to use it. Nobody knows whether the person still works there. When the customer churns, the quote stays on the homepage because no one has a list of where it appears.
Video is worse. Video testimonials convert dramatically better than text and are collected far less often, because the ask is heavy: install something, or use a link to a tool that demands an account, or record on a phone and email a 400 MB file that bounces. The friction is not the recording — it is everything around the recording.
And publishing is worse again. The teams that do collect testimonials end up stitching together three or four tools: a form builder, a video host, a transcription service, and an embed widget from a fourth vendor. Each one adds JavaScript to the marketing site. The resulting embed is frequently the heaviest thing on the page — a framework runtime, web fonts, an iframe that reflows and shifts the layout — so the social proof that was supposed to increase conversion measurably degrades the page it sits on.
Four failures, concretely:
- Collection friction. Anything that asks a customer to install, sign up, or fight with file sizes loses most of the customers who were willing to help.
- No moderation accountability. Who approved this quote? Was it edited? Did the person consent to being in a paid ad? Most teams cannot answer, and increasingly they are asked to.
- Heavy, inaccessible embeds. Widgets that ship a framework, shift layout, download fonts, and are unusable by keyboard or screen reader.
- No measurement. No idea which testimonial actually drives clicks, so curation is guesswork — and the analytics that would answer it usually arrive with a cookie banner attached.
2.2 The Product #
Collect, moderate, and publish written and video testimonials as embeddable social proof, anywhere.
A workspace creates a branded request page and shares its link. A customer opens it on their phone, records a 45-second video directly in the browser with no app and no account, ticks an explicit consent box, and submits. The video uploads while they are still speaking, is transcoded and transcribed automatically, and lands in a moderation inbox. Someone with the right role reviews it, tags it, and publishes it — and every one of those actions is recorded in an append-only audit trail. The moment it is published it appears in every widget that matches its filters, on every site those widgets are embedded in, delivered from a CDN edge in a payload under 60 KB with under 27 KB of JavaScript, no framework, no web fonts, and no cookies. Cookie-free analytics then show which testimonials people actually watch and click.
2.3 Target Users #
Primary: B2B SaaS companies and their marketing teams. They have customers who like them, a landing page that needs proof, and no systematic way to get from one to the other. They care about conversion rate, page speed, and being able to prove they had permission.
Secondary: founders and growth marketers replacing an existing point tool. They are already using Testimonial.to, Senja, or Trustmary and are migrating because of embed weight, missing moderation controls, per-seat pricing, or the custom-domain experience. They arrive with existing testimonials and expect to import them, which is why CSV import is available on every plan including Free.
Tertiary: small marketing teams needing multi-seat moderation. Three to ten people where a contractor or junior marketer collects and drafts, and a manager approves. They need real roles, not a shared password, and they need to see who approved what.
2.4 Personas #
2.4.1 Maya — Head of Marketing, 40-person B2B SaaS #
Context. Owns the website and the demand-generation number. Has two direct reports and a freelance designer. Approves anything that goes on the homepage.
Goal. Put five strong video testimonials on the pricing page before the quarter ends, and be able to swap them without a developer.
Frustrations. The last testimonial project produced four text quotes in a spreadsheet. The video her CEO recorded with a customer is sitting in a shared drive, unedited, without captions. Her developer refuses to add another third-party script to the site because the last one cost 0.4 seconds of Largest Contentful Paint.
Hires the product to: get video testimonials collected without her being the bottleneck, and put them on the site in a way her developer will actually approve.
A day in her life with the product. She opens the dashboard at app. and lands on the
Overview screen — collection funnel, pending moderation count, and the top-performing
published testimonials by click-through. Three new submissions overnight. She clicks through
to the Moderation Inbox, where each card shows the video with captions on by default, the
transcript beside it, the consent grants that were captured, and the source. She watches the
first at 1.5× while reading the transcript, taps Approve, adds the tags enterprise and
onboarding, and hits Publish. She opens the Widgets screen, sees her "Pricing page —
wall of love" widget already includes the new testimonial because it filters on the
enterprise tag, and previews it in the widget editor against a light and a dark background.
She copies nothing — the snippet on the pricing page has not changed and never will. She ends
in Analytics, sorts published testimonials by click-through rate, notices that a
30-second clip outperforms a 2-minute one by a factor of three, and reorders the wall to lead
with the short ones.
2.4.2 Dev — Solo founder, developer, 300-customer micro-SaaS #
Context. Builds and markets alone. Ships his own landing page. Cares about page weight the way other people care about rent.
Goal. Get proof on the landing page this weekend without slowing it down and without a subscription he cannot justify at 300 customers.
Frustrations. Every widget he has tried loads a framework and web fonts. One shifted his hero section by 60 pixels on mobile. He does not want an iframe. He does not want a cookie banner because he does not currently need one.
Hires the product to: be the lightest possible way to put real customer quotes on a page.
A day in his life with the product. He signs up, lands on the Onboarding screen, and
creates a request page in about two minutes by picking a template, writing three prompts, and
uploading his logo. He emails the v. link to eight customers from his own inbox. Four
respond within a day; two record video. He moderates from his phone. He creates a Wall of
Love widget, sets the accent to his brand colour — the editor warns him that his chosen
colour fails contrast on the dark theme and offers the nearest passing shade, which he
accepts — copies the two-line snippet, and pastes it into his landing page. He runs Lighthouse
before and after: the JavaScript delta is under 27 KB gzip and Cumulative Layout Shift is
unchanged. He stays on Free for two months, hits the ten-testimonial cap, sees the in-product
warning at eight, and upgrades to Starter. Nothing on his live page ever changed during the
upgrade, and nothing would have changed if he had stayed on Free forever.
2.4.3 Priya — Customer Marketing Manager, 200-person company, regulated buyers #
Context. Runs the customer advocacy programme. Her legal team reviews anything featuring a named customer. Her buyers are in financial services.
Goal. Run testimonial collection at scale with a defensible record of consent, and route approvals through the right people.
Frustrations. Her current tool has one shared login. She cannot prove who approved a quote or whether the customer agreed to appear in a paid ad. Last quarter a customer asked to be removed and it took eleven days and three people to find every place the quote appeared.
Hires the product to: make consent and moderation auditable, and make withdrawal instant.
A day in her life with the product. She invites her two contractors as Editor and her
legal reviewer as Viewer from the Team screen. Editors collect and moderate; only she
and the workspace Owner can touch billing, domains, or API keys. She configures her request
page's consent block: publication consent is required, paid-advertising consent and
case-study-contact consent are separate optional checkboxes, never pre-ticked, and the exact
wording rendered to each respondent is stored verbatim with its hash. When legal asks who
approved a specific quote, she opens the testimonial's Audit tab and sees every status
change, every tag change, and every consent grant with actor, timestamp, and before/after
snapshot. When a customer emails asking to be removed, she sends them their respondent portal
link; they withdraw themselves, the testimonial unpublishes within 60 seconds across every
embed, and she gets a notification. She sets up a custom domain so her showcase page lives
at stories.hercompany.com, watches the verification UI move from pending_dns to
active through the explicit states Section 14.3 defines, without opening a support ticket, and confirms — as the setup screen states
explicitly — that the widget snippets already live on her marketing site did not need to
change.
2.5 The Product Loop: Collect → Moderate → Publish → Measure #
Everything in this product is one of four things. This loop is the spine of the information architecture, the dashboard navigation, the milestone plan in Section 27, and the feature inventory below.
graph LR
C[COLLECT<br/>request pages, invites,<br/>video + text capture, import] --> M[MODERATE<br/>inbox, transcript + captions,<br/>tags, consent, audit trail]
M --> P[PUBLISH<br/>widgets, showcase pages,<br/>custom domains, social cards, API]
P --> Me[MEASURE<br/>cookie-free impressions,<br/>interactions, clicks, per-testimonial CTR]
Me -->|informs which prompts and<br/>which customers to ask next| C- Collect is optimised for the respondent, who is not your user and owes you nothing. No account, no install, no app, mobile-first, under 90 seconds from link to submitted video.
- Moderate is optimised for accountability. Nothing reaches the public without an explicit human action, and every action is recorded permanently.
- Publish is optimised for the host page. The embed is the hardest engineering constraint in the product and the place where it wins or loses against every competitor.
- Measure is optimised for privacy. No cookies, no local storage, no cross-site identifier, therefore no consent banner and no legal review for the customer's site.
2.6 Feature Inventory #
Fifteen capability areas. Each names its owning section, which is the single authority on its behaviour.
| # | Capability | Loop stage | Owning section |
|---|---|---|---|
| 1 | Branded request pages, custom fields & invitations | Collect | 8 |
| 2 | In-browser video & text capture with chunked upload | Collect | 9 |
| 3 | Automatic transcription & editable captions | Moderate | 10 |
| 4 | Moderation inbox, tagging, lifecycle & audit trail | Moderate | 11 |
| 5 | AI highlight assistant (select-only, never rewrite) | Moderate | 12 |
| 6 | Embeddable widgets & the embed SDK | Publish | 13 |
| 7 | Public showcase pages & custom domains | Publish | 14 |
| 8 | Social image card generator | Publish | 15 |
| 9 | Review import — Google, G2, Capterra, X, CSV | Collect | 16 |
| 10 | Cookie-free widget analytics | Measure | 17 |
| 11 | Integrations — webhooks, Zapier, Slack, HubSpot | Publish | 18 |
| 12 | Public REST API | Publish | 7 |
| 13 | Team roles, invitations & workspace management | Cross-cutting | 6 |
| 14 | Plans, billing & limit enforcement | Cross-cutting | 19 |
| 15 | Consent capture, privacy controls & data rights | Cross-cutting | 22 |
1. Branded request pages, custom fields & invitations (Section 8). A public page at
v.vouch.app/r/{slug} — or on the workspace's own custom domain — carrying the workspace's
logo, colours, and a short intro. It presents up to five prompts that give the respondent
something to answer instead of a blank box, offers a choice between recording video and
writing text, collects a configurable set of fields (name, role, company, website, headshot,
star rating, plus operator-defined custom fields), and captures consent on the same screen as
the submit button. Invitations go out by email or SMS with per-recipient tracking through
sent, opened, started, and submitted, with configurable reminders.
2. In-browser video & text capture with chunked upload (Section 9). getUserMedia plus
MediaRecorder, with a device picker, a 3-2-1 countdown, a live timer, an optional
teleprompter, and a hard stop at the plan's maximum length with a 15-second warning. The
recording uploads in 5-second chunks while the respondent is still recording, so a
five-minute take is roughly 95% uploaded the moment they stop. Retakes are unlimited and free;
a discarded take's upload is aborted immediately and its storage reclaimed. Nothing counts
against quota until submit.
3. Automatic transcription & editable captions (Section 10). Every video is transcribed automatically with word-level timings and per-word confidence into one canonical transcript structure. Captions, the searchable text, the pull-quote extraction, the highlight assistant's clip boundaries, and the text shown beside a video all derive from that one structure — there is no second copy to drift. A segment editor lets a moderator fix words, merge and split segments, and nudge timings; low-confidence words are flagged non-visually as well as visually. Captions are on by default in every player, including inside widgets.
4. Moderation inbox, tagging, lifecycle & audit trail (Section 11). A single inbox for everything — collected and imported alike — with a strict status lifecycle, bulk actions, saved filters, keyboard-driven review, tagging from a workspace-controlled vocabulary, and an append-only audit trail. Every status change, content edit, tag change, and consent change writes an immutable row with the actor, the actor type, a before/after snapshot, the IP, the user agent, and the timestamp. The table rejects updates and deletes at the database level.
5. AI highlight assistant (Section 12). Optional, Growth and Scale only, and bounded by a permanent product rule: it may only select, never rewrite. It proposes which of the respondent's own words to feature and which seconds of video to clip. Every proposed pull-quote is validated server-side by exact substring match against the stored transcript or body before a human ever sees it. Manual curation is always available and is the default, and nothing AI-proposed publishes without an explicit human approval click.
6. Embeddable widgets & the embed SDK (Section 13). Six widget types — Wall of Love, Carousel, Single Quote, Video Reel, Badge/Rating Summary, and Popup/Slide-in — from one vanilla-TypeScript rendering core with no framework. The snippet a customer pastes carries only the widget id and never has to change again. Rendering happens in an open shadow root with a single adopted stylesheet, one non-enumerable global, zero web fonts, and video that never loads until the viewer chooses to play it.
7. Public showcase pages & custom domains (Section 14). A server-rendered, crawlable, fast public page of a workspace's testimonials, available at a shared subdomain on every plan and on the customer's own domain from Starter upward. Domain setup shows the exact DNS records, polls live, displays every state in Section 14.3's machine explicitly with plain-English diagnostics, and issues TLS automatically. Adding or changing a domain never requires re-pasting an embed snippet.
8. Social image card generator (Section 15). Renders a shareable image from any published
testimonial — quote, attribution, avatar, rating, workspace branding — in several presets and
sizes, generated server-side, cached to object storage, served from cdn., and used both for
og:image on showcase pages and for direct download.
9. Review import (Section 16). Google Business Profile, G2, Capterra, X, and CSV. Every
source is an official API, an official syndication feed, or a customer-supplied export —
there is no scraping anywhere in this product. Imported reviews land in pending in the same
inbox, keep their attribution and permalink, are never editable, and are deduplicated on the
source's own review id plus a fuzzy near-duplicate warning.
10. Cookie-free widget analytics (Section 17). Impressions, interactions, clicks, and video-completion quartiles measured with no cookie, no local storage, no fingerprint, and no cross-site identifier of any kind. Dedupe within a page view uses a random id held in JavaScript memory that ceases to exist on navigation. IP is used to derive country and device class and is then discarded, never stored. Bot traffic is filtered and counted separately rather than silently dropped.
11. Integrations (Section 18). Signed outbound webhooks with a documented event catalogue, replay, and automatic disabling after sustained failure; a Zapier app built on those webhooks plus a small polling API; a Slack app that posts new submissions and lets a moderator approve from Slack; and a HubSpot integration that syncs testimonials to contact and company records.
12. Public REST API (Section 7). Versioned at /api/v1/, authenticated with workspace
API keys carrying scopes, cursor-paginated, idempotent on unsafe endpoints, rate-limited per
workspace, with one error envelope and a stable, catalogued error code for every failure.
Read-only access is available even on Free.
13. Team roles, invitations & workspace management (Section 6). Exactly four roles —
Owner, Admin, Editor, Viewer — enforced by a single can(actor, action, resource) function
that every route and server action calls. Invitations are emailed, single-use, hashed at
rest, and expire in seven days. Exactly one Owner per workspace, with an explicit confirmed
transfer.
14. Plans, billing & enforced limits (Section 19). Four plans, all limits enforced server-side, with warnings at 80% and 95% in product and emails at 90% and 100%. The rule that governs all of it: reaching a cap never degrades anything already live. Published widgets, showcase pages, and the widget data API keep serving at full quality and full volume on every plan, forever — including Free, including past-due, including cancelled.
15. Consent capture, privacy controls & data rights (Section 22). Consent is captured on the submit screen, never pre-ticked, split into one required and two optional grants, and stored with the exact text the respondent saw plus its hash. Every published testimonial carries a stable respondent portal link from which the person can see what was published, download their data, withdraw, or request erasure — with no account. Withdrawal unpublishes within 60 seconds across every embed. Erasure is a genuine hard delete that also destroys the video asset, every object-storage key, and every CDN cache entry.
2.7 Explicitly Out of Scope for v1 #
| Not building | Why | What it would take to add |
|---|---|---|
| Agency / white-label multi-workspace management | One workspace per account keeps v1's billing, permissions, and navigation simple. The tenancy seam is already built: workspace_members is a true many-to-many join and every domain table carries workspace_id (Section 6) |
UI only — a workspace switcher, a billing-ownership rule for parent accounts, and cross-workspace navigation. No schema change. Listed in Section 29.8 |
| Native iOS / Android apps | The recorder is a mobile web experience by design; a native app would duplicate it and add app-store review to the release path | A native shell around the existing capture flow, plus native camera permission handling |
| Self-hosted video transcoding | Transcode orchestration, adaptive packaging, storage lifecycle, and a player are a product in themselves and are commoditised (Section 1.2.5) | Replacing the provider adapter in Section 9 with an FFmpeg pipeline, an HLS packager, and a player — a multi-month project |
| Non-English product UI | Every dashboard string would need extraction, translation, and review, and it does not unblock any launch customer. Note that transcription captions the respondent's own words with automatic detection across five launch languages, and never translates them (Sections 1.2.14, 10.11) | String extraction, a message catalogue, locale routing, and translated email templates |
| AI rewriting or altering testimonial wording | This is a permanent product boundary, not a deferral. A testimonial that has been smoothed, corrected, translated, or paraphrased by a model is no longer the customer's words, and publishing it as if it were is a misrepresentation. The rule is stated verbatim in Section 12.1 and enforced in code by server-side exact-substring validation, not by policy alone | Nothing. This will not be added |
| Marketplace payouts or payments beyond subscription billing | There is no second-sided marketplace in this product; the only money movement is a customer paying for their own subscription | A payouts provider, tax reporting for recipients, and a ledger — none of which exist in this design |
2.8 Competitive Positioning #
Kept factual and short. All three named products are competent and all three have real strengths worth learning from.
| Product | Does well | Where this product must beat it |
|---|---|---|
| Testimonial.to | The best-known frictionless video collection flow; a strong "wall of love" page; simple mental model | Embed weight and layout stability on the host page; per-testimonial analytics |
| Senja | Broad widget variety, fast iteration, generous import and social-card tooling | Moderation accountability — role separation and a real audit trail; consent record-keeping |
| Trustmary | Serious analytics and A/B testing; enterprise-oriented review sourcing | Setup experience, particularly custom domains; embed performance; time-to-first-testimonial |
The three places this product must be better, and what "better" means concretely:
- Embed performance. Total JavaScript added to a host page ≤ 27 KB gzip, zero framework bytes, zero web fonts, zero render-blocking resources, CLS contribution ≤ 0.01, one network request before first paint. These are CI-enforced budgets, not aspirations (Sections 13 and 23).
- Moderation accountability. Four real roles, an append-only audit trail the database itself refuses to modify, consent stored with the exact text the respondent saw, and respondent-initiated withdrawal that takes effect across every embed within 60 seconds (Sections 6, 11, 22).
- Custom-domain setup experience. Exact records shown, live polling with every state in Section 14.3 shown explicitly, the records actually observed at the authoritative nameservers shown next to the records required with a plain-English diff, automatic TLS — and an unconditional guarantee that a domain change never requires re-pasting an embed snippet (Section 14).
2.9 Success Criteria — Launch-Readiness Gates #
These are gates, not goals. Each is measured by a specific method and each must pass before the product is considered launched. Section 23 owns the full non-functional requirement table and Section 25 owns the test strategy that runs these.
| # | Gate | Threshold | Verification method |
|---|---|---|---|
| SC-1 | Total widget JavaScript added to a host page | ≤ 27 KB gzip (loader ≤ 5 KB, runtime ≤ 22 KB) | size-limit check in CI, hard fail on the pull request |
| SC-2 | Framework bytes shipped to a host page | 0 | Bundle-analyzer assertion in CI that no framework runtime appears in the apps/embed dependency graph |
| SC-3 | Accessibility violations on the request page, the recorder, the showcase page, and all six widget types | 0 at "serious" or "critical" | axe-core run under Playwright against a fixture page per surface, in CI, hard fail |
| SC-4 | Widget data API response time at the edge | p95 ≤ 100 ms, p99 ≤ 250 ms, measured over 7 days from three continents | Synthetic checks from three regions plus edge access-log percentiles on the /api/v1/widgets/{id}/data route |
| SC-5 | Respondent time from opening the request link to a submitted video testimonial | ≤ 90 seconds on a mid-tier Android device over throttled 4G, for a 30-second recording | Scripted Playwright run on a throttled mobile emulation profile, plus a timed manual run on real hardware, recorded in the release checklist |
| SC-6 | Cumulative Layout Shift contributed by a widget | ≤ 0.01 | Lighthouse CI against a fixture host page, compared to a widget-free control |
| SC-7 | Largest Contentful Paint delta on a host page with a widget versus without | ≤ 25 ms | Lighthouse CI comparison run, median of 5 runs |
| SC-8 | GDPR erasure completeness | 100% of assets destroyed, completed within 30 days, typically within 15 minutes | Automated end-to-end erasure test: create a testimonial with video, request erasure, then assert 404 on the video provider's asset, 404 on every object-storage key, a CDN cache miss on every derivative, and zero rows in every table for that subject |
| SC-9 | Published-content availability during dependency failure | Widgets and showcase pages continue serving published testimonials with the video provider, the transcription provider, the AI provider, the payment provider, or the cache unavailable | Chaos test in staging: disable each dependency in turn and assert widget data API and showcase page both return 200 with complete content (Section 3.9) |
| SC-10 | Consent record integrity | 100% of published testimonials have a stored consent row with a required-grant granted = true, the verbatim text, and a matching SHA-256 |
Database invariant check run in CI against seeded data and as a nightly production assertion |
| SC-11 | Quota enforcement is server-side | Every limit rejects at the API layer with the client-side check disabled | Integration test suite that calls each metered endpoint directly at and beyond the cap, asserting the correct error code from Section 29.3 |
| SC-12 | Widget behaviour under a strict host Content Security Policy | Renders fully under script-src 'self' cdn.<domain>; img-src 'self' cdn.<domain> data:; style-src 'self' |
Playwright fixture page serving that exact CSP header, asserting rendered content and zero console CSP violations |
| SC-13 | Audit trail immutability | UPDATE and DELETE on audit_events fail for the application role |
Integration test that attempts both and asserts a database-level permission error |
| SC-14 | Request-page JavaScript weight | ≤ 140 KB gzip total | size-limit check in CI on the request-page route bundle |
| SC-15 | Recorder keyboard operability | Every recorder control reachable and operable by keyboard in a logical order with a visible focus indicator | Playwright keyboard-only walkthrough of the full record-and-submit flow, plus the manual screen-reader matrix in Section 21 |
3. Technology Stack & System Architecture #
3.1 The Canonical Stack #
This table is the single authority on technology and version lines for the entire document. No other section states a version number. Where a later section mentions a dependency, it names the technology and refers back here.
| Concern | Choice | Version line |
|---|---|---|
| Language | TypeScript | 7.x |
| Runtime | Node.js | 24.x LTS |
| Package manager | pnpm | 10.x |
| Monorepo task runner | Turborepo | 2.x |
| Web framework | Next.js (App Router) | 16.x |
| UI library | React | 19.x |
| Styling (dashboard + request pages) | Tailwind CSS | 4.x |
| Dashboard component primitives | Radix UI primitives + a local component layer | current |
| Server state (client) | TanStack Query | 5.x |
| Forms | React Hook Form 7.x with @hookform/resolvers 5.x | — |
| Validation (shared front end / back end) | Zod | 4.x |
| Database | PostgreSQL | 18 |
| ORM & migrations | Drizzle ORM 0.45.x with drizzle-kit | — |
| Cache & queue backend | Valkey 8.x (Redis protocol; Redis 7.x supported) via ioredis 6.x | — |
| Job queue | BullMQ | 6.x |
| Authentication | better-auth | 1.x |
| Billing | Stripe with stripe-node | 22.x |
| Video host / transcode / delivery | Mux with @mux/mux-node | 15.x |
| Object storage | S3-compatible (Cloudflare R2 or AWS S3) via @aws-sdk/client-s3 | 3.x |
| Transcription | Deepgram with @deepgram/sdk 5.x (default); AssemblyAI 4.x as a documented drop-in | — |
| Transactional email | Resend | current major |
| SMS | Twilio with the twilio SDK | 6.x |
| Social card rendering | satori 0.29.x with @resvg/resvg-js; sharp 0.35.x for raster post-processing | — |
| Widget bundler | esbuild | 0.28.x |
| Logging | pino | 10.x |
| Errors & tracing | Sentry (@sentry/nextjs) 10.x with the OpenTelemetry SDK | — |
| Unit & integration tests | Vitest | 4.x |
| End-to-end tests | Playwright | 1.x |
| Accessibility testing | axe-core with @axe-core/playwright | current |
| HTML sanitization | DOMPurify | current major (≥ 20) |
| AI provider | Provider-agnostic AiProvider interface; default Anthropic Claude via @anthropic-ai/sdk; OpenAI SDK 7.x documented as an alternative |
— |
These version lines are a known-good floor, not a lockfile. At build time, install the current stable release of each dependency (
npm install <pkg>@latest, or your ecosystem's equivalent), confirm the major line still matches, and let the lockfile record the exact resolved versions.When the current stable major has moved past the line stated here, resolve it as follows. If the current stable major is higher than the line above, take it and record the deviation as an ADR in
docs/DECISIONS.md, noting anything in the migration guide that touches this design. If it is lower, the line above is wrong and should be corrected in this table. Never pin below the line stated. This is the whole procedure; do not stop and ask, and do not record the resolved number anywhere in this document — the lockfile is where exact versions live.
3.2 Why These Choices #
TypeScript, strict. strict: true, noUncheckedIndexedAccess, exactOptionalPropertyTypes,
and verbatimModuleSyntax on from the first commit. The alternative — starting loose and
tightening later — never happens in practice, and this codebase shares types across four
consumers (dashboard, request page, worker, public API) where a type error is the cheapest
place to catch a contract break. Rejected: JavaScript with JSDoc types, which cannot express
the discriminated unions the job payloads and the widget configuration rely on.
Node.js LTS. Native fetch, stable AbortSignal, a built-in test runner available as a
fallback, and a support window that outlives the build. Rejected: Bun and Deno — both are
capable, but the ecosystem this product depends on (BullMQ, ioredis, the video and payment
SDKs, Drizzle's Postgres driver) is validated against Node, and this is not a project where
runtime novelty pays for itself.
pnpm + Turborepo. A monorepo is not optional here: the Zod schemas in packages/validation
must be the same objects used by the dashboard form, the API route, the worker job, and the
public API handler. pnpm's strict, non-hoisted node_modules prevents phantom dependencies —
which matters enormously for apps/embed, where an accidental transitive import of React
would blow the size budget silently. Turborepo gives content-addressed task caching so CI
rebuilds only what changed. Rejected: npm workspaces (hoisting makes the embed boundary
unenforceable) and Nx (more capability than this repo needs).
Next.js App Router + React. One framework serving four surfaces — an authenticated dashboard, public request pages, public showcase pages, and API routes — with Server Components keeping the dashboard's client bundle small and Server Actions removing an entire layer of hand-written mutation endpoints. Its Edge runtime is what lets the widget data API and the analytics beacon run at the CDN edge without a second service. Rejected: a Vite SPA plus a separate API server (two deploy targets, no server rendering for showcase pages, worse SEO), and Remix (equivalent quality, but no edge-runtime story as tightly integrated with the hosting model here).
Tailwind CSS. Utility classes with a shared preset in packages/config keep the dashboard
and request pages visually consistent without a growing pile of bespoke CSS, and its build step
means unused styles never ship. Design tokens are CSS custom properties so the same values are
readable from the request page's runtime theming. Rejected: CSS Modules (more files, weaker
consistency) and a CSS-in-JS runtime (ships a runtime, fights Server Components). Note that
Tailwind is used in neither apps/embed — see Section 4.8 for why the widget's CSS is
hand-authored.
Radix UI primitives plus a local component layer. Accessible behaviour — focus trapping,
roving tabindex, dismissal semantics, portal management — is the part of a component library
that is genuinely hard, and Radix ships it unstyled. Wrapping it in packages/ui means one
place to enforce the design tokens and one place to fix an accessibility defect. Rejected:
a fully styled component library (fights the design system, ships unused CSS) and
hand-building dialogs and menus (a reliable way to ship WCAG failures).
TanStack Query, deliberately limited. Used only where the client genuinely polls or subscribes: domain verification status (Section 14), the moderation inbox's live updates, and media processing state. Everything else is a Server Component read or a Server Action mutation. Rejected: using it as the default data layer, which would push the dashboard back into client-side fetching and inflate the bundle for no benefit.
React Hook Form + Zod resolvers. Uncontrolled inputs mean typing in a long testimonial form does not re-render the tree, which matters on the mobile request page. The resolver reuses the exact same Zod schema the server validates with, so the client cannot accept something the server will reject. Rejected: controlled state by hand (re-render cost, duplicated validation).
Zod as the single validation authority. One schema per boundary, in packages/validation,
consumed by the form, the route, the worker, and the public API. It infers TypeScript types, so
the type and the runtime check cannot diverge. Rejected: class-validator (decorator-based,
awkward to share with the browser) and hand-written guards (drift).
PostgreSQL. Row-Level Security is the reason. Tenant isolation enforced by the database
rather than by remembering a WHERE clause is a categorically stronger guarantee, and it is
the foundation of the tenancy model in Section 6. Beyond that: native enums, JSONB for
transcripts and audit snapshots, generated columns and trigram indexes for search, timestamptz
done correctly, and mature partitioning for the analytics tables. Rejected: MySQL (no
comparable RLS) and a document database (the data is deeply relational and the audit
requirements demand constraints).
Drizzle ORM. SQL-shaped, fully typed from the schema definition, with a migration generator that produces plain SQL files a human reviews and commits. Critically, it does not hide the query, which matters when every query must be workspace-scoped and must run inside a transaction that has set the RLS session variable. Rejected: Prisma (a separate engine process, historically awkward with RLS session variables and with connection pooling under serverless) and raw SQL everywhere (no type safety across four consumers).
Valkey via ioredis. Backs three things: the BullMQ queues, the token-bucket rate limiters, and short-lived caches. Valkey is the community-governed continuation of the Redis protocol; any Redis-compatible server works. Rejected: an in-process cache (breaks with more than one instance) and a database-backed queue (job throughput and latency).
BullMQ. Mature, typed, Redis-protocol-backed job queue with the four features this product actually needs: delayed jobs (reminder emails, retention sweeps), repeatable jobs (domain verification polling, analytics rollups), per-job retry policies with backoff, and a real dead-letter path. Rejected: a cloud-native queue service (couples the design to one cloud; harder local development) and cron-plus-database-polling (no backoff, no concurrency control).
better-auth. Session management, email verification, magic links, password reset, and TOTP in one library that stores its data in your Postgres rather than a third-party identity service — which matters because the session, the user, and the workspace membership need to be joinable in a single query. Rejected: a hosted identity provider (an external dependency on the login path, and user data outside the database that must satisfy erasure requests) and hand-rolled auth (the fastest way to ship a security defect).
Stripe. See Section 1.2.10. Hosted Checkout and Portal remove card handling, SCA, dunning UI, and invoicing from scope entirely.
Mux. See Section 1.2.5. Browser-recorded video arrives in inconsistent codecs and containers; normalizing it to adaptive streaming that plays on every device is a solved commodity.
S3-compatible object storage. Three requirements decide it: multipart upload (the chunked
resumable upload in Section 9 depends on it), AbortMultipartUpload (free retakes depend on
reclaiming storage immediately), and pre-signed URLs (the browser uploads directly, never
through the application server). Rejected: uploading through the application (bandwidth cost,
request timeouts, no resumability).
Deepgram. See Section 1.2.6. Word-level timings with per-word confidence are mandatory for the caption editor and the clip suggester.
Resend and Twilio. See Sections 1.2.8 and 1.2.9. Both sit behind thin adapters.
satori + resvg + sharp. Social cards are rendered from a JSX-like tree to SVG and then to PNG, entirely in-process, with no headless browser. A headless browser for image generation costs hundreds of megabytes of memory per worker and is the single most common source of memory exhaustion in Node services. Rejected: Puppeteer screenshots for exactly that reason.
esbuild for the widget. The widget is a single vanilla-TypeScript entry point compiled to an ES2022 IIFE with a hard size budget. esbuild is fast, produces small output, and — most importantly — its metafile makes the size-limit and no-framework CI assertions trivial to write. Rejected: the framework's own bundler (wrong output shape, no IIFE target, no independent size gate) and Rollup (comparable output, materially slower in watch mode).
pino. Structured JSON logging with negligible overhead and first-class redaction paths, so "never log a token or a testimonial body" is a configuration rather than a discipline. Rejected: console logging (unstructured, unqueryable) and winston (slower, heavier).
Sentry with OpenTelemetry. Errors, performance traces, and release health, emitted in a vendor-neutral protocol so the vendor is an exporter setting (Section 1.2.19). Rejected: building an in-house error pipeline.
Vitest and Playwright. Vitest shares the Vite transform pipeline, runs TypeScript natively,
and is fast enough that the unit suite runs on save. Playwright drives real browsers, which is
non-negotiable for a product whose two hardest surfaces are a MediaRecorder flow and a shadow
-DOM widget — and its --use-fake-device-for-media-stream support makes the recorder testable
in CI. Rejected: Jest (slower, more configuration for ESM and TypeScript) and Cypress (weaker
multi-origin and multi-browser support, which the embed tests need).
axe-core. Automated accessibility checks catch roughly a third of WCAG failures; that third is worth catching on every pull request. The remaining two thirds are covered by the manual test matrix in Section 21.
DOMPurify. Rich-text testimonial bodies are sanitized at write time and again at serve time, against a deliberately tiny allowlist. Sanitizing twice is not redundant: write-time sanitization protects the database, serve-time sanitization protects against a rule change or a direct database write.
3.3 Repository Layout #
vouch/
apps/
web/ Next.js App Router application. Dashboard (app.), public request pages,
public showcase pages, the widget JSON API, the embed loader route, and
all HTTP API routes. Mixed Node and Edge runtimes — see Section 3.6.
worker/ Long-lived Node service. BullMQ consumers: media/transcode callbacks,
transcription, AI jobs, social-card rendering, review imports, outbound
webhook dispatch, email and SMS delivery, analytics rollups, retention
sweeps, and domain-verification polling.
embed/ The widget. Vanilla TypeScript, compiled by esbuild to an ES2022 IIFE.
No framework, no React, no Tailwind. Renders into an open shadow root.
packages/
db/ Drizzle schema, generated SQL migrations, seed data, RLS policies, the
connection factory, and typed query helpers. The only package that
contains SQL.
core/ Domain services, framework-free: moderation, quotas and entitlements,
publishing, consent, analytics aggregation, import normalization, and AI
orchestration. No HTTP, no React, no direct SQL.
validation/ Zod schemas shared by the client forms, the server routes and actions,
the worker job payloads, and the public API.
ui/ React component layer for the dashboard: Radix primitives wrapped with
design tokens, plus composite components.
config/ Shared configuration: tsconfig bases, ESLint config, Prettier config,
the Tailwind preset, and the design-token source.
infra/ Infrastructure as code, Dockerfiles, the migration runner, and CI
workflow definitions.
docs/ Developer-facing documentation you write and maintain. Create the tree
below; every file in it is required by a later section of this document,
and the section named beside each one tells you what goes in it.
README.md What this repository is, how to run it, how to deploy it
DECISIONS.md One ADR per irreversible decision (Sections 1, 4.10)
RUNBOOKS.md One runbook per operational procedure (Section 24.8)
COMMS-TEMPLATES.md Incident and customer communication copy (Section 24.9.5)
BUILD-REPORT.md The completion report for the build (Section 28.8)
oncall.md Rota and the named incident roles (Section 22.10.3)
abuse.md Platform-abuse categories, SLAs, transparency note
(Section 22.20.4)
exports.md The data-export format, column for column (Section 22.6.1)
subprocessors.md The sub-processor list published to customers (Section 22.9)
accessibility.md Our own accessibility conformance statement (Section 21.15.2)
quarantine.md Quarantined tests, each with an owner and a deadline
(Section 25.10)
privacy/ropa.md Records of processing activities, Art. 30 (Section 22.10.1)
security/threat-model.md The threat model (Section 25.8; copy Section 22.12 into it)
fixtures/README.md What each recorded provider fixture is and when it was
last refreshed (Section 25.3.2)
a11y/at-matrix-{version}.md Assistive-technology results per release (Section 21.13.2)
a11y/manual-runs/{date}.md One file per manual accessibility run (Section 25.6)
ai-eval/{version}.md AI suggestion evaluation results per prompt version
(Section 12.9.3)
incidents/{YYYY-MM-DD}-{slug}.md One blameless post-incident review per incident
(Section 24.9.6)None of these files exists yet — they are outputs of the build, not inputs to it. Create each one the first time the section that owns it asks you to, and treat a missing file as an incomplete milestone rather than as an optional extra.
Every package has a README.md describing its purpose, its public exports, and its
constraints. Every package's package.json declares its dependencies explicitly; nothing
relies on hoisting.
3.4 System Architecture #
graph TD
subgraph Browsers
DASH[Dashboard<br/>app.vouch.app<br/>React Server + Client Components]
REQ[Request page + recorder<br/>v.vouch.app/r/-slug-<br/>mobile-first]
HOST[Third-party marketing site<br/>with the pasted embed snippet]
end
subgraph Edge
CDN[CDN edge<br/>cdn.vouch.app<br/>e.js, runtime chunk, widget JSON,<br/>posters, social cards, VTT]
EDGERT[Next.js Edge runtime routes<br/>widget data API, embed loader,<br/>analytics beacon ingest]
end
subgraph Application
WEB[apps/web - Node runtime<br/>dashboard, request pages, showcase,<br/>REST API, server actions, webhooks in]
WORKER[apps/worker<br/>BullMQ consumers]
DISPATCH[Outbound webhook dispatcher<br/>reads the transactional outbox]
end
subgraph Data
PG[(PostgreSQL<br/>RLS-enforced, outbox table)]
VK[(Valkey<br/>queues, rate limits, cache)]
OS[(S3-compatible object storage<br/>originals, posters, cards, VTT)]
end
subgraph Third-party
MUX[Mux<br/>transcode + delivery]
DG[Deepgram<br/>transcription]
STRIPE[Stripe<br/>billing]
AI[AI provider<br/>highlight assistant]
MAIL[Resend / Twilio<br/>email + SMS]
end
DASH -->|HTTPS, session cookie| WEB
REQ -->|HTTPS| WEB
REQ -->|pre-signed multipart PUT| OS
HOST -->|async script tag| CDN
HOST -->|widget JSON, CORS *| CDN
HOST -->|sendBeacon analytics| CDN
CDN -->|cache miss| EDGERT
EDGERT --> PG
EDGERT --> VK
WEB --> PG
WEB --> VK
WEB --> OS
WEB --> STRIPE
WORKER --> PG
WORKER --> VK
WORKER --> OS
WORKER --> MUX
WORKER --> DG
WORKER --> AI
WORKER --> MAIL
WORKER --> CDN
MUX -->|signed webhooks| WEB
STRIPE -->|signed webhooks| WEB
PG -->|outbox rows| DISPATCH
DISPATCH -->|signed HTTP POST| CUST[Customer webhook endpoints,<br/>Zapier, Slack, HubSpot]Three properties of this diagram matter more than the rest:
- The host page never talks to the application tier. It talks to the CDN. On a cache hit — the overwhelming majority of requests — no application code runs at all.
- The browser uploads directly to object storage. Video bytes never pass through the application server, which is what makes chunked resumable upload of a five-minute recording viable on a serverless-ish web tier.
- Everything that must happen exactly once alongside a database write goes through the outbox (Section 3.7), not through a direct call from a request handler.
3.5 Request Lifecycle Walkthroughs #
3.5.1 A respondent records and submits a video #
sequenceDiagram
autonumber
participant R as Respondent browser
participant W as apps/web (Node)
participant OS as Object storage
participant Q as Valkey / BullMQ
participant WK as apps/worker
participant MX as Mux
participant DG as Deepgram
R->>W: GET /r/{slug}
W->>W: Load request page, check collection quota (Section 19)
W-->>R: 200 page (or collectionClosed payload if capped)
R->>R: getUserMedia, device picker, codec negotiation
R->>W: POST /api/v1/upload-sessions {mimeType, requestPageId}
W->>OS: CreateMultipartUpload
W->>W: Insert upload_sessions row
W-->>R: 201 {uploadSessionId, uploadId, partUrlTemplate}
loop every 5s timeslice while recording
R->>W: POST /api/v1/upload-sessions/{id}/parts {partNumber}
W-->>R: 200 {presignedUrl}
R->>OS: PUT part bytes
OS-->>R: 200 ETag
R->>W: PATCH /api/v1/upload-sessions/{id} {partNumber, etag}
end
Note over R,OS: Retake -> AbortMultipartUpload, storage reclaimed, nothing charged
R->>W: POST /api/v1/testimonials {uploadSessionId, fields, consents}
W->>OS: CompleteMultipartUpload
W->>W: TX: insert testimonial(pending) + media_asset(uploaded)<br/>+ consents + audit_event + outbox rows
W->>Q: enqueue media.ingest
W-->>R: 201 {testimonialId} -> thank-you screen
WK->>MX: create asset from object-storage URL
MX-->>W: webhook video.asset.ready {assetId, playbackId}
W->>W: media_assets.state = ready, store playback id
W->>Q: enqueue transcription.run, media.poster
WK->>DG: transcribe(mediaUrl, detectLanguage)
DG-->>WK: word-level transcript
WK->>WK: store canonical transcript JSONB, generate VTT to object storage
WK->>Q: enqueue notification.new_submissionFailure behaviour at each step. A dropped connection mid-upload resumes from the last
acknowledged part with no re-sending. A failed part retries five times with exponential
backoff and jitter, capped at 30 seconds. If asset processing errors, the testimonial stays in
pending with media_state = errored, the moderator sees an explicit error state with a
retry action, and the respondent is not asked to record again. If transcription fails after
its retries, the testimonial still publishes with no caption track, the moderator is notified,
and a "Retry transcription" action is offered — video is never blocked on transcription.
3.5.2 A moderator publishes, and a live widget reflects it #
sequenceDiagram
autonumber
participant M as Moderator (dashboard)
participant W as apps/web (Node)
participant PG as PostgreSQL
participant Q as Valkey / BullMQ
participant WK as apps/worker
participant CDN as CDN edge
participant H as Host page widget
M->>W: Server Action: publishTestimonial(id)
W->>W: can(actor, 'testimonial:publish', resource) (Section 6)
W->>PG: BEGIN; SET LOCAL app.current_workspace_id
W->>PG: UPDATE testimonials SET status='published', published_at=now()
W->>PG: INSERT audit_events (before/after snapshot, actor, ip, ua)
W->>PG: INSERT outbox_events (widget.cache.purge, testimonial.published)
W->>PG: COMMIT
W-->>M: optimistic UI update, then revalidated Server Component
WK->>PG: claim outbox rows (FOR UPDATE SKIP LOCKED)
WK->>PG: recompute affected widget payloads
WK->>CDN: purge by tag widget:{id} and workspace:{id}
WK->>WK: dispatch testimonial.published webhook (signed, Section 7)
H->>CDN: next data refresh (max-age 60, SWR 300)
CDN-->>H: fresh payload including the new testimonialSteady-state propagation to a host page is bounded by the 60-second max-age on the widget
payload plus the CDN purge, so a newly published testimonial appears in embeds within seconds
of the purge completing and no later than 60 seconds in any case.
3.5.3 A widget loads on a third-party page #
sequenceDiagram
autonumber
participant B as Visitor browser on a customer's site
participant CDN as CDN edge
participant E as Edge runtime route
participant PG as PostgreSQL
B->>CDN: GET /e.js (async, no SRI, immutable behaviour)
CDN-->>B: 5 KB loader, long-lived cache
B->>B: loader finds every [data-vouch-widget], batches the ids
B->>CDN: GET /api/v1/widgets/data?ids=wgt_a,wgt_b
alt cache hit (typical)
CDN-->>B: pre-rendered JSON, <=60 KB, CORS *
else cache miss
CDN->>E: forward
E->>PG: read pre-rendered payloads (read replica acceptable)
E-->>CDN: JSON, Cache-Control public, max-age=60, SWR=300
CDN-->>B: JSON
end
B->>CDN: GET runtime chunk (content-hash pinned by the loader)
CDN-->>B: <=22 KB runtime
B->>B: attach open shadow root, adopt one stylesheet, render via textContent
B->>B: IntersectionObserver -> impression event queued in memory
B->>CDN: sendBeacon /api/v1/e (batched, max 1 per 5s, no cookies)
Note over B: Video chunk and player are imported only on first playExactly one network request happens before the widget's first paint: the JSON payload. The
loader is already cached from e.js, the runtime chunk loads in parallel with the data, no
fonts are requested, and the poster images are lazy with explicit dimensions so nothing
shifts.
3.5.4 A custom domain goes from added to active #
sequenceDiagram
autonumber
participant U as Owner/Admin (dashboard)
participant W as apps/web
participant Q as BullMQ repeatable job
participant DNS as Authoritative nameservers
participant ACME as ACME CA
participant EDGE as edge.vouch.app
U->>W: POST /api/v1/custom-domains {hostname}
W->>W: validate hostname, check plan entitlement (Section 19),<br/>check global uniqueness
W->>W: insert custom_domains (state=pending_dns), generate challenge token
W-->>U: exact records to create: CNAME + _vouch-challenge TXT
loop client polling: 5s for 2 min, 15s to 10 min, 60s to 24 h
U->>W: GET /api/v1/custom-domains/{id}
W-->>U: state, last checked at, records observed, plain-English diff
end
loop server polling: every 5 min for 72 h
Q->>DNS: resolve CNAME and TXT at authoritative nameservers
DNS-->>Q: observed records
alt records match
Q->>W: state = dns_detected -> verifying -> issuing_tls
Q->>ACME: request certificate (HTTP-01, DNS-01 fallback for apex)
ACME-->>Q: certificate issued
Q->>EDGE: install certificate, bind hostname
Q->>W: state = active, notify Owner
else records absent or wrong after 72 h
Q->>W: state = failed, notify with the specific mismatch
end
endThe embed snippet on the customer's marketing site is untouched by all of this — the loader origin is permanent and the widget id resolves everything at runtime (Section 13).
3.6 Runtime Split: Edge versus Node #
The rule that makes the split decidable: a route runs on the Edge runtime if and only if it is publicly cacheable or cache-adjacent, needs no session, needs no privileged credential, and touches the database either not at all or through a single pre-rendered, read-only lookup. Everything else runs on Node. If you find yourself wanting an exception, the answer is Node.
| Route | Runtime | Why |
|---|---|---|
GET /api/v1/widgets/data (single or batched) |
Edge | Public, unauthenticated, CDN-cached, reads one pre-rendered payload |
GET /e.js and the runtime chunk |
Edge (static from CDN) | Immutable assets |
POST /api/v1/e (analytics beacon) |
Edge | No session, no read; validates and pushes onto a buffered stream |
GET /api/v1/widgets/{id}/oembed |
Edge | Public, cacheable, pre-rendered |
| Showcase pages | Node | Server-rendered HTML with revalidation, reads several tables |
| Request pages and the recorder | Node | Reads request-page configuration, checks quota, sets no-store headers |
| Dashboard (all routes) | Node | Session, RLS transaction, privileged reads and writes |
| All authenticated REST API routes | Node | Key verification, RLS transaction, writes |
| Inbound webhooks (Mux, Stripe, email, SMS) | Node | Signature verification with a secret, database writes, queue writes |
| Server Actions | Node | By definition |
Two hard constraints follow from this rule and must not be violated: no Edge route ever opens a write transaction, and no Edge route ever reads a session cookie or an API key secret. Anything Edge-served is safe to cache publicly, which is only true if it is identical for every viewer.
3.7 The Transactional Outbox #
Any effect that must happen exactly once and only if a database write committed goes through the outbox. This covers outbound webhooks, integration fan-out to Zapier, Slack, and HubSpot, CDN cache purges, media purge jobs, and notification sends.
The failure this prevents is the classic dual-write: enqueue a job, then the transaction rolls back, and now a webhook has announced something that never happened. Or: commit the transaction, then the process dies before the enqueue, and the webhook never fires.
The contract, at the architecture level. Section 5 owns the table's exact columns, types, and indexes.
- Inside the same transaction as the domain write, insert one row per effect, containing: the
workspace id, an event type from the catalogue in Section 29.4, an aggregate type and
aggregate id, a JSONB payload, and a status of
pending. - A relay in
apps/workerpolls withSELECT ... FOR UPDATE SKIP LOCKED LIMIT n, so many worker instances can relay concurrently without processing a row twice. - The relay performs the effect — enqueue a BullMQ job, dispatch a signed webhook, issue a
CDN purge — and then marks the row
dispatchedwith a timestamp. - Failures increment an attempt counter and set a
next_attempt_atwith exponential backoff. After the maximum attempts the row moves tofailedand raises an operator alert. - Consumers are idempotent. Every job payload carries the outbox row's id, and every consumer is safe to run twice with the same id — because at-least-once delivery is what this pattern guarantees, and exactly-once processing is the consumer's job.
- Dispatched rows are retained 30 days for debugging and then swept.
// packages/core/outbox/emit.ts — conceptual shape; Section 5 owns the schema.
export async function emit(
tx: Transaction,
events: ReadonlyArray<{
workspaceId: string;
eventType: OutboxEventType; // e.g. 'testimonial.published'
aggregateType: 'testimonial' | 'widget' | 'workspace' | 'custom_domain';
aggregateId: string;
payload: Record<string, unknown>;
}>,
): Promise<void> {
// Runs on the SAME transaction handle as the domain write. Never opens its own.
await tx.insert(outboxEvents).values(
events.map((e) => ({ id: newId(), status: 'pending' as const, attempts: 0, ...e })),
);
}The rule for reviewers: if a code path calls an external system after a database write and
is not inside emit(), it is a bug — unless the effect is genuinely safe to lose, in which
case a comment must say so.
3.8 Caching Layers and Invalidation Ownership #
Four layers. Each has exactly one owner responsible for invalidating it, because caches with ambiguous ownership go stale.
| Layer | Contents | TTL | Invalidated by |
|---|---|---|---|
| CDN edge | e.js, runtime chunks, widget JSON payloads, posters, social cards, VTT files |
Assets immutable (1 year, content-hashed); widget JSON max-age=60, stale-while-revalidate=300 |
Tag-based purge issued by the outbox relay on publish, unpublish, edit, delete, widget configuration change, and consent withdrawal. Tags: widget:{id}, workspace:{id}, testimonial:{id} |
| Valkey | Rate-limit buckets, plan entitlements, resolved custom-domain → workspace mappings, widget configuration, session-adjacent lookups, idempotency records | Per key, below | Written by the owning domain service; every write path that changes the underlying row deletes the key in the same request |
| Next.js data cache | Server Component reads for showcase pages and public request pages | 60 s for showcase, no-store for request pages and all dashboard routes |
revalidateTag from the outbox relay, using the same tag names as the CDN |
| Browser | Static assets only. No application state is cached in the browser for the widget, and the widget writes nothing to storage of any kind (Section 17) | Immutable assets 1 year | Content hashing |
Valkey key naming — mandatory scheme: vch:{env}:{concern}:{id}, where env is one of
dev, test, staging, prod. Never construct a key by string concatenation at a call
site; build it with a helper in packages/core/cache/keys.ts so every key is greppable.
| Key | Contents | TTL |
|---|---|---|
vch:{env}:ratelimit:{scope}:{id} |
Token-bucket state | Bucket window |
vch:{env}:plan:{workspaceId} |
Resolved entitlements | 300 s, deleted on any subscription webhook |
vch:{env}:domain:{hostname} |
Hostname → workspace id and state | 300 s, deleted on domain state change |
vch:{env}:widgetcfg:{widgetId} |
Widget configuration used to render payloads | 600 s, deleted on widget update |
vch:{env}:idem:{keyHash} |
Idempotency response snapshot | 24 h (Section 7) |
vch:{env}:quota:{workspaceId}:{metric} |
Counter cache for metered usage | 60 s, always reconciled against Postgres before a hard block |
The invariant that governs all caching: a cache may make something faster; it may never be the reason something is wrong. Any quota decision that blocks a user re-reads Postgres before blocking. Any permission decision reads Postgres, never a cache.
3.9 Failure-Domain Analysis #
State this invariant loudly, and design every dependency call to preserve it:
Published widgets keep serving in every failure scenario below. Widget payloads are pre-rendered and CDN-cached with
stale-while-revalidate, so a host page continues to display published testimonials even when the origin, the database, or any third-party provider is entirely unavailable.
| Failing dependency | What still works | What degrades | Behaviour and user-facing signal |
|---|---|---|---|
| Mux (video) | Everything except new video processing. Widgets serve; posters still render because they are copied to object storage and served from cdn. rather than from Mux; text testimonials unaffected; playback of already-delivered assets follows Mux's own edge availability |
New uploads queue; new videos do not reach ready |
Upload sessions still accept and complete; the ingest job retries with backoff for 24 hours; the moderator sees media_state = processing with an explicit "video processing is delayed" note; the respondent's submission succeeds and they are never asked to re-record |
| Deepgram (transcription) | Everything. Video publishes without captions | No new transcripts, no new captions, no AI clip suggestions for new videos | The job retries with backoff; after exhaustion, the moderator is notified and offered "Retry transcription"; publishing is never blocked on transcription |
| Stripe (billing) | Everything, including collection and publishing | New checkouts and plan changes fail | Existing entitlements are read from the workspace's cached plan record in Postgres, not from Stripe, so no workspace loses access; checkout shows a dependency-unavailable message with the status page link; webhooks are replayed by Stripe when it recovers and are idempotent via stripe_events |
| Valkey (cache and queue) | Widgets and showcase pages serve entirely from the CDN. The dashboard serves reads | Job processing pauses; rate limiting falls back | Rate limiters fail closed to a conservative in-process limit rather than open — an unlimited public API is a worse outcome than a stricter one. Jobs are not lost: outbox rows stay pending in Postgres and relay when the queue returns. New submissions are accepted and their work relays later |
| AI provider | Everything. The assistant is optional and Growth+ only | No new highlight suggestions | The suggestion panel shows an unavailable state with a retry control; manual curation — which is the default anyway — is unaffected |
| Postgres in failover | Widgets and showcase pages serve from the CDN throughout | Dashboard writes, new submissions, and API writes fail for the failover window | Read paths use a replica where one is configured. Write paths return 503 DEPENDENCY_UNAVAILABLE with Retry-After. The request page shows a "we could not save that — please try again in a moment" state that preserves the respondent's recorded take in the browser so nothing is lost |
| Object storage | Widgets serve; already-cached posters and cards serve from the CDN | New uploads fail; new derivative generation fails | The recorder detects part-upload failure, retries, and if the outage persists tells the respondent explicitly rather than silently discarding the take |
| Email / SMS provider | Everything | Invitations, notifications, and dunning emails are delayed | Sends are queued through the outbox and retried; a sustained failure raises an operator alert |
| CDN edge | The origin serves widget JSON and assets directly | Higher latency, higher origin load | The Edge runtime routes are the origin for widget data and remain correct without the CDN; rate limits at the origin protect it |
Every one of these is exercised by the chaos test in gate SC-9 (Section 2.9).
3.10 Scale Targets and the First Bottleneck #
Design targets for launch scale:
| Dimension | Target |
|---|---|
| Workspaces | 10,000 |
| Published testimonials | 2,000,000 |
| Widget impressions per month | 500,000,000 |
| Widget data API requests per month | ~50,000,000 origin-visible (assuming ≥ 90% CDN hit rate) |
| Analytics events ingested per month | ~750,000,000 raw, rolled up hourly |
| Peak concurrent recordings | 500 |
| Peak object-storage part PUTs per second | 100 |
Where it breaks first, in order:
- Analytics ingestion. At 500 million impressions a month, raw event volume dominates everything else. Mitigations, all designed in from the start: events are batched in the browser (one beacon per 5 seconds per page maximum), ingested at the Edge, written in bulk, partitioned by day, kept raw for only 7 days, and rolled up hourly and daily. The rollup tables are what the dashboard reads; nothing user-facing ever scans raw events.
- Widget data API origin load. Solved by construction: the payload is pre-rendered, the
CDN hit rate target is ≥ 90%,
stale-while-revalidatemeans even a miss serves instantly from stale, and the batched?ids=form collapses multiple widgets on one page into one request. A page with six widgets makes one data request, not six. - Postgres write throughput on
audit_eventsandoutbox_events. Both are append-heavy. Both use UUIDv7 primary keys, which are time-ordered and therefore preserve index locality on insert — this is precisely why identifiers are UUIDv7 and generated application-side (Section 5).audit_eventsis partitioned by month;outbox_eventsis swept after 30 days. - Object storage cost, not throughput. Original uploads are deleted 30 days after the
asset reaches
readyunless the workspace has explicitly enabled "keep originals"; abandoned upload sessions are swept after 24 hours. Without those two sweeps, storage grows without bound. - Transcription cost. Bounded per plan by the transcription-minutes entitlement in Section 19, enforced server-side before a job is enqueued rather than after the provider has been billed.
The shape to keep in mind: the read path is almost entirely at the edge, and the write path is almost entirely asynchronous. Synchronous origin work happens only for dashboard interactions, submissions, and API writes — which are, by orders of magnitude, the smallest share of total traffic.
4. Conventions, Code Standards & Project Structure #
These conventions are not stylistic preferences. Each one exists because a specific class of defect becomes impossible when it is followed. Reviewers enforce them; the linter enforces what it can.
4.1 Naming Conventions #
4.1.1 Database #
snake_case throughout. Tables are plural nouns. Join tables are the two entity names in
alphabetical order joined by an underscore. Booleans are prefixed is_ or has_. Timestamps
end in _at. Foreign keys are {singular_referenced_table}_id. Money columns end in _cents
and always have a sibling currency column of type char(3).
| Right | Wrong | Why |
|---|---|---|
testimonials, request_pages, custom_domains |
Testimonial, requestPage, tbl_domains |
Postgres folds unquoted identifiers to lowercase; mixed case forces quoting everywhere |
testimonial_tags |
tag_testimonials, testimonials_tags_map |
Alphabetical order removes the "which way round is it" question permanently |
is_published, has_video |
published, video |
A bare adjective reads ambiguously as a column name |
created_at, published_at, deleted_at |
created, publish_date, date_deleted |
One suffix, one meaning, always timestamptz |
workspace_id |
ws_id, workspaceId, fk_workspace |
Abbreviations and camelCase in SQL cost more than they save |
amount_cents + currency |
amount, price (numeric) |
Integer minor units; never a float for money |
4.1.2 TypeScript #
camelCase for values and functions, PascalCase for types, interfaces, classes, and React
components, SCREAMING_SNAKE_CASE for module-level constants. No Hungarian notation, no I
prefix on interfaces, no T prefix on types.
| Right | Wrong |
|---|---|
const maxItems = 24 |
const MaxItems, const max_items |
type TestimonialStatus = ... |
type testimonialStatus, interface ITestimonial |
const DEFAULT_PAGE_LIMIT = 25 |
const defaultPageLimit for a module constant |
function publishTestimonial() |
function PublishTestimonial() |
export function ModerationInbox() |
export function moderationInbox() for a component |
4.1.3 Files and Directories #
Files are kebab-case. React component files are kebab-case.tsx and export a PascalCase
component. Test files sit beside their subject as *.test.ts; end-to-end specs live in
e2e/*.spec.ts. Directory names are kebab-case and singular when they name a concept,
plural when they hold a collection.
| Right | Wrong |
|---|---|
moderation-inbox.tsx exporting ModerationInbox |
ModerationInbox.tsx, moderationInbox.tsx |
publish-testimonial.ts |
publishTestimonialService.ts, utils.ts |
packages/core/quota/check-collection-quota.ts |
packages/core/helpers/misc.ts |
widget-renderer.test.ts beside widget-renderer.ts |
__tests__/widgetRenderer-test.js |
There is no utils.ts, no helpers.ts, and no misc.ts anywhere in this repository. A file
whose name does not describe its contents is a file nobody can find twice.
4.1.4 API Paths #
/api/v1/{plural-kebab-resource}. Nesting is one level maximum. No verbs in paths, with the
single exception of explicit action sub-resources on a specific entity.
| Right | Wrong |
|---|---|
GET /api/v1/testimonials |
GET /api/v1/getTestimonials |
GET /api/v1/testimonials/{id}/tags |
GET /api/v1/workspaces/{w}/testimonials/{id}/tags/{t}/details |
POST /api/v1/testimonials/{id}/publish |
POST /api/v1/publishTestimonial |
GET /api/v1/custom-domains |
GET /api/v1/customDomains, GET /api/v1/domain |
The workspace never appears in the path. It is derived from the authenticated API key or session, because a workspace id in a URL is an authorization mistake waiting to happen.
4.1.5 Query Parameters and JSON Bodies #
camelCase for both. Drizzle maps camelCase TypeScript fields to snake_case columns, so
the transformation happens exactly once, in the schema definition, and never in a handler.
| Right | Wrong |
|---|---|
?limit=25&cursor=eyJ...&sort=createdAt |
?page_size=25&offset=50 |
{ "workspaceId": "...", "publishedAt": "..." } |
{ "workspace_id": "...", "PublishedAt": "..." } |
There is no page or offset parameter anywhere in this product. Cursor pagination is the
only pagination model, and Section 7 owns its shape.
4.1.6 Environment Variables #
SCREAMING_SNAKE_CASE, prefixed by the concern they belong to. Every variable is catalogued
exactly once, in Section 29.2, with its type, whether it is required, its default, and whether
it is a secret.
| Right | Wrong |
|---|---|
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET |
stripeKey, SK_LIVE |
MUX_TOKEN_ID, MUX_TOKEN_SECRET, MUX_WEBHOOK_SECRET |
MUX_KEY, VIDEO_SECRET |
DEEPGRAM_API_KEY |
TRANSCRIPTION_KEY |
DATABASE_URL, DATABASE_MIGRATION_URL |
PG, DB |
Environment variables are read in exactly one place — packages/config/env.ts — where they
are parsed and validated by a Zod schema at process start. The process refuses to boot if a
required variable is missing or malformed. No other file calls process.env, and an ESLint
rule enforces it.
4.1.7 Feature Flags #
Prefixed flag_, stored in the feature_flags table, evaluated server-side only. A flag
resolves against a workspace and returns a boolean; the client receives the resolved value,
never the flag list.
| Right | Wrong |
|---|---|
flag_widget_popup, flag_ai_assistant, flag_hubspot_sync |
enablePopup, NEW_UI |
Server resolves and passes { popupEnabled: true } to the client |
Client fetches all flags and decides |
4.1.8 Git #
Trunk-based development. main is protected and always deployable. Branch names are
type/short-slug where type is one of feat, fix, chore, docs, refactor, test,
or perf. Commit messages follow Conventional Commits.
| Right | Wrong |
|---|---|
feat/chunked-upload-resume |
mybranch, feature/JIRA-1234-stuff-and-things |
feat(recorder): resume multipart upload after connection loss |
fixed bug |
fix(widget): prevent layout shift when poster is missing |
updates |
feat(api)!: replace offset pagination with cursors |
An unmarked breaking change |
4.1.9 Public Identifiers #
Primary keys are UUIDv7, generated application-side. That raw form is never emitted. Every
identifier that crosses a public boundary — a REST response, a widget payload, a webhook
envelope, a dashboard URL, an error message, a log line — is prefix + the Crockford base32
encoding of the row's UUIDv7, 26 characters after the prefix, and carries its prefix every
time. No identifier is ever emitted unprefixed.
Section 5.1.1 owns the prefix table and is the only place a prefix is assigned. A prefix that is not in that table does not exist: if you need one for a new entity, add it there rather than inventing it at a call site.
| Right | Wrong |
|---|---|
tst_01JBQ7S1K4Q0M8W2H9Z3XN5V7C |
01JBQ7S1K4Q0M8W2H9Z3XN5V7C, or the raw UUID |
One publicId() encoder and one parsePublicId() decoder |
Encoding assembled per route |
| A schema that validates the prefix and the base32 alphabet | z.uuid() on a caller-supplied public id |
Two consequences worth stating because they are easy to miss: a support conversation and a trace both quote the prefixed form, so the prefix is what makes an id greppable across a log sink; and the prefix is part of the public contract, so changing one is a breaking API change under the versioning rule in Section 7.
4.2 Module Boundaries #
The dependency graph is a strict hierarchy. Violating it is the fastest way to make the widget's size budget unenforceable and the domain logic untestable.
apps/web ──┐
apps/worker┼──> packages/core ──> packages/validation
│ │
│ └──────────> packages/db
apps/web ──┴──> packages/ui ────> packages/config
apps/embed ──> (nothing from packages/ui, nothing from React)The rules:
apps/*may import frompackages/*.packages/*may never import fromapps/*.packages/dbis the only place that contains SQL or Drizzle query construction. If a handler builds a query, that is a boundary violation.packages/coreholds domain logic and is framework-free: no React, no Next.js imports, no HTTP objects, no direct SQL. It receives a transaction handle and typed inputs, and returns typed results or throws anAppError. This is what makes it testable without a server.packages/validationdepends on nothing but Zod. It is imported by everything.packages/uimay import React, Radix, andpackages/config. It must not importpackages/dborpackages/core— components receive data as props.apps/embedmay import nothing frompackages/uiand nothing from React. It may import types (type-only imports, erased at compile time) frompackages/validationto keep the widget payload contract in sync. Any value import from a package that pulls in a framework fails the build.apps/webandapps/workernever import from each other. Shared logic moves topackages/core.
Enforced by ESLint. This configuration lives in packages/config/eslint:
// packages/config/eslint/boundaries.js
const RESTRICTED = {
// packages/* must never reach into apps/*
packages: [
{ group: ['@vouch/../apps/*', '**/apps/*'], message:
'packages/* must not import from apps/*. Move shared logic into packages/core.' },
],
// packages/core stays framework-free and SQL-free
core: [
{ group: ['react', 'react-dom', 'next', 'next/*'], message:
'packages/core is framework-free. Keep React and Next.js out of domain logic.' },
{ group: ['drizzle-orm', 'drizzle-orm/*', 'postgres', 'pg'], message:
'Only packages/db constructs SQL. Import a typed query helper from @vouch/db instead.' },
],
// packages/ui renders; it does not fetch or query
ui: [
{ group: ['@vouch/db', '@vouch/db/*', '@vouch/core', '@vouch/core/*'], message:
'packages/ui receives data as props. Fetch in a Server Component and pass it down.' },
],
// the widget ships zero framework bytes
embed: [
{ group: ['react', 'react-dom', 'preact', 'vue', 'svelte', '@vouch/ui', '@vouch/ui/*',
'@vouch/core', '@vouch/core/*', '@vouch/db', '@vouch/db/*', 'tailwindcss'],
message:
'apps/embed ships zero framework bytes and hand-authored CSS. Type-only imports from '
+ '@vouch/validation are the sole permitted cross-package reference.' },
],
// env is parsed once, centrally
env: [
{ group: ['**/process.env*'], message:
'Read configuration from @vouch/config env, which validates at boot.' },
],
};
module.exports = {
overrides: [
{ files: ['packages/**/*.ts', 'packages/**/*.tsx'],
rules: { 'no-restricted-imports': ['error', { patterns: RESTRICTED.packages }] } },
{ files: ['packages/core/**/*.ts'],
rules: { 'no-restricted-imports': ['error',
{ patterns: [...RESTRICTED.packages, ...RESTRICTED.core] }] } },
{ files: ['packages/ui/**/*.ts', 'packages/ui/**/*.tsx'],
rules: { 'no-restricted-imports': ['error',
{ patterns: [...RESTRICTED.packages, ...RESTRICTED.ui] }] } },
{ files: ['apps/embed/**/*.ts'],
rules: {
'no-restricted-imports': ['error', { patterns: RESTRICTED.embed }],
// the widget targets ES2022 and ships no polyfills
'no-restricted-syntax': ['error',
{ selector: "CallExpression[callee.name='require']",
message: 'apps/embed is ESM-only; esbuild bundles it to an IIFE.' }],
} },
{ files: ['**/*.ts', '**/*.tsx'],
excludedFiles: ['packages/config/env.ts'],
rules: { 'no-restricted-properties': ['error',
{ object: 'process', property: 'env',
message: 'Import validated config from @vouch/config instead of reading process.env.' }] } },
],
};The apps/embed rule is backed by a second, independent check: a CI assertion over esbuild's
metafile that no framework package appears anywhere in the widget's module graph (gate SC-2 in
Section 2.9). Lint rules can be disabled with a comment; the metafile assertion cannot.
4.3 Errors and the Result Convention #
One base class. Every error the application raises deliberately is an AppError or a
subclass. Everything else is unexpected by definition and is treated as such.
// packages/core/errors/app-error.ts
export interface AppErrorDetail {
/** Dot-path to the offending field, e.g. "fields.email" or "consents[0].granted". */
field: string;
/** Human-readable, safe to show an end user. Never contains internal identifiers. */
issue: string;
}
export class AppError extends Error {
/** SCREAMING_SNAKE, stable forever, catalogued once in Section 29.3. */
readonly code: string;
readonly httpStatus: number;
/** Safe to render to an end user in any locale-neutral surface. */
readonly publicMessage: string;
/** Always an array when present. Section 7.3 owns which statuses may carry it. */
readonly details?: AppErrorDetail[];
/** Never serialized to a client. Logged with the request id. */
readonly cause?: unknown;
constructor(args: {
code: string;
httpStatus: number;
publicMessage: string;
details?: AppErrorDetail[];
cause?: unknown;
}) {
super(args.publicMessage);
this.name = 'AppError';
this.code = args.code;
this.httpStatus = args.httpStatus;
this.publicMessage = args.publicMessage;
this.details = args.details;
this.cause = args.cause;
}
}
export class NotFoundError extends AppError { /* httpStatus 404 */ }
export class ForbiddenError extends AppError { /* httpStatus 403 */ }
export class ValidationError extends AppError { /* httpStatus 422, details required */ }
export class ConflictError extends AppError { /* httpStatus 409 */ }
export class QuotaExceededError extends AppError { /* httpStatus 409 with a plan hint; Section 19.5.7 */ }
export class DependencyUnavailableError extends AppError { /* httpStatus 503, sets Retry-After */ }The route-level boundary. Exactly one function converts a thrown value into an HTTP response. Section 7.3 owns the envelope's shape; this boundary is the only code that produces it.
// apps/web/lib/http/error-boundary.ts
export function toErrorResponse(err: unknown, requestId: string): Response {
if (err instanceof AppError) {
log.warn({ requestId, code: err.code, status: err.httpStatus }, 'handled error');
return jsonError({
code: err.code,
message: err.publicMessage,
details: err.details,
httpStatus: err.httpStatus,
requestId,
});
}
// Unexpected: log everything, return nothing.
log.error({ requestId, err }, 'unhandled error');
captureException(err, { tags: { requestId } });
return jsonError({
code: 'INTERNAL_ERROR',
message: 'Something went wrong on our side. Please try again.',
httpStatus: 500,
requestId,
});
}The rules:
- Every non-2xx response in the entire product uses the canonical envelope defined in Section 7.3. There are no exceptions and no bespoke error shapes. Section 7.3 owns that envelope's field list, its status rules, and its error-code semantics; this section deliberately does not restate them, so read 7.3 before adding a field or a status to an error path.
- Unexpected errors are logged in full with the request id and returned as a generic 500 with no internal detail — no stack, no SQL, no provider message, no identifier the client did not already know.
- Every error carries the request id — in the response envelope, in the
X-Request-Idheader, and in every log line for that request. A support conversation starts by asking for that id. detailsis always an array when present, even with a single entry. Which statuses may carry it is Section 7.3's decision, not this section's.packages/corethrowsAppError; it never returns an HTTP status and never touches aResponse. Transport is the app layer's job.- Never
catchand silently continue. Either handle the error meaningfully, rethrow it, or convert it to anAppErrorwith a code. - Error codes are permanent. Adding one is free; changing or removing one is a breaking API change and follows the versioning rule in Section 7.
4.4 Validation #
One Zod schema per boundary, in packages/validation, used by all four consumers. The
client form, the server route or action, the worker job, and the public API validate with the
same object. This is the single most important convention in the codebase, because it makes
it structurally impossible for the client to accept something the server rejects.
Naming pattern:
| Suffix | Meaning | Example |
|---|---|---|
...Input |
Accepted from an untrusted caller | createTestimonialInput, updateWidgetInput |
...Output |
Returned to an internal, authenticated caller | testimonialOutput |
...PublicOutput |
Returned to an unauthenticated caller or embedded in a widget payload | testimonialPublicOutput |
...Payload |
A queued job's payload | transcriptionRunPayload |
...Event |
A webhook or outbox event body | testimonialPublishedEvent |
...Params |
Path or query parameters | listTestimonialsParams |
// packages/validation/testimonial.ts
import { z } from 'zod';
import { publicId } from './public-id'; // prefix + Crockford base32 (Section 4.1.9)
export const testimonialStatus = z.enum([
'pending', 'approved', 'published', 'unpublished', 'rejected', 'archived',
]);
export const createTestimonialInput = z.object({
requestPageId: publicId('rqp_'), // never a raw uuid on a public boundary
kind: z.enum(['video', 'text']),
// Opaque server-issued handle (Section 9.6), not a public resource id.
// Required when kind === 'video'.
uploadSessionId: z.string().min(1).max(64).optional(),
body: z.string().trim().min(20).max(5000).optional(), // required when kind === 'text'
rating: z.number().int().min(1).max(5).optional(),
author: z.object({
name: z.string().trim().min(1).max(120),
email: z.email().max(254),
role: z.string().trim().max(120).optional(),
company: z.string().trim().max(120).optional(),
websiteUrl: z.url().max(2048).optional(),
}),
customFields: z.record(z.string(), z.union([z.string().max(2000), z.number(), z.boolean()]))
.default({}),
consents: z.array(z.object({
kind: z.enum(['publish', 'advertising', 'case_study_contact']),
granted: z.boolean(),
})).min(1),
}).superRefine((v, ctx) => {
if (v.kind === 'video' && !v.uploadSessionId) {
ctx.addIssue({ code: 'custom', path: ['uploadSessionId'],
message: 'A completed upload session is required for a video testimonial.' });
}
if (v.kind === 'text' && !v.body) {
ctx.addIssue({ code: 'custom', path: ['body'],
message: 'Testimonial text is required.' });
}
if (!v.consents.some((c) => c.kind === 'publish' && c.granted)) {
ctx.addIssue({ code: 'custom', path: ['consents'],
message: 'Permission to publish is required.' });
}
});
export type CreateTestimonialInput = z.infer<typeof createTestimonialInput>;
// The public shape embedded in widget payloads. Deliberately narrow: no email,
// no IP, no internal identifiers, no consent detail.
export const testimonialPublicOutput = z.object({
id: publicId('tst_'), // prefixed, always (Section 4.1.9)
kind: z.enum(['video', 'text']),
bodyHtml: z.string().nullable(), // sanitized at write and at serve time
rating: z.number().int().min(1).max(5).nullable(),
author: z.object({
name: z.string(),
role: z.string().nullable(),
company: z.string().nullable(),
avatarUrl: z.url().nullable(),
}),
video: z.object({
playbackId: z.string(),
posterUrl: z.url(),
durationSeconds: z.number(),
captionsUrl: z.url().nullable(),
aspectRatio: z.string(), // e.g. "9:16", used to reserve layout space
}).nullable(),
source: z.enum(['collected', 'google', 'g2', 'capterra', 'x', 'csv']),
sourceUrl: z.url().nullable(),
publishedAt: z.iso.datetime(),
});The rules:
- Validate at every boundary, including boundaries you control. A worker job payload is validated on dequeue even though the same process family enqueued it, because a payload can sit in a queue across a deploy that changed its shape.
- Never trust a type assertion at a boundary.
as CreateTestimonialInputon parsed JSON is forbidden;createTestimonialInput.parse(json)is the only accepted form. - Validation failures produce a 422 with a
detailsarray built from the Zod issue list. The mapping from Zod issues toAppErrorDetaillives in one function. - Output schemas are as important as input schemas.
testimonialPublicOutputis what guarantees a respondent's email address can never leak into a widget payload — the payload is parsed through the output schema before serialization, so an added field does not silently escape. - Maximum lengths are always specified. An unbounded string is a denial-of-service vector and a storage-cost surprise.
4.5 Data Access #
Three rules, all mandatory, all enforceable in review:
- Every query lives in
packages/db/queriesas a named, typed function. Handlers call those functions; handlers never build queries. - Every query is workspace-scoped, and every read of a soft-deletable entity goes through the
notDeleted()helper. - Every query runs inside a transaction that has already set the Row-Level Security session variable. Section 6 owns the RLS model, the roles, and the policy definitions.
// packages/db/queries/testimonials.ts
import { and, desc, eq, lt, or } from 'drizzle-orm';
import { notDeleted } from '../helpers/not-deleted';
import { testimonials } from '../schema';
import type { Tx } from '../types';
/**
* Lists testimonials for a workspace, newest first, cursor-paginated.
* The caller must already be inside a transaction with app.current_workspace_id set;
* RLS is a second line of defence, not the first (Section 6).
*/
export async function listTestimonials(
tx: Tx,
args: {
workspaceId: string;
status?: TestimonialStatus;
cursor?: { createdAt: Date; id: string };
limit: number; // caller has already clamped to 1..100 (Section 7)
},
) {
return tx
.select()
.from(testimonials)
.where(and(
eq(testimonials.workspaceId, args.workspaceId),
notDeleted(testimonials),
args.status ? eq(testimonials.status, args.status) : undefined,
args.cursor
? or(
lt(testimonials.createdAt, args.cursor.createdAt),
and(
eq(testimonials.createdAt, args.cursor.createdAt),
lt(testimonials.id, args.cursor.id),
),
)
: undefined,
))
.orderBy(desc(testimonials.createdAt), desc(testimonials.id))
.limit(args.limit + 1); // one extra row decides pagination.hasMore
}// packages/db/with-workspace.ts — the only way application code opens a transaction.
export async function withWorkspace<T>(
workspaceId: string,
fn: (tx: Tx) => Promise<T>,
): Promise<T> {
return db.transaction(async (tx) => {
// SET LOCAL is scoped to this transaction and reset automatically on commit or rollback,
// which makes it safe with a connection pool.
await tx.execute(sql`SET LOCAL app.current_workspace_id = ${workspaceId}`);
return fn(tx);
});
}Additional rules:
- Passing a
workspaceIdthat the actor is not a member of is caught bycan()(Section 6) before a query runs, and by RLS if it somehow gets past. Two independent controls, both required. - Background jobs call
withWorkspace()with the workspace id from the job payload. There is no "system" bypass in application code; the only role that bypasses RLS is the migrator, and the application never connects as it. - No raw SQL outside
packages/db, and inside it only with parameter binding. String interpolation into SQL is forbidden. - Batch inserts use a single statement. Because identifiers are UUIDv7 and generated application-side (Section 5), a batch insert is index-friendly and its ids are known before the write, which is what lets an outbox row reference an entity created in the same transaction.
4.6 Asynchronous Work and Jobs #
Every background job satisfies six requirements without exception. Section 29.5 catalogues every job in the product; this is the shape they all share.
| Requirement | Meaning |
|---|---|
| Typed payload | A Zod schema in packages/validation, parsed on dequeue, never asserted |
| Idempotent | Running twice with the same payload produces the same end state. Enforced with a natural key (a provider asset id, an outbox row id, a period boundary) rather than by hoping |
| Declared retry policy | Attempt count and backoff declared at registration, never defaulted implicitly |
| Declared dead-letter behaviour | What happens after the last attempt: alert, mark a row failed, notify a user, or all three |
Logs a job_runs row |
Job name, payload hash, workspace id, attempt number, start and end time, outcome, and error code on failure |
| Workspace-scoped | Opens its transaction with withWorkspace() using the payload's workspace id |
// apps/worker/jobs/transcription-run.ts
export const transcriptionRun = defineJob({
name: 'transcription.run',
payload: transcriptionRunPayload, // Zod schema from packages/validation
attempts: 5,
backoff: { type: 'exponential', delay: 30_000, maxDelay: 900_000, jitter: true },
concurrency: 8,
deadLetter: async ({ payload, error }) => {
// Transcription failure never blocks publishing (Section 10).
await markTranscriptFailed(payload.testimonialId, error.code);
await notifyModerators(payload.workspaceId, 'transcription_failed', payload.testimonialId);
},
async handler({ payload, logger }) {
return withWorkspace(payload.workspaceId, async (tx) => {
const existing = await getTranscript(tx, payload.testimonialId);
if (existing?.providerJobId === payload.providerJobId) {
logger.info('already transcribed; idempotent no-op');
return;
}
const transcript = await transcription.transcribe(payload.mediaUrl, {
languages: LAUNCH_LANGUAGES,
detect: true,
});
await saveTranscript(tx, payload.testimonialId, transcript);
await emit(tx, [{
workspaceId: payload.workspaceId,
eventType: 'transcript.ready',
aggregateType: 'testimonial',
aggregateId: payload.testimonialId,
payload: { language: transcript.language, confidence: transcript.confidence },
}]);
});
},
});Additional rules:
- A job name is
domain.snake_case_verb, and a hyphen never appears in one. One dotted domain segment, then a snake_case verb phrase:media.ingest,vtt.generate,cdn.purge,quota.notify_threshold,quota.roll_periods,retention.sweep_originals,retention.purge_soft_deleted. Neverretention.purge-soft-deleted, neverquota.rollPeriods, never a second dot. Every retention sweep lives in the singleretention.*family. Section 29.5 catalogues every job in the product with its trigger, payload, idempotency key, retries, timeout, dead-letter behaviour and alert; a name that is not in that catalogue is not a job, and adding a job means adding the row. - A job never enqueues another job directly from inside a transaction. It writes an outbox row and lets the relay enqueue (Section 3.7).
- A job never takes an unbounded amount of work. Sweeps and rollups process a bounded batch and re-enqueue themselves if more remains, so a single run cannot monopolise a worker.
- Every repeatable job declares a jitter so a fleet restart does not synchronise every instance onto the same second.
- Job payloads carry identifiers, never entity snapshots. The handler re-reads current state, because a payload written five minutes ago may describe a row that has since changed.
4.7 React and Next.js #
| Convention | Rule |
|---|---|
| Component type | Server Components by default. "use client" only when a component needs state, an effect, an event handler, or a browser API |
| Client boundary placement | Push "use client" as far down the tree as possible. A client boundary at the page level drags the entire subtree into the browser bundle |
| Mutations | Server Actions for every dashboard mutation. They get the session, run can(), open the RLS transaction, call a packages/core service, and revalidate |
| Client data fetching | TanStack Query only for genuinely live surfaces: domain verification polling (Section 14), moderation inbox live updates, and media processing state. Everywhere else, read in a Server Component |
| Forms | React Hook Form with the Zod resolver, using the same schema the Server Action validates with |
| Suspense and streaming | Every route segment that awaits data has a loading.tsx with a skeleton matching the final layout's dimensions, so streaming does not cause layout shift |
| Error boundaries | Every route segment has an error.tsx that shows the request id and a retry control |
useEffect |
Only for genuine synchronisation with an external system — a subscription, a browser API, a timer. Never for deriving state from props |
| Keys | Stable entity ids as list keys. Array index as a key is forbidden in any list that can reorder, filter, or paginate |
| Caching directives | Dashboard routes are no-store. Showcase pages revalidate at 60 seconds by tag. Request pages are no-store because they carry live quota state |
// apps/web/app/(dashboard)/testimonials/actions.ts
'use server';
export async function publishTestimonialAction(input: unknown) {
const parsed = publishTestimonialInput.parse(input);
const actor = await requireSession(); // throws 401 as an AppError
await can(actor, 'testimonial:publish', {
type: 'testimonial', id: parsed.testimonialId,
}); // throws 403 as an AppError
await withWorkspace(actor.workspaceId, (tx) =>
publishTestimonial(tx, { actor, testimonialId: parsed.testimonialId }),
);
revalidateTag(`workspace:${actor.workspaceId}:testimonials`);
}4.8 Styling #
Dashboard and request pages: Tailwind with a shared preset. The preset lives in
packages/config/tailwind and is the only place that defines spacing, colour, radius, and
type scale. Design tokens are emitted as CSS custom properties prefixed --vch-, so the same
values are readable at runtime — which is what lets a request page apply a workspace's brand
colour without a rebuild.
/* packages/config/tokens/base.css — the source of truth for both Tailwind and runtime theming */
:root {
--vch-color-bg: #ffffff;
--vch-color-surface: #f8fafc;
--vch-color-text: #0f172a; /* 16.1:1 on --vch-color-bg */
--vch-color-text-muted: #475569; /* 7.5:1 on --vch-color-bg */
--vch-color-accent: #4f46e5; /* 4.9:1 on --vch-color-bg for text use */
--vch-color-accent-contrast: #ffffff;
--vch-color-danger: #b91c1c;
--vch-color-success: #15803d;
--vch-radius-sm: 4px;
--vch-radius-md: 8px;
--vch-radius-lg: 16px;
--vch-font-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
"Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
--vch-text-xs: 0.8rem; --vch-text-sm: 1rem; --vch-text-md: 1.2rem;
--vch-text-lg: 1.44rem; --vch-text-xl: 1.728rem; --vch-text-2xl: 2.074rem;
}A unit test in packages/config asserts the contrast ratio of every foreground/background
token pair against the thresholds in Section 21. Adding a token that fails fails the build.
The widget: hand-authored CSS, no Tailwind, no shared preset. This is deliberate, and the reasons are worth stating because a future contributor will otherwise "fix" it:
- Size. Tailwind's output is generated per project from scanned class names. In a bundle with a 22 KB total budget, a utility-class stylesheet plus the class-name strings in markup costs more than a hand-authored stylesheet that contains only the rules actually needed.
- Isolation. The widget renders into a shadow root and injects exactly one adopted
stylesheet. It must never write to
document.heador register global CSS. Hand-authored CSS scoped to:hostmakes that guarantee trivially auditable. - Theming. The widget's theming surface is a small, fixed set of CSS custom properties
set on
:hostfrom the widget's configuration — accent, radius, font size scale, and the light/dark/auto mode. That is a simpler and smaller mechanism than a utility framework. - Independence. The widget's stylesheet must not change when the dashboard's design system changes. A shared preset would couple a third-party page's rendering to an internal design decision.
The widget stylesheet is a single file, authored with the same --vch- custom-property naming
so the two systems read consistently, and it is size-checked as part of the runtime chunk
budget in Section 23.
4.9 Accessibility Conventions in Code Review #
Section 21 owns the full accessibility specification, the WCAG 2.2 AA criteria, and the manual test matrix. These are the conventions a reviewer checks on every pull request:
- Semantics first, ARIA second. Use
<button>,<a href>,<nav>,<main>,<dialog>,<label>,<fieldset>. Reach for ARIA only when native semantics genuinely run out. A<div role="button" tabindex="0">with a keydown handler is a defect when a<button>would do. - Never
tabindexgreater than 0.tabindex="0"to make a custom control focusable andtabindex="-1"for programmatic focus are both fine. A positive value breaks document order and is rejected in review. - Focus management is explicit. On client-side route change, move focus to the new page's
<h1>and announce the change. On modal open, focus the first interactive element or the dialog itself; on close, restore focus to the trigger. On async content replacement, move focus deliberately or announce with a live region — never leave focus on a removed node. - No colour-only meaning. Every status, state, delta, and warning carries text or an icon with an accessible name in addition to colour. This is checked on moderation status pills, domain verification states, transcript confidence highlights, and analytics deltas.
- Every interactive element has an accessible name. Icon-only buttons carry
aria-label. Decorative images carryalt="". Meaningful images carry real alt text. - Images and video reserve their space. Explicit
widthandheightor anaspect-ratio, always — this is a layout-shift requirement as much as an accessibility one. - Live regions are used sparingly and politely.
aria-live="polite"for recording state and processing progress;role="alert"reserved for genuine errors. Never announce every tick of a timer. - Motion respects
prefers-reduced-motion. No auto-advancing carousel, no autoplay, no parallax, no animated entrance that cannot be disabled. - Target sizes. Minimum 24×24 CSS pixels for any control; 44×44 for the primary recorder controls on touch.
- Every drag has a non-drag equivalent. Carousel swipe always has visible buttons.
Automated axe-core checks run in CI against a fixture page for every request page variant and
every widget type, and zero violations at "serious" or "critical" is a merge gate.
4.10 Documentation #
| Artefact | Rule |
|---|---|
| TSDoc | Required on every exported function, class, and type in packages/*. It states what the function does, what it throws, and any invariant the caller must uphold — not a restatement of the signature |
docs/DECISIONS.md |
One Architecture Decision Record per irreversible decision, in the format: context, decision, alternatives considered, consequences, date. "Irreversible" means changing it later would require a data migration, a breaking API change, or asking customers to re-paste an embed snippet |
Package README.md |
Required in every package and app. States the package's purpose, its public exports, what it may and may not import, and how to run its tests |
| Route documentation | Every public API route carries a TSDoc block naming its scopes, its rate-limit bucket, its idempotency behaviour, and every error code it can return. Section 29.3 is generated from these blocks so the catalogue cannot drift from the code |
| Migration comments | Every generated SQL migration gets a header comment stating what it does and whether it is backward-compatible with the previously deployed application version |
| Inline comments | Explain why, never what. A comment restating the code is deleted in review. A comment explaining a non-obvious constraint — "part size must be ≥ 5 MB except for the final part, which is why the last chunk is merged" — is required |
4.11 Linting, Formatting, Type-Checking, and CI Gates #
| Tool | Configuration | Enforcement |
|---|---|---|
| TypeScript | strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes, noImplicitOverride, verbatimModuleSyntax, isolatedModules. Bases live in packages/config/tsconfig |
pnpm typecheck — zero errors |
| ESLint | Flat config in packages/config/eslint: TypeScript rules, React and hooks rules, jsx-a11y, import ordering, and the boundary rules in Section 4.2 |
pnpm lint — zero errors, zero warnings |
| Prettier | 100-column width, single quotes, trailing commas, semicolons. No per-file overrides | pnpm format:check |
| Vitest | Unit and integration tests, coverage thresholds set in Section 25 | pnpm test |
| Playwright | End-to-end and accessibility suites against a seeded database | pnpm test:e2e |
| size-limit | Budgets for the widget loader, the widget runtime chunk, and the request-page bundle (Section 23) | pnpm size — hard fail |
| drizzle-kit | Migration drift check: the generated schema must match the committed migrations | pnpm db:check |
The exact CI gate sequence. Every one of these must pass before a pull request can merge
into main:
pnpm install --frozen-lockfile
pnpm format:check # Prettier, no writes
pnpm lint # ESLint, --max-warnings=0
pnpm typecheck # tsc --noEmit across every package
pnpm db:check # schema and committed migrations agree
pnpm test -- --coverage # Vitest unit + integration, coverage thresholds enforced
pnpm build # every app and package builds
pnpm size # widget and request-page budgets, hard fail
pnpm test:e2e # Playwright: functional + axe-core accessibility suites
pnpm test:perf # Lighthouse CI against the widget fixture host pageNo step may be skipped, and no step may be marked "continue on error". A flaky test is fixed or quarantined with an issue link, never ignored.
4.12 Commits, Branches, Pull Requests, and Review #
Branching. Trunk-based. main is protected, requires a passing CI run and one approving
review, and is always deployable. Branches are short-lived — under two days — and named
type/short-slug. No long-running release branches.
Commits. Conventional Commits: type(scope): subject, imperative mood, no trailing
period, subject under 72 characters. type is feat, fix, chore, docs, refactor,
test, perf, or build. scope is the package or app (web, worker, embed, db,
core, validation, ui). A breaking change is marked with ! after the scope and explained
in a BREAKING CHANGE: footer.
Pull requests. One logical change per pull request. A description that states what changed, why, and how it was verified. Screenshots or a short recording for any user-visible change. If the change touches the widget, the size-limit output is pasted into the description.
The PR checklist — reproduced in .github/pull_request_template.md:
- [ ] The change does one thing, and the title says what it is.
- [ ] Every new boundary validates its input with a Zod schema from packages/validation.
- [ ] Every new query is workspace-scoped, uses notDeleted() where applicable, and runs
inside withWorkspace().
- [ ] Every deliberate failure throws an AppError with a code that exists in the error
catalogue (Section 29.3); no bespoke error shapes.
- [ ] Any effect that must happen exactly once alongside a database write goes through the
outbox, not a direct call.
- [ ] Any new background job declares a typed payload, retries, backoff, dead-letter
behaviour, and is idempotent.
- [ ] Any status change, content edit, tag change, or consent change writes an audit event.
- [ ] Any new metered action is checked against the quota service server-side.
- [ ] New UI: semantic elements, keyboard operable, visible focus, no colour-only meaning,
accessible names on icon-only controls, explicit media dimensions.
- [ ] axe-core passes with zero serious/critical violations on any changed surface.
- [ ] Widget changes: size-limit output pasted below and within budget; no framework import
introduced; nothing written to document.head; no global added beyond window.Vouch.
- [ ] No secret, token, respondent email or phone number, or testimonial body appears in any
log line.
- [ ] Tests added or updated; the full CI gate sequence passes locally.
- [ ] Irreversible decision? An ADR was added to docs/DECISIONS.md.
- [ ] Public API change? The route's TSDoc, its scopes, and its error codes are updated.Review standards. Reviewers check correctness, boundary compliance, and the checklist — in that order. A reviewer who cannot tell whether a change is correct asks for a test rather than approving. Nitpicks are prefixed "nit:" and never block a merge. Any comment marked "blocking:" must be resolved by the author, not dismissed. The author merges after approval; the reviewer does not merge on the author's behalf.
5. Data Model & Database Schema #
This section is the single source of truth for persisted state. Every table name, column
name, type, default, constraint, and index used anywhere else in this document is defined
here. If another section names a column, it is one of the columns below; nothing else
exists. An executing agent should be able to generate the entire Drizzle schema in
packages/db from this section alone.
If any other section appears to contradict this one about a table, column, type, default, constraint, or index, this section wins and the other section is the defect.
The database is PostgreSQL accessed through Drizzle ORM with drizzle-kit for
migrations (the version line is stated once, in Section 3.1). All DDL in this section is written
as plain SQL because that is what the migrations contain; the Drizzle schema files are the
TypeScript mirror of exactly these definitions.
5.1 Modelling rules that govern every table #
These rules are absolute. They are stated once here and are assumed by every table definition that follows.
5.1.1 Primary keys and identifiers #
Every table's primary key is a UUIDv7 stored in a
uuidcolumn namedid, with exactly three kinds of exception:- Join and rollup tables with a composite natural key, where a surrogate id would buy
nothing and the natural key is what every query already carries:
testimonial_tags(testimonial_id, tag_id),analytics_rollup_hourly,analytics_rollup_daily, anddelivery_usage_days(workspace_id, usage_date, source). - Partitioned tables, whose primary key must include the partition key:
analytics_events_rawandaudit_events, both(occurred_at, id). - The one strict 1:1 table,
workspace_settings, whose primary key isworkspace_id, because a second settings row for a workspace must be impossible rather than merely unwritten. A 1:1 table's natural key is the single parent column, never a composite.two_factor_secretsis deliberately not in this category: it keeps a surrogateidand enforces one-per-user withUNIQUE (user_id), because column-level grants are easier to reason about on a table with an ordinary key.
- Join and rollup tables with a composite natural key, where a surrogate id would buy
nothing and the natural key is what every query already carries:
UUIDv7 values are generated application-side, never by the database.
packages/dbexports a singlenewId()helper and it is the only place a new identifier is minted:// packages/db/src/id.ts import { uuidv7 } from 'uuidv7'; /** The only identifier factory in the system. Never call a DB-side default. */ export function newId(): string { return uuidv7(); }There is deliberately no
DEFAULT gen_random_uuid()on anyidcolumn. Application-side generation means an insert's identifier is known before the round trip, which is what makes batched inserts, the transactional outbox (outbox_events), and audit writes able to reference the row they are about to create inside the same statement group. UUIDv7 is time-ordered, so B-tree index locality is equivalent to a bigserial without exposing a guessable sequence.Public-facing identifiers are derived, never raw UUIDs in URLs where length matters. This table is the complete prefix register: no identifier is ever emitted unprefixed on any public surface, and a prefix not listed here does not exist. Unless the Encoding column says otherwise, the value is
prefix+ Crockford base32 of the row's UUIDv7 (26 characters, uppercase alphabet, lowercased on output).Prefix Used by Encoding wsp_workspacesCrockford base32 of the row's UUIDv7 (26 chars) usr_usersCrockford base32 of the row's UUIDv7 tst_testimonialsCrockford base32 of the row's UUIDv7 rqp_request_pagesCrockford base32 of the row's UUIDv7 sub_submissionsCrockford base32 of the row's UUIDv7 inv_invitesCrockford base32 of the row's UUIDv7 tag_tagsCrockford base32 of the row's UUIDv7 dom_custom_domainsCrockford base32 of the row's UUIDv7 wgt_widgets.public_idCrockford base32 of the row's UUIDv7; stored, immutable crd_social_cardsCrockford base32 of the row's UUIDv7; appears in the Open Graph and Twitter card metadata a showcase page emits (Section 15.8.3) evt_outbox_eventsand the webhook envelope'sid(Section 18.3)Crockford base32 of the row's UUIDv7 dlv_webhook_deliveriesCrockford base32 of the row's UUIDv7 req_request correlation ids Crockford base32 UUIDv7 (Section 7 owns the envelope) key_api_keysrow identifier — the value that appears in API paths and responses (Section 7.15.4). Distinct from the plaintext key below: the id is safe to log and display, the plaintext is shown exactly once and never againCrockford base32 of the row's UUIDv7 vch_live_/vch_test_api_keysplaintext value32 random base62 characters after the prefix vch_rp_respondent portal bearer token ( respondent_tokens)32 random base62 characters after the prefix whsec_webhook_endpointssecret32 random base62 characters after the prefix Third-party identifiers we merely store (
price_…,sub_…,in_…and other Stripe ids, Mux asset and playback ids) keep the provider's own form and are never re-encoded. The only Stripe prefix that collides with one of ours issub_; ours is always produced from asubmissions.idand lives in asubmissionIdfield, Stripe's always lives in astripe_subscription_idcolumn, and no serializer emits both in the same object.Slugs are used where a URL must be short, typable, and human-meaningful:
workspaces.slug,request_pages.slug,showcase_pages.slug,tags.slug. All slugs match^[a-z0-9](?:[a-z0-9-]{1,46}[a-z0-9])$(3–48 characters, lowercase, no leading/trailing or doubled hyphen) and are unique within their scope.
5.1.2 Timestamps #
- Every timestamp column is
timestamptz, stored in UTC. There are notimestamp,date-only, or epoch-integer time columns anywhere exceptanalytics_rollup_daily.bucket_date, which is a true calendar date in the workspace's reporting timezone and is documented as such. - Every table has
created_at timestamptz NOT NULL DEFAULT now(). - Every mutable table has
updated_at timestamptz NOT NULL DEFAULT now()maintained by the shared triggerset_updated_at()(Section 5.16.1). Application code never setsupdated_at. - Domain timestamps are past-participle-named and nullable:
published_at,approved_at,rejected_at,verified_at,purged_at,completed_at. - Append-only tables (
audit_events,transcript_revisions,consents) havecreated_atand noupdated_at, because they are never updated.
5.1.3 Money #
Money is stored as integer minor units in integer columns named *_cents, always paired
with a currency char(3) ISO-4217 column on the same row (or inherited from the parent
subscription row where noted). There are no numeric or floating-point money columns. Where a
row can only ever be USD in v1 (plans), the currency column still exists and is defaulted to
'USD' so that multi-currency pricing is a data change, not a migration.
5.1.4 Deletion policy #
Each entity type is on exactly one side of this line. There are no per-case exceptions.
| Policy | Tables |
|---|---|
Soft delete (deleted_at timestamptz) |
workspaces, users, workspace_members, testimonials, request_pages, widgets, showcase_pages, custom_domains, tags, contacts, api_keys (via revoked_at), webhook_endpoints, integration_connections, import_connections, highlights, inbox_saved_views |
Hard delete (row is removed, usually by ON DELETE CASCADE or a sweeper job) |
all child and join rows (request_page_fields, request_page_prompts, testimonial_tags, widget_testimonials, subscription_items, media_derivatives, consent_grants), sessions, accounts, verification_tokens, idempotency_keys past 24 h, analytics_events_raw past 7 days, job_runs past retention, upload_sessions past expiry, submission_drafts past expiry, invitations past their retention window (see below), overage_evaluations and usage_reconciliations past retention, abuse_blocks past expires_at |
| Never deleted while the workspace lives (the evidence tables) | consents, consent_versions, audit_events, suppressions, invoices, refunds, usage_credits, trial_grants, cancellation_reasons |
| Outlives the workspace entirely | erasure_log — retained 6 years from erasure_completed_at and deleted by nothing else, including the erasure it records and the purge of the workspace it belonged to (Section 5.9.7) |
| Hard erasure (row removed and external assets destroyed) | anything reached by a GDPR erasure request — see Section 22 |
invitations are not hard-deleted on acceptance. An accepted invitation is the evidence
that a membership was offered and by whom, and INVITATION_ALREADY_USED (Section 29.3)
requires the row to still exist when a used link is clicked again. Every terminal invitation —
accepted, revoked, or expired — is retained 90 days from status_changed_at and then
hard-deleted (Section 5.20). Section 6.8 owns the state machine.
testimonial_flags, testimonial_replies, usage_notifications, overage_notices,
delivery_usage_days, transcription_jobs and usage_counters are hard-deleted only with
their workspace: they are per-workspace history whose value is that it does not vanish.
Rules:
A soft-deleted row is invisible to every read path.
packages/dbexports anotDeleted()helper and every query builder in the application composes it:// packages/db/src/filters.ts import { isNull, and, eq, type SQL } from 'drizzle-orm'; /** Appended to every read of a soft-deletable table. Enforced by lint rule `vouch/no-raw-select`. */ export const notDeleted = <T extends { deletedAt: any }>(t: T): SQL => isNull(t.deletedAt); export const inWorkspace = <T extends { workspaceId: any }>(t: T, workspaceId: string): SQL => eq(t.workspaceId, workspaceId); export const scoped = <T extends { workspaceId: any; deletedAt: any }>(t: T, workspaceId: string) => and(inWorkspace(t, workspaceId), notDeleted(t))!;Soft deletion never orphans external assets. Soft-deleting a
testimonialsrow enqueuesmedia.purge(Section 9.10.2) with a delay equal to the 30-day restore window described in Section 11.2.3 T11, so that a restore inside the window still has its media. The job id is deterministic —media.purge:{testimonialId}— precisely so that restoring the testimonial can cancel the pending job by id (Section 11.2.3 T12). GDPR erasure enqueues the same job with zero delay. When it runs,media.purgedestroys the Mux asset, every object-storage key, and the CDN cache entries before settingmedia_assets.purged_at. A row is never treated as permanently deleted while its assets survive.GDPR erasure is always a hard delete. It removes the row rather than stamping
deleted_at, and it is the only path that deletes fromconsentsandaudit_events(see Section 22.6 for the narrow, logged exception procedure for append-only tables).Unique constraints on soft-deletable tables are partial:
UNIQUE (...) WHERE deleted_at IS NULL. This lets a workspace re-use a slug after deleting the row that held it.
5.1.5 Postgres enum versus lookup table #
- A Postgres native
enumtype is used when the product owns the value set and it cannot expand at runtime: statuses, kinds, states, roles. Adding a value is a deliberate migration. - A lookup table is used when an operator or a customer can extend the set:
tags,plans,feature_flags,consent_versions. - Custom request-page field types are an enum, not a lookup table. A customer defines as
many fields as they like (
request_page_fieldsis a table of definitions), but the seven types a field may have are fixed, because Section 8.5.2 attaches per-type validation rules and per-type mobile rendering to each one. A type added at runtime would have neither, so runtime extension is impossible by construction andrequest_field_typeis a native enum. - Third-party value sets that we mirror rather than own (Stripe subscription status, Mux asset
state) are stored as the provider's raw string in a
*_rawcolumn and mapped into our own enum. We never widen our enum to chase a provider's vocabulary.
5.1.6 The tenancy column, and its three documented exceptions #
Every domain table carries workspace_id uuid NOT NULL REFERENCES workspaces(id), including
tables that are only ever reached through a parent. The reasoning and the RLS machinery are
owned by Section 6.2; the schema consequence is stated here so no table definition is
surprising.
There are exactly three categories of table without workspace_id, and no others may be added:
| Category | Tables | Why |
|---|---|---|
| Global identity | users, sessions, accounts, verification_tokens, two_factor_secrets |
A user is a global principal who may hold membership in more than one workspace (Section 6.4). Putting workspace_id on users would hard-code the v1 pin and break the deferred capability. |
| Platform catalogue | plans, feature_flags, consent_versions (global defaults only; workspace overrides carry workspace_id) |
Operator-owned reference data, identical for every tenant. |
| Nullable-tenant platform tables | job_runs, stripe_events, email_deliveries, abuse_blocks |
Rows may exist before the workspace is resolved (an unmatched Stripe webhook, a platform maintenance job, an IP blocked at the edge before any tenant is known). workspace_id is present but nullable, and their RLS policy is workspace_id IS NULL OR workspace_id = current_setting('app.current_workspace_id')::uuid. These tables are operator-facing; no tenant-facing endpoint reads them directly. |
workspace_members and invitations carry workspace_id and are therefore fully
workspace-scoped even though they reference global users.
The tables a reader will ask about, resolved explicitly. Each of the following was
considered for the nullable-tenant category and each is workspace_id uuid NOT NULL, so no
fourth exception is needed:
| Table | Why the tenant is always known |
|---|---|
idempotency_keys |
The key is scoped by (workspace_id, endpoint, key) and is only written after authentication has resolved a workspace (Section 7.9.1). An unauthenticated request never reaches the idempotency layer. |
outbox_events |
Every outbox row is written inside the same transaction as a workspace-scoped state change; there is no platform-level event. |
webhook_deliveries |
Derived from an outbox_events row and a webhook_endpoints row, both workspace-scoped. |
sms_deliveries |
Every SMS is an invite or a notification for one workspace; platform mail is email-only, which is why email_deliveries is nullable and this one is not. |
notifications |
Addressed to a member of a specific workspace; a platform-wide announcement is not a notification row, it is a status-page entry. |
suppressions |
Deliberately per workspace: one customer's unsubscribe must not silence another customer's mail (Section 20.8). |
Tenant-detached-on-purge. Three tables — invoices, refunds, trial_grants — declare
workspace_id nullable but are never written with a null. The column is nulled only by the
workspace-purge procedure, because each of these rows must outlive the workspace: financial
records for the operator's books, and the trial guard so that a purge is not a way to earn a
second free trial (Sections 5.11.12 and 5.12). They share the nullable-tenant RLS policy and, like
the other nullable-tenant tables, no tenant-facing endpoint reads a detached row. This is not a
fourth category: they are ordinary workspace-scoped tables for the whole time a workspace exists.
One table keeps a NOT NULL tenancy column with no foreign key behind it: erasure_log
(Section 5.9.7). It must survive a workspace purge intact rather than detached, because it is the
evidence that a person's data was destroyed and it is retained six years. A nullable, detached
tenancy column would have lost which workspace the erasure belonged to; a foreign key would have
cascaded the row away. So the column is NOT NULL, is still the RLS predicate, and is simply not
enforced by a constraint. It is the only table in the schema written this way.
5.1.7 JSONB rules #
JSONB is used only for these four purposes, and every JSONB column in this section falls into one of them:
- Provider payload capture —
imported_reviews.raw_payload,stripe_events.payload,ai_suggestions.raw_response,invoices.provider_payload. Kept verbatim for audit and re-processing. Never queried by a hot path. - Genuinely open-shape user configuration —
widgets.config,request_pages.theme,request_page_fields.options,inbox_saved_views.filter. Every one of these has a Zod schema inpackages/validationthat is the real contract, validated on write and on read. - Immutable document structures —
transcripts.doc(the single transcript structure defined in Section 10.3),audit_events.before/.after,submission_drafts.answers,overage_evaluations.inputs. - Sparse per-row metadata —
submissions.custom_field_values,testimonials.case_study_fields,analytics_events_raw.extras,testimonial_replies.attachments.
Where custom-field answers live, stated once. The answers a respondent gives to the
customer-defined fields of a request page are stored on the submission, in
submissions.custom_field_values, and nowhere else. testimonials has no
custom_field_values column: a testimonial reaches its answers by joining
testimonials.submission_id → submissions.id. Imported and manually created testimonials have
no submission and therefore no custom-field answers, which is correct rather than missing —
those fields are a property of the collection form, not of the testimonial. The column holds a
versioned object keyed by field key, never an array; the shape, its default, and its check
constraints are in Section 5.5.7, and Section 8.5.3 owns the capture and the answer format.
JSONB is never used to avoid designing a column. Anything filtered, sorted, joined, or
aggregated is a real column. Where a JSONB key must be queried, a GIN index with jsonb_path_ops
is declared explicitly in Section 5.13.
5.1.8 Secrets at rest #
Columns named *_encrypted hold envelope-encrypted values, never plaintext. The stored
value is base64 of a JSON structure { "v": 1, "k": "<key-version>", "iv": "<12-byte base64>", "ct": "<ciphertext base64>", "tag": "<16-byte base64>" }, produced by AES-256-GCM using a data
key wrapped by the platform KMS key referenced in Section 26. packages/db exposes
encryptSecret() / decryptSecret() and no other code may construct the structure.
Columns named *_hash hold a SHA-256 digest, hex-encoded, 64 characters, char(64), and the
plaintext is shown to a human exactly once at creation. This applies to api_keys.key_hash,
invitations.token_hash, invites.token_hash, respondent_tokens.token_hash,
sessions.token_hash, verification_tokens.token_hash, submission_drafts.token_hash.
The same 64-character digest form is also used for the non-secret fingerprints
suppressions.address_hash, trial_grants.billing_email_hash, and
abuse_blocks.subject_hash, where the point is to compare without storing the raw value at
all. Passwords are the exception: they use
argon2id via better-auth (parameters in Section 6.5.3) and live in accounts.password_hash.
5.1.9 Required extensions #
CREATE EXTENSION IF NOT EXISTS citext; -- case-insensitive email, hostname, slug comparison
CREATE EXTENSION IF NOT EXISTS pg_trgm; -- fuzzy near-duplicate detection on imported reviews
CREATE EXTENSION IF NOT EXISTS btree_gin; -- composite GIN indexes mixing tsvector with scalars
CREATE EXTENSION IF NOT EXISTS pgcrypto; -- digest() for in-database integrity checks in testsExtensions are created in the first migration, owned by vouch_migrator, and installed into the
extensions schema which is on the search path. No other extension may be added without a
migration and an entry in the future developer's docs/DECISIONS.md.
5.1.10 Standard column preamble and how DDL is presented here #
Unless a table's definition says otherwise, every table has:
id uuid PRIMARY KEY, -- UUIDv7 from newId()
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()Column tables below are the normative definition: every column, its type, nullability,
default, and meaning. CREATE TABLE SQL is given wherever the DDL carries information the
column table cannot — check constraints, partial unique indexes, generated columns,
partitioning, privileges, exclusion constraints. Where a table's DDL is the mechanical
translation of its column table plus this preamble, the SQL is not repeated; the migration
still contains it.
5.1.11 Object storage key convention #
Every *_object_key column holds a key in the single application bucket, never a URL, never a
host. Keys are:
workspaces/{workspaceId}/{kind}/{entityId}/{filename}kind is one of originals, posters, captions, avatars, company-logos,
brand, social-cards, exports, imports, backgrounds. Example:
workspaces/018f8b2c-1a4d-7b31-9c02-2f0a5b1d7e10/originals/018f8b2c-2233-7c00-91aa-9f3b0d2c4e55/take-01.mp4Storing keys rather than URLs means the CDN host, the bucket, and the signing strategy can all
change without a data migration. Public URLs are composed at read time by
packages/core/storage/url.ts.
5.2 Enum type catalogue #
Every native Postgres enum in the system, with every allowed value. Adding a value is a
forward-only migration (ALTER TYPE ... ADD VALUE, which cannot run inside a transaction
block — see the expand/contract rules in Section 5.17.4). Removing a value is never done; a
retired value is left in the type and rejected at the validation layer.
| Enum type | Values | Used by |
|---|---|---|
member_role |
owner, admin, editor, viewer |
workspace_members.role, invitations.role |
member_seat_state |
active, seat_suspended |
workspace_members.seat_state |
invitation_status |
pending, accepted, expired, revoked |
invitations.status |
billing_state |
trialing, active, past_due, canceled |
workspaces.billing_state |
limit_state |
ok, over_limit |
workspaces.limit_state |
testimonial_status |
pending, approved, published, unpublished, rejected, archived |
testimonials.status |
testimonial_source |
collected, imported, manual |
testimonials.source |
testimonial_kind |
video, text |
testimonials.kind, submissions.kind |
content_language |
en, es, fr, de, pt |
testimonials.language, transcripts.language, consent_versions.locale |
media_provider |
mux |
media_assets.provider |
media_state |
uploading, uploaded, processing, ready, errored |
media_assets.state |
upload_session_state |
active, completing, completed, aborted, expired, failed |
upload_sessions.state |
request_field_type |
short_text, long_text, select, star_rating, url, file, boolean |
request_page_fields.type |
prompt_applies_to |
video, text, both |
request_page_prompts.applies_to |
invite_channel |
email, sms |
invites.channel, invite_batches.channel |
invite_status |
queued, sending, sent, delivered, opened, clicked, started, submitted, bounced, complained, failed, revoked, canceled, expired |
invites.status |
submission_state |
in_progress, submitted, abandoned, rejected_spam |
submissions.state |
widget_type |
wall_of_love, carousel, single_quote, video_reel, badge, popup |
widgets.type |
widget_curation_mode |
filtered, curated, mixed |
widgets.curation_mode |
widget_sort |
newest, oldest, rating_desc, manual, random |
widgets.sort |
widget_theme_mode |
light, dark, auto, custom |
widgets.theme_mode, showcase_pages.theme_mode |
domain_state |
pending_dns, dns_detected, verifying, issuing_tls, active, suspended, failed |
custom_domains.state |
domain_kind |
subdomain, apex |
custom_domains.kind |
import_source |
google, g2, capterra, x, csv |
import_connections.source, import_runs.source, imported_reviews.source, testimonials.source_platform |
import_connection_status |
connected, expired, revoked, error, entitlement_missing |
import_connections.status |
import_run_status |
queued, running, succeeded, partial, failed, canceled |
import_runs.status |
import_trigger |
manual, scheduled, api, csv_upload |
import_runs.trigger |
imported_review_state |
new, converted, duplicate, skipped, rejected |
imported_reviews.state |
consent_kind |
publish, advertising, case_study_contact |
consents.kind |
dsr_type |
export, erasure, withdrawal |
data_subject_requests.type |
dsr_status |
received, verifying, verified, in_progress, completed, rejected |
data_subject_requests.status |
dsr_verification_method |
respondent_token, email_challenge, workspace_admin, support_manual |
data_subject_requests.verification_method |
transcript_edit_class |
timing_only, punctuation_only, verbatim_correction, substantive |
transcript_revisions.edit_class |
highlight_type |
pull_quote_text, pull_quote_transcript, clip |
highlights.type |
ai_suggestion_kind |
highlight, tag, alt_text, headline |
ai_suggestions.kind |
ai_suggestion_state |
proposed, accepted, dismissed, expired, stale, discarded |
ai_suggestions.state |
flag_reason |
abusive_language, third_party_pii, legal_request, consent_withdrawn, other |
testimonial_flags.reason |
reply_direction |
outbound, inbound |
testimonial_replies.direction |
ai_validation_outcome |
passed, rejected_not_verbatim, rejected_out_of_range, rejected_unknown_tag, rejected_schema, rejected_provider_error |
ai_suggestions.validation_outcome |
alt_text_source |
ai_drafted, human |
social_cards.alt_text_source |
actor_type |
user, api_key, system, respondent |
audit_events.actor_type, transcript_revisions.actor_type, and every other actor column |
analytics_event_type |
impression, interaction, click, video_start, video_progress, video_complete, video_error, page_view, request_started, request_captured, request_submitted |
analytics_events_raw.event_type, rollups |
device_class |
mobile, tablet, desktop, bot, unknown |
analytics tables |
webhook_delivery_status |
pending, succeeded, failed, exhausted, canceled |
webhook_deliveries.status |
integration_provider |
slack, hubspot, zapier |
integration_connections.provider |
integration_status |
connected, expired, revoked, error |
integration_connections.status |
job_status |
queued, running, succeeded, failed, canceled |
job_runs.status |
subscription_status |
trialing, active, past_due, canceled, incomplete, incomplete_expired, unpaid, paused |
subscriptions.status |
subscription_item_kind |
plan, domain_addon, seat |
subscription_items.kind |
usage_metric |
testimonials.collected, seats.active, request_pages.active, storage.bytes, delivery.minutes, transcription.minutes, custom_domains.active |
usage_counters.metric, usage_notifications.metric, usage_credits.metric, usage_reconciliations.metric |
usage_period |
lifetime, monthly |
usage_counters.period |
invoice_status |
draft, open, paid, uncollectible, void |
invoices.status |
refund_status |
pending, succeeded, failed, canceled |
refunds.status |
credit_reason |
support_goodwill, billing_error, service_incident, migration_adjustment, trial_conversion |
usage_credits.reason |
suppression_reason |
unsubscribe, hard_bounce, complaint, manual, erasure |
suppressions.reason |
abuse_block_reason |
submission_flood, credential_stuffing, scraping, spam_content, manual |
abuse_blocks.reason |
notification_channel |
in_app, email, sms, slack |
notifications.channel |
notification_severity |
info, success, warning, critical |
notifications.severity |
email_status |
queued, sent, delivered, opened, clicked, bounced, complained, failed |
email_deliveries.status |
sms_status |
queued, sent, delivered, undelivered, failed |
sms_deliveries.status |
verification_purpose |
email_verify, magic_link, password_reset, email_change, two_factor_recovery |
verification_tokens.purpose |
api_key_mode |
live, test |
api_keys.mode |
idempotency_state |
in_progress, completed |
idempotency_keys.state |
On invite_status, and why it is not invitation_status. These are two different enums on
two different tables and they are never interchangeable. invitation_status
(pending, accepted, expired, revoked) belongs to invitations — an offer of a seat to a
teammate, which is accepted or not. invite_status belongs to invites — a request for a
testimonial sent to a respondent, which travels a delivery-and-engagement funnel. A seat offer
cannot bounce and a testimonial request cannot be accepted; giving them one enum would have
produced a type on which half the values are meaningless whichever row you are holding.
Section 5.4.4 keeps the table names deliberately apart for the same reason.
invite_status's fourteen values, with the terminal ones marked. Section 20.7 owns the send
cadence; this is the vocabulary it moves through:
| Value | Meaning | Terminal |
|---|---|---|
queued |
Accepted for sending, not yet handed to a provider | |
sending |
Handed to the delivery provider, not yet accepted by it. The window in which a crash must not double-send | |
sent |
The provider accepted it for delivery | |
delivered |
The provider confirmed delivery to the destination | |
opened |
First open only; email pixel or SMS link fetch | |
clicked |
First click of the invite link | |
started |
The respondent began a submission | |
submitted |
The respondent completed one — the funnel's success state | Yes |
bounced |
The destination rejected it after the provider accepted it: a hard bounce, a dead mailbox, an unreachable number | Yes |
complained |
The recipient marked it as spam — a distinct and permanent signal, not a variant of bounced. It must suppress every future send to that address, so it always writes a suppressions row (Section 5.9.6) with reason complaint and no expiry |
Yes |
failed |
The provider never accepted it at all — malformed address, auth failure, transport error. Distinct from bounced: nothing was ever in flight |
Yes |
revoked |
The sender withdrew an invite that had already been delivered; the link stops working, but the recipient may still be holding the message | Yes |
canceled |
The sender withdrew it before delivery, from queued or sending — a cancelled batch, or a suppression matched at dispatch. Nothing ever reached the recipient, so there is nothing to take back |
Yes |
expired |
expires_at passed without a submission. The page offers a request-a-new-link action |
Yes |
The pairs that look redundant are the ones carrying the most weight. bounced versus failed
separates "their mailbox rejected us" from "we never got off the ground", which are different
problems with different owners — one is a list-hygiene issue for the customer, the other is an
incident for us. complained is a third thing again, and folding it into bounced would be the
costliest mistake of the three: a bounce is a fact about an address and can be transient, whereas
a complaint is a decision by a person, is permanent, and carries a deliverability penalty from
the provider. It must suppress future sends where a soft bounce must not. revoked versus
canceled separates "recall the link we sent" from "never send one", which matter differently to
a respondent: one may already have the message in their inbox and the other never will.
Collapsing any of these pairs would make the deliverability reporting in Section 20.10 unable to
state what actually happened.
On dotted enum labels. usage_metric's labels contain a period (storage.bytes). Postgres
enum labels are arbitrary strings, so this is legal, and it is deliberate: the same literal is
the metric key in the quota API (Section 19.5), the notification key, and the column value, so
there is exactly one spelling of each metric in the whole system and no translation table.
On billing_state and limit_state. These are two orthogonal facts about a workspace and
they were deliberately split into two enums rather than combined into one status column. "The
card was declined" and "you have more testimonials than your plan allows" can be true
independently, in any combination, and each is cleared by a different action. A single column
would force the two to be encoded as one value and would make "past due and over limit"
unrepresentable. There is no workspace_status type and no workspaces.status column;
Section 19.8 owns the transitions of both.
On ai_suggestion_state: expired and stale are different. expired is time-based — the
proposal passed expires_at without anyone acting on it. stale is content-based — the
testimonial or transcript the proposal was made against has since changed, so the proposal may
no longer be verbatim and must never be shown or accepted. Section 12.4's metrics count them
separately because they mean different things about the assistant's usefulness.
On analytics_event_type and video progress. There are no video_complete_25/50/75/100
values. Progress markers ride in the event payload: a video_progress event carries its
percentage in analytics_events_raw.extras as {"pct":25} — the object the beacon sends as
x (Section 17.3 owns the marker set).
Encoding the percentage in the type would have made "add a 10 % marker" a migration; encoding
it in the payload makes it a client change.
On testimonial_status and deletion. Soft deletion is orthogonal to lifecycle status:
a deleted testimonial keeps whatever status it held and gains a non-null deleted_at. There is
deliberately no deleted enum value, because a status value would make "what was this row's
state when it was removed?" unanswerable. The public API projects a derived status of
"deleted" when deleted_at IS NOT NULL; that projection lives in
packages/core/testimonial/serialize.ts and is the only place the derived value is produced.
5.3 Entity-relationship diagrams #
Three diagrams, split so each stays legible. Attributes shown are keys and discriminators only; the full column lists are in Sections 5.4–5.11.
5.3.1 Identity, tenancy and billing #
erDiagram
users ||--o{ workspace_members : "is member via"
users ||--o{ sessions : "has"
users ||--o{ accounts : "has credential"
users ||--o| two_factor_secrets : "may have"
workspaces ||--o{ workspace_members : "has"
workspaces ||--|| workspace_settings : "has one"
workspaces ||--o{ invitations : "issues"
workspaces ||--o{ api_keys : "owns"
workspaces ||--o{ workspace_feature_flags : "overrides"
workspaces ||--o| subscriptions : "has current"
workspaces ||--o{ usage_counters : "meters"
workspaces ||--o{ usage_notifications : "warned by"
workspaces ||--o{ usage_credits : "credited by"
workspaces ||--o{ usage_reconciliations : "audited by"
workspaces ||--o{ overage_evaluations : "evaluated by"
workspaces ||--o{ overage_notices : "noticed by"
workspaces ||--o{ delivery_usage_days : "meters delivery"
workspaces ||--o| trial_grants : "granted once"
workspaces ||--o{ cancellation_reasons : "explains exit"
workspaces ||--o{ abuse_blocks : "may scope"
plans ||--o{ workspaces : "priced by"
plans ||--o{ subscriptions : "priced by"
subscriptions ||--o{ subscription_items : "contains"
subscriptions ||--o{ invoices : "bills through"
invoices ||--o{ refunds : "refunded by"
feature_flags ||--o{ workspace_feature_flags : "defined by"
users {
uuid id PK
citext email UK
timestamptz deleted_at
}
workspaces {
uuid id PK
citext slug UK
uuid owner_user_id FK
billing_state billing_state
limit_state limit_state
smallint usage_anchor_day
uuid plan_id FK
}
workspace_members {
uuid id PK
uuid workspace_id FK
uuid user_id FK
member_role role
member_seat_state seat_state
}
usage_counters {
uuid id PK
uuid workspace_id FK
usage_metric metric
usage_period period
timestamptz period_start
bigint value
}
invoices {
uuid id PK
uuid workspace_id FK
text stripe_invoice_id UK
invoice_status status
integer total_cents
}
invitations {
uuid id PK
uuid workspace_id FK
citext email
invitation_status status
char token_hash
}
api_keys {
uuid id PK
uuid workspace_id FK
api_key_mode mode
text scopes
}5.3.2 Collection, content and media #
erDiagram
request_pages ||--o{ request_page_fields : "declares"
request_pages ||--o{ request_page_prompts : "asks"
request_pages ||--o{ invites : "targets via"
request_pages ||--o{ submissions : "receives"
request_pages ||--o{ submission_drafts : "holds drafts for"
invite_batches ||--o{ invites : "groups"
contacts ||--o{ invites : "addressed to"
invites ||--o| submissions : "converts to"
submission_drafts ||--o| submissions : "promotes to"
submissions ||--o| upload_sessions : "uses"
submissions ||--o{ consents : "records"
submissions ||--o{ consent_grants : "currently grants"
consents ||--o| consent_grants : "projects into"
submissions ||--o| testimonials : "creates"
imported_reviews ||--o| testimonials : "creates"
testimonials ||--o| media_assets : "may have"
media_assets ||--o{ media_derivatives : "renders to"
media_assets ||--o{ transcription_jobs : "transcribed by"
testimonials ||--o| transcripts : "may have"
testimonials ||--o{ testimonial_tags : "labelled by"
tags ||--o{ testimonial_tags : "labels"
testimonials ||--o{ highlights : "features"
testimonials ||--o{ ai_suggestions : "proposed for"
testimonials ||--o{ social_cards : "renders to"
testimonials ||--o{ testimonial_flags : "flagged by"
testimonials ||--o{ testimonial_replies : "threaded with"
testimonials ||--o| respondent_tokens : "reachable by"
users ||--o{ inbox_saved_views : "saves"
transcripts ||--o{ transcript_revisions : "versioned by"
ai_suggestions ||--o{ highlights : "may originate"
media_assets ||--o| transcripts : "transcribed as"
testimonials {
uuid id PK
uuid workspace_id FK
testimonial_status status
testimonial_status pre_archive_status
testimonial_status pre_delete_status
testimonial_source source
testimonial_kind kind
smallint rating
integer version
boolean is_featured
timestamptz published_at
timestamptz deleted_at
}
testimonial_flags {
uuid id PK
uuid testimonial_id FK
flag_reason reason
timestamptz cleared_at
}
testimonial_replies {
uuid id PK
uuid testimonial_id FK
uuid thread_id
reply_direction direction
}
submission_drafts {
uuid id PK
uuid request_page_id FK
char token_hash UK
integer revision
timestamptz expires_at
}
media_assets {
uuid id PK
media_state state
text provider_asset_id
text playback_id
timestamptz purged_at
}
transcripts {
uuid id PK
content_language language
integer version
jsonb doc
}
submissions {
uuid id PK
submission_state state
testimonial_kind kind
}5.3.3 Publishing, import, analytics and platform #
erDiagram
workspaces ||--o{ widgets : "publishes"
workspaces ||--o{ showcase_pages : "publishes"
workspaces ||--o{ custom_domains : "claims"
workspaces ||--o{ import_connections : "connects"
workspaces ||--o{ webhook_endpoints : "registers"
workspaces ||--o{ integration_connections : "connects"
widgets ||--o{ widget_testimonials : "curates"
testimonials ||--o{ widget_testimonials : "pinned into"
custom_domains ||--o| showcase_pages : "serves"
custom_domains ||--o| subscription_items : "billed by"
import_connections ||--o{ import_runs : "runs"
import_runs ||--o{ imported_reviews : "yields"
widgets ||--o{ analytics_events_raw : "emits"
showcase_pages ||--o{ analytics_events_raw : "emits"
request_pages ||--o{ analytics_events_raw : "emits funnel events"
analytics_events_raw }o--|| analytics_rollup_hourly : "aggregates into"
analytics_rollup_hourly }o--|| analytics_rollup_daily : "aggregates into"
outbox_events ||--o{ webhook_deliveries : "fans out to"
webhook_endpoints ||--o{ webhook_deliveries : "receives"
workspaces ||--o{ audit_events : "records"
workspaces ||--o{ data_subject_requests : "processes"
data_subject_requests ||..o{ erasure_log : "proves (no FK)"
workspaces ||..o{ erasure_log : "records erasures (no FK)"
testimonials ||..o| erasure_log : "erased into (no FK)"
workspaces ||--o{ suppressions : "suppresses"
workspaces ||--o{ notifications : "notifies"
suppressions {
uuid id PK
uuid workspace_id FK
char address_hash UK
suppression_reason reason
}
erasure_log {
uuid id PK
uuid workspace_id
uuid testimonial_id
text storage_prefix
timestamptz erasure_completed_at
timestamptz verified_at
}
widgets {
uuid id PK
text public_id UK
widget_type type
jsonb config
boolean is_active
integer data_version
}
custom_domains {
uuid id PK
citext hostname UK
domain_state state
text challenge_token
timestamptz tls_expires_at
}
analytics_events_raw {
timestamptz occurred_at PK
uuid id PK
analytics_event_type event_type
uuid request_page_id FK
text view_id
}
audit_events {
timestamptz occurred_at PK
uuid id PK
actor_type actor_type
text action
}5.4 Identity and tenancy tables #
5.4.1 users #
A global principal. Not workspace-scoped, for the reason given in Section 5.1.6: a user may hold membership in more than one workspace, and v1 pins the active workspace in the UI rather than in the schema (Section 6.4).
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 primary key |
email |
citext |
No | — | Login identity, unique among non-deleted rows |
email_verified_at |
timestamptz |
Yes | null |
Set when the verification token is consumed |
pending_email |
citext |
Yes | null |
Requested new address during an email change; cleared on confirm |
name |
text |
No | '' |
Display name, 0–120 chars |
avatar_object_key |
text |
Yes | null |
Storage key per Section 5.1.11 |
locale |
text |
No | 'en' |
UI language; one of the five in content_language plus future additions |
timezone |
text |
No | 'UTC' |
IANA zone, validated against the ICU list |
default_workspace_id |
uuid |
Yes | null |
The workspace opened at sign-in; FK added after workspaces exists |
is_two_factor_enabled |
boolean |
No | false |
Mirror of a confirmed two_factor_secrets row, kept for cheap policy checks |
last_seen_at |
timestamptz |
Yes | null |
Updated at most once per 5 minutes by session middleware |
failed_sign_in_count |
smallint |
No | 0 |
Reset on success; drives the progressive delay in Section 6.5.5 |
last_failed_sign_in_at |
timestamptz |
Yes | null |
Drives the progressive sign-in delay in Section 6.5.5; there is no account-lockout state |
marketing_opt_in |
boolean |
No | false |
Explicit, never pre-checked |
anonymized_at |
timestamptz |
Yes | null |
Set when a user erasure replaces PII with tombstone values |
created_at |
timestamptz |
No | now() |
|
updated_at |
timestamptz |
No | now() |
Trigger-maintained |
deleted_at |
timestamptz |
Yes | null |
Soft delete |
Constraints
UNIQUE (email) WHERE deleted_at IS NULL— partial, so a deleted account frees its address.CHECK (char_length(name) <= 120)CHECK (email <> pending_email)CHECK (last_failed_sign_in_at IS NULL OR failed_sign_in_count > 0)
Indexes
users_email_active_uq— partial unique above; serves sign-in lookup.users_default_workspace_idxon(default_workspace_id)— session bootstrap.users_last_seen_idxon(last_seen_at DESC)— operator activity reporting.
CREATE TABLE users (
id uuid PRIMARY KEY,
email citext NOT NULL,
email_verified_at timestamptz,
pending_email citext,
name text NOT NULL DEFAULT '',
avatar_object_key text,
locale text NOT NULL DEFAULT 'en',
timezone text NOT NULL DEFAULT 'UTC',
default_workspace_id uuid,
is_two_factor_enabled boolean NOT NULL DEFAULT false,
last_seen_at timestamptz,
failed_sign_in_count smallint NOT NULL DEFAULT 0,
last_failed_sign_in_at timestamptz,
marketing_opt_in boolean NOT NULL DEFAULT false,
anonymized_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT users_name_len_ck CHECK (char_length(name) <= 120),
CONSTRAINT users_pending_ck CHECK (pending_email IS NULL OR pending_email <> email),
CONSTRAINT users_lock_ck CHECK (last_failed_sign_in_at IS NULL OR failed_sign_in_count > 0)
);
CREATE UNIQUE INDEX users_email_active_uq ON users (email) WHERE deleted_at IS NULL;
CREATE INDEX users_default_workspace_idx ON users (default_workspace_id);
CREATE INDEX users_last_seen_idx ON users (last_seen_at DESC NULLS LAST);Lifecycle. Created by sign-up or by accepting an invitation. Soft-deleted when the person
leaves the product; hard-erased only by a GDPR request, which replaces email with
deleted+{id}@invalid, empties name, drops avatar_object_key (and its object), sets
anonymized_at, and leaves audit_events rows intact but attributed to the tombstone id
(Section 6.7.3).
5.4.2 workspaces #
The tenancy boundary. Every domain row in the system belongs to exactly one.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 primary key |
name |
text |
No | — | Display name, 2–80 chars |
slug |
citext |
No | — | Globally unique; drives {slug}.vouch.app |
owner_user_id |
uuid |
No | — | Exactly one Owner; mirrors the owner row in workspace_members |
plan_id |
uuid |
No | — | FK to plans; free on creation |
billing_state |
billing_state |
No | 'active' |
trialing, active, past_due, canceled. Answers "is this workspace paid up?" |
limit_state |
limit_state |
No | 'ok' |
ok, over_limit. Answers "is this workspace within its plan's capacity?" |
status_changed_at |
timestamptz |
No | now() |
Set on any transition of either state column |
usage_anchor_day |
smallint |
No | — | Day of month, 1–28, on which monthly usage periods roll for this workspace. Chosen at creation from the creation date, clamped to 28 so every month has the day |
over_limit_since |
timestamptz |
Yes | null |
Start of the current over-limit condition |
soft_overage_since |
timestamptz |
Yes | null |
Start of >20% overage on a soft metric (Section 19) |
stripe_customer_id |
text |
Yes | null |
Unique when present |
trial_ends_at |
timestamptz |
Yes | null |
Null on Free |
canceled_at |
timestamptz |
Yes | null |
Subscription cancellation; dashboard becomes read-only, serving continues |
data_region |
text |
No | 'us-east' |
us-east or eu-central; see Section 26 |
is_two_factor_enforced |
boolean |
No | false |
Owner-set, Growth+ only |
onboarding_completed_at |
timestamptz |
Yes | null |
Set when the first request page is published |
created_at |
timestamptz |
No | now() |
|
updated_at |
timestamptz |
No | now() |
|
deleted_at |
timestamptz |
Yes | null |
Soft delete; triggers a 30-day purge countdown |
Constraints
UNIQUE (slug) WHERE deleted_at IS NULLUNIQUE (stripe_customer_id)— a Stripe customer maps to at most one workspace.CHECK (char_length(name) BETWEEN 2 AND 80)CHECK (slug ~ '^[a-z0-9](?:[a-z0-9-]{1,46}[a-z0-9])$')CHECK (data_region IN ('us-east','eu-central'))CHECK (limit_state <> 'over_limit' OR over_limit_since IS NOT NULL)CHECK (usage_anchor_day BETWEEN 1 AND 28)CHECK (billing_state <> 'canceled' OR canceled_at IS NOT NULL)CHECK (billing_state <> 'trialing' OR trial_ends_at IS NOT NULL)
Indexes
workspaces_slug_uq(partial unique) — public request-page and showcase host resolution.workspaces_owner_idxon(owner_user_id).workspaces_billing_idxon(billing_state, status_changed_at)— the dunning sweep.workspaces_limit_idxon(limit_state, over_limit_since)— the over-limit sweep. Two indexes rather than one composite, because the two sweeps run on different schedules and neither ever filters on the other column.workspaces_anchor_idxon(usage_anchor_day)— the dailyquota.roll_periodsjob selects the workspaces whose period rolls today with a single index scan.workspaces_plan_idxon(plan_id)— plan-population reporting.
CREATE TABLE workspaces (
id uuid PRIMARY KEY,
name text NOT NULL,
slug citext NOT NULL,
owner_user_id uuid NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
plan_id uuid NOT NULL REFERENCES plans(id) ON DELETE RESTRICT,
billing_state billing_state NOT NULL DEFAULT 'active',
limit_state limit_state NOT NULL DEFAULT 'ok',
status_changed_at timestamptz NOT NULL DEFAULT now(),
usage_anchor_day smallint NOT NULL,
over_limit_since timestamptz,
soft_overage_since timestamptz,
stripe_customer_id text,
trial_ends_at timestamptz,
canceled_at timestamptz,
data_region text NOT NULL DEFAULT 'us-east',
is_two_factor_enforced boolean NOT NULL DEFAULT false,
onboarding_completed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT workspaces_name_len_ck CHECK (char_length(name) BETWEEN 2 AND 80),
CONSTRAINT workspaces_slug_fmt_ck CHECK (slug ~ '^[a-z0-9](?:[a-z0-9-]{1,46}[a-z0-9])$'),
CONSTRAINT workspaces_region_ck CHECK (data_region IN ('us-east','eu-central')),
CONSTRAINT workspaces_overlimit_ck CHECK (limit_state <> 'over_limit' OR over_limit_since IS NOT NULL),
CONSTRAINT workspaces_anchor_ck CHECK (usage_anchor_day BETWEEN 1 AND 28),
CONSTRAINT workspaces_canceled_ck CHECK (billing_state <> 'canceled' OR canceled_at IS NOT NULL),
CONSTRAINT workspaces_trial_ck CHECK (billing_state <> 'trialing' OR trial_ends_at IS NOT NULL),
CONSTRAINT workspaces_stripe_uq UNIQUE (stripe_customer_id)
);
CREATE UNIQUE INDEX workspaces_slug_uq ON workspaces (slug) WHERE deleted_at IS NULL;
CREATE INDEX workspaces_owner_idx ON workspaces (owner_user_id);
CREATE INDEX workspaces_billing_idx ON workspaces (billing_state, status_changed_at);
CREATE INDEX workspaces_limit_idx ON workspaces (limit_state, over_limit_since);
CREATE INDEX workspaces_anchor_idx ON workspaces (usage_anchor_day);
CREATE INDEX workspaces_plan_idx ON workspaces (plan_id);Two state columns, not one. A payment problem and a capacity problem are independent facts
and are stored independently. billing_state moves only on the billing path (trial start,
successful charge, failed charge, cancellation); limit_state moves only on the quota path
(a counter crossing its limit, or falling back under it). A workspace can be
past_due + ok, active + over_limit, or both at once, and each clears on its own. There
is deliberately no combined status column and no suspended value: "suspended" would have
been a third thing that is really a consequence of one of the two, and every place that wanted
to ask "why is this workspace restricted?" would have had to guess. Section 19.8 owns the
transition rules; Section 19.10 owns what each combination does to serving and to the dashboard.
On usage_anchor_day. Monthly usage periods do not roll at midnight UTC on the first for
everyone — that would put every workspace's reconciliation into the same minute. The anchor is
the workspace's creation day-of-month clamped to 28, so periods roll evenly across the month and
a workspace's usage month always lines up with the day it signed up. Section 19.3 owns the
period arithmetic.
Lifecycle. workspaces is the RLS anchor and is therefore not itself protected by a
workspace policy — it is protected by an id = current_setting('app.current_workspace_id')::uuid
policy, described in Section 6.2.3. Soft deletion schedules retention.purge_workspaces for 30
days later; that job hard-deletes every child row and destroys every external asset. Published
widgets belonging to a workspace in billing_state = 'canceled' (but not deleted) keep serving
forever — cancellation and deletion are different states and only deletion stops delivery.
5.4.3 workspace_members #
The many-to-many join between users and workspaces. It already models the deferred multi-workspace capability (Section 6.4); v1 simply creates one row per user.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
user_id |
uuid |
No | — | Member |
role |
member_role |
No | 'viewer' |
One of exactly four roles |
seat_state |
member_seat_state |
No | 'active' |
seat_suspended when a downgrade left the workspace with more members than seats (Section 19.4 E6). A suspended seat keeps its row, its role, and its history but cannot sign in to the workspace |
seat_suspended_at |
timestamptz |
Yes | null |
When the seat was suspended; the reactivation order in Section 19.12.5 is oldest-membership-first, so this is recorded but created_at is what orders |
invited_by_user_id |
uuid |
Yes | null |
Null for the founding Owner |
invitation_id |
uuid |
Yes | null |
The invitation that produced this membership |
joined_at |
timestamptz |
No | now() |
|
last_active_at |
timestamptz |
Yes | null |
Throttled write, once per 5 minutes |
created_at / updated_at |
timestamptz |
No | now() |
|
deleted_at |
timestamptz |
Yes | null |
Removal from the workspace; audit history stays attributed |
Constraints
UNIQUE (workspace_id, user_id) WHERE deleted_at IS NULLEXCLUDEis not used; the single-Owner rule is enforced byCREATE UNIQUE INDEX workspace_members_one_owner_uq ON workspace_members (workspace_id) WHERE role = 'owner' AND deleted_at IS NULL;This makes "exactly one Owner" a database invariant, not an application convention.CHECK (deleted_at IS NULL OR role <> 'owner')— an Owner must be demoted or transferred before removal; the last-Owner protection in Section 6.7.4 relies on this.CHECK (seat_state <> 'seat_suspended' OR (seat_suspended_at IS NOT NULL AND role <> 'owner'))— the Owner's seat is never suspended, because a workspace with no reachable Owner cannot be brought back under its limit.
Indexes
workspace_members_ws_user_uq(partial unique).workspace_members_one_owner_uq(partial unique).workspace_members_user_idxon(user_id) WHERE deleted_at IS NULL— "which workspaces can this user open?", the query the deferred workspace switcher will use unchanged.workspace_members_ws_role_idxon(workspace_id, role) WHERE deleted_at IS NULL— seat counting for quota enforcement.workspace_members_suspended_idxon(workspace_id, created_at) WHERE seat_state = 'seat_suspended' AND deleted_at IS NULL— the reactivation queue. When seats free up, Section 19.12.5 reactivates oldest membership first, which is exactly this index read in order.
CREATE TABLE workspace_members (
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role member_role NOT NULL DEFAULT 'viewer',
seat_state member_seat_state NOT NULL DEFAULT 'active',
seat_suspended_at timestamptz,
invited_by_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
invitation_id uuid,
joined_at timestamptz NOT NULL DEFAULT now(),
last_active_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT workspace_members_owner_removal_ck CHECK (deleted_at IS NULL OR role <> 'owner'),
CONSTRAINT workspace_members_seat_ck CHECK (
seat_state <> 'seat_suspended' OR (seat_suspended_at IS NOT NULL AND role <> 'owner'))
);
CREATE UNIQUE INDEX workspace_members_ws_user_uq
ON workspace_members (workspace_id, user_id) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX workspace_members_one_owner_uq
ON workspace_members (workspace_id) WHERE role = 'owner' AND deleted_at IS NULL;
CREATE INDEX workspace_members_user_idx
ON workspace_members (user_id) WHERE deleted_at IS NULL;
CREATE INDEX workspace_members_ws_role_idx
ON workspace_members (workspace_id, role) WHERE deleted_at IS NULL;
CREATE INDEX workspace_members_suspended_idx
ON workspace_members (workspace_id, created_at)
WHERE seat_state = 'seat_suspended' AND deleted_at IS NULL;seat_state is where seat suspension lives. It is a column on the membership, not a role
change and not a deletion, because the member's role must survive the suspension unchanged so
that reactivation restores exactly what they had. Suspension changes the actor's effective
permissions without changing role, so every session belonging to a member entering or leaving
seat_suspended is revoked at the moment of the change (Section 7.7.1) — otherwise a suspended
member would keep acting for up to the life of their session.
5.4.4 invitations #
A pending offer of membership. Distinct from invites, which are testimonial requests sent
to respondents — the two are never conflated and their names are deliberately kept apart.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
email |
citext |
No | — | Invitee address |
role |
member_role |
No | 'editor' |
Cannot be owner |
token_hash |
char(64) |
No | — | SHA-256 of a 32-byte random token; plaintext only in the email |
status |
invitation_status |
No | 'pending' |
State machine in Section 6.8 |
status_changed_at |
timestamptz |
No | now() |
Set on every status transition; the retention clock in Section 5.20 runs from here |
invited_by_user_id |
uuid |
No | — | Actor |
message |
text |
Yes | null |
Optional personal note, ≤ 500 chars |
expires_at |
timestamptz |
No | now() + interval '7 days' |
Single-use, 7-day life |
accepted_at |
timestamptz |
Yes | null |
|
accepted_user_id |
uuid |
Yes | null |
The user that accepted |
revoked_at |
timestamptz |
Yes | null |
|
revoked_by_user_id |
uuid |
Yes | null |
|
resend_count |
smallint |
No | 0 |
Max 5, rate-limited in Section 6.8.4 |
last_sent_at |
timestamptz |
No | now() |
|
email_delivery_id |
uuid |
Yes | null |
Latest delivery record |
created_at / updated_at |
timestamptz |
No | now() |
Constraints
UNIQUE (workspace_id, email) WHERE status = 'pending'— one open invitation per address.UNIQUE (token_hash)CHECK (role <> 'owner')— ownership is transferred, never invited.CHECK (status <> 'accepted' OR (accepted_at IS NOT NULL AND accepted_user_id IS NOT NULL))CHECK (resend_count <= 5)
Indexes
invitations_token_uqon(token_hash)— acceptance lookup.invitations_ws_status_idxon(workspace_id, status, created_at DESC)— members screen.invitations_expiry_idxon(expires_at) WHERE status = 'pending'— the expiry sweeper.
Lifecycle. An invitation row is never deleted on acceptance. Every terminal invitation —
accepted, revoked, or expired alike — is retained 90 days from status_changed_at and
then hard-deleted by invitation.expire, which does both jobs: it moves overdue pending rows
to expired and removes terminal rows past their retention. Keeping the accepted row is what
makes three things work: the members screen can show who invited whom and when
(Section 6.8), clicking a used link returns INVITATION_ALREADY_USED rather than a bare 404,
and the accepted enum value is actually observable rather than a state no row is ever seen in.
Acceptance writes workspace_members and an audit_events row in the same transaction.
5.4.5 sessions #
better-auth's session store, extended with the active-workspace pin. Not workspace-scoped (Section 5.1.6).
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
user_id |
uuid |
No | — | Owner of the session |
token_hash |
char(64) |
No | — | SHA-256 of the cookie value |
active_workspace_id |
uuid |
Yes | null |
The pinned workspace (Section 6.4.2) |
expires_at |
timestamptz |
No | now() + interval '30 days' |
Rolling window |
absolute_expires_at |
timestamptz |
No | now() + interval '90 days' |
Hard ceiling, never extended |
ip_truncated |
inet |
Yes | null |
/24 IPv4, /48 IPv6 |
user_agent |
text |
Yes | null |
≤ 512 chars |
is_two_factor_satisfied |
boolean |
No | false |
Set after TOTP challenge |
last_used_at |
timestamptz |
No | now() |
Throttled to once per 5 minutes |
revoked_at |
timestamptz |
Yes | null |
Sign-out, password change, or admin revoke |
revoked_reason |
text |
Yes | null |
sign_out, password_change, admin_revoke, 2fa_enrolled, email_change |
created_at / updated_at |
timestamptz |
No | now() |
Constraints: UNIQUE (token_hash); CHECK (absolute_expires_at >= expires_at).
Indexes: sessions_token_uq; sessions_user_idx (user_id, created_at DESC) for the
"active sessions" screen; sessions_expiry_idx (expires_at) WHERE revoked_at IS NULL for the
sweeper. Hard-deleted 7 days after absolute_expires_at by session.sweep.
5.4.6 accounts #
better-auth's credential/provider rows. In v1 the only provider is credential; the table
shape accommodates OAuth providers without migration.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
user_id |
uuid |
No | — | |
provider_id |
text |
No | 'credential' |
credential in v1 |
provider_account_id |
text |
No | — | The user id for credential rows; provider subject for OAuth |
password_hash |
text |
Yes | null |
argon2id encoded string; null for OAuth rows |
password_changed_at |
timestamptz |
Yes | null |
Drives "sessions revoked on password change" |
access_token_encrypted |
text |
Yes | null |
Envelope-encrypted per Section 5.1.8 |
refresh_token_encrypted |
text |
Yes | null |
|
token_expires_at |
timestamptz |
Yes | null |
|
scope |
text |
Yes | null |
Space-delimited |
created_at / updated_at |
timestamptz |
No | now() |
Constraints: UNIQUE (provider_id, provider_account_id);
CHECK (provider_id <> 'credential' OR password_hash IS NOT NULL).
Indexes: accounts_user_idx (user_id). Hard-deleted with the user (ON DELETE CASCADE).
5.4.7 verification_tokens #
Single-use tokens for email verification, magic-link sign-in, password reset, email change, and
two-factor recovery. One table for all five, discriminated by purpose, because they share
identical hashing, expiry, and consumption semantics.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
identifier |
citext |
No | — | Email address the token was issued to |
user_id |
uuid |
Yes | null |
Null for sign-up verification before the user exists |
purpose |
verification_purpose |
No | — | See enum catalogue |
token_hash |
char(64) |
No | — | SHA-256 of a 32-byte random token |
payload |
jsonb |
Yes | null |
Purpose-specific data, e.g. {"newEmail":"a@b.com"} for email_change |
expires_at |
timestamptz |
No | — | Per-purpose lifetimes in Section 6.5.6 |
consumed_at |
timestamptz |
Yes | null |
Single use; a consumed token is never valid again |
requested_ip |
inet |
Yes | null |
Truncated |
created_at |
timestamptz |
No | now() |
Constraints: UNIQUE (token_hash);
UNIQUE (identifier, purpose) WHERE consumed_at IS NULL — requesting a new token invalidates
the previous one for the same purpose, which is what makes "click the newest link" always
correct.
Indexes: verification_tokens_expiry_idx (expires_at) WHERE consumed_at IS NULL.
Hard-deleted 24 hours after expires_at by session.sweep, which sweeps both token stores.
5.4.8 two_factor_secrets #
One optional row per user. Separated from users so the secret can be read only by the auth
service and so column-level grants can restrict it.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
user_id |
uuid |
No | — | Unique |
secret_encrypted |
text |
No | — | Envelope-encrypted TOTP secret (160-bit, base32) |
algorithm |
text |
No | 'SHA1' |
RFC 6238 default for authenticator-app compatibility |
digits |
smallint |
No | 6 |
|
period_seconds |
smallint |
No | 30 |
|
confirmed_at |
timestamptz |
Yes | null |
2FA is inactive until the first valid code is submitted |
recovery_code_hashes |
char(64)[] |
No | '{}' |
10 SHA-256 digests; each consumed once |
recovery_codes_remaining |
smallint |
No | 0 |
Denormalized for the settings screen |
last_used_at |
timestamptz |
Yes | null |
|
last_used_counter |
bigint |
Yes | null |
Replay protection: a counter value is never accepted twice |
created_at / updated_at |
timestamptz |
No | now() |
Constraints: UNIQUE (user_id); CHECK (array_length(recovery_code_hashes,1) IS NULL OR array_length(recovery_code_hashes,1) <= 10);
CHECK (recovery_codes_remaining BETWEEN 0 AND 10).
5.4.9 api_keys #
Public REST API credentials. Keys belong to a workspace, never to a user, so revoking a person does not silently break an integration.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
name |
text |
No | — | Human label, 1–80 chars |
mode |
api_key_mode |
No | 'live' |
live → vch_live_, test → vch_test_ |
key_prefix |
char(12) |
No | — | First 12 chars of the plaintext, shown in the UI for identification |
key_hash |
char(64) |
No | — | SHA-256 of the full plaintext |
scopes |
text[] |
No | '{}' |
Subset of the six API scopes; the scope-to-permission map is owned by Section 6.6.4 |
created_by_user_id |
uuid |
Yes | null |
Null if created by another key with webhooks:manage |
last_used_at |
timestamptz |
Yes | null |
Throttled to once per minute |
last_used_ip |
inet |
Yes | null |
Truncated |
request_count |
bigint |
No | 0 |
Lifetime counter, incremented asynchronously |
expires_at |
timestamptz |
Yes | null |
Optional expiry chosen at creation |
revoked_at |
timestamptz |
Yes | null |
Immediate, irreversible |
revoked_by_user_id |
uuid |
Yes | null |
|
rate_limit_per_minute |
integer |
Yes | null |
Per-key override; null means the plan default |
created_at / updated_at |
timestamptz |
No | now() |
Constraints
UNIQUE (key_hash)CHECK (scopes <@ ARRAY['testimonials:read','testimonials:write','widgets:read','widgets:write','analytics:read','webhooks:manage']::text[])— an unknown scope cannot be stored, so scope drift is impossible.CHECK (array_length(scopes,1) >= 1)CHECK (key_prefix = left(key_prefix, 12))
Indexes: api_keys_hash_uq (key_hash) — the authentication lookup, a single index probe;
api_keys_ws_idx (workspace_id, created_at DESC) WHERE revoked_at IS NULL.
CREATE TABLE api_keys (
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
name text NOT NULL,
mode api_key_mode NOT NULL DEFAULT 'live',
key_prefix char(12) NOT NULL,
key_hash char(64) NOT NULL,
scopes text[] NOT NULL DEFAULT '{}',
created_by_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
last_used_at timestamptz,
last_used_ip inet,
request_count bigint NOT NULL DEFAULT 0,
expires_at timestamptz,
revoked_at timestamptz,
revoked_by_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
rate_limit_per_minute integer,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT api_keys_hash_uq UNIQUE (key_hash),
CONSTRAINT api_keys_name_ck CHECK (char_length(name) BETWEEN 1 AND 80),
CONSTRAINT api_keys_scopes_ck CHECK (
array_length(scopes, 1) >= 1 AND scopes <@ ARRAY[
'testimonials:read','testimonials:write','widgets:read',
'widgets:write','analytics:read','webhooks:manage']::text[]),
CONSTRAINT api_keys_rate_ck CHECK (rate_limit_per_minute IS NULL OR rate_limit_per_minute > 0)
);
CREATE INDEX api_keys_ws_idx ON api_keys (workspace_id, created_at DESC) WHERE revoked_at IS NULL;Lifecycle. Never soft-deleted in the deleted_at sense; revocation is revoked_at, and
revoked rows are retained forever so historical audit_events referencing the key still
resolve to a name.
5.4.10 feature_flags and workspace_feature_flags #
Two tables: a platform-owned definition table and a per-workspace override table. Flags are evaluated server-side only; no flag value is ever sent to the widget bundle.
feature_flags (platform, no workspace_id):
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
key |
text |
No | — | Must start with flag_; unique |
description |
text |
No | '' |
What it gates and who owns it |
is_enabled_default |
boolean |
No | false |
Global default |
rollout_percent |
smallint |
No | 0 |
0–100; hashed on workspace_id for stable bucketing |
payload |
jsonb |
Yes | null |
Optional configuration carried with the flag |
is_archived |
boolean |
No | false |
Retired flags stay for audit |
updated_by_user_id |
uuid |
Yes | null |
Operator |
created_at / updated_at |
timestamptz |
No | now() |
workspace_feature_flags (workspace-scoped override):
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
feature_flag_id |
uuid |
No | — | |
is_enabled |
boolean |
No | — | Explicit override, wins over rollout |
reason |
text |
Yes | null |
Why this workspace was overridden |
expires_at |
timestamptz |
Yes | null |
Temporary overrides auto-expire |
created_at / updated_at |
timestamptz |
No | now() |
Constraints: feature_flags: UNIQUE (key), CHECK (key ~ '^flag_[a-z0-9_]{3,60}$'),
CHECK (rollout_percent BETWEEN 0 AND 100). workspace_feature_flags:
UNIQUE (workspace_id, feature_flag_id).
Evaluation order, implemented in packages/core/flags/evaluate.ts: non-expired workspace
override → rollout bucket (hashint('{key}:{workspaceId}') % 100 < rollout_percent) →
is_enabled_default. The result is cached in Valkey for 30 seconds and invalidated on write.
5.4.11 workspace_settings #
Exactly one row per workspace, created in the same transaction as the workspace. The primary
key is workspace_id; there is no separate id, because a second row must be impossible.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
workspace_id |
uuid |
No | — | Primary key and FK |
logo_object_key |
text |
Yes | null |
Square mark, ≥ 256×256, PNG/SVG/WebP |
wordmark_object_key |
text |
Yes | null |
Horizontal lockup for request pages |
brand_primary_color |
char(7) |
No | '#111827' |
#rrggbb, lowercase hex |
brand_accent_color |
char(7) |
No | '#2563eb' |
Default widget accent; contrast-checked in the editor (Section 21) |
brand_text_color |
char(7) |
No | '#111827' |
|
brand_background_color |
char(7) |
No | '#ffffff' |
|
font_scale |
numeric(3,2) |
No | 1.00 |
0.85–1.30, applied to widgets and request pages |
border_radius_px |
smallint |
No | 12 |
0–32 |
default_language |
content_language |
No | 'en' |
Fallback when detection confidence < 0.6 |
reply_to_email |
citext |
Yes | null |
Verified before use |
reply_to_verified_at |
timestamptz |
Yes | null |
|
from_name |
text |
No | '' |
Falls back to workspace name when empty |
default_consent_version_id |
uuid |
No | — | Current consent text for new request pages |
is_badge_hidden |
boolean |
No | false |
Ignored on Free — the badge is re-asserted server-side (Section 19) |
keep_originals |
boolean |
No | false |
Growth+ only; counts against storage quota |
auto_transcribe |
boolean |
No | true |
|
ai_assistant_enabled |
boolean |
No | false |
Growth+ only; Owner/Admin toggle |
notify_on_submission |
boolean |
No | true |
|
notify_on_import_complete |
boolean |
No | true |
|
notify_digest_frequency |
text |
No | 'weekly' |
off, daily, weekly |
reporting_timezone |
text |
No | 'UTC' |
IANA zone used for daily analytics buckets |
week_start |
smallint |
No | 1 |
0 = Sunday, 1 = Monday |
showcase_default_theme_mode |
widget_theme_mode |
No | 'auto' |
|
created_at / updated_at |
timestamptz |
No | now() |
Constraints: every colour column CHECK (value ~ '^#[0-9a-f]{6}$');
CHECK (font_scale BETWEEN 0.85 AND 1.30); CHECK (border_radius_px BETWEEN 0 AND 32);
CHECK (notify_digest_frequency IN ('off','daily','weekly'));
CHECK (week_start IN (0,1)).
CREATE TABLE workspace_settings (
workspace_id uuid PRIMARY KEY REFERENCES workspaces(id) ON DELETE CASCADE,
logo_object_key text,
wordmark_object_key text,
brand_primary_color char(7) NOT NULL DEFAULT '#111827',
brand_accent_color char(7) NOT NULL DEFAULT '#2563eb',
brand_text_color char(7) NOT NULL DEFAULT '#111827',
brand_background_color char(7) NOT NULL DEFAULT '#ffffff',
font_scale numeric(3,2) NOT NULL DEFAULT 1.00,
border_radius_px smallint NOT NULL DEFAULT 12,
default_language content_language NOT NULL DEFAULT 'en',
reply_to_email citext,
reply_to_verified_at timestamptz,
from_name text NOT NULL DEFAULT '',
default_consent_version_id uuid NOT NULL REFERENCES consent_versions(id) ON DELETE RESTRICT,
is_badge_hidden boolean NOT NULL DEFAULT false,
keep_originals boolean NOT NULL DEFAULT false,
auto_transcribe boolean NOT NULL DEFAULT true,
ai_assistant_enabled boolean NOT NULL DEFAULT false,
notify_on_submission boolean NOT NULL DEFAULT true,
notify_on_import_complete boolean NOT NULL DEFAULT true,
notify_digest_frequency text NOT NULL DEFAULT 'weekly',
reporting_timezone text NOT NULL DEFAULT 'UTC',
week_start smallint NOT NULL DEFAULT 1,
showcase_default_theme_mode widget_theme_mode NOT NULL DEFAULT 'auto',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT ws_settings_primary_ck CHECK (brand_primary_color ~ '^#[0-9a-f]{6}$'),
CONSTRAINT ws_settings_accent_ck CHECK (brand_accent_color ~ '^#[0-9a-f]{6}$'),
CONSTRAINT ws_settings_text_ck CHECK (brand_text_color ~ '^#[0-9a-f]{6}$'),
CONSTRAINT ws_settings_bg_ck CHECK (brand_background_color ~ '^#[0-9a-f]{6}$'),
CONSTRAINT ws_settings_scale_ck CHECK (font_scale BETWEEN 0.85 AND 1.30),
CONSTRAINT ws_settings_radius_ck CHECK (border_radius_px BETWEEN 0 AND 32),
CONSTRAINT ws_settings_digest_ck CHECK (notify_digest_frequency IN ('off','daily','weekly')),
CONSTRAINT ws_settings_weekstart_ck CHECK (week_start IN (0,1))
);5.4.12 plans #
Operator-owned pricing catalogue. A lookup table rather than an enum because prices, limits, and plan names change without a code deploy. Section 19 owns the commercial meaning of every value; this table is where the numbers live.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
code |
text |
No | — | free, starter, growth, scale; unique |
name |
text |
No | — | Display name |
position |
smallint |
No | — | Ordering on the pricing screen; also the upgrade/downgrade comparison key |
price_cents_monthly |
integer |
No | — | 0, 1900, 4900, 14900 |
price_cents_annual |
integer |
No | — | 10 × monthly |
currency |
char(3) |
No | 'USD' |
ISO-4217 |
stripe_price_id_monthly |
text |
Yes | null |
Null for Free |
stripe_price_id_annual |
text |
Yes | null |
Null for Free |
limits |
jsonb |
No | — | Machine-readable limit set, schema below |
features |
jsonb |
No | '{}' |
Boolean capability set (aiAssistant, webhooks, hubspot, enforced2fa, badgeRemovable, allImportSources) |
is_public |
boolean |
No | true |
Hidden plans support grandfathering |
created_at / updated_at |
timestamptz |
No | now() |
limits shape, validated by planLimitsSchema in packages/validation:
{
"testimonialsLifetime": 500,
"seats": 10,
"requestPages": 25,
"maxVideoSeconds": 300,
"maxVideoHeight": 1080,
"storageBytes": 107374182400,
"deliveryMinutesPerMonth": 50000,
"transcriptionMinutesPerMonth": 1500,
"includedCustomDomains": 1,
"extraDomainPriceCents": 1000,
"apiRequestsPerMinute": 120,
"analyticsRetentionDays": 730,
"maxApiKeys": 25,
"maxWebhookEndpoints": 20,
"maxContacts": 50000,
"maxTags": 200
}null in any numeric limit means unlimited (used for requestPages on Scale). Widgets and
widget views have no limit key at all, on any plan, because they are unlimited by product
design and an absent key cannot be accidentally enforced.
Two of these keys are not metered counters and the distinction matters.
apiRequestsPerMinute configures the Valkey token bucket in Section 7.10; it is a rate, refilled
continuously, and it never appears in usage_counters or in the usage_metric enum, because
there is no period over which it accumulates and nothing to reconcile. The same is true of
maxApiKeys, maxWebhookEndpoints, maxContacts, and maxTags: each is a ceiling checked with
a count(*) at creation time, refused with the relevant *_LIMIT_REACHED code from Section 29.3,
and never metered. Only the seven usage_metric values are counters. Section 19.2 states the
per-plan values of every key here; this table is where they live at runtime.
Constraints: UNIQUE (code); UNIQUE (position);
CHECK (price_cents_monthly >= 0 AND price_cents_annual >= 0);
CHECK (code = 'free' OR stripe_price_id_monthly IS NOT NULL).
5.5 Collection tables #
5.5.1 request_pages #
A public page at https://v.vouch.app/r/{slug} (or https://{customDomain}/r/{slug}) that asks
a respondent for a testimonial. Section 8 owns the behaviour; this is the storage.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
slug |
citext |
No | — | Unique per workspace among non-deleted rows |
name |
text |
No | — | Internal label, 1–120 chars |
headline |
text |
No | — | Public heading, 1–160 chars |
intro_body |
text |
No | '' |
Markdown-lite, sanitized; ≤ 2,000 chars |
logo_object_key |
text |
Yes | null |
Overrides the workspace logo |
background_object_key |
text |
Yes | null |
Optional hero image |
theme |
jsonb |
No | '{}' |
Per-page overrides of the brand tokens in workspace_settings |
allow_video |
boolean |
No | true |
At least one of video/text must be true |
allow_text |
boolean |
No | true |
|
allow_audio_only |
boolean |
No | false |
Records audio with a generated waveform poster |
require_rating |
boolean |
No | false |
|
require_email |
boolean |
No | true |
Needed to issue a respondent token |
require_name |
boolean |
No | true |
|
collect_role |
boolean |
No | true |
|
collect_company |
boolean |
No | true |
|
collect_avatar |
boolean |
No | true |
Optional photo upload |
collect_company_logo |
boolean |
No | false |
|
max_video_seconds |
smallint |
Yes | null |
Page-level cap; the effective cap is least(page, plan) |
min_video_seconds |
smallint |
No | 5 |
Guards against empty takes |
max_text_chars |
integer |
No | 2000 |
100–5,000 |
min_text_chars |
integer |
No | 40 |
|
teleprompter_default_on |
boolean |
No | false |
Off by default, per Section 9 |
consent_version_id |
uuid |
No | — | The consent text this page renders |
thank_you_headline |
text |
No | 'Thank you!' |
|
thank_you_body |
text |
No | '' |
≤ 1,000 chars |
redirect_url |
text |
Yes | null |
HTTPS only; shown as a button, never an auto-redirect |
auto_approve |
boolean |
No | false |
When true, submissions land approved instead of pending; never published |
notify_emails |
citext[] |
No | '{}' |
Extra recipients on submission, max 10 |
is_active |
boolean |
No | true |
Inactive pages return the closed state, not a 404 |
locale |
content_language |
No | 'en' |
Page copy language |
submission_count |
integer |
No | 0 |
Denormalized counter, incremented in the submit transaction |
view_count |
integer |
No | 0 |
Incremented asynchronously from analytics |
published_at |
timestamptz |
Yes | null |
First time it went live; drives onboarding_completed_at |
created_by_user_id |
uuid |
Yes | null |
|
created_at / updated_at |
timestamptz |
No | now() |
|
deleted_at |
timestamptz |
Yes | null |
Soft delete; existing links show a closed state |
Constraints
UNIQUE (workspace_id, slug) WHERE deleted_at IS NULLCHECK (allow_video OR allow_text)— a page that collects nothing cannot exist.CHECK (max_text_chars BETWEEN 100 AND 5000 AND min_text_chars BETWEEN 0 AND max_text_chars)CHECK (max_video_seconds IS NULL OR max_video_seconds BETWEEN 15 AND 300)CHECK (min_video_seconds BETWEEN 1 AND 60)CHECK (redirect_url IS NULL OR redirect_url ~ '^https://')CHECK (array_length(notify_emails,1) IS NULL OR array_length(notify_emails,1) <= 10)CHECK (NOT allow_audio_only OR allow_video)
Indexes
request_pages_ws_slug_uq(partial unique) — public page resolution, one probe.request_pages_ws_active_idxon(workspace_id, is_active, created_at DESC) WHERE deleted_at IS NULL— the list screen.request_pages_consent_idxon(consent_version_id)— impact analysis when consent text changes.
CREATE TABLE request_pages (
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
slug citext NOT NULL,
name text NOT NULL,
headline text NOT NULL,
intro_body text NOT NULL DEFAULT '',
logo_object_key text,
background_object_key text,
theme jsonb NOT NULL DEFAULT '{}'::jsonb,
allow_video boolean NOT NULL DEFAULT true,
allow_text boolean NOT NULL DEFAULT true,
allow_audio_only boolean NOT NULL DEFAULT false,
require_rating boolean NOT NULL DEFAULT false,
require_email boolean NOT NULL DEFAULT true,
require_name boolean NOT NULL DEFAULT true,
collect_role boolean NOT NULL DEFAULT true,
collect_company boolean NOT NULL DEFAULT true,
collect_avatar boolean NOT NULL DEFAULT true,
collect_company_logo boolean NOT NULL DEFAULT false,
max_video_seconds smallint,
min_video_seconds smallint NOT NULL DEFAULT 5,
max_text_chars integer NOT NULL DEFAULT 2000,
min_text_chars integer NOT NULL DEFAULT 40,
teleprompter_default_on boolean NOT NULL DEFAULT false,
consent_version_id uuid NOT NULL REFERENCES consent_versions(id) ON DELETE RESTRICT,
thank_you_headline text NOT NULL DEFAULT 'Thank you!',
thank_you_body text NOT NULL DEFAULT '',
redirect_url text,
auto_approve boolean NOT NULL DEFAULT false,
notify_emails citext[] NOT NULL DEFAULT '{}',
is_active boolean NOT NULL DEFAULT true,
locale content_language NOT NULL DEFAULT 'en',
submission_count integer NOT NULL DEFAULT 0,
view_count integer NOT NULL DEFAULT 0,
published_at timestamptz,
created_by_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT request_pages_kind_ck CHECK (allow_video OR allow_text),
CONSTRAINT request_pages_audio_ck CHECK (NOT allow_audio_only OR allow_video),
CONSTRAINT request_pages_text_len_ck CHECK (max_text_chars BETWEEN 100 AND 5000
AND min_text_chars BETWEEN 0 AND max_text_chars),
CONSTRAINT request_pages_vid_max_ck CHECK (max_video_seconds IS NULL OR max_video_seconds BETWEEN 15 AND 300),
CONSTRAINT request_pages_vid_min_ck CHECK (min_video_seconds BETWEEN 1 AND 60),
CONSTRAINT request_pages_redirect_ck CHECK (redirect_url IS NULL OR redirect_url ~ '^https://'),
CONSTRAINT request_pages_notify_ck CHECK (coalesce(array_length(notify_emails,1),0) <= 10),
CONSTRAINT request_pages_slug_fmt_ck CHECK (slug ~ '^[a-z0-9](?:[a-z0-9-]{1,46}[a-z0-9])$')
);
CREATE UNIQUE INDEX request_pages_ws_slug_uq
ON request_pages (workspace_id, slug) WHERE deleted_at IS NULL;
CREATE INDEX request_pages_ws_active_idx
ON request_pages (workspace_id, is_active, created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX request_pages_consent_idx ON request_pages (consent_version_id);Lifecycle. Soft delete keeps the row so that existing invite links resolve to a polite
closed state rather than a 404, and so historical submissions keep a resolvable parent. The page
count that quota enforcement reads is count(*) WHERE deleted_at IS NULL.
5.5.2 request_page_fields #
Customer-defined extra fields on a request page. The definitions are rows, because a customer
adds as many fields as they need; the types a field may take are the seven values of the
request_field_type enum and cannot be extended at runtime, for the reason given in
Section 5.1.5. Section 8.5.2 owns the seven types and their validation and rendering rules.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
request_page_id |
uuid |
No | — | Parent |
key |
text |
No | — | Stable machine key, ^[a-z][a-z0-9_]{1,38}$, unique per page |
label |
text |
No | — | Shown to the respondent, 1–120 chars |
help_text |
text |
Yes | null |
≤ 300 chars, rendered as the field description |
placeholder |
text |
Yes | null |
≤ 120 chars |
type |
request_field_type |
No | — | short_text, long_text, select, star_rating, url, file, boolean (Section 8.5.2) |
is_required |
boolean |
No | false |
|
position |
smallint |
No | — | 0-based order; unique per page |
options |
jsonb |
Yes | null |
Required for select; [{"value":"smb","label":"Small business"}] |
validation |
jsonb |
No | '{}' |
Type-specific rules, shape below |
default_value |
jsonb |
Yes | null |
Must satisfy validation |
is_archived |
boolean |
No | false |
Hidden from new submissions; historical values keep their label |
created_at / updated_at |
timestamptz |
No | now() |
validation shape per type, enforced by requestFieldValidationSchema:
| Type | Keys | Defaults and limits |
|---|---|---|
short_text |
minLength, maxLength, pattern |
max 200; pattern is a safe RE2-compatible subset, rejected if it can backtrack |
long_text |
minLength, maxLength |
max 2,000 |
select |
multiple, minSelected, maxSelected |
multiple false; 2–50 options |
star_rating |
scale, allowHalf |
scale 5 (3, 5, or 10 allowed); allowHalf false |
url |
schemes, maxLength |
["https"]; max 500 |
file |
accept, maxBytes, maxFiles |
accept ["image/png","image/jpeg","image/webp","application/pdf"]; maxBytes 10,485,760; maxFiles 1 |
boolean |
mustBeTrue |
false |
Constraints
UNIQUE (request_page_id, key)UNIQUE (request_page_id, position) DEFERRABLE INITIALLY DEFERRED— deferrable so a reorder can be applied as a singleUPDATE ... FROMwithout a shuffle through temporary positions.CHECK (type <> 'select' OR options IS NOT NULL)CHECK (key ~ '^[a-z][a-z0-9_]{1,38}$')CHECK (position BETWEEN 0 AND 19)— a page may declare at most 20 custom fields, the limit Section 8.5.1 owns; the twenty-first is refused withCUSTOM_FIELD_LIMIT_REACHED.
Indexes: request_page_fields_page_idx (request_page_id, position) — the render query.
Lifecycle. Hard delete via ON DELETE CASCADE from request_pages. Deleting a field that
already has stored values is blocked by the API (REQUEST_FIELD_HAS_RESPONSES, Section 29.3);
the customer archives it instead. submissions.custom_field_values stores the label and type
alongside the value, so an archived field's historical answers remain readable without the
definition.
5.5.3 request_page_prompts #
The ordered questions a respondent answers. For video these drive the teleprompter; for text they become labelled sub-prompts.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
request_page_id |
uuid |
No | — | Parent |
position |
smallint |
No | — | 0-based; unique per page, deferrable |
prompt_text |
text |
No | — | 1–280 chars |
helper_text |
text |
Yes | null |
≤ 300 chars |
applies_to |
prompt_applies_to |
No | 'both' |
video, text, both |
is_teleprompter_enabled |
boolean |
No | true |
Whether this prompt appears in the scroller |
suggested_seconds |
smallint |
Yes | null |
Guidance shown to the respondent, 5–120 |
created_at / updated_at |
timestamptz |
No | now() |
Constraints: UNIQUE (request_page_id, position) DEFERRABLE INITIALLY DEFERRED;
CHECK (char_length(prompt_text) BETWEEN 1 AND 280);
CHECK (position BETWEEN 0 AND 9) — at most 10 prompts, because a respondent will not answer
more and the teleprompter becomes unusable; the limit is a product decision, enforced in the
schema.
Indexes: request_page_prompts_page_idx (request_page_id, position).
5.5.4 contacts #
People a workspace invites. Deduplicated per workspace so repeated imports do not create duplicates and so unsubscribe state is honoured across batches.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
email |
citext |
Yes | null |
At least one of email/phone required |
phone_e164 |
text |
Yes | null |
^\+[1-9]\d{7,14}$ |
name |
text |
No | '' |
|
role |
text |
Yes | null |
≤ 120 chars |
company |
text |
Yes | null |
≤ 160 chars |
locale |
content_language |
Yes | null |
Preferred invite language |
external_ref |
text |
Yes | null |
Customer's own CRM id, unique per workspace when present |
source |
text |
No | 'manual' |
manual, csv, api, hubspot |
invite_count |
smallint |
No | 0 |
|
last_invited_at |
timestamptz |
Yes | null |
Drives the 30-day re-invite guard |
submitted_at |
timestamptz |
Yes | null |
Set on their first submission |
unsubscribed_at |
timestamptz |
Yes | null |
Honoured forever; blocks all future invites |
bounced_at |
timestamptz |
Yes | null |
Hard bounce suppresses future email invites |
bounce_type |
text |
Yes | null |
hard, soft, complaint |
created_by_user_id |
uuid |
Yes | null |
|
created_at / updated_at |
timestamptz |
No | now() |
|
deleted_at |
timestamptz |
Yes | null |
Soft delete |
Constraints
CHECK (email IS NOT NULL OR phone_e164 IS NOT NULL)UNIQUE (workspace_id, email) WHERE email IS NOT NULL AND deleted_at IS NULLUNIQUE (workspace_id, phone_e164) WHERE phone_e164 IS NOT NULL AND deleted_at IS NULLUNIQUE (workspace_id, external_ref) WHERE external_ref IS NOT NULL AND deleted_at IS NULLCHECK (phone_e164 IS NULL OR phone_e164 ~ '^\+[1-9]\d{7,14}$')CHECK (bounce_type IS NULL OR bounce_type IN ('hard','soft','complaint'))CHECK (source IN ('manual','csv','api','hubspot'))
Indexes: the three partial uniques above; contacts_ws_created_idx (workspace_id, created_at DESC) WHERE deleted_at IS NULL;
contacts_ws_suppressed_idx (workspace_id) WHERE unsubscribed_at IS NOT NULL OR bounced_at IS NOT NULL
— the suppression check performed before every send.
Lifecycle. A contact is never hard-deleted by unsubscribing: suppression requires keeping
the address. GDPR erasure of a contact is a hard delete that also removes their invites rows
but leaves aggregate counters intact.
5.5.5 invite_batches #
A named send. Exists so that a partially failed send is diagnosable and resumable.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
request_page_id |
uuid |
No | — | Destination page |
name |
text |
No | — | 1–120 chars |
channel |
invite_channel |
No | 'email' |
|
subject_template |
text |
Yes | null |
Email only; ≤ 160 chars, supports {{name}}, {{company}}, {{workspaceName}} |
body_template |
text |
No | — | Same placeholder set; ≤ 2,000 chars |
reminder_schedule |
smallint[] |
No | '{3,7}' |
Days after send; max 2 entries, each 1–30. One invite and at most two reminders is the respondent-protection principle in Section 20.1, and the array length is where it is enforced |
scheduled_for |
timestamptz |
Yes | null |
Null = send immediately |
status |
import_run_status |
No | 'queued' |
Reuses the run-status enum: queued, running, succeeded, partial, failed, canceled |
total_count |
integer |
No | 0 |
|
sent_count |
integer |
No | 0 |
|
failed_count |
integer |
No | 0 |
|
suppressed_count |
integer |
No | 0 |
Skipped for unsubscribe/bounce/re-invite guard |
started_at / finished_at |
timestamptz |
Yes | null |
|
created_by_user_id |
uuid |
Yes | null |
|
created_at / updated_at |
timestamptz |
No | now() |
Constraints: CHECK (coalesce(array_length(reminder_schedule,1),0) <= 2);
CHECK (channel <> 'email' OR subject_template IS NOT NULL);
CHECK (total_count >= sent_count + failed_count + suppressed_count - total_count * 0) expressed
concretely as CHECK (sent_count + failed_count + suppressed_count <= total_count).
Indexes: invite_batches_ws_idx (workspace_id, created_at DESC);
invite_batches_due_idx (scheduled_for) WHERE status = 'queued'.
5.5.6 invites #
One row per person invited, per request. Carries delivery, open, click, start, and submit tracking — the funnel Section 8 reports on.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
invite_batch_id |
uuid |
Yes | null |
Null for one-off sends |
contact_id |
uuid |
No | — | Recipient |
request_page_id |
uuid |
No | — | Destination |
channel |
invite_channel |
No | — | email or sms |
token_hash |
char(64) |
No | — | SHA-256 of a 32-byte token; the link is /r/{slug}?i={token} |
status |
invite_status |
No | 'queued' |
Monotonic within the funnel, see below |
subject_snapshot |
text |
Yes | null |
Rendered subject actually sent |
body_snapshot |
text |
No | — | Rendered body actually sent, kept for disputes |
email_delivery_id |
uuid |
Yes | null |
|
sms_delivery_id |
uuid |
Yes | null |
|
sent_at |
timestamptz |
Yes | null |
|
delivered_at |
timestamptz |
Yes | null |
|
opened_at |
timestamptz |
Yes | null |
First open only |
clicked_at |
timestamptz |
Yes | null |
First click only |
started_at |
timestamptz |
Yes | null |
Respondent began a submission |
submitted_at |
timestamptz |
Yes | null |
|
submission_id |
uuid |
Yes | null |
Set on submit |
bounced_at |
timestamptz |
Yes | null |
|
failure_code |
text |
Yes | null |
Provider error code |
failure_message |
text |
Yes | null |
≤ 500 chars |
reminder_count |
smallint |
No | 0 |
Max 2 (Section 20.1) |
last_reminder_at |
timestamptz |
Yes | null |
|
next_reminder_at |
timestamptz |
Yes | null |
Null once submitted or exhausted |
expires_at |
timestamptz |
No | now() + interval '60 days' |
Link stops working; page shows "this link has expired" with a request-a-new-link action |
complained_at |
timestamptz |
Yes | null |
Spam complaint received; also writes a suppressions row |
revoked_at |
timestamptz |
Yes | null |
An already-sent invite was deliberately invalidated |
canceled_at |
timestamptz |
Yes | null |
The send was called off before it left |
created_at / updated_at |
timestamptz |
No | now() |
Status is monotonic. The application only ever moves status forward along
queued → sending → sent → delivered → opened → clicked → started → submitted, with bounced,
complained, failed, revoked, canceled, and expired as terminal branches. A late-arriving
provider webhook for an earlier stage updates the timestamp column but never regresses status —
providers routinely deliver a delivered callback after an opened one, and a funnel that could
walk backwards would report fewer opens than it had already counted. That rule lives in
packages/core/invite/transition.ts and is covered by a unit test per pair.
canceled is reachable only from queued and sending; revoked only from sent and beyond.
That is the whole difference between them, and it is enforced rather than documented: the
transition function rejects canceled on a row with a non-null sent_at.
Constraints
UNIQUE (token_hash)CHECK (reminder_count <= 2)CHECK (channel <> 'sms' OR sms_delivery_id IS NOT NULL OR status IN ('queued','sending','canceled','failed'))CHECK (status <> 'submitted' OR submission_id IS NOT NULL)CHECK (opened_at IS NULL OR sent_at IS NOT NULL)CHECK (status <> 'canceled' OR (canceled_at IS NOT NULL AND sent_at IS NULL))— a cancelled invite never left, so it cannot carry a send timestamp.CHECK (status <> 'revoked' OR (revoked_at IS NOT NULL AND sent_at IS NOT NULL))— and a revoked one always did.CHECK (status <> 'complained' OR complained_at IS NOT NULL)
Indexes
invites_token_uq (token_hash)— link resolution.invites_ws_page_status_idx (workspace_id, request_page_id, status)— per-page funnel.invites_batch_idx (invite_batch_id, status)— batch progress.invites_contact_idx (contact_id, created_at DESC)— re-invite guard.invites_reminder_due_idx (next_reminder_at) WHERE next_reminder_at IS NOT NULL AND submitted_at IS NULL AND revoked_at IS NULL— the reminder scheduler's only query.
CREATE TABLE invites (
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
invite_batch_id uuid REFERENCES invite_batches(id) ON DELETE SET NULL,
contact_id uuid NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
request_page_id uuid NOT NULL REFERENCES request_pages(id) ON DELETE CASCADE,
channel invite_channel NOT NULL,
token_hash char(64) NOT NULL,
status invite_status NOT NULL DEFAULT 'queued',
subject_snapshot text,
body_snapshot text NOT NULL,
email_delivery_id uuid,
sms_delivery_id uuid,
sent_at timestamptz,
delivered_at timestamptz,
opened_at timestamptz,
clicked_at timestamptz,
started_at timestamptz,
submitted_at timestamptz,
submission_id uuid,
bounced_at timestamptz,
complained_at timestamptz,
canceled_at timestamptz,
failure_code text,
failure_message text,
reminder_count smallint NOT NULL DEFAULT 0,
last_reminder_at timestamptz,
next_reminder_at timestamptz,
expires_at timestamptz NOT NULL DEFAULT (now() + interval '60 days'),
revoked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT invites_token_uq UNIQUE (token_hash),
CONSTRAINT invites_reminder_ck CHECK (reminder_count <= 2),
CONSTRAINT invites_submitted_ck CHECK (status <> 'submitted' OR submission_id IS NOT NULL),
CONSTRAINT invites_opened_ck CHECK (opened_at IS NULL OR sent_at IS NOT NULL),
CONSTRAINT invites_sms_ck CHECK (channel <> 'sms' OR sms_delivery_id IS NOT NULL
OR status IN ('queued','sending','canceled','failed')),
CONSTRAINT invites_canceled_ck CHECK (status <> 'canceled'
OR (canceled_at IS NOT NULL AND sent_at IS NULL)),
CONSTRAINT invites_revoked_ck CHECK (status <> 'revoked'
OR (revoked_at IS NOT NULL AND sent_at IS NOT NULL)),
CONSTRAINT invites_complained_ck CHECK (status <> 'complained' OR complained_at IS NOT NULL)
);
CREATE INDEX invites_ws_page_status_idx ON invites (workspace_id, request_page_id, status);
CREATE INDEX invites_batch_idx ON invites (invite_batch_id, status);
CREATE INDEX invites_contact_idx ON invites (contact_id, created_at DESC);
CREATE INDEX invites_reminder_due_idx ON invites (next_reminder_at)
WHERE next_reminder_at IS NOT NULL AND submitted_at IS NULL AND revoked_at IS NULL;5.5.7 submissions #
The raw act of completing a request page: one row per respondent completion, created the moment
the respondent starts and finalized on submit. Kept distinct from testimonials because a
submission is evidence of what happened, while a testimonial is the curated artefact — editing a
testimonial must never rewrite history.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
request_page_id |
uuid |
No | — | |
invite_id |
uuid |
Yes | null |
Null for organic (link-shared) submissions |
contact_id |
uuid |
Yes | null |
Resolved or created on submit when an email is given |
state |
submission_state |
No | 'in_progress' |
in_progress, submitted, abandoned, rejected_spam |
kind |
testimonial_kind |
No | — | What the respondent actually produced |
respondent_name |
text |
Yes | null |
≤ 120 chars |
respondent_email |
citext |
Yes | null |
|
respondent_role |
text |
Yes | null |
≤ 120 chars |
respondent_company |
text |
Yes | null |
≤ 160 chars |
respondent_company_url |
text |
Yes | null |
HTTPS only |
respondent_avatar_object_key |
text |
Yes | null |
|
respondent_company_logo_object_key |
text |
Yes | null |
|
body_text |
text |
Yes | null |
Text submissions; raw as typed |
rating |
smallint |
Yes | null |
1–5 |
custom_field_values |
jsonb |
No | '{"version":1,"values":{}}' |
The respondent's answers to the page's custom fields, in the versioned-object shape owned by Section 8.5.3 (below). Empty — not null — when the page declared no custom fields |
locale |
content_language |
No | 'en' |
Page locale at submit time |
upload_session_id |
uuid |
Yes | null |
Video/audio submissions |
testimonial_id |
uuid |
Yes | null |
Set when the testimonial row is created |
consent_group_id |
uuid |
No | — | Groups this submission's consents rows; equal to id in practice but declared so consents can be re-grouped after a withdrawal |
ip_truncated |
inet |
Yes | null |
/24 or /48 |
user_agent |
text |
Yes | null |
≤ 512 chars |
referrer_host |
text |
Yes | null |
Host only, never a full URL with query |
device_class |
device_class |
No | 'unknown' |
|
spam_score |
numeric(4,3) |
Yes | null |
0–1 from the heuristics in Section 8.9 |
challenge_provider |
text |
Yes | null |
turnstile or none |
challenge_passed_at |
timestamptz |
Yes | null |
|
started_at |
timestamptz |
No | now() |
|
submitted_at |
timestamptz |
Yes | null |
|
duration_ms |
integer |
Yes | null |
Start → submit; used in bot detection |
created_at / updated_at |
timestamptz |
No | now() |
Constraints
CHECK (state <> 'submitted' OR submitted_at IS NOT NULL)CHECK (kind <> 'text' OR body_text IS NOT NULL OR state <> 'submitted')CHECK (kind <> 'video' OR upload_session_id IS NOT NULL OR state <> 'submitted')CHECK (rating IS NULL OR rating BETWEEN 1 AND 5)CHECK (spam_score IS NULL OR spam_score BETWEEN 0 AND 1)CHECK (jsonb_typeof(custom_field_values) = 'object')CHECK ((custom_field_values ->> 'version')::int = 1)— the answer-document version is asserted by the database, exactly astranscripts.docasserts its own, so a write in an unrecognized shape cannot land.CHECK (jsonb_typeof(custom_field_values -> 'values') = 'object')UNIQUE (testimonial_id)— a submission yields at most one testimonial.UNIQUE (invite_id) WHERE invite_id IS NOT NULL AND state = 'submitted'— an invite converts once.
The shape of custom_field_values. Section 8.5.3 owns the answer format; this is its
storage. It is a versioned object, not an array:
{
"version": 1,
"values": {
"industry": { "label": "Industry", "type": "select", "value": "smb" },
"team_size": { "label": "Team size", "type": "short_text", "value": "50-200" },
"nps": { "label": "How likely are you to recommend us?",
"type": "star_rating", "value": 5 }
}
}Three things follow from that shape, and each is the reason it was chosen over an array of
{key, …} records:
- Keyed by
request_page_fields.key, so reading one answer isvalues -> 'industry'rather than a scan for the element whosekeymatches. The dashboard renders answers by field, and an array made every render O(n) in the number of fields. labelandtypeare snapshotted next to the value, so an answer stays readable after the field is renamed, retyped, or archived. That is why deleting a field with stored responses is refused (REQUEST_FIELD_HAS_RESPONSES, Section 5.5.2) but archiving it is always allowed: the historical answers carry everything needed to display themselves.versionis a real column of the document, so the shape can change once without a migration over every historical row — a reader switches on it. There is exactly one version today and theCHECKabove pins it.
An empty answer set is not an error. The default is {"version":1,"values":{}} and never
null, so every row has a well-formed document and no reader needs a null branch. A submission
against a page that declares no custom fields stores the empty document. A testimonial whose
submission_id IS NULL — every manual and every imported row — has no answer set at all
and that is correct rather than missing: custom fields are a property of the collection form, so
a testimonial typed by a team member or pulled from a review site was never asked them. Serializers
return an empty object for those rows; nothing raises, and nothing reports a data-quality problem.
The column, its default, and its guards, stated as DDL because the default literal is the part a reader will otherwise get wrong:
ALTER TABLE submissions
ADD COLUMN custom_field_values jsonb NOT NULL
DEFAULT '{"version":1,"values":{}}'::jsonb;
ALTER TABLE submissions
ADD CONSTRAINT submissions_cfv_object_ck CHECK (jsonb_typeof(custom_field_values) = 'object'),
ADD CONSTRAINT submissions_cfv_version_ck CHECK ((custom_field_values ->> 'version')::int = 1),
ADD CONSTRAINT submissions_cfv_values_ck CHECK (jsonb_typeof(custom_field_values -> 'values') = 'object');Indexes: submissions_ws_created_idx (workspace_id, created_at DESC);
submissions_page_state_idx (workspace_id, request_page_id, state);
submissions_abandoned_idx (started_at) WHERE state = 'in_progress' — the abandonment sweeper
(marks abandoned after 6 hours);
submissions_email_idx (workspace_id, respondent_email) WHERE respondent_email IS NOT NULL —
data-subject lookup.
Lifecycle. in_progress rows older than 6 hours become abandoned and their
upload_sessions are aborted. Abandoned rows are hard-deleted after 30 days along with any
partial media. Submitted rows are retained as long as the testimonial they produced; erasure
deletes both.
5.5.8 upload_sessions #
Multipart upload state for the chunked resumable recorder (Section 9.6). This table is what makes "a dropped connection resumes from the last acknowledged part" true.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
request_page_id |
uuid |
No | — | For quota and plan-cap resolution without a join |
submission_id |
uuid |
Yes | null |
Attached once the submission row exists |
provider |
text |
No | 's3' |
S3-compatible object storage |
bucket |
text |
No | — | Resolved from configuration at creation, stored so a bucket change cannot orphan a session |
object_key |
text |
No | — | Destination key per Section 5.1.11 |
provider_upload_id |
text |
No | — | The multipart upload id |
parts |
jsonb |
No | '[]' |
[{"n":1,"etag":"\"ab…\"","bytes":1048576,"at":"2026-08-19T10:00:01Z"}] |
part_count |
smallint |
No | 0 |
Denormalized jsonb_array_length(parts) |
bytes_received |
bigint |
No | 0 |
Sum of acknowledged part sizes |
expected_bytes |
bigint |
Yes | null |
Client estimate; advisory only |
mime_type |
text |
No | — | The negotiated recorder mime type, e.g. video/mp4;codecs=avc1,mp4a |
recorder_codec |
text |
Yes | null |
Short label: avc1, vp9, vp8, unknown |
duration_seconds |
numeric(9,3) |
Yes | null |
Reported by the client at stop |
state |
upload_session_state |
No | 'active' |
active, completing, completed, aborted, expired, failed |
take_number |
smallint |
No | 1 |
Increments on retake; discarded takes are aborted immediately |
expires_at |
timestamptz |
No | now() + interval '24 hours' |
Sweeper aborts anything past this |
last_part_at |
timestamptz |
Yes | null |
Staleness detection |
completed_at |
timestamptz |
Yes | null |
|
aborted_at |
timestamptz |
Yes | null |
|
error_code |
text |
Yes | null |
|
error_message |
text |
Yes | null |
≤ 500 chars |
created_at / updated_at |
timestamptz |
No | now() |
Constraints
UNIQUE (provider_upload_id)CHECK (part_count = jsonb_array_length(parts))— the denormalized count cannot drift.CHECK (bytes_received >= 0)CHECK (state <> 'completed' OR completed_at IS NOT NULL)CHECK (take_number BETWEEN 1 AND 50)— retakes are free and unlimited in spirit; 50 is a runaway guard, and hitting it returnsUPLOAD_TAKE_LIMIT_REACHED.
Indexes: upload_sessions_upload_uq (provider_upload_id);
upload_sessions_sweep_idx (expires_at) WHERE state IN ('active','completing');
upload_sessions_submission_idx (submission_id).
CREATE TABLE upload_sessions (
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
request_page_id uuid NOT NULL REFERENCES request_pages(id) ON DELETE CASCADE,
submission_id uuid REFERENCES submissions(id) ON DELETE SET NULL,
provider text NOT NULL DEFAULT 's3',
bucket text NOT NULL,
object_key text NOT NULL,
provider_upload_id text NOT NULL,
parts jsonb NOT NULL DEFAULT '[]'::jsonb,
part_count smallint NOT NULL DEFAULT 0,
bytes_received bigint NOT NULL DEFAULT 0,
expected_bytes bigint,
mime_type text NOT NULL,
recorder_codec text,
duration_seconds numeric(9,3),
state upload_session_state NOT NULL DEFAULT 'active',
take_number smallint NOT NULL DEFAULT 1,
expires_at timestamptz NOT NULL DEFAULT (now() + interval '24 hours'),
last_part_at timestamptz,
completed_at timestamptz,
aborted_at timestamptz,
error_code text,
error_message text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT upload_sessions_upload_uq UNIQUE (provider_upload_id),
CONSTRAINT upload_sessions_parts_ck CHECK (part_count = jsonb_array_length(parts)),
CONSTRAINT upload_sessions_bytes_ck CHECK (bytes_received >= 0),
CONSTRAINT upload_sessions_done_ck CHECK (state <> 'completed' OR completed_at IS NOT NULL),
CONSTRAINT upload_sessions_take_ck CHECK (take_number BETWEEN 1 AND 50)
);
CREATE INDEX upload_sessions_sweep_idx ON upload_sessions (expires_at)
WHERE state IN ('active','completing');
CREATE INDEX upload_sessions_submission_idx ON upload_sessions (submission_id);Lifecycle. Hard-deleted 7 days after reaching a terminal state. The retention.sweep_uploads
job runs hourly, calls AbortMultipartUpload for anything active/completing past
expires_at, sets state = 'expired', and deletes the row a week later so the operator can
still see what expired. The only two states an incomplete session can be in are active and
completing; there is no initiated or uploading state anywhere in the system.
5.5.9 submission_drafts #
A respondent's saved-but-not-submitted answers. Section 8.9 owns the resume experience; this is its storage. A draft exists so that closing the tab, losing signal on a train, or coming back tomorrow does not cost the respondent their typing — and so that "resume where you left off" survives a device change, because the draft is keyed by a token in the link rather than by a cookie.
A draft is deliberately not a submissions row in in_progress state. A submission is
evidence that a respondent engaged with a page and is counted in the funnel; a draft is
scratch. Promoting a draft creates the submission, and the draft row is deleted in the same
transaction. Nothing counts a draft as a submission, and no draft ever appears in the inbox.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
request_page_id |
uuid |
No | — | The page being answered |
invite_id |
uuid |
Yes | null |
Present when the respondent arrived from an invite link |
token_hash |
char(64) |
No | — | SHA-256 of the 32-byte draft token; the plaintext lives only in the resume link and in the respondent's browser |
token_prefix |
char(8) |
No | — | Lets support identify a draft without seeing the token |
answers |
jsonb |
No | '{}' |
The partially completed form: {"bodyText":"…","rating":4,"custom":{"industry":"SaaS"}}. Validated against the page's field definitions on every save, so a draft can never hold a shape the submit endpoint would reject |
revision |
integer |
No | 1 |
Incremented on every save; a save carrying a stale revision is refused with DRAFT_REVISION_CONFLICT so two open tabs cannot silently overwrite each other |
upload_session_id |
uuid |
Yes | null |
A video draft's in-flight upload, so a resumed draft resumes the upload too |
respondent_email |
citext |
Yes | null |
Captured early when the page requires it; used only to email the resume link |
resume_link_sent_at |
timestamptz |
Yes | null |
At most one resume email per draft, ever |
last_saved_at |
timestamptz |
No | now() |
Drives the "saved a moment ago" indicator |
save_count |
integer |
No | 0 |
Autosave fires on a 5-second debounce; this bounds abuse |
locale |
content_language |
No | 'en' |
Page locale at first save |
expires_at |
timestamptz |
No | now() + interval '30 days' |
After this the link shows "this draft has expired" and offers a fresh start |
promoted_submission_id |
uuid |
Yes | null |
Set in the promoting transaction, immediately before the row is deleted; present only inside that transaction, and stored so the delete is idempotent on retry |
created_at / updated_at |
timestamptz |
No | now() |
Constraints
UNIQUE (token_hash)UNIQUE (invite_id) WHERE invite_id IS NOT NULL— one open draft per invite, so a respondent who clicks the link twice returns to the same draft rather than starting a second one.CHECK (jsonb_typeof(answers) = 'object')CHECK (revision >= 1)CHECK (save_count >= 0 AND save_count <= 2000)— a runaway autosave loop is refused rather than allowed to write forever.CHECK (expires_at > created_at)
Indexes
submission_drafts_token_uqon(token_hash)— resume, a single probe.submission_drafts_invite_uq(partial unique above).submission_drafts_expiry_idxon(expires_at)— the sweeper's only query.submission_drafts_page_idxon(workspace_id, request_page_id, last_saved_at DESC)— the "drafts in progress" count shown on the request-page screen.
CREATE TABLE submission_drafts (
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
request_page_id uuid NOT NULL REFERENCES request_pages(id) ON DELETE CASCADE,
invite_id uuid REFERENCES invites(id) ON DELETE SET NULL,
token_hash char(64) NOT NULL,
token_prefix char(8) NOT NULL,
answers jsonb NOT NULL DEFAULT '{}'::jsonb,
revision integer NOT NULL DEFAULT 1,
upload_session_id uuid REFERENCES upload_sessions(id) ON DELETE SET NULL,
respondent_email citext,
resume_link_sent_at timestamptz,
last_saved_at timestamptz NOT NULL DEFAULT now(),
save_count integer NOT NULL DEFAULT 0,
locale content_language NOT NULL DEFAULT 'en',
expires_at timestamptz NOT NULL DEFAULT (now() + interval '30 days'),
promoted_submission_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT submission_drafts_token_uq UNIQUE (token_hash),
CONSTRAINT submission_drafts_answers_ck CHECK (jsonb_typeof(answers) = 'object'),
CONSTRAINT submission_drafts_rev_ck CHECK (revision >= 1),
CONSTRAINT submission_drafts_saves_ck CHECK (save_count BETWEEN 0 AND 2000),
CONSTRAINT submission_drafts_expiry_ck CHECK (expires_at > created_at)
);
CREATE UNIQUE INDEX submission_drafts_invite_uq
ON submission_drafts (invite_id) WHERE invite_id IS NOT NULL;
CREATE INDEX submission_drafts_expiry_idx ON submission_drafts (expires_at);
CREATE INDEX submission_drafts_page_idx
ON submission_drafts (workspace_id, request_page_id, last_saved_at DESC);Lifecycle and deletion. Created on the first autosave, updated on every subsequent one, and
hard-deleted in one of three ways: in the promoting transaction when the respondent submits
(drafts.purge_submitted is the safety net that removes any row whose
promoted_submission_id is set and whose delete did not land), at expires_at by
drafts.sweep, or immediately when the respondent clicks "start over". A draft is never soft
deleted — there is nothing in it worth an audit trail, and the privacy answer to "how long do
you keep what I typed but never sent?" should be a short number, not "forever with a flag".
Erasure of a respondent removes any draft carrying their respondent_email as well.
5.6 Content tables #
5.6.1 testimonials #
The canonical unit of the product. Everything a widget renders, the showcase page lists, the public API returns, and the moderation inbox reviews is a row here. Section 11 owns the state machine; this section owns the storage and the invariants.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
status |
testimonial_status |
No | 'pending' |
pending, approved, published, unpublished, rejected, archived |
pre_archive_status |
testimonial_status |
Yes | null |
The status held immediately before archiving. Non-null exactly while status = 'archived'; unarchiving restores this value and clears the column |
pre_delete_status |
testimonial_status |
Yes | null |
The status held immediately before soft deletion. Non-null exactly while deleted_at IS NOT NULL; restoring returns the row to this status |
version |
integer |
No | 1 |
Optimistic-concurrency token. Incremented by trigger on every content-column update; a write carrying a stale expectedVersion is refused with 409 TESTIMONIAL_VERSION_CONFLICT (Section 11.2.4) |
source |
testimonial_source |
No | — | collected (a submission), imported (a review), manual (typed by a team member) |
kind |
testimonial_kind |
No | — | video or text; audio-only submissions are video with media_assets.is_audio_only |
request_page_id |
uuid |
Yes | null |
Present when source = 'collected' |
submission_id |
uuid |
Yes | null |
Present when source = 'collected' |
imported_review_id |
uuid |
Yes | null |
Present when source = 'imported' |
media_asset_id |
uuid |
Yes | null |
Present when kind = 'video' |
transcript_id |
uuid |
Yes | null |
Set when transcription succeeds |
body_text |
text |
Yes | null |
Plain text: the typed testimonial, the imported review body, or the transcript's flattened text for video |
body_html_sanitized |
text |
Yes | null |
Sanitized rich text, allowlist p, br, strong, em, a[href] |
body_sanitizer_version |
smallint |
No | 1 |
Bumped when the sanitizer allowlist changes; drives a re-sanitize sweep |
word_count |
integer |
No | 0 |
Computed on write; used for layout hints and search ranking |
rating |
smallint |
Yes | null |
1–5 canonical |
headline |
text |
Yes | null |
≤ 160 chars; platform-authored or human-written, never presented as the respondent's words |
headline_source |
alt_text_source |
Yes | null |
ai_drafted or human; null when there is no headline |
respondent_name |
text |
Yes | null |
≤ 120 chars; may be a display name from an imported source |
respondent_role |
text |
Yes | null |
≤ 120 chars |
respondent_company |
text |
Yes | null |
≤ 160 chars |
respondent_company_url |
text |
Yes | null |
HTTPS only |
respondent_avatar_object_key |
text |
Yes | null |
|
respondent_company_logo_object_key |
text |
Yes | null |
|
respondent_email |
citext |
Yes | null |
Private. Never serialized to any public surface; masked for Viewers |
language |
content_language |
No | 'en' |
Drives the text-search configuration |
is_featured |
boolean |
No | false |
Surfaces first in default sorts |
sort_weight |
integer |
No | 0 |
Manual ordering; higher first, ties broken by created_at DESC, id DESC |
case_study_fields |
jsonb |
No | '{}' |
{"industry":"SaaS","teamSize":"50-200","metrics":[{"label":"Time saved","value":"12h/week"}]} |
internal_note |
text |
Yes | null |
Team-only, ≤ 2,000 chars, never published |
source_platform |
import_source |
Yes | null |
Present when source = 'imported' |
source_review_id |
text |
Yes | null |
The platform's own id |
source_permalink |
text |
Yes | null |
Canonical link back to the original review |
source_author_handle |
text |
Yes | null |
e.g. an X handle |
source_rating_raw |
text |
Yes | null |
Original value in the source's own scale |
source_reviewed_at |
timestamptz |
Yes | null |
When the review was written on the source platform |
collected_at |
timestamptz |
No | now() |
Submission time, import time, or manual creation time — the date shown publicly |
approved_at |
timestamptz |
Yes | null |
|
approved_by_user_id |
uuid |
Yes | null |
|
published_at |
timestamptz |
Yes | null |
First publish; retained through unpublish |
last_published_at |
timestamptz |
Yes | null |
Most recent publish |
unpublished_at |
timestamptz |
Yes | null |
|
rejected_at |
timestamptz |
Yes | null |
|
rejected_by_user_id |
uuid |
Yes | null |
|
rejection_reason |
text |
Yes | null |
≤ 500 chars; shown only internally |
archived_at |
timestamptz |
Yes | null |
|
withdrawn_at |
timestamptz |
Yes | null |
Set by a respondent withdrawal; forces unpublished |
search_tsv |
tsvector |
No | generated | Generated column, Section 5.14 |
created_by_user_id |
uuid |
Yes | null |
Non-null only for manual |
created_at / updated_at |
timestamptz |
No | now() |
|
deleted_at |
timestamptz |
Yes | null |
Soft delete; enqueues media.purge |
deleted_by_user_id |
uuid |
Yes | null |
Constraints
CHECK (rating IS NULL OR rating BETWEEN 1 AND 5)CHECK (source <> 'collected' OR (request_page_id IS NOT NULL AND submission_id IS NOT NULL))CHECK (source <> 'imported' OR (imported_review_id IS NOT NULL AND source_platform IS NOT NULL AND source_permalink IS NOT NULL))CHECK (source <> 'manual' OR created_by_user_id IS NOT NULL)CHECK (kind <> 'video' OR media_asset_id IS NOT NULL)CHECK (body_text IS NOT NULL OR kind = 'video')— a text testimonial always has text; a video one may exist before its transcript arrives.CHECK (status <> 'published' OR published_at IS NOT NULL)CHECK (status <> 'approved' OR approved_at IS NOT NULL)CHECK (status <> 'rejected' OR rejected_at IS NOT NULL)CHECK (withdrawn_at IS NULL OR status <> 'published')— a withdrawn testimonial cannot be live.CHECK (char_length(coalesce(body_text,'')) <= 20000)CHECK (status <> 'archived' OR pre_archive_status IS NOT NULL)— a row cannot enterarchivedwithout recording what it was, so unarchive is always possible.CHECK (deleted_at IS NULL OR pre_delete_status IS NOT NULL)— likewise for restore.CHECK (version >= 1)UNIQUE (submission_id)andUNIQUE (imported_review_id)— one testimonial per source record.
Indexes (each stated with the query it serves; all workspace-scoped indexes lead with
workspace_id)
| Index | Definition | Serves |
|---|---|---|
testimonials_pk |
(id) |
point reads |
testimonials_ws_status_created_idx |
(workspace_id, status, created_at DESC, id DESC) WHERE deleted_at IS NULL |
moderation inbox, the single hottest dashboard query |
testimonials_ws_published_idx |
(workspace_id, published_at DESC, id DESC) WHERE status = 'published' AND deleted_at IS NULL |
widget payload build and showcase listing |
testimonials_ws_featured_idx |
(workspace_id, is_featured, sort_weight DESC, created_at DESC) WHERE status = 'published' AND deleted_at IS NULL |
curated/manual widget ordering |
testimonials_ws_kind_rating_idx |
(workspace_id, kind, rating) WHERE status = 'published' AND deleted_at IS NULL |
widget filters by media type and minimum rating |
testimonials_search_gin |
GIN (search_tsv) |
full-text search |
testimonials_ws_source_idx |
(workspace_id, source, source_platform) |
import reporting |
testimonials_media_idx |
(media_asset_id) |
webhook → testimonial resolution |
testimonials_transcript_idx |
(transcript_id) |
transcript editor |
testimonials_email_idx |
(workspace_id, respondent_email) WHERE respondent_email IS NOT NULL |
data-subject lookup |
testimonials_purge_idx |
(deleted_at) WHERE deleted_at IS NOT NULL |
purge sweeper |
CREATE TABLE testimonials (
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
status testimonial_status NOT NULL DEFAULT 'pending',
pre_archive_status testimonial_status,
pre_delete_status testimonial_status,
version integer NOT NULL DEFAULT 1,
source testimonial_source NOT NULL,
kind testimonial_kind NOT NULL,
request_page_id uuid REFERENCES request_pages(id) ON DELETE SET NULL,
submission_id uuid REFERENCES submissions(id) ON DELETE SET NULL,
imported_review_id uuid,
media_asset_id uuid,
transcript_id uuid,
body_text text,
body_html_sanitized text,
body_sanitizer_version smallint NOT NULL DEFAULT 1,
word_count integer NOT NULL DEFAULT 0,
rating smallint,
headline text,
headline_source alt_text_source,
respondent_name text,
respondent_role text,
respondent_company text,
respondent_company_url text,
respondent_avatar_object_key text,
respondent_company_logo_object_key text,
respondent_email citext,
language content_language NOT NULL DEFAULT 'en',
is_featured boolean NOT NULL DEFAULT false,
sort_weight integer NOT NULL DEFAULT 0,
case_study_fields jsonb NOT NULL DEFAULT '{}'::jsonb,
internal_note text,
source_platform import_source,
source_review_id text,
source_permalink text,
source_author_handle text,
source_rating_raw text,
source_reviewed_at timestamptz,
collected_at timestamptz NOT NULL DEFAULT now(),
approved_at timestamptz,
approved_by_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
published_at timestamptz,
last_published_at timestamptz,
unpublished_at timestamptz,
rejected_at timestamptz,
rejected_by_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
rejection_reason text,
archived_at timestamptz,
withdrawn_at timestamptz,
search_tsv tsvector NOT NULL
GENERATED ALWAYS AS (vouch_tsv(language::text, body_text, respondent_name, respondent_company)) STORED,
created_by_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
deleted_by_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT testimonials_rating_ck CHECK (rating IS NULL OR rating BETWEEN 1 AND 5),
CONSTRAINT testimonials_collected_ck CHECK (source <> 'collected'
OR (request_page_id IS NOT NULL AND submission_id IS NOT NULL)),
CONSTRAINT testimonials_imported_ck CHECK (source <> 'imported'
OR (imported_review_id IS NOT NULL
AND source_platform IS NOT NULL
AND source_permalink IS NOT NULL)),
CONSTRAINT testimonials_manual_ck CHECK (source <> 'manual' OR created_by_user_id IS NOT NULL),
CONSTRAINT testimonials_video_ck CHECK (kind <> 'video' OR media_asset_id IS NOT NULL),
CONSTRAINT testimonials_body_ck CHECK (body_text IS NOT NULL OR kind = 'video'),
CONSTRAINT testimonials_body_len_ck CHECK (char_length(coalesce(body_text,'')) <= 20000),
CONSTRAINT testimonials_published_ck CHECK (status <> 'published' OR published_at IS NOT NULL),
CONSTRAINT testimonials_approved_ck CHECK (status <> 'approved' OR approved_at IS NOT NULL),
CONSTRAINT testimonials_rejected_ck CHECK (status <> 'rejected' OR rejected_at IS NOT NULL),
CONSTRAINT testimonials_withdrawn_ck CHECK (withdrawn_at IS NULL OR status <> 'published'),
CONSTRAINT testimonials_archived_ck CHECK (status <> 'archived' OR pre_archive_status IS NOT NULL),
CONSTRAINT testimonials_predelete_ck CHECK (deleted_at IS NULL OR pre_delete_status IS NOT NULL),
CONSTRAINT testimonials_version_ck CHECK (version >= 1),
CONSTRAINT testimonials_submission_uq UNIQUE (submission_id),
CONSTRAINT testimonials_imported_uq UNIQUE (imported_review_id)
);
CREATE INDEX testimonials_ws_status_created_idx
ON testimonials (workspace_id, status, created_at DESC, id DESC) WHERE deleted_at IS NULL;
CREATE INDEX testimonials_ws_published_idx
ON testimonials (workspace_id, published_at DESC, id DESC)
WHERE status = 'published' AND deleted_at IS NULL;
CREATE INDEX testimonials_ws_featured_idx
ON testimonials (workspace_id, is_featured, sort_weight DESC, created_at DESC)
WHERE status = 'published' AND deleted_at IS NULL;
CREATE INDEX testimonials_ws_kind_rating_idx
ON testimonials (workspace_id, kind, rating)
WHERE status = 'published' AND deleted_at IS NULL;
CREATE INDEX testimonials_search_gin ON testimonials USING gin (search_tsv);
CREATE INDEX testimonials_ws_source_idx ON testimonials (workspace_id, source, source_platform);
CREATE INDEX testimonials_media_idx ON testimonials (media_asset_id);
CREATE INDEX testimonials_transcript_idx ON testimonials (transcript_id);
CREATE INDEX testimonials_email_idx ON testimonials (workspace_id, respondent_email)
WHERE respondent_email IS NOT NULL;
CREATE INDEX testimonials_purge_idx ON testimonials (deleted_at) WHERE deleted_at IS NOT NULL;Lifecycle and deletion. Soft delete only, from the dashboard or API. The soft delete
transaction: copy status into pre_delete_status, set deleted_at and deleted_by_user_id,
write an audit_events row, insert an outbox_events row with testimonial.deleted, enqueue
media.purge with the 30-day delay described in Section 5.1.4 rule 2, and purge the CDN tags
widget:* for every widget that contained it plus workspace:{id}. The row itself survives
until either the workspace is purged or a GDPR erasure hard-deletes it.
A soft-deleted testimonial still occupies its lifetime slot. Deleting never returns a
collected-testimonial slot to the workspace and restoring never consumes a new one — the count
is of rows the workspace has ever held. This is a deliberate anti-abuse decision (collect,
delete, collect again would otherwise make the lifetime cap meaningless) and Section 19.5.3 owns
it. The schema follows: nothing decrements a counter on deleted_at.
On pre_archive_status and pre_delete_status. Both exist because archiving and deleting
are reversible and the reverse must land somewhere specific. Without them, unarchiving a row
that was published before it was archived could only guess — and guessing published would
silently re-publish content, while guessing pending would silently unpublish it. Storing the
prior value makes T10 (unarchive) and T12 (restore) in Section 11.2.3 exact rather than
heuristic. Both columns are cleared by the transition that consumes them, and the two CHECK
constraints above make "archived with nothing to go back to" unrepresentable.
On version. Every optimistic-concurrency claim in Sections 10, 11 and 12 reads this
column. It is incremented by the bump_testimonial_version() trigger (Section 5.16.4) on any
update that touches a content column, and deliberately not on updates that touch only
denormalized or operational columns (word_count, payload_bytes-style bookkeeping,
last_published_at), so a background job cannot invalidate a moderator's open editor.
Imported testimonials are never edited: body_text, body_html_sanitized, rating, and
every source_* column are rejected by the update validator when source = 'imported',
returning IMPORTED_CONTENT_IMMUTABLE.
5.6.2 media_assets #
One row per video or audio asset. Separated from testimonials so that provider state,
purge state, and byte accounting have a home that survives testimonial edits.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
testimonial_id |
uuid |
Yes | null |
Null between submit and testimonial creation |
submission_id |
uuid |
Yes | null |
Provenance |
upload_session_id |
uuid |
Yes | null |
|
provider |
media_provider |
No | 'mux' |
|
provider_upload_id |
text |
Yes | null |
Mux direct-upload id |
provider_asset_id |
text |
Yes | null |
Mux asset id; unique when present |
playback_id |
text |
Yes | null |
Mux playback id used by the player |
playback_policy |
text |
No | 'public' |
public or signed; signed reserved for a future private-showcase capability |
state |
media_state |
No | 'uploading' |
uploading, uploaded, processing, ready, errored |
is_audio_only |
boolean |
No | false |
Waveform poster is generated instead of a frame |
duration_seconds |
numeric(9,3) |
Yes | null |
Authoritative from the provider |
width |
smallint |
Yes | null |
|
height |
smallint |
Yes | null |
|
aspect_ratio |
text |
Yes | null |
"16:9", "9:16", "1:1", or w:h as reported |
frame_rate |
numeric(6,3) |
Yes | null |
|
max_stored_height |
smallint |
Yes | null |
Cap applied per plan (720 or 1080) |
poster_object_key |
text |
Yes | null |
Cached poster served from the CDN host |
poster_timestamp_seconds |
numeric(9,3) |
No | 1.000 |
Frame chosen for the poster; editable |
poster_generated_at |
timestamptz |
Yes | null |
|
original_object_key |
text |
Yes | null |
The uploaded original |
original_bytes |
bigint |
Yes | null |
Counts against storage quota |
original_retain_until |
timestamptz |
Yes | null |
ready_at + 30 days, or null when "keep originals" is on |
mime_type |
text |
Yes | null |
|
ready_at |
timestamptz |
Yes | null |
|
errored_at |
timestamptz |
Yes | null |
|
error_code |
text |
Yes | null |
Provider code, mapped to our catalogue |
error_message |
text |
Yes | null |
≤ 500 chars, operator-facing |
retry_count |
smallint |
No | 0 |
Provider-level retries attempted |
purge_requested_at |
timestamptz |
Yes | null |
Set when media.purge is enqueued |
purged_at |
timestamptz |
Yes | null |
Set only after every external asset is gone |
purge_attempts |
smallint |
No | 0 |
Operator alert fires at 3 |
created_at / updated_at |
timestamptz |
No | now() |
Constraints
UNIQUE (provider_asset_id),UNIQUE (provider_upload_id)— both partial onIS NOT NULL.CHECK (state <> 'ready' OR (playback_id IS NOT NULL AND duration_seconds IS NOT NULL))CHECK (state <> 'errored' OR error_code IS NOT NULL)CHECK (purged_at IS NULL OR purge_requested_at IS NOT NULL)CHECK (max_stored_height IS NULL OR max_stored_height IN (720, 1080))CHECK (duration_seconds IS NULL OR duration_seconds >= 0)
Indexes: media_assets_provider_asset_uq; media_assets_provider_upload_uq;
media_assets_ws_state_idx (workspace_id, state);
media_assets_testimonial_idx (testimonial_id);
media_assets_retain_idx (original_retain_until) WHERE original_object_key IS NOT NULL AND original_retain_until IS NOT NULL
— the 30-day originals sweep;
media_assets_purge_idx (purge_requested_at) WHERE purged_at IS NULL AND purge_requested_at IS NOT NULL
— the purge worker's queue-of-record, so a lost job is always recoverable from the database.
CREATE TABLE media_assets (
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
testimonial_id uuid,
submission_id uuid REFERENCES submissions(id) ON DELETE SET NULL,
upload_session_id uuid REFERENCES upload_sessions(id) ON DELETE SET NULL,
provider media_provider NOT NULL DEFAULT 'mux',
provider_upload_id text,
provider_asset_id text,
playback_id text,
playback_policy text NOT NULL DEFAULT 'public',
state media_state NOT NULL DEFAULT 'uploading',
is_audio_only boolean NOT NULL DEFAULT false,
duration_seconds numeric(9,3),
width smallint,
height smallint,
aspect_ratio text,
frame_rate numeric(6,3),
max_stored_height smallint,
poster_object_key text,
poster_timestamp_seconds numeric(9,3) NOT NULL DEFAULT 1.000,
poster_generated_at timestamptz,
original_object_key text,
original_bytes bigint,
original_retain_until timestamptz,
mime_type text,
ready_at timestamptz,
errored_at timestamptz,
error_code text,
error_message text,
retry_count smallint NOT NULL DEFAULT 0,
purge_requested_at timestamptz,
purged_at timestamptz,
purge_attempts smallint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT media_assets_ready_ck CHECK (state <> 'ready'
OR (playback_id IS NOT NULL AND duration_seconds IS NOT NULL)),
CONSTRAINT media_assets_errored_ck CHECK (state <> 'errored' OR error_code IS NOT NULL),
CONSTRAINT media_assets_purge_ck CHECK (purged_at IS NULL OR purge_requested_at IS NOT NULL),
CONSTRAINT media_assets_height_ck CHECK (max_stored_height IS NULL OR max_stored_height IN (720,1080)),
CONSTRAINT media_assets_dur_ck CHECK (duration_seconds IS NULL OR duration_seconds >= 0),
CONSTRAINT media_assets_policy_ck CHECK (playback_policy IN ('public','signed'))
);
CREATE UNIQUE INDEX media_assets_provider_asset_uq ON media_assets (provider_asset_id)
WHERE provider_asset_id IS NOT NULL;
CREATE UNIQUE INDEX media_assets_provider_upload_uq ON media_assets (provider_upload_id)
WHERE provider_upload_id IS NOT NULL;
CREATE INDEX media_assets_ws_state_idx ON media_assets (workspace_id, state);
CREATE INDEX media_assets_testimonial_idx ON media_assets (testimonial_id);
CREATE INDEX media_assets_retain_idx ON media_assets (original_retain_until)
WHERE original_object_key IS NOT NULL AND original_retain_until IS NOT NULL;
CREATE INDEX media_assets_purge_idx ON media_assets (purge_requested_at)
WHERE purged_at IS NULL AND purge_requested_at IS NOT NULL;
-- The circular FK between testimonials and media_assets is closed after both tables exist.
ALTER TABLE testimonials ADD CONSTRAINT testimonials_media_fk
FOREIGN KEY (media_asset_id) REFERENCES media_assets(id) ON DELETE SET NULL;
ALTER TABLE media_assets ADD CONSTRAINT media_assets_testimonial_fk
FOREIGN KEY (testimonial_id) REFERENCES testimonials(id) ON DELETE CASCADE;Lifecycle. A row is created when the recorder opens the upload session, advances through
provider webhooks, and is hard-deleted only when its testimonial is hard-deleted. purged_at
being non-null while the row still exists is the durable record that external assets are gone —
that is what lets the erasure guarantee in Section 22 be verifiable rather than assumed.
5.6.3 transcripts #
Exactly one current transcript per media asset. The doc column holds the single transcript
structure defined in Section 10.3; captions, highlight suggestions, pull-quote validation, search
text, and the on-page text beside a video all derive from it. There is no second copy anywhere.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
testimonial_id |
uuid |
Yes | null |
Set once the testimonial exists |
media_asset_id |
uuid |
No | — | Unique — one current transcript per asset |
provider |
text |
No | 'deepgram' |
deepgram or assemblyai |
model |
text |
No | — | e.g. nova-3 |
language |
content_language |
No | — | Effective language |
detected_language |
text |
Yes | null |
Raw provider detection, may be outside our five |
confidence |
numeric(4,3) |
Yes | null |
0–1 overall |
is_low_confidence |
boolean |
No | false |
True when confidence < 0.6; flags for review |
needs_review |
boolean |
No | false |
Set on low confidence or fallback language |
version |
integer |
No | 1 |
The transcript's edit revision number, incremented on every human save. Not to be confused with the schema version inside doc — see the note below |
doc |
jsonb |
No | — | The transcript structure; validated by transcriptDocSchema |
text_flat |
text |
No | — | Whitespace-normalized concatenation of segment text; the substring-match target for verbatim enforcement (Section 12.4) |
word_count |
integer |
No | 0 |
|
duration_seconds |
numeric(9,3) |
Yes | null |
From the provider |
vtt_object_key |
text |
Yes | null |
Regenerated on every save |
vtt_generated_at |
timestamptz |
Yes | null |
|
billed_minutes |
numeric(9,3) |
No | 0 |
Counts against the transcription quota |
generated_at |
timestamptz |
Yes | null |
Provider completion |
failed_at |
timestamptz |
Yes | null |
|
failure_code |
text |
Yes | null |
|
failure_message |
text |
Yes | null |
≤ 500 chars |
retry_count |
smallint |
No | 0 |
Max 3 automatic, then manual retry |
edited_by_user_id |
uuid |
Yes | null |
Last human editor |
edited_at |
timestamptz |
Yes | null |
|
created_at / updated_at |
timestamptz |
No | now() |
Constraints
UNIQUE (media_asset_id)CHECK (confidence IS NULL OR confidence BETWEEN 0 AND 1)CHECK (version >= 1)CHECK (jsonb_typeof(doc -> 'segments') = 'array')CHECK ((doc ->> 'version')::int = 1)— the document schema version is asserted in the database, so a malformed write cannot land.CHECK (retry_count <= 3)
Two different things are called "version" here, and conflating them is the most likely mistake in this table. They are:
transcripts.version |
doc ->> 'version' |
|
|---|---|---|
| What it counts | Edit revisions. How many times a human has saved this transcript | The document schema. Which shape the JSON inside doc conforms to |
| Who moves it | The transcript editor's save path, in the same transaction that writes the transcript_revisions snapshot |
Us, when we change the transcript structure defined in Section 10.3 — a code change and a backfill, never a user action |
| Today's value | 1 on a fresh transcript, N after N-1 human edits | Always 1, asserted by the CHECK above |
| Cardinality | One per transcript, climbing | One per schema, shared by every transcript in the system |
They are deliberately not merged and deliberately not named alike in code: the column is
transcripts.version and the JSON key is addressed as content.version in Section 10. A reader
who assumes a transcript with version = 4 holds a v4 document has misread the table — it holds a
v1 document that has been edited three times. The CHECK on the JSON key would fire immediately
if the two were ever crossed, which is one of the reasons it is asserted in the database rather
than only in the validator.
Indexes: transcripts_media_uq (media_asset_id);
transcripts_testimonial_idx (testimonial_id);
transcripts_review_idx (workspace_id) WHERE needs_review;
transcripts_failed_idx (workspace_id, failed_at) WHERE failed_at IS NOT NULL AND generated_at IS NULL.
CREATE TABLE transcripts (
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
testimonial_id uuid REFERENCES testimonials(id) ON DELETE CASCADE,
media_asset_id uuid NOT NULL REFERENCES media_assets(id) ON DELETE CASCADE,
provider text NOT NULL DEFAULT 'deepgram',
model text NOT NULL,
language content_language NOT NULL,
detected_language text,
confidence numeric(4,3),
is_low_confidence boolean NOT NULL DEFAULT false,
needs_review boolean NOT NULL DEFAULT false,
version integer NOT NULL DEFAULT 1,
doc jsonb NOT NULL,
text_flat text NOT NULL,
word_count integer NOT NULL DEFAULT 0,
duration_seconds numeric(9,3),
vtt_object_key text,
vtt_generated_at timestamptz,
billed_minutes numeric(9,3) NOT NULL DEFAULT 0,
generated_at timestamptz,
failed_at timestamptz,
failure_code text,
failure_message text,
retry_count smallint NOT NULL DEFAULT 0,
edited_by_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
edited_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT transcripts_media_uq UNIQUE (media_asset_id),
CONSTRAINT transcripts_conf_ck CHECK (confidence IS NULL OR confidence BETWEEN 0 AND 1),
CONSTRAINT transcripts_ver_ck CHECK (version >= 1),
CONSTRAINT transcripts_doc_ck CHECK (jsonb_typeof(doc -> 'segments') = 'array'),
CONSTRAINT transcripts_docver_ck CHECK ((doc ->> 'version')::int = 1),
CONSTRAINT transcripts_retry_ck CHECK (retry_count <= 3)
);
ALTER TABLE testimonials ADD CONSTRAINT testimonials_transcript_fk
FOREIGN KEY (transcript_id) REFERENCES transcripts(id) ON DELETE SET NULL;5.6.4 transcript_revisions #
Append-only history. Every save of the transcript editor writes the previous document here
before overwriting transcripts.doc, so version N-1 is always recoverable.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
transcript_id |
uuid |
No | — | Parent |
version |
integer |
No | — | The transcripts.version this snapshot represents — the edit revision, not the document schema (Section 5.6.3) |
edit_class |
transcript_edit_class |
No | — | How far this edit moved the words: timing_only, punctuation_only, verbatim_correction, substantive. Load-bearing for privacy, not a report field — see below |
doc |
jsonb |
No | — | Full document at that version |
text_flat |
text |
No | — | Flattened text at that version |
change_summary |
jsonb |
No | '{}' |
{"segmentsEdited":3,"segmentsMerged":1,"segmentsSplit":0,"timingsAdjusted":2} |
actor_type |
actor_type |
No | — | user or system (a re-run of transcription) |
actor_user_id |
uuid |
Yes | null |
|
created_at |
timestamptz |
No | now() |
No updated_at — the table is append-only |
Constraints: UNIQUE (transcript_id, version); CHECK (version >= 1);
CHECK (edit_class <> 'substantive' OR actor_type = 'user') — an automated re-run may correct
timings, punctuation or mishearings, but nothing except a human may be recorded as having changed
what a person is quoted as saying.
Indexes: the unique above serves both "list history" and "fetch version N";
transcript_revisions_substantive_idx (workspace_id, transcript_id, created_at DESC) WHERE edit_class = 'substantive'
— the re-consent check described below, which runs on every publish of a video testimonial and
must not scan the history.
What edit_class is for. It is not a changelog nicety. The four values are ordered by how
much they change the meaning of what the respondent said, and the last one has a consequence:
| Value | What changed | Consequence |
|---|---|---|
timing_only |
Segment boundaries or timestamps; not a character of text | None |
punctuation_only |
Punctuation, casing, filler-word removal — the words as spoken are unchanged | None |
verbatim_correction |
The transcriber misheard and a human fixed it to match the audio. The text now matches the recording better than it did | None; this is the edit the feature exists for |
substantive |
The text no longer matches what the person actually said — a tidied quote, a removed hedge, a rephrasing | Publication is blocked pending respondent re-consent (Section 10.7.2) |
The reasoning is the one the whole product rests on: a testimonial is a real person's words, and
the consent they gave was to publish those words. An edit that changes the meaning invalidates
the basis for publishing, so the schema records the distinction at the moment of the edit rather
than trying to infer it later from a text diff — by which time the editor's intent is gone and
only a machine's guess remains. verbatim_correction is carved out deliberately and is the
common case: fixing "Kubernetes" from "coober netties" makes the record more faithful, and
requiring re-consent for it would make the transcript editor unusable and push people towards
leaving errors in.
Section 10.7.2 owns the blocking rule and the re-consent flow; this section owns the column it reads and guarantees that the column is written on every save.
Enforcement: REVOKE UPDATE, DELETE ON transcript_revisions FROM vouch_app; alongside the
rule in Section 5.16.3. edit_class in particular must be immutable once written — an edit that
could be reclassified from substantive down to punctuation_only after the fact would
un-block a publication that consent never covered. Retained per Section 5.20 (the 20 most recent
revisions per transcript, and 12 months) and hard-deleted with the transcript.
5.6.5 tags and testimonial_tags #
A lookup table, because customers extend the vocabulary. Section 11 owns tagging behaviour.
tags:
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
name |
text |
No | — | Display form, 1–40 chars |
slug |
citext |
No | — | Normalized; unique per workspace among non-deleted rows |
color_token |
text |
No | 'slate' |
One of 10 named design tokens, never a raw hex — keeps contrast guarantees |
description |
text |
Yes | null |
≤ 200 chars |
is_system |
boolean |
No | false |
Seeded vocabulary; renameable but not deletable |
usage_count |
integer |
No | 0 |
Denormalized, maintained by trigger on testimonial_tags |
created_by_user_id |
uuid |
Yes | null |
|
created_at / updated_at |
timestamptz |
No | now() |
|
deleted_at |
timestamptz |
Yes | null |
Soft delete; join rows are hard-deleted |
Constraints: UNIQUE (workspace_id, slug) WHERE deleted_at IS NULL;
CHECK (char_length(name) BETWEEN 1 AND 40);
CHECK (slug ~ '^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$');
CHECK (color_token IN ('slate','red','orange','amber','green','teal','blue','indigo','violet','pink'));
CHECK (NOT is_system OR deleted_at IS NULL) — a system tag cannot be deleted.
Indexes: the partial unique; tags_ws_usage_idx (workspace_id, usage_count DESC) WHERE deleted_at IS NULL
for the tag picker's "most used first" ordering.
testimonial_tags — a join table with a composite primary key and no surrogate id:
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
workspace_id |
uuid |
No | — | Tenancy column, present even though derivable |
testimonial_id |
uuid |
No | — | |
tag_id |
uuid |
No | — | |
actor_type |
actor_type |
No | 'user' |
user, api_key, or system (accepted AI suggestion) |
actor_user_id |
uuid |
Yes | null |
|
ai_suggestion_id |
uuid |
Yes | null |
Non-null when the tag came from an accepted suggestion |
created_at |
timestamptz |
No | now() |
CREATE TABLE testimonial_tags (
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
testimonial_id uuid NOT NULL REFERENCES testimonials(id) ON DELETE CASCADE,
tag_id uuid NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
actor_type actor_type NOT NULL DEFAULT 'user',
actor_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
ai_suggestion_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (testimonial_id, tag_id)
);
CREATE INDEX testimonial_tags_tag_idx ON testimonial_tags (workspace_id, tag_id, testimonial_id);The reverse index (workspace_id, tag_id, testimonial_id) serves the widget tag filter, which is
the query that must stay fast as a workspace grows: "published testimonials carrying tag X".
5.6.6 highlights #
Approved pull-quotes and clips. A highlight only ever exists after a human clicks approve; there is no state in which an AI proposal is a highlight. The verbatim guarantee in Section 12.1 is enforced before insert, and the check digest is stored so the guarantee remains auditable.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
testimonial_id |
uuid |
No | — | Parent |
type |
highlight_type |
No | — | pull_quote_text, pull_quote_transcript, clip |
text |
text |
Yes | null |
The excerpt, exactly as it will render; required for pull quotes |
spans |
jsonb |
No | '[]' |
Ordered exact substrings: [{"start":120,"end":180}]; multi-part excerpts render joined by "…" |
segment_ids |
text[] |
Yes | null |
For pull_quote_transcript, the transcript segment ids covered |
start_seconds |
numeric(9,3) |
Yes | null |
Clips only |
end_seconds |
numeric(9,3) |
Yes | null |
Clips only |
position |
smallint |
No | 0 |
Display order within the testimonial |
is_primary |
boolean |
No | false |
At most one per testimonial; used by Single Quote widgets and social cards |
verbatim_source |
text |
No | — | body_text or transcript_text_flat: which field the excerpt was verified against |
verbatim_digest |
char(64) |
No | — | SHA-256 of the NFC-normalized, whitespace-collapsed source at verification time |
actor_type |
actor_type |
No | 'user' |
|
created_by_user_id |
uuid |
Yes | null |
|
ai_suggestion_id |
uuid |
Yes | null |
Provenance when accepted from a proposal |
created_at / updated_at |
timestamptz |
No | now() |
|
deleted_at |
timestamptz |
Yes | null |
Soft delete so social cards referencing it stay explicable |
Constraints
CHECK (type = 'clip' OR text IS NOT NULL)CHECK (type <> 'clip' OR (start_seconds IS NOT NULL AND end_seconds IS NOT NULL AND end_seconds > start_seconds))CHECK (type <> 'clip' OR end_seconds - start_seconds <= 120)— a clip is a highlight, not a re-cut of the whole video.CHECK (jsonb_typeof(spans) = 'array')CHECK (verbatim_source IN ('body_text','transcript_text_flat'))CREATE UNIQUE INDEX highlights_one_primary_uq ON highlights (testimonial_id) WHERE is_primary AND deleted_at IS NULL;
Indexes: the primary-uniqueness index above;
highlights_testimonial_idx (workspace_id, testimonial_id, position) WHERE deleted_at IS NULL;
highlights_suggestion_idx (ai_suggestion_id).
The invariant, stated plainly. Insert and update both run
assertVerbatim(spans, sourceText) in packages/core/ai/verbatim.ts. It NFC-normalizes,
collapses runs of whitespace to a single space, and asserts that concatenating
sourceText.slice(start, end) for each span, in order, equals text when the same
normalization is applied to text. A failure throws before the insert; no row can exist that
violates it. When a transcript is edited, transcript.reverify re-runs the assertion for every
highlight of that testimonial and marks any that no longer match as deleted_at = now() with an
audit_events row and a moderator notification — the published quote is withdrawn rather than
silently drifting from the corrected words.
5.6.7 ai_suggestions #
Every proposal the assistant makes, whether it survived validation or not. Kept because the 2%-rejection alert in Section 12.4 and the auditability of "which prompt version produced this" both depend on the record.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
testimonial_id |
uuid |
No | — | Subject |
kind |
ai_suggestion_kind |
No | — | highlight, tag, alt_text, headline |
provider |
text |
No | — | anthropic or openai |
model |
text |
No | — | Exact model identifier used |
prompt_version |
text |
No | — | e.g. highlight.v3; the versioned prompt in packages/core/ai/prompts |
request_digest |
char(64) |
No | — | SHA-256 of the rendered prompt; lets identical requests be recognized without storing the prompt twice |
raw_response |
jsonb |
No | — | Provider response verbatim |
proposals |
jsonb |
No | '[]' |
Parsed candidates: up to 5 quotes, 3 clips, N tags, or 1 draft |
validation_outcome |
ai_validation_outcome |
No | — | passed or a specific rejection reason |
validation_details |
jsonb |
No | '{}' |
Which candidates failed and why |
state |
ai_suggestion_state |
No | 'proposed' |
proposed, accepted, dismissed, expired, stale, discarded |
stale_at |
timestamptz |
Yes | null |
Set when the underlying testimonial or transcript changed after the proposal was made; a stale proposal is never shown and never acceptable |
discarded_count |
smallint |
No | 0 |
Candidates dropped before a human saw them |
tokens_in |
integer |
Yes | null |
|
tokens_out |
integer |
Yes | null |
|
cost_cents |
integer |
Yes | null |
Integer minor units, per Section 5.1.3 |
latency_ms |
integer |
Yes | null |
|
requested_by_user_id |
uuid |
Yes | null |
|
accepted_by_user_id |
uuid |
Yes | null |
|
accepted_at |
timestamptz |
Yes | null |
|
dismissed_at |
timestamptz |
Yes | null |
|
expires_at |
timestamptz |
No | now() + interval '30 days' |
Stale proposals disappear from the UI |
created_at / updated_at |
timestamptz |
No | now() |
Constraints
CHECK (state <> 'accepted' OR (accepted_by_user_id IS NOT NULL AND accepted_at IS NOT NULL))— nothing is ever accepted without a human, on any plan.CHECK (validation_outcome <> 'passed' OR jsonb_array_length(proposals) >= 1)CHECK (state <> 'accepted' OR validation_outcome = 'passed')— a rejected proposal is unacceptable by construction, not by convention.CHECK ((state = 'stale') = (stale_at IS NOT NULL))CHECK (tokens_in IS NULL OR tokens_in >= 0), same fortokens_out,cost_cents,latency_ms.
Indexes: ai_suggestions_testimonial_idx (workspace_id, testimonial_id, created_at DESC);
ai_suggestions_outcome_idx (workspace_id, kind, validation_outcome, created_at DESC) — the
rejection-rate metric;
ai_suggestions_expiry_idx (expires_at) WHERE state = 'proposed'.
Hard-deleted 180 days after created_at by retention.purge_ai_suggestions, except rows in
state accepted, which are retained for the life of the testimonial because a highlights row
points at them.
5.6.8 social_cards #
Rendered share images (Section 15 owns the generator). One row per template/variant/testimonial combination.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
testimonial_id |
uuid |
No | — | Subject |
highlight_id |
uuid |
Yes | null |
The quote rendered, when the card features one |
template |
text |
No | — | quote_light, quote_dark, video_still, rating_summary, case_study |
variant |
text |
No | 'og' |
og (1200×630), square (1080×1080), story (1080×1920) |
object_key |
text |
No | — | Rendered image key |
width |
smallint |
No | — | |
height |
smallint |
No | — | |
format |
text |
No | 'png' |
png, jpeg, webp |
bytes |
integer |
No | — | Counts against storage quota |
alt_text |
text |
No | — | 10–420 chars, always present — a card without alt text is not shippable, and a one-character alt text defeats the purpose, so the floor is meaningful rather than nominal |
alt_text_source |
alt_text_source |
No | — | ai_drafted or human; prose about the card, never the testimonial's words |
ai_suggestion_id |
uuid |
Yes | null |
When alt text was drafted |
theme |
jsonb |
No | '{}' |
Colours and font scale actually used, so a re-render is reproducible |
generator_version |
text |
No | — | e.g. cards.v2; a version bump marks existing rows stale |
is_stale |
boolean |
No | false |
Set when the testimonial, highlight, brand, or generator changes |
generated_at |
timestamptz |
No | now() |
|
created_at / updated_at |
timestamptz |
No | now() |
Constraints: UNIQUE (testimonial_id, template, variant);
CHECK (template IN ('quote_light','quote_dark','video_still','rating_summary','case_study'));
CHECK (variant IN ('og','square','story'));
CHECK (format IN ('png','jpeg','webp'));
CHECK (char_length(alt_text) BETWEEN 10 AND 420);
CHECK (width BETWEEN 200 AND 4096 AND height BETWEEN 200 AND 4096).
Indexes: the unique above; social_cards_stale_idx (workspace_id) WHERE is_stale.
Hard-deleted with the testimonial, and their objects are destroyed by media.purge.
5.6.9 media_derivatives #
Every stored output of the media pipeline other than the original: each rendition ladder rung, each poster, each waveform, each caption file. One row per object in storage.
This table exists for one reason above all others: storage.bytes is metered from it.
Section 19.5.1 recomputes a workspace's storage usage as the sum of bytes over non-purged
media_derivatives plus non-purged media_assets.original_bytes. Without a per-object row,
"how many bytes does this workspace store?" would be an estimate derived from durations and
bitrates, and a customer disputing their storage bill would have no answer. With it, the number
is a SUM over rows that each name a real object key.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
media_asset_id |
uuid |
No | — | Parent asset |
kind |
text |
No | — | rendition, poster, waveform, caption, thumbnail_sprite |
label |
text |
No | — | Human/machine label within the kind: 1080p, 720p, 480p, audio, poster, vtt-en |
object_key |
text |
Yes | null |
Storage key per Section 5.1.11; null when the object lives with the media provider rather than in our bucket |
provider_ref |
text |
Yes | null |
Provider-side identifier when object_key is null |
mime_type |
text |
No | — | |
bytes |
bigint |
No | — | The metered quantity. Never estimated — written from the storage response |
width |
smallint |
Yes | null |
Null for captions and audio |
height |
smallint |
Yes | null |
|
duration_seconds |
numeric(9,3) |
Yes | null |
|
checksum_sha256 |
char(64) |
Yes | null |
Written when the pipeline computes one; lets an integrity sweep detect silent corruption |
is_billable |
boolean |
No | true |
False for objects we generate and choose not to charge for, currently only waveform |
generated_at |
timestamptz |
No | now() |
|
purge_requested_at |
timestamptz |
Yes | null |
|
purged_at |
timestamptz |
Yes | null |
Once set, the row stops counting toward storage.bytes |
created_at / updated_at |
timestamptz |
No | now() |
Constraints
UNIQUE (media_asset_id, kind, label)— one 1080p rendition per asset, not two.UNIQUE (object_key) WHERE object_key IS NOT NULLCHECK (kind IN ('rendition','poster','waveform','caption','thumbnail_sprite'))CHECK (bytes >= 0)CHECK (object_key IS NOT NULL OR provider_ref IS NOT NULL)— an object we cannot address is an object we cannot purge, and an unpurgeable object breaks the erasure guarantee.CHECK (purged_at IS NULL OR purge_requested_at IS NOT NULL)
Indexes
media_derivatives_asset_kind_uq(unique above) — the pipeline's upsert target.media_derivatives_ws_bytes_idxon(workspace_id) INCLUDE (bytes) WHERE purged_at IS NULL AND is_billable— the storage recompute, which is a single index-only scan per workspace.media_derivatives_purge_idxon(purge_requested_at) WHERE purged_at IS NULL AND purge_requested_at IS NOT NULL— the same durable queue-of-record patternmedia_assetsuses.
CREATE TABLE media_derivatives (
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
media_asset_id uuid NOT NULL REFERENCES media_assets(id) ON DELETE CASCADE,
kind text NOT NULL,
label text NOT NULL,
object_key text,
provider_ref text,
mime_type text NOT NULL,
bytes bigint NOT NULL,
width smallint,
height smallint,
duration_seconds numeric(9,3),
checksum_sha256 char(64),
is_billable boolean NOT NULL DEFAULT true,
generated_at timestamptz NOT NULL DEFAULT now(),
purge_requested_at timestamptz,
purged_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT media_derivatives_asset_kind_uq UNIQUE (media_asset_id, kind, label),
CONSTRAINT media_derivatives_kind_ck CHECK (kind IN ('rendition','poster','waveform','caption','thumbnail_sprite')),
CONSTRAINT media_derivatives_bytes_ck CHECK (bytes >= 0),
CONSTRAINT media_derivatives_addr_ck CHECK (object_key IS NOT NULL OR provider_ref IS NOT NULL),
CONSTRAINT media_derivatives_purge_ck CHECK (purged_at IS NULL OR purge_requested_at IS NOT NULL)
);
CREATE UNIQUE INDEX media_derivatives_key_uq ON media_derivatives (object_key)
WHERE object_key IS NOT NULL;
CREATE INDEX media_derivatives_ws_bytes_idx ON media_derivatives (workspace_id) INCLUDE (bytes)
WHERE purged_at IS NULL AND is_billable;
CREATE INDEX media_derivatives_purge_idx ON media_derivatives (purge_requested_at)
WHERE purged_at IS NULL AND purge_requested_at IS NOT NULL;Lifecycle and deletion. Rows are written by the pipeline as each output lands, cascade-deleted
with the asset, and marked purged_at by media.purge once the object is actually gone.
Derivatives are permanent for the life of the testimonial — only the original is swept at 30
days (Section 5.20), and that sweep sets media_assets.original_object_key to null without
touching this table. A derivative row whose object has been purged is retained until its parent
asset is hard-deleted, because "we deleted these 6 objects on this date" is exactly the record
the erasure proof in Section 22 needs.
5.6.10 testimonial_flags #
A raised safety concern. Section 11.9 owns the workflow — who may raise a flag, what auto-unpublishing does, and how a flag is cleared; this is where a flag lives.
A flag is a row rather than a boolean column on testimonials for two reasons. A testimonial can
be flagged for more than one reason at once (a legal request and third-party PII), and the
history of "this was flagged, then cleared, then flagged again" is exactly what a moderator needs
when the same content is disputed twice. A boolean would collapse both.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
testimonial_id |
uuid |
No | — | Subject |
reason |
flag_reason |
No | — | abusive_language, third_party_pii, legal_request, consent_withdrawn, other |
note |
text |
Yes | null |
≤ 1,000 chars. Required when reason = 'other'; internal only, never published |
raised_by_actor_type |
actor_type |
No | — | user, system (an automated check), or respondent (a withdrawal raising consent_withdrawn) |
raised_by_user_id |
uuid |
Yes | null |
Set when raised_by_actor_type = 'user' |
raised_at |
timestamptz |
No | now() |
|
unpublished_on_raise |
boolean |
No | false |
True when raising this flag is what took the testimonial off the air; clearing then knows whether re-publishing is the flag's business |
cleared_at |
timestamptz |
Yes | null |
Null while the flag is live |
cleared_by_user_id |
uuid |
Yes | null |
|
clearance_note |
text |
Yes | null |
≤ 1,000 chars; why the concern was resolved |
created_at / updated_at |
timestamptz |
No | now() |
Constraints
UNIQUE (testimonial_id, reason) WHERE cleared_at IS NULL— the same reason cannot be raised twice concurrently, so "flag it again" on a live flag is a no-op rather than a duplicate.CHECK (reason <> 'other' OR note IS NOT NULL)CHECK (raised_by_actor_type <> 'user' OR raised_by_user_id IS NOT NULL)CHECK (cleared_at IS NULL OR cleared_at >= raised_at)CHECK ((cleared_by_user_id IS NULL) OR (cleared_at IS NOT NULL))
Indexes
testimonial_flags_open_uq(partial unique above).testimonial_flags_ws_open_idxon(workspace_id, raised_at DESC) WHERE cleared_at IS NULL— the "flagged" filter in the inbox, and the count on the moderation badge.testimonial_flags_testimonial_idxon(workspace_id, testimonial_id, raised_at DESC)— the flag history on a single testimonial.
CREATE TABLE testimonial_flags (
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
testimonial_id uuid NOT NULL REFERENCES testimonials(id) ON DELETE CASCADE,
reason flag_reason NOT NULL,
note text,
raised_by_actor_type actor_type NOT NULL,
raised_by_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
raised_at timestamptz NOT NULL DEFAULT now(),
unpublished_on_raise boolean NOT NULL DEFAULT false,
cleared_at timestamptz,
cleared_by_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
clearance_note text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT testimonial_flags_note_ck CHECK (reason <> 'other' OR note IS NOT NULL),
CONSTRAINT testimonial_flags_raiser_ck CHECK (raised_by_actor_type <> 'user' OR raised_by_user_id IS NOT NULL),
CONSTRAINT testimonial_flags_cleared_ck CHECK (cleared_at IS NULL OR cleared_at >= raised_at),
CONSTRAINT testimonial_flags_clearer_ck CHECK (cleared_by_user_id IS NULL OR cleared_at IS NOT NULL)
);
CREATE UNIQUE INDEX testimonial_flags_open_uq
ON testimonial_flags (testimonial_id, reason) WHERE cleared_at IS NULL;
CREATE INDEX testimonial_flags_ws_open_idx
ON testimonial_flags (workspace_id, raised_at DESC) WHERE cleared_at IS NULL;
CREATE INDEX testimonial_flags_testimonial_idx
ON testimonial_flags (workspace_id, testimonial_id, raised_at DESC);Lifecycle and deletion. Raising a flag on a published testimonial unpublishes it in the
same transaction and sets unpublished_on_raise. Clearing the last live flag on a testimonial
offers — but never performs automatically — re-publication; a human decides. Attempting to
publish a testimonial that has a live flag is refused with 409 TESTIMONIAL_FLAGGED. Raising
requires testimonial:flag and clearing requires testimonial:flag_clear, which are held by
different roles on purpose (Section 6.6.2). Flags are
hard-deleted only with their testimonial, because the flag history is the record of how a
disputed testimonial was handled and is worth more than the storage it costs.
5.6.11 testimonial_replies #
The threaded conversation between the workspace and the respondent about one testimonial: a thank-you, a follow-up question, a request for a longer case study, and whatever the respondent sends back. Section 11.8 owns the behaviour.
Replies are a separate table from notifications and from email_deliveries because a reply is
content — it is read in the testimonial's detail view, it can carry an attachment, and it
belongs to the testimonial's history. A delivery record says an email left the building; a reply
says what was said.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
testimonial_id |
uuid |
No | — | Subject |
thread_id |
uuid |
No | — | Groups a conversation. The first message's own id, reused by every later message in the thread |
in_reply_to_id |
uuid |
Yes | null |
The message this one answers; null for the first in a thread |
direction |
reply_direction |
No | — | outbound (workspace → respondent) or inbound (respondent → workspace) |
channel |
invite_channel |
No | 'email' |
email in v1; sms reuses the same table shape |
subject |
text |
Yes | null |
≤ 200 chars; null on sms and on inbound messages without one |
body |
text |
No | — | Plain text as sent or received, 1–10,000 chars |
body_html_sanitized |
text |
Yes | null |
Sanitized inbound HTML, same allowlist as testimonials.body_html_sanitized |
attachments |
jsonb |
No | '[]' |
[{"objectKey":"…","filename":"brief.pdf","bytes":81234,"mimeType":"application/pdf"}]. Max 5 entries, 10 MB each; every entry is re-hosted in our bucket and scanned before it is readable |
actor_type |
actor_type |
No | — | user or api_key on outbound, respondent on inbound |
actor_user_id |
uuid |
Yes | null |
|
email_delivery_id |
uuid |
Yes | null |
The outbound send record |
provider_message_id |
text |
Yes | null |
Inbound provider id; unique when present, so a redelivered inbound webhook cannot double-insert |
sent_at |
timestamptz |
Yes | null |
Outbound |
received_at |
timestamptz |
Yes | null |
Inbound |
read_at |
timestamptz |
Yes | null |
When a team member opened an inbound message |
consent_basis |
consent_kind |
Yes | null |
case_study_contact for a follow-up that relies on that grant; null for a plain thank-you. A follow-up whose basis is missing is refused with FOLLOW_UP_CONSENT_MISSING |
created_at / updated_at |
timestamptz |
No | now() |
Constraints
UNIQUE (provider_message_id) WHERE provider_message_id IS NOT NULLCHECK (char_length(body) BETWEEN 1 AND 10000)CHECK (jsonb_typeof(attachments) = 'array' AND jsonb_array_length(attachments) <= 5)CHECK ((direction = 'outbound') = (sent_at IS NOT NULL OR email_delivery_id IS NOT NULL))CHECK (direction <> 'inbound' OR received_at IS NOT NULL)CHECK (direction <> 'inbound' OR actor_type = 'respondent')CHECK (read_at IS NULL OR direction = 'inbound')— only inbound messages are "read".
Indexes
testimonial_replies_thread_idxon(workspace_id, thread_id, created_at)— rendering a thread in order, which is the only way this table is read in the UI.testimonial_replies_testimonial_idxon(workspace_id, testimonial_id, created_at DESC)— "does this testimonial have replies?" on the detail view.testimonial_replies_unread_idxon(workspace_id, received_at DESC) WHERE direction = 'inbound' AND read_at IS NULL— the unread-replies badge.testimonial_replies_provider_uq(partial unique above) — inbound idempotency.
CREATE TABLE testimonial_replies (
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
testimonial_id uuid NOT NULL REFERENCES testimonials(id) ON DELETE CASCADE,
thread_id uuid NOT NULL,
in_reply_to_id uuid REFERENCES testimonial_replies(id) ON DELETE SET NULL,
direction reply_direction NOT NULL,
channel invite_channel NOT NULL DEFAULT 'email',
subject text,
body text NOT NULL,
body_html_sanitized text,
attachments jsonb NOT NULL DEFAULT '[]'::jsonb,
actor_type actor_type NOT NULL,
actor_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
email_delivery_id uuid,
provider_message_id text,
sent_at timestamptz,
received_at timestamptz,
read_at timestamptz,
consent_basis consent_kind,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT testimonial_replies_body_ck CHECK (char_length(body) BETWEEN 1 AND 10000),
CONSTRAINT testimonial_replies_attach_ck CHECK (jsonb_typeof(attachments) = 'array'
AND jsonb_array_length(attachments) <= 5),
CONSTRAINT testimonial_replies_inbound_ck CHECK (direction <> 'inbound' OR
(received_at IS NOT NULL AND actor_type = 'respondent')),
CONSTRAINT testimonial_replies_read_ck CHECK (read_at IS NULL OR direction = 'inbound')
);
CREATE UNIQUE INDEX testimonial_replies_provider_uq
ON testimonial_replies (provider_message_id) WHERE provider_message_id IS NOT NULL;
CREATE INDEX testimonial_replies_thread_idx
ON testimonial_replies (workspace_id, thread_id, created_at);
CREATE INDEX testimonial_replies_testimonial_idx
ON testimonial_replies (workspace_id, testimonial_id, created_at DESC);
CREATE INDEX testimonial_replies_unread_idx
ON testimonial_replies (workspace_id, received_at DESC)
WHERE direction = 'inbound' AND read_at IS NULL;Lifecycle and deletion. Sending an outbound reply requires testimonial:reply
(Section 6.6.2) and is checked against suppressions (Section 5.9.6) and against the
case_study_contact grant in consent_grants before it leaves; inbound messages are written by
the provider webhook as the system actor and need no permission. Rows are cascade-deleted with
the testimonial and hard-erased with it under a GDPR request, along with every attachment object
— a reply contains the respondent's own words and is personal data by any reading. Replies are
never soft-deleted: an editor who sends the
wrong message cannot un-send it, and pretending otherwise by hiding the row would misrepresent
what the respondent actually received.
5.6.12 inbox_saved_views #
A named, persisted set of moderation-inbox filters. Section 11.3.4 owns the product behaviour.
Saved views are per user, not per workspace, and that is the design decision worth stating: a
saved view is a working preference, and two moderators splitting an inbox by tag want different
views without negotiating. A view can be shared explicitly, which copies it to the workspace
scope by setting is_shared; sharing is an act, not the default.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
user_id |
uuid |
No | — | Owner of the view |
name |
text |
No | — | 1–60 chars |
filter |
jsonb |
No | '{}' |
The saved query: {"status":["pending"],"kind":["video"],"tagIds":["…"],"minRating":4,"flagged":true,"search":"onboarding"}. Validated by inboxFilterSchema, the same schema the inbox endpoint validates its query string against, so a saved view can never encode a filter the API would reject |
sort |
text |
No | 'newest' |
newest, oldest, rating_desc, rating_asc |
position |
smallint |
No | — | 0-based order in the sidebar; unique per user per workspace, deferrable |
is_shared |
boolean |
No | false |
Visible to every member of the workspace when true |
is_default |
boolean |
No | false |
At most one per user per workspace; the view the inbox opens on |
last_used_at |
timestamptz |
Yes | null |
|
created_at / updated_at |
timestamptz |
No | now() |
|
deleted_at |
timestamptz |
Yes | null |
Soft delete, so an accidental removal is recoverable for 30 days |
Constraints
UNIQUE (workspace_id, user_id, name) WHERE deleted_at IS NULLUNIQUE (workspace_id, user_id, position) DEFERRABLE INITIALLY DEFERREDCHECK (char_length(name) BETWEEN 1 AND 60)CHECK (position BETWEEN 0 AND 24)— 25 saved views per user, a runaway guard rather than a product limit.CHECK (jsonb_typeof(filter) = 'object')CHECK (sort IN ('newest','oldest','rating_desc','rating_asc'))CREATE UNIQUE INDEX inbox_saved_views_one_default_uq ON inbox_saved_views (workspace_id, user_id) WHERE is_default AND deleted_at IS NULL;
Indexes: the uniques above;
inbox_saved_views_user_idx (workspace_id, user_id, position) WHERE deleted_at IS NULL — the
sidebar render; inbox_saved_views_shared_idx (workspace_id, position) WHERE is_shared AND deleted_at IS NULL
— the shared-views group.
Lifecycle and deletion. Cascade-deleted when the user is deleted and when the workspace is
purged. Removing a member soft-deletes their private views but leaves any view they shared, with
its user_id intact, because a shared view the team now depends on should not vanish when its
author leaves. Hard-deleted 30 days after deleted_at by retention.purge_soft_deleted.
5.7 Publishing tables #
5.7.1 widgets #
An embeddable surface. Section 13 owns the catalogue and the embed contract; this is the row the
snippet's widget id resolves to. public_id is the value pasted into the customer's HTML, so it
is immutable for the life of the row — there is no code path that updates it.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
public_id |
text |
No | — | wgt_ + Crockford base32 of id; globally unique; never updated |
name |
text |
No | — | Internal label, 1–120 chars |
type |
widget_type |
No | — | Six presets, Section 13.2 |
curation_mode |
widget_curation_mode |
No | 'filtered' |
filtered, curated, mixed |
config |
jsonb |
No | '{}' |
Per-type configuration, shape below |
theme_mode |
widget_theme_mode |
No | 'auto' |
light, dark, auto, custom |
accent_color |
char(7) |
Yes | null |
Null inherits the workspace accent |
font_scale |
numeric(3,2) |
Yes | null |
Null inherits workspace_settings.font_scale |
border_radius_px |
smallint |
Yes | null |
Null inherits |
filter_tag_ids |
uuid[] |
No | '{}' |
Empty = no tag filter; OR semantics, max 20 |
filter_min_rating |
smallint |
Yes | null |
1–5 |
filter_kinds |
testimonial_kind[] |
No | '{}' |
Empty = both |
filter_language |
content_language |
Yes | null |
Null = all |
sort |
widget_sort |
No | 'newest' |
manual requires curation_mode <> 'filtered' |
max_items |
smallint |
No | 24 |
1–100; the payload budget in Section 13 assumes ≤ 24 for a wall |
is_active |
boolean |
No | true |
Inactive serves an empty payload with active:false, never an error |
is_badge_hidden |
boolean |
No | false |
Effective value is false on Free regardless of this column |
data_version |
integer |
No | 1 |
Bumped on any change affecting the payload; part of the CDN cache key |
payload_bytes |
integer |
Yes | null |
Size of the last built payload; alerted above 60 KB |
payload_built_at |
timestamptz |
Yes | null |
|
last_purged_at |
timestamptz |
Yes | null |
Last CDN tag purge |
created_by_user_id |
uuid |
Yes | null |
|
created_at / updated_at |
timestamptz |
No | now() |
|
deleted_at |
timestamptz |
Yes | null |
Soft delete; the data endpoint then returns 410 Gone with WIDGET_DELETED |
config shape per type (each validated by a discriminated-union Zod schema keyed on type):
type |
Keys |
|---|---|
wall_of_love |
layout (masonry|grid|list), columns (1–4), gap (sm|md|lg), showRating, showAvatar, showCompanyLogo, showDate, showSource, truncateAt (chars, 0 = never), showFilters |
carousel |
slidesPerView (1–4), loop, autoAdvanceSeconds (0 = off; forced to 0 under prefers-reduced-motion), showArrows, showDots, showRating |
single_quote |
rotateOnLoad, alignment (left|center), showAvatar, showRating, quoteMarkStyle |
video_reel |
thumbHeight (120–320), showTitles, showDuration, playsInline |
badge |
style (compact|full), showCount, linkToShowcase, minReviews |
popup |
position (bottom-left|bottom-right), delaySeconds (≥ 5), intervalSeconds (≥ 20), maxPerSession (1–5), dismissible (always true, present for explicitness) |
Constraints: UNIQUE (public_id);
CHECK (public_id ~ '^wgt_[0-9a-hjkmnp-tv-z]{26}$');
CHECK (max_items BETWEEN 1 AND 100);
CHECK (filter_min_rating IS NULL OR filter_min_rating BETWEEN 1 AND 5);
CHECK (coalesce(array_length(filter_tag_ids,1),0) <= 20);
CHECK (sort <> 'manual' OR curation_mode <> 'filtered');
CHECK (accent_color IS NULL OR accent_color ~ '^#[0-9a-f]{6}$').
Indexes: widgets_public_uq (public_id) — the only lookup the public data endpoint performs;
widgets_ws_idx (workspace_id, created_at DESC) WHERE deleted_at IS NULL;
widgets_tag_filter_gin GIN (filter_tag_ids) — "which widgets contain tag X?", used to compute
the purge set when a tag assignment changes.
Lifecycle. A widget is never hard-deleted while the workspace lives, because a customer's
page may still contain the snippet. Soft-deleted widgets return 410 Gone from the data endpoint
with a stable body so the loader can log a clear console warning and render nothing.
5.7.2 widget_testimonials #
Explicit curation. Present only when curation_mode is curated or mixed.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
widget_id |
uuid |
No | — | |
testimonial_id |
uuid |
No | — | |
position |
smallint |
No | — | 0-based, unique per widget, deferrable |
is_pinned |
boolean |
No | true |
In mixed mode, pinned rows lead and filtered results fill the remainder |
added_by_user_id |
uuid |
Yes | null |
|
created_at / updated_at |
timestamptz |
No | now() |
Constraints: UNIQUE (widget_id, testimonial_id);
UNIQUE (widget_id, position) DEFERRABLE INITIALLY DEFERRED;
CHECK (position BETWEEN 0 AND 99).
Indexes: widget_testimonials_widget_idx (widget_id, position) — the payload build;
widget_testimonials_testimonial_idx (workspace_id, testimonial_id) — the purge-set computation
when a testimonial changes. Hard-deleted by cascade from either parent.
5.7.3 showcase_pages #
A hosted public page of testimonials (Section 14). At most one per custom domain; a workspace may have several, each on its own slug.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
slug |
citext |
No | — | Unique per workspace; served at /s/{slug} |
custom_domain_id |
uuid |
Yes | null |
When set and the domain is active, the page is also served at the domain root |
title |
text |
No | — | 1–120 chars |
headline |
text |
No | — | 1–160 chars |
intro |
text |
No | '' |
≤ 1,000 chars, sanitized |
seo_title |
text |
Yes | null |
≤ 60 chars recommended, 120 hard limit |
seo_description |
text |
Yes | null |
≤ 160 chars recommended, 320 hard limit |
og_image_object_key |
text |
Yes | null |
Falls back to a generated rating-summary card |
layout |
text |
No | 'grid' |
grid, masonry, list |
theme_mode |
widget_theme_mode |
No | 'auto' |
|
accent_color |
char(7) |
Yes | null |
|
filter_tag_ids |
uuid[] |
No | '{}' |
|
filter_min_rating |
smallint |
Yes | null |
|
show_rating_summary |
boolean |
No | true |
|
show_filters |
boolean |
No | true |
|
items_per_page |
smallint |
No | 24 |
12–60; cursor-paginated like every other collection |
cta_label |
text |
Yes | null |
≤ 40 chars |
cta_url |
text |
Yes | null |
HTTPS only |
is_published |
boolean |
No | false |
|
is_indexable |
boolean |
No | true |
Emits noindex when false |
published_at |
timestamptz |
Yes | null |
|
created_at / updated_at |
timestamptz |
No | now() |
|
deleted_at |
timestamptz |
Yes | null |
Soft delete |
Constraints: UNIQUE (workspace_id, slug) WHERE deleted_at IS NULL;
UNIQUE (custom_domain_id) WHERE custom_domain_id IS NOT NULL AND deleted_at IS NULL;
CHECK (items_per_page BETWEEN 12 AND 60);
CHECK (cta_url IS NULL OR cta_url ~ '^https://');
CHECK ((cta_label IS NULL) = (cta_url IS NULL)).
Indexes: the two partial uniques; showcase_pages_ws_idx (workspace_id, is_published).
5.7.4 custom_domains #
Section 14 owns the verification flow; this table is its state. Seven states exist and all seven
are shown in the UI — there is no hidden eighth. Six of them are steps on the setup path that a
customer walks; the seventh, suspended, is the only one a customer action can neither enter
nor leave, because it is entered solely by the billing downgrade path in Section 19.4 E6 and
left solely by the upgrade path in Section 19.12.5.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
hostname |
citext |
No | — | Lowercase, punycode-encoded, no trailing dot; globally unique |
kind |
domain_kind |
No | — | subdomain or apex; decides CNAME vs ALIAS/A |
state |
domain_state |
No | 'pending_dns' |
pending_dns, dns_detected, verifying, issuing_tls, active, suspended, failed |
state_changed_at |
timestamptz |
No | now() |
Drives the "last updated" line in the UI |
suspended_at |
timestamptz |
Yes | null |
When the plan stopped covering this domain |
pre_suspend_state |
domain_state |
Yes | null |
The state to restore on upgrade; recorded for the same reason testimonials.pre_archive_status is |
challenge_token |
text |
No | — | 32 random base62 chars, published at _vouch-challenge.{hostname} |
required_records |
jsonb |
No | — | Exactly what the customer must create, generated at insert |
observed_records |
jsonb |
No | '[]' |
What was actually seen at the authoritative nameservers on the last check |
records_match |
boolean |
No | false |
Whether observed satisfies required |
last_checked_at |
timestamptz |
Yes | null |
Shown in the UI verbatim |
next_check_at |
timestamptz |
Yes | null |
Server-side repeatable check, every 5 min for 72 h |
check_count |
integer |
No | 0 |
|
verified_at |
timestamptz |
Yes | null |
Ownership proven |
acme_provider |
text |
Yes | null |
letsencrypt or zerossl |
acme_order_url |
text |
Yes | null |
For operator debugging |
acme_challenge_type |
text |
Yes | null |
http-01 or dns-01 |
tls_issued_at |
timestamptz |
Yes | null |
|
tls_expires_at |
timestamptz |
Yes | null |
Renewal at 30 days, alert at 14, page at 7 |
tls_renewal_attempted_at |
timestamptz |
Yes | null |
|
tls_renewal_failures |
smallint |
No | 0 |
|
failure_code |
text |
Yes | null |
One of the catalogued domain failure codes owned by Section 14.3.2, e.g. DNS_NOT_FOUND, DNS_RECORD_MISMATCH, CAA_FORBIDS_ISSUANCE, ACME_RATE_LIMITED |
failure_reason |
text |
Yes | null |
Plain-English, shown to the customer |
is_primary |
boolean |
No | false |
At most one primary per workspace |
add_on_subscription_item_id |
uuid |
Yes | null |
The billed line item, when this domain is an extra |
created_by_user_id |
uuid |
Yes | null |
|
created_at / updated_at |
timestamptz |
No | now() |
|
deleted_at |
timestamptz |
Yes | null |
Soft delete; removing a domain never changes any embed snippet |
Constraints: custom_domains_hostname_uk — a global partial unique index on hostname,
which is what makes "one domain, one workspace" enforceable; UNIQUE (challenge_token);
CHECK (hostname ~ '^(?!-)[a-z0-9-]{1,63}(\.[a-z0-9-]{1,63})+$') expressed in Postgres as
CHECK (hostname ~ '^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$');
CHECK (state <> 'active' OR (verified_at IS NOT NULL AND tls_issued_at IS NOT NULL));
CHECK (state <> 'failed' OR failure_code IS NOT NULL);
CHECK ((state = 'suspended') = (suspended_at IS NOT NULL));
CHECK (state <> 'suspended' OR pre_suspend_state IS NOT NULL);
CREATE UNIQUE INDEX custom_domains_one_primary_uq ON custom_domains (workspace_id) WHERE is_primary AND deleted_at IS NULL;
-- The hostname claim. Global, not per workspace, and NOT restricted by state.
CREATE UNIQUE INDEX custom_domains_hostname_uk
ON custom_domains (hostname) WHERE deleted_at IS NULL;The claim is held in all seven states, not only active. The index predicate is
deleted_at IS NULL and deliberately says nothing about state. The moment a workspace adds a
hostname it holds that hostname, right through pending_dns, dns_detected, verifying,
issuing_tls, suspended and failed, and it keeps holding it until the row is soft-deleted.
Narrowing the predicate to state = 'active' would look tidier and would be a live
vulnerability: a domain parked mid-verification by one workspace could be claimed by another,
and whichever finished verifying second would find its hostname taken — or worse, two workspaces
would each believe they owned the routing for the same name. Releasing the claim is an explicit
act (deleting the domain), never a side effect of a verification stalling or a plan lapsing.
Indexes: the uniques above; custom_domains_ws_idx (workspace_id, state);
custom_domains_check_due_idx (next_check_at) WHERE next_check_at IS NOT NULL AND state NOT IN ('active','suspended');
custom_domains_renewal_idx (tls_expires_at) WHERE state = 'active'.
On suspended. A downgrade that leaves a workspace with more custom domains than its plan
covers suspends the excess rather than deleting them: the certificate is left to expire, the edge
stops routing the hostname, and the row keeps everything needed to bring it back in one click on
upgrade. Deletion would destroy the customer's DNS work and their certificate history for a
billing event that is frequently temporary. A suspended domain is excluded from the
custom_domains.active usage metric (Section 19.5.1) and from certificate renewal, and every
widget payload in the workspace is rebuilt so that permalinkUrl falls back to the platform host
before the hostname stops answering (Section 19.4 E6).
Lifecycle. Deleting a domain sets deleted_at, cancels the ACME order, removes the edge
routing entry, and — if an add-on item exists — removes the Stripe subscription item with
proration. Widgets are untouched: the loader origin is a platform-owned host and never carries a
customer domain, so no snippet ever needs re-pasting (Section 13.1).
5.8 Import tables #
5.8.1 import_connections #
An authorized link to a review source. CSV imports have no connection row; every other source does.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
source |
import_source |
No | — | google, g2, capterra, x (never csv) |
external_account_id |
text |
No | — | Provider account/listing/profile id |
external_account_name |
text |
Yes | null |
Display label |
scopes |
text[] |
No | '{}' |
Granted OAuth scopes as returned |
access_token_encrypted |
text |
Yes | null |
Envelope-encrypted |
refresh_token_encrypted |
text |
Yes | null |
|
token_expires_at |
timestamptz |
Yes | null |
|
status |
import_connection_status |
No | 'connected' |
Includes entitlement_missing for the syndication-gated sources |
entitlement_checked_at |
timestamptz |
Yes | null |
|
sync_enabled |
boolean |
No | true |
|
sync_frequency |
text |
No | 'daily' |
manual or daily |
last_synced_at |
timestamptz |
Yes | null |
|
next_sync_at |
timestamptz |
Yes | null |
|
last_error_code |
text |
Yes | null |
|
last_error_at |
timestamptz |
Yes | null |
|
consecutive_failures |
smallint |
No | 0 |
Disables sync at 5 and notifies |
created_by_user_id |
uuid |
Yes | null |
|
created_at / updated_at |
timestamptz |
No | now() |
|
deleted_at |
timestamptz |
Yes | null |
Soft delete; tokens are wiped on delete |
Constraints: UNIQUE (workspace_id, source, external_account_id) WHERE deleted_at IS NULL;
CHECK (source <> 'csv'); CHECK (sync_frequency IN ('manual','daily'));
CHECK (status <> 'connected' OR access_token_encrypted IS NOT NULL).
Indexes: the unique; import_connections_due_idx (next_sync_at) WHERE sync_enabled AND deleted_at IS NULL AND status = 'connected'.
entitlement_missing is a first-class state, not an error. For G2 and Capterra, a workspace
without a syndication entitlement gets a connection row in that state and the UI routes them to
the customer-supplied export path. Nothing fails, nothing 500s, and the flow is documented in
Section 16.3.
5.8.2 import_runs #
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
import_connection_id |
uuid |
Yes | null |
Null for CSV |
source |
import_source |
No | — | Includes csv |
trigger |
import_trigger |
No | — | manual, scheduled, api, csv_upload |
status |
import_run_status |
No | 'queued' |
|
file_object_key |
text |
Yes | null |
The uploaded CSV, retained 30 days |
file_bytes |
integer |
Yes | null |
Max 10 MB |
column_mapping |
jsonb |
Yes | null |
CSV column → canonical field |
cursor |
jsonb |
Yes | null |
Provider pagination state, so a resumed run does not restart |
fetched_count |
integer |
No | 0 |
|
created_count |
integer |
No | 0 |
New imported_reviews rows |
duplicate_count |
integer |
No | 0 |
Exact (source, source_review_id) matches |
near_duplicate_count |
integer |
No | 0 |
Flagged by text hash similarity |
skipped_count |
integer |
No | 0 |
Below rating floor, unsupported language, empty body |
failed_count |
integer |
No | 0 |
|
error_summary |
jsonb |
No | '[]' |
[{"row":42,"code":"INVALID_RATING","message":"…"}], capped at 100 entries |
started_at / finished_at |
timestamptz |
Yes | null |
|
actor_type |
actor_type |
No | 'user' |
|
actor_user_id |
uuid |
Yes | null |
|
created_at / updated_at |
timestamptz |
No | now() |
Constraints: CHECK (source = 'csv' OR import_connection_id IS NOT NULL);
CHECK (source <> 'csv' OR file_object_key IS NOT NULL);
CHECK (file_bytes IS NULL OR file_bytes <= 10485760);
CHECK (jsonb_array_length(error_summary) <= 100).
Indexes: import_runs_ws_idx (workspace_id, created_at DESC);
import_runs_connection_idx (import_connection_id, created_at DESC).
5.8.3 imported_reviews #
The normalized landing zone. Every row here becomes a pending testimonial through the same
moderation pipeline as collected content — there is no separate inbox.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
import_run_id |
uuid |
No | — | |
import_connection_id |
uuid |
Yes | null |
|
source |
import_source |
No | — | |
source_review_id |
text |
No | — | Provider id; for CSV, a deterministic hash of the row |
raw_payload |
jsonb |
No | — | Verbatim provider record |
author_name |
text |
Yes | null |
Display name as published by the source |
author_handle |
text |
Yes | null |
|
author_avatar_url |
text |
Yes | null |
Remote URL; fetched and re-hosted on conversion |
author_role |
text |
Yes | null |
|
author_company |
text |
Yes | null |
|
body_text |
text |
No | — | The review body; immutable forever |
rating |
smallint |
Yes | null |
Canonical 1–5 |
source_rating_raw |
text |
Yes | null |
Original value, e.g. "8/10" |
source_rating_scale |
text |
Yes | null |
e.g. "1-10" |
permalink |
text |
No | — | Link back to the original |
reviewed_at |
timestamptz |
Yes | null |
When it was written on the source |
language |
content_language |
Yes | null |
Detected; null when outside the five |
dedupe_hash |
char(64) |
No | — | SHA-256 of lower(unaccent(trim(body_text))) with runs of whitespace collapsed |
near_duplicate_of_testimonial_id |
uuid |
Yes | null |
Set when trigram similarity ≥ 0.85 |
state |
imported_review_state |
No | 'new' |
new, converted, duplicate, skipped, rejected |
testimonial_id |
uuid |
Yes | null |
Set on conversion |
created_at / updated_at |
timestamptz |
No | now() |
Constraints: UNIQUE (workspace_id, source, source_review_id) — the exact-duplicate guard;
UNIQUE (testimonial_id);
CHECK (rating IS NULL OR rating BETWEEN 1 AND 5);
CHECK (permalink ~ '^https://');
CHECK (state <> 'converted' OR testimonial_id IS NOT NULL).
Indexes: the uniques; imported_reviews_ws_state_idx (workspace_id, state, created_at DESC);
imported_reviews_dedupe_idx (workspace_id, dedupe_hash);
imported_reviews_trgm_idx GIN (body_text gin_trgm_ops) — the near-duplicate warning, the only
place trigram search is used.
ALTER TABLE testimonials ADD CONSTRAINT testimonials_imported_fk
FOREIGN KEY (imported_review_id) REFERENCES imported_reviews(id) ON DELETE SET NULL;5.9 Privacy tables #
5.9.1 consent_versions #
The canonical consent texts. A platform-owned default set exists with workspace_id IS NULL;
a workspace that customizes its wording gets its own rows. This is the one table where the
nullable-tenant pattern is used for a customer-visible entity, and it is deliberate: a workspace
must be able to point at the platform default without copying it.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
Yes | null |
Null = platform default |
version |
integer |
No | — | Monotonic per scope |
locale |
content_language |
No | 'en' |
|
publish_text |
text |
No | — | The required grant |
advertising_text |
text |
No | — | Optional grant |
case_study_text |
text |
No | — | Optional grant |
full_text |
text |
No | — | The complete rendered block, exactly as shown |
text_sha256 |
char(64) |
No | — | Digest of full_text |
effective_from |
timestamptz |
No | now() |
|
retired_at |
timestamptz |
Yes | null |
Never deleted — historical consents point here |
created_by_user_id |
uuid |
Yes | null |
|
created_at |
timestamptz |
No | now() |
Append-only; no updated_at |
Constraints: UNIQUE (workspace_id, version, locale) (with workspace_id IS NULL treated as
its own scope via UNIQUE NULLS NOT DISTINCT); CHECK (version >= 1);
CHECK (text_sha256 = encode(digest(full_text,'sha256'),'hex')) — the digest cannot drift from
the text, checked by the database itself.
Enforcement: REVOKE UPDATE, DELETE ... FROM vouch_app. A wording change is a new row.
5.9.2 consents #
One row per grant per submission. Append-only: a withdrawal inserts a superseding row rather than mutating the original, because the original is the evidence.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
submission_id |
uuid |
No | — | |
testimonial_id |
uuid |
Yes | null |
Backfilled when the testimonial is created |
consent_group_id |
uuid |
No | — | Groups the grants captured together |
consent_version_id |
uuid |
No | — | |
kind |
consent_kind |
No | — | publish (required), advertising, case_study_contact |
granted |
boolean |
No | — | Never defaulted — the checkbox is never pre-checked |
granted_at |
timestamptz |
No | — | |
consent_text |
text |
No | — | The exact text rendered for this grant, stored verbatim |
consent_text_sha256 |
char(64) |
No | — | |
supersedes_consent_id |
uuid |
Yes | null |
Set on a withdrawal row |
withdrawal_reason |
text |
Yes | null |
≤ 500 chars, respondent's own words if supplied |
ip_truncated |
inet |
No | — | /24 IPv4, /48 IPv6 |
user_agent |
text |
Yes | null |
≤ 512 chars |
request_page_id |
uuid |
No | — | Where it was captured |
locale |
content_language |
No | — | |
created_at |
timestamptz |
No | now() |
Append-only |
Constraints: UNIQUE (consent_group_id, kind, coalesce(supersedes_consent_id, '00000000-0000-0000-0000-000000000000'::uuid))
expressed as a unique index over (consent_group_id, kind) WHERE supersedes_consent_id IS NULL
plus a plain index for superseding rows;
CHECK (consent_text_sha256 = encode(digest(consent_text,'sha256'),'hex'));
CHECK (kind <> 'publish' OR granted OR supersedes_consent_id IS NOT NULL) — the required grant
can only be false as a withdrawal.
Indexes: consents_testimonial_idx (workspace_id, testimonial_id, kind);
consents_submission_idx (submission_id);
consents_group_idx (consent_group_id).
Enforcement: REVOKE UPDATE, DELETE ON consents FROM vouch_app; except through the erasure
path described in Section 22.6, which runs as vouch_migrator under an audited procedure.
5.9.3 respondent_tokens #
The stable, account-free link that lets a respondent see, export, withdraw, or erase what was
published about them: https://v.vouch.app/p/{token}.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
testimonial_id |
uuid |
No | — | Subject |
submission_id |
uuid |
Yes | null |
|
token_hash |
char(64) |
No | — | SHA-256 of a 32-byte token |
token_prefix |
char(8) |
No | — | For support to identify a link without seeing it |
issued_at |
timestamptz |
No | now() |
|
last_used_at |
timestamptz |
Yes | null |
|
use_count |
integer |
No | 0 |
|
rotated_from_id |
uuid |
Yes | null |
Rotation chain |
revoked_at |
timestamptz |
Yes | null |
|
expires_at |
timestamptz |
Yes | null |
Null = never expires, which is the default: a |
| respondent's right to withdraw does not lapse | ||||
created_at / updated_at |
timestamptz |
No | now() |
Constraints: UNIQUE (token_hash);
UNIQUE (testimonial_id) WHERE revoked_at IS NULL — exactly one live token per testimonial.
Indexes: the uniques; respondent_tokens_testimonial_idx (testimonial_id).
Rate limiting on this endpoint is 10 requests/minute per IP (Section 7.10).
5.9.4 data_subject_requests #
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
type |
dsr_type |
No | — | export, erasure, withdrawal |
status |
dsr_status |
No | 'received' |
received, verifying, verified, in_progress, completed, rejected |
subject_email |
citext |
Yes | null |
|
testimonial_id |
uuid |
Yes | null |
Scoped requests |
respondent_token_id |
uuid |
Yes | null |
When raised from the respondent link |
verification_method |
dsr_verification_method |
No | — | respondent_token, email_challenge, workspace_admin, support_manual |
verified_at |
timestamptz |
Yes | null |
|
requested_at |
timestamptz |
No | now() |
|
due_at |
timestamptz |
No | requested_at + 30 days |
The statutory clock, stored not computed |
started_at |
timestamptz |
Yes | null |
|
completed_at |
timestamptz |
Yes | null |
|
artifacts |
jsonb |
No | '{}' |
{"exportObjectKey":"…","exportBytes":123,"expiresAt":"…"} |
deleted_entity_counts |
jsonb |
No | '{}' |
Proof of what an erasure removed, per table |
rejection_reason |
text |
Yes | null |
|
actor_type |
actor_type |
No | 'respondent' |
Who raised it |
actor_user_id |
uuid |
Yes | null |
|
notes |
text |
Yes | null |
Operator notes, ≤ 2,000 chars |
created_at / updated_at |
timestamptz |
No | now() |
Constraints: CHECK (status <> 'completed' OR completed_at IS NOT NULL);
CHECK (status <> 'rejected' OR rejection_reason IS NOT NULL);
CHECK (due_at > requested_at).
Indexes: dsr_ws_status_idx (workspace_id, status, due_at);
dsr_due_idx (due_at) WHERE status NOT IN ('completed','rejected') — the overdue alert, which
pages an operator at due_at - 5 days.
5.9.5 consent_grants #
The current state of each grant, projected from the append-only consents ledger. One row
per (consent_group_id, kind); every capture writes one consents row and one consent_grants
row per grant, and every withdrawal appends a new consents row and updates the matching
consent_grants row in the same transaction.
Two tables rather than one, because they answer two different questions and the answers have
different shapes. consents answers "what exactly was agreed, in what words, from what address,
at what moment?" — it is evidence, it is append-only, and reading it correctly means finding the
newest row in a supersession chain. consent_grants answers "may we publish this right now?" —
a question asked on every publish, every widget payload build, and every social-card render, and
one that must be a single indexed lookup rather than a window function over a ledger. The
projection is derived, never authoritative: if the two ever disagree, consents is right and
consent.reproject rebuilds the row.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
consent_group_id |
uuid |
No | — | The group these grants were captured in |
submission_id |
uuid |
No | — | Provenance |
testimonial_id |
uuid |
Yes | null |
Backfilled when the testimonial is created |
kind |
consent_kind |
No | — | publish, advertising, case_study_contact |
is_granted |
boolean |
No | — | The current answer, and the only column a hot path reads |
current_consent_id |
uuid |
No | — | The consents row this projection reflects — always the newest in the supersession chain |
granted_at |
timestamptz |
Yes | null |
When the grant was given; retained through a withdrawal so "granted then withdrawn" is legible |
withdrawn_at |
timestamptz |
Yes | null |
Null while granted |
consent_version_id |
uuid |
No | — | The wording in force for the current state |
reprojected_at |
timestamptz |
Yes | null |
Set by consent.reproject; a non-null value on a row nobody withdrew is a bug worth investigating |
created_at / updated_at |
timestamptz |
No | now() |
Constraints
UNIQUE (consent_group_id, kind)— the projection is one row per grant, by definition.CHECK (is_granted = (withdrawn_at IS NULL))— the boolean and the timestamp cannot disagree.CHECK (withdrawn_at IS NULL OR granted_at IS NOT NULL)CHECK (kind <> 'publish' OR is_granted OR withdrawn_at IS NOT NULL)— the required grant is never simply absent; it is granted, or it was granted and withdrawn.
Indexes
consent_grants_group_kind_uq(unique above) — the projection upsert.consent_grants_testimonial_idxon(workspace_id, testimonial_id, kind) INCLUDE (is_granted)— the publish-time check, an index-only scan.consent_grants_withdrawn_idxon(workspace_id, withdrawn_at DESC) WHERE withdrawn_at IS NOT NULL— the withdrawal report and the 60-second withdrawal guarantee's verification query.
CREATE TABLE consent_grants (
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
consent_group_id uuid NOT NULL,
submission_id uuid NOT NULL REFERENCES submissions(id) ON DELETE CASCADE,
testimonial_id uuid REFERENCES testimonials(id) ON DELETE SET NULL,
kind consent_kind NOT NULL,
is_granted boolean NOT NULL,
current_consent_id uuid NOT NULL REFERENCES consents(id) ON DELETE RESTRICT,
granted_at timestamptz,
withdrawn_at timestamptz,
consent_version_id uuid NOT NULL REFERENCES consent_versions(id) ON DELETE RESTRICT,
reprojected_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT consent_grants_group_kind_uq UNIQUE (consent_group_id, kind),
CONSTRAINT consent_grants_agree_ck CHECK (is_granted = (withdrawn_at IS NULL)),
CONSTRAINT consent_grants_order_ck CHECK (withdrawn_at IS NULL OR granted_at IS NOT NULL),
CONSTRAINT consent_grants_publish_ck CHECK (kind <> 'publish' OR is_granted OR withdrawn_at IS NOT NULL)
);
CREATE INDEX consent_grants_testimonial_idx
ON consent_grants (workspace_id, testimonial_id, kind) INCLUDE (is_granted);
CREATE INDEX consent_grants_withdrawn_idx
ON consent_grants (workspace_id, withdrawn_at DESC) WHERE withdrawn_at IS NOT NULL;Lifecycle and deletion. Written in the submit transaction alongside the consents rows,
updated in the withdrawal transaction, and hard-deleted with its submission. Unlike consents,
this table is not append-only and carries no REVOKE: it is a cache with a database behind
it. A GDPR erasure deletes the projection outright and records the count in
data_subject_requests.deleted_entity_counts and in erasure_log; the audited procedure in
Section 22.6 remains the only path that touches consents itself.
5.9.6 suppressions #
The list of addresses this workspace may not contact, whatever the sending path. Section 20.8 owns the policy; this is the table every sender checks.
Suppression is per workspace, deliberately. One customer's respondent unsubscribing from
their invitations must not silence a different customer who has an independent relationship with
the same person. The tenancy column is NOT NULL for exactly this reason, and there is no
platform-wide suppression row — a platform-level block is an abuse_blocks row, which is a
different thing with a different remedy.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
channel |
invite_channel |
No | — | email or sms |
address_hash |
char(64) |
No | — | SHA-256 of the normalized address (lowercased email, E.164 phone). The comparison key |
address_masked |
text |
No | — | j•••@e•••.com — enough for support to confirm a match, never the raw value |
address_encrypted |
text |
Yes | null |
Envelope-encrypted original, retained only where a regulator may require proof of who was suppressed; null everywhere else |
reason |
suppression_reason |
No | — | unsubscribe, hard_bounce, complaint, manual, erasure |
source |
text |
No | — | respondent_link, provider_webhook, dashboard, dsr, import |
contact_id |
uuid |
Yes | null |
The contact this came from, when there was one |
evidence |
jsonb |
No | '{}' |
Provider bounce code, complaint feedback loop id, or the request id of the unsubscribe click |
suppressed_at |
timestamptz |
No | now() |
|
expires_at |
timestamptz |
Yes | null |
Null means forever, and forever is the default. Only hard_bounce may carry an expiry, and only when a later successful delivery to the same address proves the bounce was transient |
released_at |
timestamptz |
Yes | null |
Set when a suppression is lifted; the row is kept so the lift is auditable |
released_by_user_id |
uuid |
Yes | null |
|
created_at / updated_at |
timestamptz |
No | now() |
Constraints
UNIQUE (workspace_id, channel, address_hash) WHERE released_at IS NULL— one live suppression per address per channel; re-suppressing is an update, not a second row.CHECK (reason <> 'hard_bounce' OR expires_at IS NULL OR expires_at > suppressed_at)CHECK (reason = 'hard_bounce' OR expires_at IS NULL)— an unsubscribe never expires.CHECK (source IN ('respondent_link','provider_webhook','dashboard','dsr','import'))CHECK (released_by_user_id IS NULL OR released_at IS NOT NULL)
Indexes
suppressions_live_uq(partial unique above) — the query: one probe before every send.suppressions_ws_recent_idxon(workspace_id, suppressed_at DESC)— the suppression screen.suppressions_contact_idxon(contact_id) WHERE contact_id IS NOT NULL.
CREATE TABLE suppressions (
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
channel invite_channel NOT NULL,
address_hash char(64) NOT NULL,
address_masked text NOT NULL,
address_encrypted text,
reason suppression_reason NOT NULL,
source text NOT NULL,
contact_id uuid REFERENCES contacts(id) ON DELETE SET NULL,
evidence jsonb NOT NULL DEFAULT '{}'::jsonb,
suppressed_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz,
released_at timestamptz,
released_by_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT suppressions_source_ck CHECK (source IN ('respondent_link','provider_webhook','dashboard','dsr','import')),
CONSTRAINT suppressions_expiry_ck CHECK (reason = 'hard_bounce' OR expires_at IS NULL),
CONSTRAINT suppressions_window_ck CHECK (expires_at IS NULL OR expires_at > suppressed_at),
CONSTRAINT suppressions_release_ck CHECK (released_by_user_id IS NULL OR released_at IS NOT NULL)
);
CREATE UNIQUE INDEX suppressions_live_uq
ON suppressions (workspace_id, channel, address_hash) WHERE released_at IS NULL;
CREATE INDEX suppressions_ws_recent_idx ON suppressions (workspace_id, suppressed_at DESC);
CREATE INDEX suppressions_contact_idx ON suppressions (contact_id) WHERE contact_id IS NOT NULL;Lifecycle and deletion. Never swept and never expired away except by the narrow
hard_bounce case above. A suppression row outlives the contact it came from — that is the
entire point, since deleting the contact must not make it legal to mail them again — which is why
contact_id is ON DELETE SET NULL and the address is kept as a hash rather than as a contact
reference. Suppressions are hard-deleted only with the workspace.
5.9.7 erasure_log #
One row per testimonial erased. This is the table that makes both erasure guarantees in Section 22 real rather than aspirational: the external verification that the media is actually gone from the video provider, the object store and the CDN, and the backup re-erasure that stops a restore resurrecting what we promised to destroy.
The problem it solves, stated precisely, because the failure mode is silent. Erasure hard-
deletes the testimonials row, which cascades to media_assets and media_derivatives. Any
verification that tries to read the provider asset id or the storage prefix from those tables
after the fact reads columns that no longer exist on rows that no longer exist: it gets NULL,
finds nothing to check against the provider, returns zero rows, and that empty result looks
exactly like a clean pass. A proof that cannot fail is not a proof. The only way to verify an
external deletion is to have written down the external coordinates before deleting anything.
So this row is written at the start of the erasure — the freeze step, before a single row or
object is destroyed — capturing the provider asset ids, the storage prefix and the CDN urls while
they are still readable. The verification job then reads this table, not the wreckage, and asks
the provider and the object store directly. A row whose erasure_completed_at is set but whose
verified_at is still null is an erasure that claims to be done and has not been proven, which is
precisely the state an alert should fire on.
The record therefore outlives everything it describes, by design. It holds no personal data: an asset id, a storage prefix, a set of urls, and a salted subject key — nothing that identifies a person to anyone who does not already hold the key.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column. Carries no foreign key — see the note below |
testimonial_id |
uuid |
No | — | The testimonial erased. Carries no foreign key, deliberately: the row it names is destroyed by the erasure this row exists to prove |
provider_asset_ids |
text[] |
No | '{}' |
Every media-provider asset id attached to the testimonial, captured at freeze. The verification job asks the provider whether each is gone |
storage_prefix |
text |
No | — | The object-storage key prefix covering every object of this testimonial — workspaces/{workspaceId}/…/{testimonialId}/ per Section 5.1.11. The verification job lists this prefix and expects zero keys |
cdn_urls |
text[] |
No | '{}' |
The public urls that must stop resolving: poster, captions, social cards, and any permalink built from a custom domain. Checked for a 404 or 410 |
requested_at |
timestamptz |
No | — | When the erasure was requested. The statutory clock in Section 22 runs from here |
erasure_completed_at |
timestamptz |
Yes | null |
When the destroying steps finished. Null means the erasure is in flight or failed part-way — a row that stays null is an incident, not a record |
verified_at |
timestamptz |
Yes | null |
When the external check confirmed the provider, the object store and the CDN all agree the media is gone. Null with a non-null erasure_completed_at is an unproven erasure and alerts |
data_subject_request_id |
uuid |
Yes | null |
The request that ordered it; null for an operator-initiated erasure under the abuse policy. No foreign key, for the same reason as the two above |
subject_key |
char(64) |
No | — | HMAC-SHA-256 of the normalized subject identifier under the platform erasure key. Deterministic, so the same person hashes identically on a restored copy; irreversible, so the log itself leaks nothing |
deleted_counts |
jsonb |
No | '{}' |
Per-table counts written at completion, e.g. {"testimonials":1,"consents":3,"media_derivatives":6} — the same proof mirrored into data_subject_requests.deleted_entity_counts |
verification_attempts |
smallint |
No | 0 |
External checks made. A provider is allowed to be briefly eventually-consistent; three failures page an operator |
verification_failure |
text |
Yes | null |
What was still present at the last failed check, ≤ 500 chars. The most operationally useful column in the table |
backups_cleared_at |
timestamptz |
Yes | null |
Set when the last backup predating erasure_completed_at has aged out. Until then this row must be replayed against any restore |
replay_count |
smallint |
No | 0 |
How many times this erasure has been replayed against a restored copy |
last_replayed_at |
timestamptz |
Yes | null |
|
created_at |
timestamptz |
No | now() |
Append-only; there is no updated_at |
No foreign keys, on any column, and that is the point. testimonial_id, workspace_id and
data_subject_request_id are all plain uuid columns with no REFERENCES clause. A foreign key
would cascade this row away at the exact moment it becomes the only remaining evidence — the
testimonial is destroyed by step 9 of the erasure, and a purged workspace takes its
data_subject_requests with it, so any of the three would delete the proof along with the thing
it proves. workspace_id is NOT NULL and is still the RLS predicate and the tenancy column in
every ordinary sense; it simply is not enforced by the database, because the one row that must
survive a workspace purge is the row recording that we destroyed someone's data. This is the only
table in the schema with a NOT NULL tenancy column and no foreign key behind it, and the
exception is deliberate rather than an oversight.
Constraints
UNIQUE (testimonial_id, requested_at)— a testimonial can only be erased once, but a re-request after a partial failure gets its own row rather than overwriting the first attempt.CHECK (erasure_completed_at IS NULL OR erasure_completed_at >= requested_at)CHECK (verified_at IS NULL OR erasure_completed_at IS NOT NULL)— nothing is verified before it is completed, so an out-of-order write is rejected rather than recorded.CHECK (verified_at IS NULL OR verified_at >= erasure_completed_at)CHECK (storage_prefix <> '')— an empty prefix would list the whole bucket and "verify" that a great many unrelated objects exist.CHECK (verification_attempts >= 0)CHECK (backups_cleared_at IS NULL OR erasure_completed_at IS NOT NULL)
Indexes
erasure_log_testimonial_idxon(testimonial_id)— "was this testimonial erased, and was it proven?", asked with an id that no longer resolves to anything else in the database.erasure_log_unverified_idxon(erasure_completed_at) WHERE verified_at IS NULL— the verification job's queue and the alert's query. Every row in it is an erasure we have claimed and not proven.erasure_log_subject_idxon(subject_key)— the replay lookup, which runs without workspace context because a restore replays every workspace's erasures at once.erasure_log_pending_backup_idxon(erasure_completed_at) WHERE backups_cleared_at IS NULL— the set that must be replayed against a restore.erasure_log_ws_idxon(workspace_id, requested_at DESC)— the workspace's erasure history.
CREATE TABLE erasure_log (
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL, -- no FK: must outlive a workspace purge
testimonial_id uuid NOT NULL, -- no FK: the row it names is destroyed
provider_asset_ids text[] NOT NULL DEFAULT '{}',
storage_prefix text NOT NULL,
cdn_urls text[] NOT NULL DEFAULT '{}',
requested_at timestamptz NOT NULL,
erasure_completed_at timestamptz,
verified_at timestamptz,
data_subject_request_id uuid, -- no FK: outlives the request
subject_key char(64) NOT NULL,
deleted_counts jsonb NOT NULL DEFAULT '{}'::jsonb,
verification_attempts smallint NOT NULL DEFAULT 0,
verification_failure text,
backups_cleared_at timestamptz,
replay_count smallint NOT NULL DEFAULT 0,
last_replayed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT erasure_log_uq UNIQUE (testimonial_id, requested_at),
CONSTRAINT erasure_log_completed_ck CHECK (erasure_completed_at IS NULL
OR erasure_completed_at >= requested_at),
CONSTRAINT erasure_log_verify_ord_ck CHECK (verified_at IS NULL OR erasure_completed_at IS NOT NULL),
CONSTRAINT erasure_log_verify_ck CHECK (verified_at IS NULL OR verified_at >= erasure_completed_at),
CONSTRAINT erasure_log_prefix_ck CHECK (storage_prefix <> ''),
CONSTRAINT erasure_log_attempts_ck CHECK (verification_attempts >= 0),
CONSTRAINT erasure_log_backup_ck CHECK (backups_cleared_at IS NULL
OR erasure_completed_at IS NOT NULL)
);
CREATE INDEX erasure_log_testimonial_idx ON erasure_log (testimonial_id);
CREATE INDEX erasure_log_unverified_idx ON erasure_log (erasure_completed_at)
WHERE verified_at IS NULL;
CREATE INDEX erasure_log_subject_idx ON erasure_log (subject_key);
CREATE INDEX erasure_log_pending_backup_idx ON erasure_log (erasure_completed_at)
WHERE backups_cleared_at IS NULL;
CREATE INDEX erasure_log_ws_idx ON erasure_log (workspace_id, requested_at DESC);The three-phase timeline. requested_at is written at the freeze step, together with the
provider ids, the storage prefix and the CDN urls, before anything is destroyed.
erasure_completed_at is written when the destroying steps finish. verified_at is written only
by the external verification job, after it has asked the provider and the object store and been
told the assets are gone. The three are separate columns rather than one status because the gap
between the second and the third is the interesting one: it is where an erasure that reported
success but left an asset behind becomes visible, and collapsing them into a single erased_at
is precisely the mistake that makes the guarantee unfalsifiable.
Enforcement. erasure_log is append-only in the same manner as audit_events
(Section 5.16.3): REVOKE UPDATE, DELETE from vouch_app. Two SECURITY DEFINER functions owned
by vouch_migrator provide the only writes after insert, and each can set nothing outside its own
short list — erasure_log_mark_verified(p_id uuid, p_ok boolean, p_failure text) sets
verified_at, verification_attempts and verification_failure, and
erasure_log_mark_replayed(p_id uuid, p_backups_cleared boolean) sets replay_count,
last_replayed_at and backups_cleared_at. An erasure record that the application could edit
would be worth nothing as evidence.
Lifecycle and deletion. Retained 6 years from erasure_completed_at, matching the
retention of the minimized consent record it corresponds to, then hard-deleted by
retention.purge_soft_deleted. It is exempt from the erasure it records: a subsequent
data-subject request from the same person does not remove these rows, because they contain no
personal data — only a salted key and a set of external coordinates — and destroying the proof of
a prior erasure on request would make the guarantee unauditable exactly when it is questioned.
Erasing or purging a workspace does not delete its erasure_log rows either, which is what the
absent foreign key on workspace_id buys.
5.10 Analytics tables #
Section 17 owns the measurement rules — no cookies, no local storage, no cross-site identifier.
The schema encodes that: there is no visitor id column anywhere, and view_id is a random value
that exists only in page memory and is never correlated across page loads.
5.10.1 analytics_events_raw #
Raw events, kept 7 days for debugging and rollup repair, then dropped a partition at a time.
Declaratively partitioned by day on occurred_at.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
occurred_at |
timestamptz |
No | — | Client-reported, clamped to [received_at - 10 min, received_at] |
received_at |
timestamptz |
No | now() |
Server clock |
workspace_id |
uuid |
No | — | Tenancy column |
widget_id |
uuid |
Yes | null |
Set for widget surfaces |
showcase_page_id |
uuid |
Yes | null |
Set for showcase surfaces |
request_page_id |
uuid |
Yes | null |
Set for request-page surfaces; this is what makes the collection funnel in Section 17.7 (request_started → request_captured → request_submitted) storable at all |
testimonial_id |
uuid |
Yes | null |
|
event_type |
analytics_event_type |
No | — | The eleven values of analytics_event_type, defined in Section 17.3 |
view_id |
char(22) |
No | — | Random per page view, memory-only on the client, used solely for within-view dedupe |
country_code |
char(2) |
Yes | null |
Derived from IP, then the IP is discarded |
device_class |
device_class |
No | 'unknown' |
|
referrer_host |
text |
Yes | null |
Host only |
is_bot |
boolean |
No | false |
Counted separately, never silently dropped |
bot_reason |
text |
Yes | null |
ua_match, impossible_timing, no_viewport, prefetch |
extras |
jsonb |
No | '{}' |
The event's per-event attributes — the object the beacon sends as x (Section 17.3). Carries everything that varies by event type instead of being encoded in the type name: {"pct":25} on video_progress, {"action":"next"} on interaction, {"href":"https://…"} on click, {"code":"MEDIA_DECODE"} on video_error, {"step":"consent"} on request_captured. Section 17.3 owns the shape per type |
No IP column exists. The address is used in-process to derive country_code and
device_class and is never written. That is a schema-level guarantee, not a policy.
Constraints: PRIMARY KEY (occurred_at, id);
CHECK (widget_id IS NOT NULL OR showcase_page_id IS NOT NULL OR request_page_id IS NOT NULL)
— every event names the surface it came from, and there are exactly three surfaces;
CHECK (country_code IS NULL OR country_code ~ '^[A-Z]{2}$');
CHECK (jsonb_typeof(extras) = 'object').
Indexes (per partition, created by the partition-maintenance job):
(workspace_id, occurred_at), (workspace_id, widget_id, event_type, occurred_at),
(workspace_id, request_page_id, event_type, occurred_at) for the funnel,
and (view_id) for dedupe verification.
CREATE TABLE analytics_events_raw (
id uuid NOT NULL,
occurred_at timestamptz NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
workspace_id uuid NOT NULL,
widget_id uuid,
showcase_page_id uuid,
request_page_id uuid,
testimonial_id uuid,
event_type analytics_event_type NOT NULL,
view_id char(22) NOT NULL,
country_code char(2),
device_class device_class NOT NULL DEFAULT 'unknown',
referrer_host text,
is_bot boolean NOT NULL DEFAULT false,
bot_reason text,
extras jsonb NOT NULL DEFAULT '{}'::jsonb,
PRIMARY KEY (occurred_at, id),
CONSTRAINT analytics_events_raw_target_ck CHECK (widget_id IS NOT NULL
OR showcase_page_id IS NOT NULL
OR request_page_id IS NOT NULL),
CONSTRAINT analytics_events_raw_country_ck CHECK (country_code IS NULL OR country_code ~ '^[A-Z]{2}$'),
CONSTRAINT analytics_events_raw_extras_ck CHECK (jsonb_typeof(extras) = 'object')
) PARTITION BY RANGE (occurred_at);Foreign keys are deliberately omitted on this table. It is a high-write append path, the referenced rows are validated at ingest, and a missing FK check per event is the difference between absorbing a traffic spike and queueing behind index maintenance. Referential integrity is restored at rollup time: a rollup row whose widget no longer exists is discarded.
5.10.2 analytics_rollup_hourly #
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
workspace_id |
uuid |
No | — | Tenancy column |
bucket_hour |
timestamptz |
No | — | Truncated to the hour, UTC |
widget_id |
uuid |
Yes | null |
Null row = workspace total |
request_page_id |
uuid |
Yes | null |
Null row = all request pages; carries the conversion funnel |
testimonial_id |
uuid |
Yes | null |
Null row = all testimonials |
event_type |
analytics_event_type |
No | — | Any of the eleven values; the rollup keys on the same set the raw table stores, so a new event type needs no rollup change |
country_code |
char(2) |
Yes | null |
|
device_class |
device_class |
No | — | |
event_count |
bigint |
No | 0 |
|
view_count |
bigint |
No | 0 |
Distinct view_id within the bucket — "unique-ish views" |
bot_event_count |
bigint |
No | 0 |
Filtered events, retained for transparency |
updated_at |
timestamptz |
No | now() |
Primary key: (workspace_id, bucket_hour, widget_id, request_page_id, testimonial_id, event_type, country_code, device_class)
declared with UNIQUE NULLS NOT DISTINCT semantics so null dimensions participate in the key and
INSERT ... ON CONFLICT DO UPDATE is a single idempotent statement. The rollup job is therefore
safe to re-run over any window, which is what makes rollup repair from the 7-day raw table
possible.
Indexes: the primary key; analytics_rollup_hourly_ws_time_idx (workspace_id, bucket_hour DESC);
analytics_rollup_hourly_funnel_idx (workspace_id, request_page_id, event_type, bucket_hour DESC) WHERE request_page_id IS NOT NULL
— the conversion funnel in Section 17.7.
Retention, and why the dashboard shows less than the table holds. Hourly rollups are stored for 35 days. The analytics UI offers hourly granularity only for the last 7 days (Section 17.8) and daily beyond that. The difference is deliberate: the extra 28 days exist for rollup repair and for operators investigating an incident three weeks later, not for the customer-facing granularity picker, where an hourly chart spanning a month is unreadable and expensive. After 35 days the hourly rows are compacted into the daily table and deleted.
5.10.3 analytics_rollup_daily #
Identical shape with bucket_date date NOT NULL (in workspace_settings.reporting_timezone)
instead of bucket_hour, plus unique_view_estimate bigint for the day-level distinct count,
which is computed from the hourly buckets and explicitly documented in the dashboard as an
estimate rather than an exact distinct count. Retained per plan: 30 days on Free, 12 months on
Starter, 24 months on Growth, 36 months on Scale (Section 19). The
retention.sweep_analytics_rollups job deletes rows past the workspace's plan retention nightly.
5.11 Platform tables #
5.11.1 audit_events #
Append-only, monthly-partitioned. Section 11.6 defines which actions must be recorded; this is the storage and the enforcement.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
occurred_at |
timestamptz |
No | now() |
Partition key |
workspace_id |
uuid |
No | — | Tenancy column |
actor_type |
actor_type |
No | — | user, api_key, system, respondent |
actor_user_id |
uuid |
Yes | null |
Retained after the user leaves the workspace |
actor_api_key_id |
uuid |
Yes | null |
|
actor_label |
text |
No | — | Denormalized display name at the time of the action, so history reads correctly after renames and removals |
action |
text |
No | — | resource.verb, e.g. testimonial.publish, member.role_changed |
resource_type |
text |
No | — | testimonial, widget, workspace_member, … |
resource_id |
uuid |
Yes | null |
Null for workspace-level actions |
before |
jsonb |
Yes | null |
Snapshot before, PII-redacted per Section 22.4 |
after |
jsonb |
Yes | null |
Snapshot after |
changed_keys |
text[] |
No | '{}' |
Cheap filter without opening the JSONB |
ip_truncated |
inet |
Yes | null |
|
user_agent |
text |
Yes | null |
≤ 512 chars |
request_id |
text |
Yes | null |
Correlates to logs and to the API response envelope |
metadata |
jsonb |
No | '{}' |
Action-specific extras |
Constraints: PRIMARY KEY (occurred_at, id);
CHECK (actor_type <> 'user' OR actor_user_id IS NOT NULL);
CHECK (actor_type <> 'api_key' OR actor_api_key_id IS NOT NULL);
CHECK (action ~ '^[a-z_]+\.[a-z_]+$').
Indexes: (workspace_id, occurred_at DESC); (workspace_id, resource_type, resource_id, occurred_at DESC);
(workspace_id, actor_user_id, occurred_at DESC); GIN (changed_keys).
Append-only enforcement is in Section 5.16.3. Retention: 24 months, then partitions are detached
and archived to object storage before being dropped.
5.11.2 outbox_events #
The transactional outbox. Every state change that must reach a webhook, an integration, a notification, or the analytics pipeline writes a row here in the same transaction as the change. Nothing publishes directly from a request handler.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
aggregate_type |
text |
No | — | testimonial, widget, custom_domain, workspace, … |
aggregate_id |
uuid |
Yes | null |
|
event_type |
text |
No | — | The catalogued name, e.g. testimonial.published (Section 29.4) |
payload |
jsonb |
No | — | The serialized public shape of the event |
dedupe_key |
text |
Yes | null |
Unique when present; makes at-least-once safe to retry |
occurred_at |
timestamptz |
No | now() |
|
published_at |
timestamptz |
Yes | null |
Set when the relay has fanned it out |
attempts |
smallint |
No | 0 |
|
next_attempt_at |
timestamptz |
No | now() |
|
last_error |
text |
Yes | null |
≤ 500 chars |
Constraints: UNIQUE (dedupe_key) WHERE dedupe_key IS NOT NULL;
CHECK (event_type ~ '^[a-z_]+\.[a-z_]+$').
Indexes: outbox_pending_idx (next_attempt_at, id) WHERE published_at IS NULL — the relay's
only query, an index-only scan. Rows are deleted 7 days after published_at.
5.11.3 webhook_endpoints #
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
url |
text |
No | — | HTTPS only; private/loopback/link-local ranges rejected at write time |
description |
text |
Yes | null |
≤ 200 chars |
secret_encrypted |
text |
No | — | The whsec_… value, envelope-encrypted |
secret_prefix |
char(12) |
No | — | Shown in the UI |
event_types |
text[] |
No | '{}' |
Empty = all events; each must exist in the catalogue |
is_active |
boolean |
No | true |
|
consecutive_failures |
smallint |
No | 0 |
|
recent_outcomes |
boolean[] |
No | '{}' |
The last 200 delivery outcomes, newest first, truncated on write. Feeds the failure-ratio disable rule without a scan of webhook_deliveries |
disabled_at |
timestamptz |
Yes | null |
Auto-disabled by any of the three rules in disabled_reason |
disabled_reason |
text |
Yes | null |
attempts_exhausted (an event exhausted all 9 sends), gone (the endpoint answered 410), failure_ratio (more than 90 % of the last 200 deliveries failed), or manual. Section 18.5 owns the rules; the reason is carried into the webhook.endpoint_disabled event so the customer is told which one fired |
last_success_at / last_failure_at |
timestamptz |
Yes | null |
|
created_by_user_id |
uuid |
Yes | null |
|
created_at / updated_at |
timestamptz |
No | now() |
|
deleted_at |
timestamptz |
Yes | null |
Soft delete |
Constraints: CHECK (url ~ '^https://'); CHECK (char_length(url) <= 2048);
UNIQUE (workspace_id, url) WHERE deleted_at IS NULL;
CHECK (disabled_reason IS NULL OR disabled_reason IN ('attempts_exhausted','gone','failure_ratio','manual'));
CHECK ((disabled_at IS NULL) = (disabled_reason IS NULL));
CHECK (coalesce(array_length(recent_outcomes,1),0) <= 200).
Indexes: the unique; webhook_endpoints_ws_active_idx (workspace_id) WHERE is_active AND deleted_at IS NULL.
5.11.4 webhook_deliveries #
One row per attempt per endpoint per event; attempt records which of the nine sends this
row represents — the first send plus eight retries — so the full history is inspectable.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
webhook_endpoint_id |
uuid |
No | — | |
outbox_event_id |
uuid |
Yes | null |
Null once the outbox row is swept |
event_type |
text |
No | — | |
payload |
jsonb |
No | — | Exactly what was sent, so a signature dispute is resolvable |
signature_timestamp |
bigint |
No | — | The t= value |
attempt |
smallint |
No | 1 |
1–9; attempt 1 is the initial send |
max_attempts |
smallint |
No | 9 |
|
status |
webhook_delivery_status |
No | 'pending' |
|
scheduled_for |
timestamptz |
No | now() |
Attempt 1 is immediate; the eight retry delays are 1 m, 5 m, 30 m, 2 h, 5 h, 10 h, 24 h, 24 h, giving a final attempt at roughly 65 h 36 m (Section 18.5) |
delivered_at |
timestamptz |
Yes | null |
|
response_status |
smallint |
Yes | null |
|
response_body_snippet |
text |
Yes | null |
First 512 bytes only |
duration_ms |
integer |
Yes | null |
|
error |
text |
Yes | null |
≤ 500 chars |
created_at / updated_at |
timestamptz |
No | now() |
Constraints: UNIQUE (webhook_endpoint_id, outbox_event_id, attempt);
CHECK (attempt BETWEEN 1 AND max_attempts).
Indexes: the unique; webhook_deliveries_due_idx (scheduled_for) WHERE status = 'pending';
webhook_deliveries_endpoint_idx (workspace_id, webhook_endpoint_id, created_at DESC).
Retained 30 days.
5.11.5 integration_connections #
Slack, HubSpot, and Zapier links. Zapier authenticates with an API key rather than OAuth, so its rows carry no tokens — the shape accommodates both.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
provider |
integration_provider |
No | — | slack, hubspot, zapier |
external_account_id |
text |
Yes | null |
Slack team id, HubSpot portal id |
external_account_name |
text |
Yes | null |
|
config |
jsonb |
No | '{}' |
Slack: {"channelId":"C123","channelName":"#wins","events":["testimonial.published"]}; HubSpot: object/property mapping |
access_token_encrypted |
text |
Yes | null |
|
refresh_token_encrypted |
text |
Yes | null |
|
token_expires_at |
timestamptz |
Yes | null |
|
scopes |
text[] |
No | '{}' |
|
api_key_id |
uuid |
Yes | null |
Zapier's key |
status |
integration_status |
No | 'connected' |
|
last_error |
text |
Yes | null |
|
last_used_at |
timestamptz |
Yes | null |
|
created_by_user_id |
uuid |
Yes | null |
|
created_at / updated_at |
timestamptz |
No | now() |
|
deleted_at |
timestamptz |
Yes | null |
Soft delete; tokens wiped |
Constraints: UNIQUE (workspace_id, provider, external_account_id) WHERE deleted_at IS NULL;
CHECK (provider <> 'zapier' OR api_key_id IS NOT NULL);
CHECK (provider = 'zapier' OR access_token_encrypted IS NOT NULL OR status <> 'connected').
5.11.6 idempotency_keys #
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
key |
text |
No | — | The client's Idempotency-Key, 8–255 chars |
endpoint |
text |
No | — | METHOD /api/v1/path |
request_digest |
char(64) |
No | — | SHA-256 of the canonicalized body; a reused key with a different body returns IDEMPOTENCY_KEY_REUSED (409) |
api_key_id |
uuid |
Yes | null |
|
state |
idempotency_state |
No | 'in_progress' |
|
locked_at |
timestamptz |
No | now() |
A concurrent replay while in_progress gets 409 IDEMPOTENCY_IN_PROGRESS |
response_status |
smallint |
Yes | null |
|
response_body |
jsonb |
Yes | null |
The full stored envelope, replayed verbatim |
completed_at |
timestamptz |
Yes | null |
|
expires_at |
timestamptz |
No | now() + interval '24 hours' |
|
created_at |
timestamptz |
No | now() |
Constraints: UNIQUE (workspace_id, endpoint, key);
CHECK (state <> 'completed' OR (response_status IS NOT NULL AND response_body IS NOT NULL)).
Indexes: the unique; idempotency_keys_expiry_idx (expires_at). Hard-deleted at expiry.
5.11.7 job_runs #
An observability record for every background job, independent of the queue backend, so that job history survives a Valkey flush. Section 24 owns the operational reading of this table; Section 29.5 catalogues the job names.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
Yes | null |
Null for platform-wide jobs |
queue |
text |
No | — | media, transcription, ai, email, sms, webhooks, imports, analytics, domains, maintenance |
job_name |
text |
No | — | e.g. media.purge |
queue_job_id |
text |
Yes | null |
The backend's own id |
payload |
jsonb |
No | '{}' |
Redacted: no tokens, no PII beyond identifiers |
status |
job_status |
No | 'queued' |
|
attempt |
smallint |
No | 1 |
|
max_attempts |
smallint |
No | 5 |
|
scheduled_for |
timestamptz |
Yes | null |
|
started_at / finished_at |
timestamptz |
Yes | null |
|
duration_ms |
integer |
Yes | null |
|
result |
jsonb |
Yes | null |
Small summary only |
error_code |
text |
Yes | null |
|
error_message |
text |
Yes | null |
≤ 1,000 chars |
stack_snippet |
text |
Yes | null |
First 2,000 chars, operator-only |
worker_host |
text |
Yes | null |
|
trace_id |
text |
Yes | null |
Correlates to the tracing backend |
created_at / updated_at |
timestamptz |
No | now() |
Constraints: CHECK (job_name ~ '^[a-z_]+\.[a-z_]+$') — job names are
domain.snake_case_verb and a hyphen cannot be stored, so the naming convention is enforced by
the database rather than by review.
Indexes: job_runs_name_status_idx (job_name, status, created_at DESC);
job_runs_ws_idx (workspace_id, created_at DESC);
job_runs_failed_idx (created_at DESC) WHERE status = 'failed'.
Retention is split by outcome: succeeded and canceled runs are kept 30 days, failed and
dead-lettered runs 180 days, both swept by retention.purge_job_runs. A failure is worth
six months because the question it answers — "has this been breaking quietly since the release in
March?" — cannot be asked of a 30-day window; a success is worth thirty days because nobody has
ever needed to know that a sweep ran cleanly last quarter.
5.11.8 stripe_events #
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
stripe_event_id |
text |
No | — | Unique — the idempotency guarantee for billing |
type |
text |
No | — | e.g. customer.subscription.updated |
api_version |
text |
Yes | null |
|
workspace_id |
uuid |
Yes | null |
Resolved from the customer id when possible |
payload |
jsonb |
No | — | Verbatim |
status |
text |
No | 'received' |
received, processed, ignored, failed |
attempts |
smallint |
No | 0 |
|
error |
text |
Yes | null |
|
received_at |
timestamptz |
No | now() |
|
processed_at |
timestamptz |
Yes | null |
Constraints: UNIQUE (stripe_event_id);
CHECK (status IN ('received','processed','ignored','failed')).
Indexes: the unique; stripe_events_unprocessed_idx (received_at) WHERE processed_at IS NULL.
Retained 12 months.
5.11.9 subscriptions and subscription_items #
subscriptions mirrors Stripe, which remains the source of truth (Section 19). The mirror exists
so that a plan check never makes a network call.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column; unique among non-canceled rows |
stripe_subscription_id |
text |
No | — | Unique |
stripe_customer_id |
text |
No | — | |
plan_id |
uuid |
No | — | Resolved from the price id |
status |
subscription_status |
No | — | Mirrors Stripe |
status_raw |
text |
No | — | Provider's exact string |
billing_interval |
text |
No | 'month' |
month or year |
currency |
char(3) |
No | 'USD' |
|
seats_purchased |
smallint |
No | 1 |
|
current_period_start / current_period_end |
timestamptz |
Yes | null |
|
cancel_at_period_end |
boolean |
No | false |
|
canceled_at |
timestamptz |
Yes | null |
|
trial_start / trial_end |
timestamptz |
Yes | null |
|
past_due_since |
timestamptz |
Yes | null |
|
dunning_attempts |
smallint |
No | 0 |
3 retries over 14 days |
default_payment_brand |
text |
Yes | null |
e.g. visa |
default_payment_last4 |
char(4) |
Yes | null |
The only card data stored anywhere |
latest_invoice_id |
text |
Yes | null |
|
created_at / updated_at |
timestamptz |
No | now() |
Constraints: UNIQUE (stripe_subscription_id);
UNIQUE (workspace_id) WHERE status NOT IN ('canceled','incomplete_expired');
CHECK (billing_interval IN ('month','year')); CHECK (seats_purchased >= 1);
CHECK (default_payment_last4 IS NULL OR default_payment_last4 ~ '^[0-9]{4}$').
subscription_items: id, workspace_id, subscription_id,
stripe_subscription_item_id (unique), stripe_price_id, kind (plan, domain_addon,
seat), quantity smallint NOT NULL DEFAULT 1, unit_amount_cents integer NOT NULL,
currency char(3) NOT NULL, custom_domain_id uuid (non-null exactly when
kind = 'domain_addon', enforced by
CHECK ((kind = 'domain_addon') = (custom_domain_id IS NOT NULL))), plus timestamps.
Indexes: (subscription_id, kind); (custom_domain_id).
5.11.10 usage_counters #
The counters quota enforcement reads. Every value here is authoritative and server-maintained; the UI's own arithmetic is advisory only (Section 19).
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
metric |
usage_metric |
No | — | One of the seven dotted metric keys. The column is the enum, never text: a typo in a metric name is a write failure, not a counter nobody reads |
period |
usage_period |
No | — | lifetime or monthly. testimonials.collected is the only lifetime metric |
period_start |
timestamptz |
No | — | Epoch 1970-01-01 for lifetime counters, so the unique key is uniform. Monthly periods start on the workspace's usage_anchor_day |
period_end |
timestamptz |
Yes | null |
Null for lifetime |
value |
bigint |
No | 0 |
Current usage |
limit_value |
bigint |
Yes | null |
Snapshot of the plan limit at period start; null = unlimited |
warned_at_80 / warned_at_90 / warned_at_95 / warned_at_100 |
timestamptz |
Yes | null |
Each threshold notifies exactly once per period |
soft_over_since |
timestamptz |
Yes | null |
Start of >20% overage on a soft metric |
last_event_at |
timestamptz |
Yes | null |
|
created_at / updated_at |
timestamptz |
No | now() |
Constraints: UNIQUE (workspace_id, metric, period_start); CHECK (value >= 0);
CHECK (limit_value IS NULL OR limit_value >= 0);
CHECK (period <> 'lifetime' OR period_end IS NULL).
Indexes: the unique — every quota check is one index probe;
usage_counters_over_idx (workspace_id) WHERE limit_value IS NOT NULL AND value >= limit_value.
Increments use INSERT ... ON CONFLICT ... DO UPDATE SET value = usage_counters.value + EXCLUDED.value,
so concurrent increments never lose a write and no read-modify-write race exists.
5.11.11 notifications, email_deliveries, sms_deliveries #
notifications — the in-app inbox. Columns: id, workspace_id, user_id, channel
(notification_channel), severity (notification_severity), kind (a catalogued string such
as testimonial.received, quota.threshold, domain.failed), title (≤ 120 chars), body
(≤ 500 chars), resource_type, resource_id, url (relative dashboard path), group_key
(collapses bursts: 12 submissions become one row), group_count smallint NOT NULL DEFAULT 1,
read_at, dismissed_at, email_delivery_id, timestamps.
Constraints: CHECK (char_length(title) <= 120);
UNIQUE (user_id, group_key) WHERE read_at IS NULL AND group_key IS NOT NULL.
Indexes: notifications_user_unread_idx (user_id, created_at DESC) WHERE read_at IS NULL.
Retained 90 days.
email_deliveries — columns: id, workspace_id (nullable for platform mail), to_email,
template (catalogued name), subject, provider (resend), provider_message_id (unique
when present), status (email_status), related_type, related_id, attempts smallint,
error, sent_at, delivered_at, opened_at, clicked_at, bounced_at, bounce_type,
complaint_at, timestamps.
Indexes: (provider_message_id); (workspace_id, created_at DESC);
(to_email, created_at DESC). Retained 180 days.
sms_deliveries — columns: id, workspace_id, to_phone_e164, body_snippet (first 120
chars, for support), provider (twilio), provider_message_sid (unique), status
(sms_status), segments smallint, price_cents integer, currency char(3), error_code,
error_message, related_type, related_id, sent_at, delivered_at, timestamps.
Indexes: (provider_message_sid); (workspace_id, created_at DESC). Retained 180 days.
5.11.12 Usage, overage and billing ledger tables #
usage_counters (5.11.10) holds the current number for each metric. These eleven tables hold
everything else billing needs: where the numbers come from, what we told the customer about them,
what we decided as a result, and what we charged. Section 19 owns the commercial rules; this
subsection owns the rows they read and write.
The organising principle is that every number a customer can be billed or blocked on is reconstructible from rows, not from a counter alone. A counter is a fast answer; a ledger is a defensible one. When a customer disputes an invoice or a quota block, the support answer is a query, not a shrug.
usage_notifications — what we have already told this workspace about its usage.
The threshold notifier in Section 19.4 E2 must send each threshold exactly once per metric per period. This table is that guarantee, and it is a unique constraint rather than a Valkey key because "we emailed you four times that you were at 80 %" is the kind of failure that costs trust, and it must survive a cache flush.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
metric |
usage_metric |
No | — | Which counter crossed |
threshold |
smallint |
No | — | 80, 90, 95, or 100 — the four thresholds Section 19.4 E2 emits |
period_start |
timestamptz |
No | — | Matches usage_counters.period_start, so a new period re-arms every threshold |
value_at_notify |
bigint |
No | — | The counter value when it fired, kept so a customer asking "at what point?" gets a number |
limit_at_notify |
bigint |
Yes | null |
The limit in force at the time; null when the metric was unlimited |
channel |
notification_channel |
No | — | in_app at 80 and 95, email at 90 and 100, per Section 19.4 E2 |
notification_id |
uuid |
Yes | null |
The in-app row, when there was one |
email_delivery_id |
uuid |
Yes | null |
The email, when there was one |
notified_at |
timestamptz |
No | now() |
|
created_at |
timestamptz |
No | now() |
Append-only; no updated_at |
Constraints: UNIQUE (workspace_id, metric, threshold, period_start) — the whole point;
CHECK (threshold IN (80,90,95,100)); CHECK (value_at_notify >= 0).
Indexes: the unique above, which is also the idempotency probe;
usage_notifications_ws_recent_idx (workspace_id, notified_at DESC) for the usage screen's
"we told you on…" line.
Lifecycle: hard-deleted with the workspace. Never swept: it is small (at most four rows per
metric per month) and it is the evidence that a warning was sent.
overage_evaluations — the audit trail of every over-limit decision.
Section 19.4 E5 claims that any over-limit decision can be explained after the fact. That claim is only true if the inputs are recorded at the moment of the decision, because the plan limits, the counters, and the credits can all change afterwards. One row per evaluation, per workspace, per run.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
evaluated_at |
timestamptz |
No | now() |
|
trigger |
text |
No | — | scheduled, usage_write, plan_change, manual |
metric |
usage_metric |
Yes | null |
Null when the evaluation covered every metric |
inputs |
jsonb |
No | — | The frozen inputs: {"plan":"growth","limit":50000,"value":51204,"credits":0,"softAllowancePct":20,"periodStart":"…"} |
outcome |
text |
No | — | within_limit, soft_overage, over_limit, cleared |
previous_limit_state |
limit_state |
No | — | What workspaces.limit_state was before |
new_limit_state |
limit_state |
No | — | What it was set to; equal to the previous value when nothing changed |
actions_taken |
jsonb |
No | '[]' |
["notice_sent","seats_suspended","domains_suspended"] |
job_run_id |
uuid |
Yes | null |
Correlates to job_runs for the scheduled path |
created_at |
timestamptz |
No | now() |
Append-only |
Constraints: CHECK (trigger IN ('scheduled','usage_write','plan_change','manual'));
CHECK (outcome IN ('within_limit','soft_overage','over_limit','cleared'));
CHECK (jsonb_typeof(inputs) = 'object').
Indexes: overage_evaluations_ws_time_idx (workspace_id, evaluated_at DESC) — "why is this
workspace over limit?" answered in one query;
overage_evaluations_changed_idx (workspace_id, evaluated_at DESC) WHERE previous_limit_state <> new_limit_state
— the far smaller set a human usually wants.
Lifecycle: hard-deleted after 180 days by retention.purge_job_runs, which sweeps this
table on the same schedule and for the same reason it sweeps failed job runs.
overage_notices — what the customer was told about an overage, and when.
Distinct from usage_notifications, which is about approaching a limit. An overage notice is
sent after a limit has been exceeded and carries a deadline: the grace window before the
consequences in Section 19.4 E6 apply.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
overage_evaluation_id |
uuid |
No | — | The decision that produced this notice |
metric |
usage_metric |
No | — | |
stage |
text |
No | — | first_notice, reminder, final_notice, enforced, resolved |
overage_amount |
bigint |
No | — | How far over, in the metric's own units |
grace_ends_at |
timestamptz |
Yes | null |
When enforcement begins; null on resolved |
email_delivery_id |
uuid |
Yes | null |
|
acknowledged_at |
timestamptz |
Yes | null |
Set when an Owner opens the billing screen from the notice |
resolved_at |
timestamptz |
Yes | null |
Set when usage came back under the limit or the plan changed |
sent_at |
timestamptz |
No | now() |
|
created_at / updated_at |
timestamptz |
No | now() |
Constraints: UNIQUE (workspace_id, metric, stage, overage_evaluation_id);
CHECK (stage IN ('first_notice','reminder','final_notice','enforced','resolved'));
CHECK (overage_amount > 0 OR stage = 'resolved');
CHECK (stage <> 'resolved' OR resolved_at IS NOT NULL).
Indexes: the unique; overage_notices_open_idx (workspace_id, grace_ends_at) WHERE resolved_at IS NULL — the enforcement sweep reads exactly this.
Lifecycle: hard-deleted with the workspace; never swept, for the same reason as
usage_notifications.
delivery_usage_days — the source of truth for delivery.minutes.
Video delivery is metered from the CDN and the media provider, both of which report per day and
both of which restate recent days as their own numbers settle. A per-day row with a
is_final flag lets a restatement be an update rather than an argument, and lets the monthly
counter be recomputed from rows instead of trusted blindly.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
workspace_id |
uuid |
No | — | Tenancy column, part of the key |
usage_date |
date |
No | — | UTC calendar day, part of the key |
source |
text |
No | — | provider (media provider playback minutes) or cdn (edge egress), part of the key |
delivered_minutes |
numeric(14,3) |
No | 0 |
The metered quantity |
delivered_bytes |
bigint |
No | 0 |
Recorded alongside, for the egress cost model in Section 23 |
play_count |
integer |
No | 0 |
|
is_final |
boolean |
No | false |
True once the provider stops restating the day, typically at +48 h |
restated_count |
smallint |
No | 0 |
How many times this row has been rewritten; a value above 3 is an alertable provider problem |
ingested_at |
timestamptz |
No | now() |
|
created_at / updated_at |
timestamptz |
No | now() |
Primary key: (workspace_id, usage_date, source) — a natural composite key, because there is
nothing else to identify the row by and an INSERT ... ON CONFLICT DO UPDATE per provider report
is exactly the write pattern.
Constraints: CHECK (delivered_minutes >= 0 AND delivered_bytes >= 0 AND play_count >= 0);
CHECK (restated_count >= 0).
Indexes: the primary key;
delivery_usage_days_open_idx (usage_date) WHERE NOT is_final — the restatement window the
ingest job re-reads each night.
Lifecycle: retained 25 months so that a year-over-year comparison and a full annual
invoice dispute both remain answerable; swept by retention.purge_job_runs's sibling schedule
in Section 5.20.
transcription_jobs — the source of truth for transcription.minutes.
transcripts holds the result; this table holds every attempt, including the failed and
retried ones. The distinction matters commercially: a provider charges for a submitted minute
whether or not the output was usable, and a customer must not be billed twice for one video
because our retry logic ran three times.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
media_asset_id |
uuid |
No | — | Subject |
transcript_id |
uuid |
Yes | null |
Set on success |
provider |
text |
No | — | deepgram or assemblyai |
model |
text |
No | — | The exact model identifier submitted |
provider_job_id |
text |
Yes | null |
Unique when present |
attempt |
smallint |
No | 1 |
1–4: the initial submission plus at most three automatic retries |
status |
job_status |
No | 'queued' |
Reuses the shared job-status enum |
submitted_seconds |
numeric(9,3) |
No | 0 |
Media duration submitted to the provider |
billable_minutes |
numeric(9,3) |
No | 0 |
What counts against the quota. Zero on every attempt after the first for the same asset, so retries are free to the customer and paid by us |
is_billable |
boolean |
No | true |
False for internal re-runs, evaluation runs, and support-initiated retries |
provider_cost_cents |
integer |
Yes | null |
Our cost, for margin reporting |
language_requested |
text |
Yes | null |
Null when detection was used |
queued_at / started_at / finished_at |
timestamptz |
Yes | null |
|
failure_code |
text |
Yes | null |
|
failure_message |
text |
Yes | null |
≤ 500 chars |
created_at / updated_at |
timestamptz |
No | now() |
Constraints: UNIQUE (media_asset_id, attempt);
UNIQUE (provider_job_id) WHERE provider_job_id IS NOT NULL;
CHECK (attempt BETWEEN 1 AND 4);
CHECK (billable_minutes >= 0 AND submitted_seconds >= 0);
CHECK (attempt = 1 OR billable_minutes = 0) — the no-double-billing rule, in the database;
CHECK (status <> 'failed' OR failure_code IS NOT NULL).
Indexes: the uniques;
transcription_jobs_ws_period_idx (workspace_id, finished_at DESC) WHERE is_billable AND billable_minutes > 0
— the monthly recompute;
transcription_jobs_stuck_idx (started_at) WHERE status = 'running' — the stuck-job alert.
Lifecycle: hard-deleted with the media asset; retained otherwise for the life of the
workspace, because it is billing evidence.
usage_credits — deliberate reductions to counted usage.
Support gives a workspace 500 testimonials back after a botched import. An incident wasted a customer's transcription minutes. A migration double-counted storage for a week. Each of those is a credit: a signed adjustment against a metric, with a reason and an author, that the recompute subtracts. Credits exist so that fixing a customer's number never means editing a counter by hand, which would be untraceable and unauditable.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
metric |
usage_metric |
No | — | Which counter this credit reduces |
period_start |
timestamptz |
Yes | null |
Null credits a lifetime metric; set credits one monthly period |
amount |
bigint |
No | — | Always positive; it is subtracted, never added. A negative adjustment is not a credit and is not representable here |
reason |
credit_reason |
No | — | support_goodwill, billing_error, service_incident, migration_adjustment, trial_conversion |
note |
text |
No | — | 10–1,000 chars, required. A credit without an explanation is indistinguishable from a mistake |
related_entity_ids |
uuid[] |
No | '{}' |
The specific rows credited — the testimonials from the botched import, say — so the credit can be verified rather than trusted |
granted_by_user_id |
uuid |
Yes | null |
The operator; null for system-issued credits |
granted_by_system |
text |
Yes | null |
The job that issued it, when no human did |
effective_from |
timestamptz |
No | now() |
|
expires_at |
timestamptz |
Yes | null |
Null = permanent, which is the default |
revoked_at |
timestamptz |
Yes | null |
Credits are revoked, never deleted |
revoked_by_user_id |
uuid |
Yes | null |
|
created_at / updated_at |
timestamptz |
No | now() |
Constraints: CHECK (amount > 0);
CHECK (char_length(note) BETWEEN 10 AND 1000);
CHECK (granted_by_user_id IS NOT NULL OR granted_by_system IS NOT NULL);
CHECK (expires_at IS NULL OR expires_at > effective_from);
CHECK (revoked_by_user_id IS NULL OR revoked_at IS NOT NULL);
CHECK (coalesce(array_length(related_entity_ids,1),0) <= 1000).
Indexes: usage_credits_active_idx (workspace_id, metric, period_start) WHERE revoked_at IS NULL — the recompute's lookup;
usage_credits_ws_time_idx (workspace_id, created_at DESC) — the credit history on the billing
screen, which is customer-visible.
Lifecycle: never deleted while the workspace lives. Revocation is a timestamp, so a credit
granted in error leaves a trace.
usage_reconciliations — the nightly proof that the counters are right.
usage_counters is incremented transactionally, which is fast and, over months, drift-prone: a
job crashes between the work and the increment, a manual data fix moves rows, a provider restates
a day. quota.reconcile recomputes every metric from its source of record each night and writes
what it found here. A reconciliation that changes a counter by more than a threshold alerts
rather than silently correcting, because a large silent correction is how a billing bug hides.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
metric |
usage_metric |
No | — | |
period_start |
timestamptz |
No | — | The period reconciled |
counter_value |
bigint |
No | — | What usage_counters held before the run |
recomputed_value |
bigint |
No | — | What the source of record says |
credits_applied |
bigint |
No | 0 |
Sum of live usage_credits for the metric and period |
delta |
bigint |
No | — | recomputed_value - counter_value; stored rather than computed so it is indexable |
applied |
boolean |
No | false |
Whether the counter was corrected. False when the delta exceeded the alert threshold and a human was asked first |
alerted |
boolean |
No | false |
|
source_query_digest |
char(64) |
No | — | SHA-256 of the recompute SQL actually run, so a changed definition is visible in the history |
duration_ms |
integer |
Yes | null |
|
job_run_id |
uuid |
Yes | null |
|
reconciled_at |
timestamptz |
No | now() |
|
created_at |
timestamptz |
No | now() |
Append-only |
Constraints: UNIQUE (workspace_id, metric, period_start, reconciled_at);
CHECK (delta = recomputed_value - counter_value) — the stored delta cannot drift from its
operands; CHECK (counter_value >= 0 AND recomputed_value >= 0).
Indexes: the unique;
usage_reconciliations_drift_idx (reconciled_at DESC) WHERE delta <> 0 — the drift report, which
is the one an engineer reads;
usage_reconciliations_ws_idx (workspace_id, metric, reconciled_at DESC).
Lifecycle: hard-deleted after 180 days, alongside overage_evaluations.
invoices — the local mirror of what the customer was charged.
Stripe remains the source of truth for billing (Section 19.7). The mirror exists so that the billing screen renders without a network call, so that a support question about a charge from eight months ago is a query, and so that an invoice line can be joined to the workspace state that produced it.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
subscription_id |
uuid |
Yes | null |
Null for one-off invoices |
stripe_invoice_id |
text |
No | — | Unique; the join key back to the provider |
number |
text |
Yes | null |
The human invoice number as issued |
status |
invoice_status |
No | — | draft, open, paid, uncollectible, void |
status_raw |
text |
No | — | The provider's exact string, per the mirroring rule in Section 5.1.5 |
currency |
char(3) |
No | 'USD' |
|
subtotal_cents |
integer |
No | 0 |
|
tax_cents |
integer |
No | 0 |
|
discount_cents |
integer |
No | 0 |
|
total_cents |
integer |
No | 0 |
|
amount_paid_cents |
integer |
No | 0 |
|
amount_remaining_cents |
integer |
No | 0 |
|
period_start / period_end |
timestamptz |
Yes | null |
The service period billed |
lines |
jsonb |
No | '[]' |
Normalized line items: [{"kind":"plan","description":"Growth (monthly)","quantity":1,"unitAmountCents":4900,"amountCents":4900}]. kind matches subscription_item_kind plus proration and tax |
hosted_invoice_url |
text |
Yes | null |
The provider-hosted copy; the only place a PDF lives |
pdf_url |
text |
Yes | null |
|
attempt_count |
smallint |
No | 0 |
Payment attempts |
next_payment_attempt_at |
timestamptz |
Yes | null |
Drives the dunning display |
paid_at |
timestamptz |
Yes | null |
|
voided_at |
timestamptz |
Yes | null |
|
provider_payload |
jsonb |
No | '{}' |
The provider object verbatim at last sync |
synced_at |
timestamptz |
No | now() |
|
created_at / updated_at |
timestamptz |
No | now() |
Constraints: UNIQUE (stripe_invoice_id);
CHECK (total_cents = subtotal_cents + tax_cents - discount_cents);
CHECK (amount_paid_cents >= 0 AND amount_remaining_cents >= 0);
CHECK (status <> 'paid' OR paid_at IS NOT NULL);
CHECK (status <> 'void' OR voided_at IS NOT NULL);
CHECK (jsonb_typeof(lines) = 'array').
Indexes: the unique; invoices_ws_time_idx (workspace_id, created_at DESC) — the billing
screen; invoices_unpaid_idx (workspace_id) WHERE status = 'open' — the dunning banner.
Lifecycle: retained for the life of the workspace and not removed by workspace deletion:
a deleted workspace's invoices survive in the operator's financial records, which is why the
workspace_id foreign key on this table is ON DELETE SET NULL rather than CASCADE and the
row additionally carries stripe_customer_id inside provider_payload. This is the one place in
the schema where a workspace purge deliberately leaves something behind, and the reason is that
tax records outlive customer relationships.
refunds — money returned, and why.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
Yes | null |
Nullable for the same reason as invoices |
invoice_id |
uuid |
Yes | null |
The invoice refunded, when there was one |
stripe_refund_id |
text |
No | — | Unique |
stripe_charge_id |
text |
Yes | null |
|
status |
refund_status |
No | — | pending, succeeded, failed, canceled |
amount_cents |
integer |
No | — | Positive; a partial refund is a smaller amount, never a negative one |
currency |
char(3) |
No | 'USD' |
|
reason |
text |
No | — | duplicate, fraudulent, requested_by_customer, service_incident, billing_error |
note |
text |
Yes | null |
≤ 1,000 chars, operator-facing |
initiated_by_user_id |
uuid |
Yes | null |
Null when the provider initiated it (a dispute) |
dispute_id |
text |
Yes | null |
Set when the refund arose from a chargeback |
refunded_at |
timestamptz |
Yes | null |
|
failure_reason |
text |
Yes | null |
|
created_at / updated_at |
timestamptz |
No | now() |
Constraints: UNIQUE (stripe_refund_id); CHECK (amount_cents > 0);
CHECK (reason IN ('duplicate','fraudulent','requested_by_customer','service_incident','billing_error'));
CHECK (status <> 'succeeded' OR refunded_at IS NOT NULL);
CHECK (status <> 'failed' OR failure_reason IS NOT NULL).
Indexes: the unique; refunds_invoice_idx (invoice_id);
refunds_ws_time_idx (workspace_id, created_at DESC).
Lifecycle: as invoices.
cancellation_reasons — why workspaces leave.
One row per cancellation event, not one per workspace: a workspace can cancel, come back, and cancel again, and the second answer is often different from the first. The table is small, it is never on a hot path, and it exists because the alternative — reading churn out of subscription status changes — tells you that people left but never why.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
No | — | Tenancy column |
subscription_id |
uuid |
Yes | null |
The subscription cancelled |
plan_id_at_cancel |
uuid |
No | — | Which plan they were on, so churn can be read per plan |
reason_code |
text |
No | — | too_expensive, missing_feature, switched_provider, no_longer_needed, too_difficult, technical_problems, temporary_pause, other |
detail |
text |
Yes | null |
≤ 2,000 chars, the customer's own words. Required when reason_code = 'other' |
competitor |
text |
Yes | null |
≤ 120 chars; only asked when reason_code = 'switched_provider' |
would_return |
boolean |
Yes | null |
Null when not answered — the question is optional and an unanswered optional question is not a false |
nps_at_cancel |
smallint |
Yes | null |
0–10, last recorded score |
tenure_days |
integer |
No | — | Days from workspace creation to cancellation, denormalized so cohort analysis needs no join |
testimonials_at_cancel |
integer |
No | 0 |
Usage at the moment of leaving, for the "did they ever get value?" question |
cancelled_by_user_id |
uuid |
Yes | null |
|
cancelled_at |
timestamptz |
No | now() |
|
effective_at |
timestamptz |
No | — | End of the paid period; cancellation is never immediate |
created_at |
timestamptz |
No | now() |
Append-only |
Constraints: CHECK (reason_code IN ('too_expensive','missing_feature','switched_provider','no_longer_needed','too_difficult','technical_problems','temporary_pause','other'));
CHECK (reason_code <> 'other' OR detail IS NOT NULL);
CHECK (nps_at_cancel IS NULL OR nps_at_cancel BETWEEN 0 AND 10);
CHECK (tenure_days >= 0); CHECK (effective_at >= cancelled_at).
Indexes: cancellation_reasons_ws_idx (workspace_id, cancelled_at DESC);
cancellation_reasons_cohort_idx (plan_id_at_cancel, cancelled_at DESC) — the churn report.
Lifecycle: retained for the life of the workspace and anonymized rather than deleted if the
cancelling user is erased: cancelled_by_user_id is set null and detail is retained, because
the reason is the company's feedback, not the individual's personal data.
trial_grants — the anti-abuse control on free trials.
Section 19.7.4 offers a 14-day cardless trial. Cardless trials are abusable by creating workspace after workspace, so the grant is recorded against both the workspace and a hash of the billing email, and a second grant to either is refused. Recording only the workspace would let one person take an unlimited number of trials; recording only the email would break a legitimate agency running several workspaces. Both, and the refusal message names which one matched.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
Yes | — | Tenancy column; unique. Nulled — not deleted — when the workspace is purged, so the billing_email_hash guard survives |
billing_email_hash |
char(64) |
No | — | SHA-256 of the normalized billing email at grant time |
granted_plan_id |
uuid |
No | — | The plan the trial gives access to |
granted_at |
timestamptz |
No | now() |
|
expires_at |
timestamptz |
No | — | granted_at + 14 days |
converted_at |
timestamptz |
Yes | null |
Set when a paid subscription starts before expiry |
converted_to_plan_id |
uuid |
Yes | null |
|
expired_at |
timestamptz |
Yes | null |
Set by the sweep when the trial lapsed unconverted |
granted_by_user_id |
uuid |
Yes | null |
Null for self-service; set when support extends a trial |
extension_days |
smallint |
No | 0 |
Support may extend once, by at most 14 further days |
signup_ip_truncated |
inet |
Yes | null |
/24 or /48; a coarse abuse signal, never a block on its own |
created_at / updated_at |
timestamptz |
No | now() |
Constraints: UNIQUE (workspace_id) — one trial per workspace, ever;
UNIQUE (billing_email_hash) — one trial per billing identity, ever;
CHECK (expires_at > granted_at);
CHECK (extension_days BETWEEN 0 AND 14);
CHECK (converted_at IS NULL OR expired_at IS NULL) — a trial converts or lapses, not both;
CHECK (converted_at IS NULL OR converted_to_plan_id IS NOT NULL).
Indexes: the two uniques — the second is the abuse check, one probe at trial request;
trial_grants_expiry_idx (expires_at) WHERE converted_at IS NULL AND expired_at IS NULL — the
trial-ending notification and the expiry sweep.
Lifecycle: never deleted while the workspace lives. When a workspace is purged the row is
retained with workspace_id set null, because deleting it would hand the abuser a reset button.
5.11.13 abuse_blocks #
Platform-level blocks on a source of traffic. Section 7.10 owns rate limiting, which is a per-window token bucket in Valkey; this table is the slower, heavier, durable layer underneath it: the blocks that must survive a cache flush, be visible to an operator, and expire on a schedule rather than in sixty seconds.
workspace_id is nullable here — one of the four documented nullable-tenant tables in
Section 5.1.6 — because a block is frequently applied before any workspace is known: an IP
hammering the public request-page endpoint has no tenant. When a block is scoped to one
workspace (an abusive respondent hitting one collection page), the column is set and the block
applies only there.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
uuid |
No | — | UUIDv7 |
workspace_id |
uuid |
Yes | null |
Null = platform-wide block |
subject_kind |
text |
No | — | ip, ip_range, email_domain, fingerprint, asn |
subject_hash |
char(64) |
No | — | SHA-256 of the normalized subject; the lookup key, so the block list holds no raw addresses |
subject_display |
text |
No | — | Operator-readable form: 203.0.113.0/24, example.invalid, AS64500. Truncated IPs only, per the logging rules in Section 22 |
reason |
abuse_block_reason |
No | — | submission_flood, credential_stuffing, scraping, spam_content, manual |
evidence |
jsonb |
No | '{}' |
What triggered it: {"requests":1840,"windowSeconds":60,"endpoint":"POST /r/:slug/submit"} |
scope |
text |
No | 'all' |
all, collection, auth, api — which surfaces the block covers |
action |
text |
No | 'block' |
block (refuse), challenge (force a CAPTCHA), throttle (reduce the bucket) |
blocked_at |
timestamptz |
No | now() |
|
expires_at |
timestamptz |
Yes | null |
Null = indefinite, which only a human may set |
hit_count |
bigint |
No | 0 |
How many requests this block has stopped; incremented asynchronously |
last_hit_at |
timestamptz |
Yes | null |
|
created_by_user_id |
uuid |
Yes | null |
Null for automatic blocks |
released_at |
timestamptz |
Yes | null |
Manual release; the row is kept |
released_by_user_id |
uuid |
Yes | null |
|
release_note |
text |
Yes | null |
≤ 500 chars |
created_at / updated_at |
timestamptz |
No | now() |
Constraints
UNIQUE (subject_hash, scope, coalesce(workspace_id, '00000000-0000-0000-0000-000000000000'::uuid)) WHERE released_at IS NULL— expressed as a unique index over the same expression; one live block per subject per scope per tenancy.CHECK (subject_kind IN ('ip','ip_range','email_domain','fingerprint','asn'))CHECK (scope IN ('all','collection','auth','api'))CHECK (action IN ('block','challenge','throttle'))CHECK (expires_at IS NULL OR expires_at > blocked_at)CHECK (expires_at IS NOT NULL OR created_by_user_id IS NOT NULL)— an indefinite block always has a named human behind it; automation may only block with an expiry.CHECK (released_by_user_id IS NULL OR released_at IS NOT NULL)
Indexes
abuse_blocks_live_uq(partial unique above) — the check performed at the edge.abuse_blocks_lookup_idxon(subject_hash) WHERE released_at IS NULL AND (expires_at IS NULL OR expires_at > now())is not used, becausenow()is not immutable and cannot appear in an index predicate. The live check readsabuse_blocks_live_uqand evaluatesexpires_atin the query. This is stated because it is the mistake a reader would otherwise make.abuse_blocks_expiry_idxon(expires_at) WHERE released_at IS NULL AND expires_at IS NOT NULL— the expiry sweep.abuse_blocks_ws_idxon(workspace_id, blocked_at DESC) WHERE workspace_id IS NOT NULL.
RLS. As a nullable-tenant table its policy is
workspace_id IS NULL OR workspace_id = current_setting('app.current_workspace_id')::uuid, and
no tenant-facing endpoint reads it: the edge check runs as the platform role before any workspace
context is established.
Lifecycle and deletion. Hard-deleted 90 days after expires_at or released_at, whichever
applies, by retention.purge_job_runs's maintenance schedule. Blocks that never expire are never
swept. hit_count makes a stale indefinite block visible in the operator review: a block that has
stopped nothing in six months is a block to reconsider.
5.12 Foreign keys and ON DELETE behaviour #
Every foreign key in the schema, with its delete action and the reason it was chosen. The
default is CASCADE for ownership edges and SET NULL for attribution edges — an audit trail
must survive the departure of the person it names.
| Child → Parent | Column | On delete | Why |
|---|---|---|---|
everything workspace-scoped → workspaces |
workspace_id |
CASCADE |
The workspace is the ownership root; purging it must leave nothing behind. |
workspaces → users |
owner_user_id |
RESTRICT |
A workspace cannot be left ownerless by deleting a user; ownership must be transferred first. |
workspaces → plans |
plan_id |
RESTRICT |
A plan in use is never deletable. |
workspace_members → users |
user_id |
CASCADE |
Membership is meaningless without the user. |
workspace_members → users |
invited_by_user_id |
SET NULL |
Attribution; the record survives the inviter. |
sessions, accounts, two_factor_secrets → users |
user_id |
CASCADE |
Credentials are part of the user. |
invitations → users |
invited_by_user_id, revoked_by_user_id, accepted_user_id |
SET NULL |
Attribution. |
api_keys → users |
created_by_user_id, revoked_by_user_id |
SET NULL |
A key outlives its creator. |
workspace_settings → workspaces |
workspace_id |
CASCADE |
1:1 ownership. |
workspace_settings → consent_versions |
default_consent_version_id |
RESTRICT |
A consent version in use is never deletable. |
request_pages → consent_versions |
consent_version_id |
RESTRICT |
Same. |
request_page_fields, request_page_prompts → request_pages |
request_page_id |
CASCADE |
Definition parts of the page. |
invites → contacts |
contact_id |
CASCADE |
An invite without a recipient is meaningless. |
invites → request_pages |
request_page_id |
CASCADE |
|
invites → invite_batches |
invite_batch_id |
SET NULL |
Deleting a batch record must not destroy sent-mail history. |
submissions → request_pages |
request_page_id |
CASCADE |
|
submissions → invites |
invite_id |
SET NULL |
Provenance only. |
submissions → contacts |
contact_id |
SET NULL |
Erasing a contact must not erase the testimonial's evidence trail. |
submission_drafts → request_pages |
request_page_id |
CASCADE |
A draft of a page that no longer exists cannot be resumed. |
submission_drafts → invites |
invite_id |
SET NULL |
The draft survives a revoked invite so a respondent mid-answer is not silently cut off. |
submission_drafts → upload_sessions |
upload_session_id |
SET NULL |
The text of a draft outlives an expired upload. |
upload_sessions → submissions |
submission_id |
SET NULL |
Sessions can precede submissions. |
testimonials → submissions |
submission_id |
SET NULL |
Kept as provenance; the testimonial is independent. |
testimonials → imported_reviews |
imported_review_id |
SET NULL |
Same. |
testimonials → media_assets |
media_asset_id |
SET NULL |
The purge job clears the asset row after the testimonial is handled. |
testimonials → transcripts |
transcript_id |
SET NULL |
|
testimonials → users |
approved_by_user_id, rejected_by_user_id, created_by_user_id, deleted_by_user_id |
SET NULL |
Attribution survives departure. |
media_assets → testimonials |
testimonial_id |
CASCADE |
Hard-deleting a testimonial must not orphan an asset row whose objects still need purging — the purge job runs before the delete, so cascade is safe and is asserted by a test. |
transcripts → media_assets |
media_asset_id |
CASCADE |
A transcript of nothing cannot exist. |
transcript_revisions → transcripts |
transcript_id |
CASCADE |
|
testimonial_tags → testimonials, tags |
both | CASCADE |
Pure join. |
highlights → testimonials |
testimonial_id |
CASCADE |
|
highlights → ai_suggestions |
ai_suggestion_id |
SET NULL |
Provenance may be swept before the highlight. |
ai_suggestions → testimonials |
testimonial_id |
CASCADE |
|
social_cards → testimonials |
testimonial_id |
CASCADE |
|
social_cards → highlights |
highlight_id |
SET NULL |
A card survives a withdrawn quote long enough to be marked stale. |
media_derivatives → media_assets |
media_asset_id |
CASCADE |
A rendition of nothing cannot exist; the objects are destroyed by media.purge before the cascade runs. |
transcription_jobs → media_assets |
media_asset_id |
CASCADE |
Same. |
transcription_jobs → transcripts |
transcript_id |
SET NULL |
The billing record outlives a re-transcribed result. |
testimonial_flags → testimonials |
testimonial_id |
CASCADE |
A flag on nothing is noise. |
testimonial_flags → users |
raised_by_user_id, cleared_by_user_id |
SET NULL |
Attribution. |
testimonial_replies → testimonials |
testimonial_id |
CASCADE |
|
testimonial_replies → testimonial_replies |
in_reply_to_id |
SET NULL |
A thread survives a deleted parent message; thread_id still groups it. |
inbox_saved_views → users |
user_id |
CASCADE |
A view belongs to the person who saved it. |
widget_testimonials → widgets, testimonials |
both | CASCADE |
Pure join. |
showcase_pages → custom_domains |
custom_domain_id |
SET NULL |
Removing a domain must not delete the page. |
custom_domains → subscription_items |
add_on_subscription_item_id |
SET NULL |
Billing record removal must not delete the domain. |
subscription_items → subscriptions |
subscription_id |
CASCADE |
|
subscription_items → custom_domains |
custom_domain_id |
SET NULL |
|
import_runs → import_connections |
import_connection_id |
SET NULL |
Run history survives a disconnect. |
imported_reviews → import_runs |
import_run_id |
CASCADE |
|
consents → consent_versions |
consent_version_id |
RESTRICT |
Evidence must always resolve. |
consents → submissions |
submission_id |
CASCADE |
|
consents → testimonials |
testimonial_id |
SET NULL |
Consent evidence outlives the published artefact. |
consent_grants → consents |
current_consent_id |
RESTRICT |
The projection must always resolve to the ledger row it claims to reflect. |
consent_grants → submissions |
submission_id |
CASCADE |
The projection is scoped to the capture. |
consent_grants → testimonials |
testimonial_id |
SET NULL |
As consents. |
suppressions → contacts |
contact_id |
SET NULL |
A suppression must outlive the contact it came from — deleting the contact must not make it legal to mail them again. |
erasure_log → anything |
workspace_id, testimonial_id, data_subject_request_id |
no FK at all | The only table in the schema with no foreign keys on columns that name real rows. Each of the three targets is destroyed by the erasure this row exists to prove, so any constraint — cascade or restrict — would either delete the evidence or block the deletion it records. Section 5.9.7 states the reasoning in full. |
respondent_tokens → testimonials |
testimonial_id |
CASCADE |
|
data_subject_requests → testimonials |
testimonial_id |
SET NULL |
The request record survives what it erased — that is the proof. |
webhook_deliveries → webhook_endpoints |
webhook_endpoint_id |
CASCADE |
|
webhook_deliveries → outbox_events |
outbox_event_id |
SET NULL |
The outbox row is swept after 7 days. |
notifications → users |
user_id |
CASCADE |
|
usage_notifications, overage_evaluations, overage_notices, delivery_usage_days, usage_credits, usage_reconciliations, cancellation_reasons → workspaces |
workspace_id |
CASCADE |
Metering and quota history is workspace-owned and dies with it. |
overage_notices → overage_evaluations |
overage_evaluation_id |
CASCADE |
A notice about a decision that no longer exists is unexplainable. |
invoices, refunds, trial_grants → workspaces |
workspace_id |
SET NULL |
The three deliberate exceptions to the cascade rule. Financial records outlive the customer relationship — a purged workspace leaves its invoices behind, tenant-less, in the operator's books. And a purged workspace must not hand an abuser a fresh cardless trial, so its trial_grants row survives with billing_email_hash intact. (erasure_log also outlives a purge, but by carrying no foreign key at all rather than by being detached — see its own row above.) |
refunds → invoices |
invoice_id |
SET NULL |
A refund can outlive a voided invoice. |
abuse_blocks → workspaces |
workspace_id |
CASCADE |
A workspace-scoped block is meaningless without the workspace; platform-wide blocks carry null and are untouched. |
analytics_events_raw, analytics_rollup_* |
— | no FKs | Deliberate; see Section 5.10.1. |
audit_events |
— | no FKs | Append-only and partitioned; referential checks would block writes and cascades would violate append-only. Integrity is by convention plus the actor_label denormalization. |
5.13 Composite index catalogue #
Every non-trivial index in the system with the exact query it exists to serve. Indexes not listed
here or in the table definitions above do not exist. Every workspace-scoped index leads with
workspace_id, without exception, because RLS adds workspace_id = … to every predicate and a
leading tenancy column makes that filter free.
| Index | Table | Definition | Query served |
|---|---|---|---|
testimonials_ws_status_created_idx |
testimonials |
(workspace_id, status, created_at DESC, id DESC) WHERE deleted_at IS NULL |
Moderation inbox with cursor pagination: WHERE workspace_id=$1 AND status=$2 ORDER BY created_at DESC, id DESC |
testimonials_ws_published_idx |
testimonials |
(workspace_id, published_at DESC, id DESC) WHERE status='published' AND deleted_at IS NULL |
Widget payload build and showcase listing |
testimonials_ws_featured_idx |
testimonials |
(workspace_id, is_featured, sort_weight DESC, created_at DESC) WHERE status='published' AND deleted_at IS NULL |
Manual/curated widget ordering |
testimonials_ws_kind_rating_idx |
testimonials |
(workspace_id, kind, rating) WHERE status='published' AND deleted_at IS NULL |
Widget media-type and minimum-rating filters |
testimonials_search_gin |
testimonials |
GIN (search_tsv) |
Dashboard search |
testimonial_tags_tag_idx |
testimonial_tags |
(workspace_id, tag_id, testimonial_id) |
Tag-filtered widget payloads and the purge-set computation |
widgets_public_uq |
widgets |
UNIQUE (public_id) |
Public widget data endpoint — one probe, no workspace context needed before RLS is set |
widgets_tag_filter_gin |
widgets |
GIN (filter_tag_ids) |
"Which widgets must be purged when tag X changes?" |
widget_testimonials_testimonial_idx |
widget_testimonials |
(workspace_id, testimonial_id) |
Purge-set computation on testimonial change |
request_pages_ws_slug_uq |
request_pages |
UNIQUE (workspace_id, slug) WHERE deleted_at IS NULL |
Public request-page resolution |
invites_token_uq |
invites |
UNIQUE (token_hash) |
Invite-link resolution |
invites_reminder_due_idx |
invites |
(next_reminder_at) WHERE next_reminder_at IS NOT NULL AND submitted_at IS NULL AND revoked_at IS NULL |
Reminder scheduler |
invites_ws_page_status_idx |
invites |
(workspace_id, request_page_id, status) |
Per-page funnel counts |
contacts_ws_suppressed_idx |
contacts |
(workspace_id) WHERE unsubscribed_at IS NOT NULL OR bounced_at IS NOT NULL |
Suppression check before every send |
submissions_abandoned_idx |
submissions |
(started_at) WHERE state='in_progress' |
Abandonment sweeper |
upload_sessions_sweep_idx |
upload_sessions |
(expires_at) WHERE state IN ('active','completing') |
Multipart abort sweeper |
media_assets_purge_idx |
media_assets |
(purge_requested_at) WHERE purged_at IS NULL AND purge_requested_at IS NOT NULL |
Purge worker's durable queue-of-record |
media_assets_retain_idx |
media_assets |
(original_retain_until) WHERE original_object_key IS NOT NULL AND original_retain_until IS NOT NULL |
30-day originals sweep |
transcripts_review_idx |
transcripts |
(workspace_id) WHERE needs_review |
"Transcripts needing review" filter |
ai_suggestions_outcome_idx |
ai_suggestions |
(workspace_id, kind, validation_outcome, created_at DESC) |
Verbatim-rejection-rate metric |
custom_domains_check_due_idx |
custom_domains |
(next_check_at) WHERE next_check_at IS NOT NULL AND state <> 'active' |
Domain verification poller |
custom_domains_renewal_idx |
custom_domains |
(tls_expires_at) WHERE state='active' |
Certificate renewal sweep |
imported_reviews_dedupe_idx |
imported_reviews |
(workspace_id, dedupe_hash) |
Exact duplicate detection |
imported_reviews_trgm_idx |
imported_reviews |
GIN (body_text gin_trgm_ops) |
Near-duplicate warning |
outbox_pending_idx |
outbox_events |
(next_attempt_at, id) WHERE published_at IS NULL |
Outbox relay |
webhook_deliveries_due_idx |
webhook_deliveries |
(scheduled_for) WHERE status='pending' |
Delivery scheduler |
usage_counters_ws_metric_uq |
usage_counters |
UNIQUE (workspace_id, metric, period_start) |
Every quota check |
usage_notifications_uq |
usage_notifications |
UNIQUE (workspace_id, metric, threshold, period_start) |
Threshold-notification idempotency — "have we already told them about 90 %?" |
usage_credits_active_idx |
usage_credits |
(workspace_id, metric, period_start) WHERE revoked_at IS NULL |
Credit lookup during a quota recompute |
media_derivatives_ws_bytes_idx |
media_derivatives |
(workspace_id) INCLUDE (bytes) WHERE purged_at IS NULL AND is_billable |
storage.bytes recompute, index-only |
transcription_jobs_ws_period_idx |
transcription_jobs |
(workspace_id, finished_at DESC) WHERE is_billable AND billable_minutes > 0 |
transcription.minutes recompute |
delivery_usage_days_pk |
delivery_usage_days |
PRIMARY KEY (workspace_id, usage_date, source) |
delivery.minutes recompute and the provider upsert |
workspace_members_suspended_idx |
workspace_members |
(workspace_id, created_at) WHERE seat_state = 'seat_suspended' AND deleted_at IS NULL |
Seat-reactivation ordering on upgrade |
workspaces_anchor_idx |
workspaces |
(usage_anchor_day) |
The daily period-roll job's workspace selection |
consent_grants_testimonial_idx |
consent_grants |
(workspace_id, testimonial_id, kind) INCLUDE (is_granted) |
The publish-time consent check, index-only |
suppressions_live_uq |
suppressions |
UNIQUE (workspace_id, channel, address_hash) WHERE released_at IS NULL |
The suppression probe before every send |
erasure_log_unverified_idx |
erasure_log |
(erasure_completed_at) WHERE verified_at IS NULL |
The external verification job's queue — every row in it is an erasure claimed and not yet proven |
erasure_log_subject_idx |
erasure_log |
(subject_key) |
Re-erasure replay against a restored backup |
abuse_blocks_live_uq |
abuse_blocks |
UNIQUE (subject_hash, scope, coalesce(workspace_id, …)) WHERE released_at IS NULL |
The edge block check |
testimonial_flags_ws_open_idx |
testimonial_flags |
(workspace_id, raised_at DESC) WHERE cleared_at IS NULL |
The "flagged" inbox filter and moderation badge |
testimonial_replies_thread_idx |
testimonial_replies |
(workspace_id, thread_id, created_at) |
Rendering a reply thread in order |
submission_drafts_token_uq |
submission_drafts |
UNIQUE (token_hash) |
Draft resume, one probe |
analytics_rollup_hourly_funnel_idx |
analytics_rollup_hourly |
(workspace_id, request_page_id, event_type, bucket_hour DESC) WHERE request_page_id IS NOT NULL |
The collection conversion funnel |
audit_events_ws_time_idx |
audit_events |
(workspace_id, occurred_at DESC) |
Audit log screen |
audit_events_ws_resource_idx |
audit_events |
(workspace_id, resource_type, resource_id, occurred_at DESC) |
"History of this testimonial" |
analytics_rollup_hourly_ws_time_idx |
analytics_rollup_hourly |
(workspace_id, bucket_hour DESC) |
Analytics dashboard range queries |
notifications_user_unread_idx |
notifications |
(user_id, created_at DESC) WHERE read_at IS NULL |
Unread badge and inbox |
Index review rule. A new index requires a demonstrated query plan improvement recorded in the
migration's PR description. EXPLAIN (ANALYZE, BUFFERS) output for the target query before and
after is the accepted evidence. Section 25 makes this a review checklist item.
5.14 Full-text search #
Search covers the testimonial body plus the respondent's name and company, weighted so a name match ranks below a body match but above nothing.
CREATE FUNCTION vouch_tsv(lang text, body text, respondent_name text, company text)
RETURNS tsvector
LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
SELECT
setweight(to_tsvector(vouch_regconfig(lang), coalesce(body, '')), 'A')
|| setweight(to_tsvector(vouch_regconfig(lang), coalesce(respondent_name, '')), 'B')
|| setweight(to_tsvector(vouch_regconfig(lang), coalesce(company, '')), 'C');
$$;
CREATE FUNCTION vouch_regconfig(lang text) RETURNS regconfig
LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
SELECT CASE lang
WHEN 'es' THEN 'spanish'::regconfig
WHEN 'fr' THEN 'french'::regconfig
WHEN 'de' THEN 'german'::regconfig
WHEN 'pt' THEN 'portuguese'::regconfig
ELSE 'english'::regconfig
END;
$$;Both functions are IMMUTABLE, which is what allows testimonials.search_tsv to be a true
GENERATED ALWAYS AS ... STORED column: the two-argument to_tsvector(regconfig, text) is
immutable, and selecting among literal configurations keeps the whole expression immutable.
Using the one-argument form, which reads default_text_search_config, would be merely STABLE
and Postgres would reject the generated column — this is the reason for the explicit mapping
function rather than a simpler expression.
Query pattern (packages/db/src/queries/search-testimonials.ts):
SELECT t.id, t.body_text, ts_rank_cd(t.search_tsv, q) AS rank
FROM testimonials t,
websearch_to_tsquery(vouch_regconfig($2), $3) AS q
WHERE t.workspace_id = $1
AND t.deleted_at IS NULL
AND t.search_tsv @@ q
ORDER BY rank DESC, t.created_at DESC, t.id DESC
LIMIT $4;websearch_to_tsquery is used rather than plainto_tsquery so that quoted phrases, OR, and
-exclusion work the way a user expects from a search box. The language argument is the
workspace's default_language, not the row's, because a query is typed in one language while
results may span several; rows in other languages still match on shared tokens (names, product
terms), which is the pragmatic behaviour.
Language rule. A testimonial's language is set at creation: from the request page's locale
for collected text, from the transcript's detected language for video, from the import source's
metadata for imported reviews, and from the author for manual entries. Changing language on an
existing row automatically regenerates search_tsv because it is a generated column — no
backfill job is needed. Changing either function is a breaking change and follows the
expand/contract procedure in Section 5.17.4: create vouch_tsv_v2, add a new generated column,
backfill by ALTER TABLE, swap the index, drop the old column.
5.15 Partitioning #
Two tables are partitioned. Nothing else is, because nothing else grows without bound.
| Table | Strategy | Interval | Retention | Maintenance |
|---|---|---|---|---|
analytics_events_raw |
RANGE (occurred_at) |
Daily | 7 days | maintenance.partitions creates tomorrow's and the next 6 days' partitions; retention.sweep_analytics_raw detaches and drops anything older than 7 days |
audit_events |
RANGE (occurred_at) |
Monthly | 24 months, then archived | maintenance.partitions creates the next two months and, at 24 months, detaches the oldest partition, copies it to object storage as compressed JSONL, verifies the object's checksum, and only then drops it |
Creation and removal are two jobs, not one. Creating a partition ahead of time is a
liveness concern — if it fails, inserts start erroring — and it is monitored and alerted as one.
Dropping an old partition is a retention concern, belongs with every other retention sweep in the
retention.* family (Section 5.20), and must never be able to take the write path down by
failing. Splitting them means a broken retention sweep costs disk and a broken partition creator
pages someone, which is the correct asymmetry. audit_events is the exception: its removal is an
archive-then-drop with a checksum verification between the two, so the whole operation stays in
maintenance.partitions where the create-side buffer logic already lives.
-- Naming: {table}_p{YYYYMMDD} for daily, {table}_p{YYYYMM} for monthly.
CREATE TABLE analytics_events_raw_p20260819 PARTITION OF analytics_events_raw
FOR VALUES FROM ('2026-08-19 00:00:00+00') TO ('2026-08-20 00:00:00+00');
CREATE INDEX ON analytics_events_raw_p20260819 (workspace_id, occurred_at);
CREATE INDEX ON analytics_events_raw_p20260819 (workspace_id, widget_id, event_type, occurred_at);
CREATE TABLE audit_events_p202608 PARTITION OF audit_events
FOR VALUES FROM ('2026-08-01 00:00:00+00') TO ('2026-09-01 00:00:00+00');A default partition is deliberately not created. A row whose occurred_at falls outside every
partition must fail loudly rather than accumulate in a catch-all that can never be detached.
maintenance.partitions runs hourly, keeps a 7-day forward buffer, and alerts if the buffer
drops below 3 days — so a failed maintenance run is visible days before it can cause an insert
error.
RLS policies are declared on the parent table and are inherited by every partition. Indexes declared on the parent propagate to new partitions automatically in the Postgres major line named in Section 3.1, and the maintenance job asserts index presence on each new partition as a defence in depth.
5.16 Triggers, generated columns and append-only enforcement #
5.16.1 set_updated_at() #
CREATE FUNCTION set_updated_at() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END $$;Attached as BEFORE UPDATE FOR EACH ROW to every table that has an updated_at column:
users, workspaces, workspace_members, invitations, sessions, accounts,
two_factor_secrets, api_keys, feature_flags, workspace_feature_flags,
workspace_settings, plans, request_pages, request_page_fields, request_page_prompts,
contacts, invite_batches, invites, submissions, upload_sessions, testimonials,
media_assets, transcripts, tags, highlights, ai_suggestions, social_cards, widgets,
widget_testimonials, showcase_pages, custom_domains, import_connections, import_runs,
imported_reviews, respondent_tokens, data_subject_requests, analytics_rollup_hourly,
analytics_rollup_daily, webhook_endpoints, webhook_deliveries, integration_connections,
job_runs, subscriptions, subscription_items, usage_counters, notifications,
email_deliveries, sms_deliveries, submission_drafts, media_derivatives,
testimonial_flags, testimonial_replies, inbox_saved_views, consent_grants,
suppressions, overage_notices, delivery_usage_days, transcription_jobs, usage_credits,
invoices, refunds, trial_grants, abuse_blocks.
Not attached to append-only tables (audit_events, outbox_events, consents,
consent_versions, transcript_revisions, analytics_events_raw, idempotency_keys,
stripe_events, verification_tokens, testimonial_tags, usage_notifications,
overage_evaluations, usage_reconciliations, cancellation_reasons, erasure_log), which
have no updated_at.
A migration test asserts the invariant directly: every table with an updated_at column has the
trigger, and no table without the column has it.
-- packages/db/test/updated-at.test.sql — run in CI
SELECT c.relname FROM pg_class c
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attname = 'updated_at' AND NOT a.attisdropped
WHERE c.relkind = 'r' AND c.relnamespace = 'public'::regnamespace
AND NOT EXISTS (SELECT 1 FROM pg_trigger t
WHERE t.tgrelid = c.oid AND t.tgname = 'set_updated_at' AND NOT t.tgisinternal);
-- must return zero rows5.16.2 tags.usage_count maintenance #
CREATE FUNCTION bump_tag_usage() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
UPDATE tags SET usage_count = usage_count + 1 WHERE id = NEW.tag_id;
ELSE
UPDATE tags SET usage_count = greatest(usage_count - 1, 0) WHERE id = OLD.tag_id;
END IF;
RETURN NULL;
END $$;
CREATE TRIGGER testimonial_tags_usage
AFTER INSERT OR DELETE ON testimonial_tags
FOR EACH ROW EXECUTE FUNCTION bump_tag_usage();The counter is advisory (it drives ordering in the tag picker); a nightly maintenance.recount
job reconciles it, so drift from a cascade delete self-heals within a day.
5.16.3 Append-only enforcement #
Five tables are append-only: audit_events, consents, consent_versions,
transcript_revisions, erasure_log. Two mechanisms, both required — privileges stop the
ordinary path, the rule stops a mistake made while holding elevated privileges.
-- 1. Privileges: the application role can only insert and select.
REVOKE UPDATE, DELETE, TRUNCATE
ON audit_events, consents, consent_versions, transcript_revisions, erasure_log
FROM vouch_app;
GRANT SELECT, INSERT
ON audit_events, consents, consent_versions, transcript_revisions, erasure_log
TO vouch_app;
-- 2. Rules: even a superuser mistake is turned into a no-op rather than a silent loss.
CREATE RULE audit_events_no_update AS ON UPDATE TO audit_events DO INSTEAD NOTHING;
CREATE RULE audit_events_no_delete AS ON DELETE TO audit_events DO INSTEAD NOTHING;
CREATE RULE consents_no_update AS ON UPDATE TO consents DO INSTEAD NOTHING;
CREATE RULE consents_no_delete AS ON DELETE TO consents DO INSTEAD NOTHING;
CREATE RULE consent_versions_no_update AS ON UPDATE TO consent_versions DO INSTEAD NOTHING;
CREATE RULE consent_versions_no_delete AS ON DELETE TO consent_versions DO INSTEAD NOTHING;
CREATE RULE transcript_revisions_no_update AS ON UPDATE TO transcript_revisions DO INSTEAD NOTHING;
CREATE RULE transcript_revisions_no_delete AS ON DELETE TO transcript_revisions DO INSTEAD NOTHING;
CREATE RULE erasure_log_no_update AS ON UPDATE TO erasure_log DO INSTEAD NOTHING;
CREATE RULE erasure_log_no_delete AS ON DELETE TO erasure_log DO INSTEAD NOTHING;erasure_log has two narrow write paths of its own, because two jobs must record an outcome
against a row the application may not touch. erasure_log_mark_verified(p_id uuid, p_ok boolean, p_failure text) lets the external verification job set verified_at, verification_attempts
and verification_failure; erasure_log_mark_replayed(p_id uuid, p_backups_cleared boolean)
lets the platform backup job set replay_count, last_replayed_at and backups_cleared_at.
Both are SECURITY DEFINER functions owned by vouch_migrator, both have fixed bodies, neither
takes free-form input that reaches SQL, and neither can write any other column or any other
table. The retention sweep in Section 5.20 is the only other statement permitted to remove rows.
The one exception, and how it is controlled: a GDPR erasure must be able to remove personal
data from consents. That path runs as vouch_migrator inside a stored procedure
gdpr_erase_consents(p_testimonial_id uuid, p_dsr_id uuid) that (a) requires an existing
data_subject_requests row in status verified or in_progress, (b) drops and recreates the
rules around the delete inside a single transaction, and (c) writes the deleted row count into
data_subject_requests.deleted_entity_counts before committing. Section 22.6 owns the runbook.
audit_events is never erased; personal data is kept out of it by the redaction rules in
Section 22.4, so there is nothing in it to erase.
Partitioned tables carry the rules on each partition, applied by the partition-maintenance job at creation time and asserted by a CI check.
5.16.4 testimonials.version maintenance #
Optimistic concurrency needs a token that moves when the content moves and at no other time. If
every write bumped it, a background job normalizing word_count or stamping payload_built_at
would invalidate a moderator's open editor and produce a TESTIMONIAL_VERSION_CONFLICT that no
human caused. So the trigger is explicit about which columns count:
CREATE FUNCTION bump_testimonial_version() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF (NEW.body_text, NEW.body_html_sanitized, NEW.headline,
NEW.rating, NEW.status, NEW.language,
NEW.respondent_name, NEW.respondent_role, NEW.respondent_company,
NEW.case_study_fields, NEW.is_featured, NEW.sort_weight)
IS DISTINCT FROM
(OLD.body_text, OLD.body_html_sanitized, OLD.headline,
OLD.rating, OLD.status, OLD.language,
OLD.respondent_name, OLD.respondent_role, OLD.respondent_company,
OLD.case_study_fields, OLD.is_featured, OLD.sort_weight)
THEN
NEW.version := OLD.version + 1;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER testimonials_version
BEFORE UPDATE ON testimonials
FOR EACH ROW EXECUTE FUNCTION bump_testimonial_version();It is BEFORE UPDATE FOR EACH ROW, the same family and the same timing as set_updated_at(), so
one update statement produces one consistent (updated_at, version) pair and a client that reads
both back sees them agree. Application code never assigns version; the vouch/no-raw-select
lint family bans writing it, exactly as it bans writing updated_at. Adding a content column to
testimonials means adding it to this list, and the migration checklist in Section 5.17.2
requires the author to say whether the new column is content or bookkeeping.
transcripts.version is maintained differently and deliberately so: it is incremented by the
transcript editor's save path, in the same transaction that writes the transcript_revisions
snapshot, because there the version is the revision number and the two must be allocated
together.
5.17 Migration strategy #
5.17.1 Tooling and shape #
Migrations are generated by drizzle-kit from the schema in packages/db/src/schema/*.ts and
committed as plain SQL under packages/db/migrations/. Generated SQL is always reviewed and
frequently hand-edited (to add CONCURRENTLY, to split a rewrite, to add a backfill). The
generated file is a starting point, never the final artefact.
5.17.2 Rules #
- Forward-only. There are no
downmigrations. Rolling back means writing a new forward migration. A down migration that has never been executed in production is untested code that runs during the worst ten minutes of the quarter. - One migration per pull request. If a change needs three steps, it needs three pull requests, because each step must be independently deployable.
- Naming:
NNNN_verb_object.sql, zero-padded four digits, e.g.0042_add_testimonial_sort_weight.sql. The number is allocated at merge; a collision is a rebase, not a renumber. - Every migration is transactional except those that cannot be —
CREATE INDEX CONCURRENTLYandALTER TYPE ... ADD VALUE— which are marked with a leading comment-- vouch:no-transactionthat the runner reads. - No migration takes a long-lived exclusive lock.
lock_timeoutis set to3sandstatement_timeoutto30sfor the migration session; a migration that cannot acquire its lock fails fast and is retried rather than queueing behind a long read and blocking the table. - Indexes are created
CONCURRENTLYon any table expected to exceed 100,000 rows. - No
DROP COLUMNin the same release that stops writing it. See expand/contract below. - Every migration is tested against a restored copy of production-shaped data in CI (Section 25), with the timing recorded.
- A migration that adds a column to
testimonialsstates in its PR description whether the column is content or bookkeeping, and adds it tobump_testimonial_version()(Section 5.16.4) when it is content. A content column that does not bump the version silently breaks optimistic concurrency for every client editing that field.
5.17.3 Running migrations #
pnpm --filter @vouch/db migrate:generate # drizzle-kit generate, then hand-edit
pnpm --filter @vouch/db migrate:check # lint: transactional?, locks?, forbidden ops?
pnpm --filter @vouch/db migrate:up # apply, as vouch_migratorIn CI, migrate:check plus migrate:up against an ephemeral database runs on every pull
request. In deploy, migrations run as a separate step before the application rollout, as
vouch_migrator, using an advisory lock (pg_advisory_lock(4711)) so concurrent deploys
serialize. The application boots only after the migration step reports success, and the
application asserts on boot that SELECT max(version) FROM __drizzle_migrations is at least the
version the build expects, failing the health check otherwise.
5.17.4 Expand / contract for breaking changes #
Renaming testimonials.body to testimonials.body_text (a change of exactly this shape) takes
three releases:
| Release | Migration | Application |
|---|---|---|
| 1 — expand | ALTER TABLE testimonials ADD COLUMN body_text text; then a batched backfill in chunks of 5,000 rows with a sleep between chunks |
Writes both columns, reads body |
| 2 — switch | none | Reads body_text, still writes both |
| 3 — contract | ALTER TABLE testimonials DROP COLUMN body; |
Writes only body_text |
Rules that make this safe: no release ever both stops writing a column and drops it; every
backfill is idempotent and resumable (WHERE body_text IS NULL LIMIT 5000 in a loop); and the
contract migration is gated on a query proving the old column is unread, recorded in the PR.
Enum value additions are additive-only and must be deployed at least one release before any code
writes the new value, because a running replica may not yet know the value exists.
5.18 Seed data #
pnpm --filter @vouch/db seed is idempotent — running it twice changes nothing — and is used for
local development, for CI, and (with --production restricting it to the catalogue sets) for
first boot of a new environment.
1. Plans. Four rows matching Section 19 exactly:
code |
name |
position |
price_cents_monthly |
price_cents_annual |
|---|---|---|---|---|
free |
Free | 0 | 0 | 0 |
starter |
Starter | 1 | 1900 | 19000 |
growth |
Growth | 2 | 4900 | 49000 |
scale |
Scale | 3 | 14900 | 149000 |
with the limits JSON from Section 5.4.12 populated from the plan table in Section 19 and
features set to {"aiAssistant":false,...} per plan.
2. Platform consent version 1 (workspace_id IS NULL, locale en), with these exact texts:
publish_text: "I give {workspaceName} permission to publish this testimonial — including my name, role, company, and my photo or video likeness as provided — on their website and marketing materials."advertising_text: "I also allow {workspaceName} to use this testimonial in paid advertising."case_study_text: "{workspaceName} may contact me about taking part in a longer case study."
full_text is the three statements plus the standing footer: "You can view, download, or
withdraw this testimonial at any time using the private link we email you. Withdrawing removes it
from every place it is published, usually within a minute." text_sha256 is computed at seed
time by the same helper the application uses. Spanish, French, German, and Portuguese rows are
seeded with professionally translated equivalents at the same version number.
3. System tag vocabulary — seeded into every new workspace with is_system = true:
product-quality, customer-support, ease-of-use, onboarding, value-for-money,
results, integration, reliability, case-study, video. They are renameable (a customer's
vocabulary is theirs) but not deletable, so a widget filter can never point at nothing.
4. Feature flags — one row per flag the code reads, so a missing flag is never a silent
false: flag_ai_assistant, flag_video_reel_widget, flag_hubspot_integration,
flag_eu_region, flag_import_g2, flag_import_capterra, flag_signed_playback.
5. Development and E2E fixture (skipped under --production): a demo workspace
slug = 'acme-demo' owned by owner@example.test (password Testing-Password-123!), plus one
Admin, one Editor, and one Viewer user; two request pages (one video-only, one both); 24
testimonials — 8 video with ready media assets and transcripts, 12 text, 4 imported (2 Google,
1 G2, 1 X) — spread across pending, approved, published, rejected, and archived; four
widgets, one of each of the wall, carousel, single-quote, and badge types, one curated; one
published showcase page; 30 days of synthetic analytics rollups. Media assets in the fixture
reference a deterministic set of fake provider ids and locally-hosted poster images, so E2E runs
never call a third party. The fixture is fully deterministic: identifiers are generated from a
fixed UUIDv7 seed sequence so a Playwright test can hard-code them.
The fixture also exercises the states that are easy to forget and expensive to get wrong: the
demo workspace has billing_state = 'active', limit_state = 'ok', and
usage_anchor_day = 14; the archived testimonials carry a pre_archive_status of published
so the unarchive path has something real to restore; one testimonial is soft-deleted with
pre_delete_status = 'approved' and a pending delayed media.purge; one carries a live
testimonial_flags row with reason third_party_pii so the flagged filter is never empty in
development; one has a two-message testimonial_replies thread, one inbound and one outbound;
and the Editor user owns two inbox_saved_views, one of them shared. Every one of those states
is otherwise reachable only by a sequence of actions a developer will not perform by hand, and a
screen that is only ever seen empty is a screen that ships broken.
6. Usage and billing fixtures (skipped under --production): usage_counters rows for all
seven metrics with the demo workspace at 62 % of testimonials.collected and 91 % of
transcription.minutes, so the threshold banner and the 90 % email are both live; the matching
usage_notifications rows for the 80 and 90 thresholds, so re-running the notifier is provably
idempotent; three invoices (two paid, one open); one usage_credits row for 50
testimonials with reason support_goodwill; and a trial_grants row that has already converted.
5.19 Data volumes and growth #
Launch scale target for capacity planning: 2,000 active workspaces at month 12, median 60 testimonials each, p95 800, with 20 million widget impressions per month.
| Table | Rows at month 12 | Avg row | Total | Growth driver |
|---|---|---|---|---|
workspaces |
2,000 | 0.4 KB | < 1 MB | Sign-ups |
users |
6,000 | 0.4 KB | 2 MB | Seats |
testimonials |
180,000 | 2.5 KB (incl. search_tsv) |
450 MB | Collection + import |
media_assets |
70,000 | 0.6 KB | 42 MB | Video share ≈ 40 % |
transcripts |
70,000 | 22 KB (doc JSONB) |
1.5 GB | Video count × length |
transcript_revisions |
25,000 | 22 KB | 550 MB | Editor usage |
submissions |
240,000 | 1.2 KB | 288 MB | Includes abandoned |
upload_sessions |
90,000 | 3 KB (parts) |
270 MB | Retakes; swept weekly |
invites |
900,000 | 1.5 KB | 1.4 GB | The largest collection-side table |
contacts |
400,000 | 0.5 KB | 200 MB | |
imported_reviews |
120,000 | 6 KB (raw_payload) |
720 MB | Import adoption |
widgets |
12,000 | 1 KB | 12 MB | ~6 per workspace |
analytics_events_raw |
55 M live (7 days of 240 M/month) | 180 B | ~10 GB | Impressions; bounded by retention |
analytics_rollup_hourly |
40 M | 120 B | 4.8 GB | Dimension cardinality; bounded at 35 days |
analytics_rollup_daily |
60 M | 130 B | 7.8 GB | Bounded by plan retention |
audit_events |
9 M | 1.8 KB | 16 GB | Every mutation; the biggest single consumer |
outbox_events |
400 K live | 1.5 KB | 600 MB | Swept at 7 days |
webhook_deliveries |
6 M | 2 KB | 12 GB | Swept at 30 days |
job_runs |
20 M | 0.7 KB | 14 GB | Split sweep: 30 d succeeded, 180 d failed |
media_derivatives |
350,000 | 0.3 KB | 105 MB | ~5 objects per media asset |
submission_drafts |
30,000 live | 2 KB | 60 MB | Bounded by the 30-day expiry |
transcription_jobs |
85,000 | 0.5 KB | 43 MB | Attempts, not results |
delivery_usage_days |
1.5 M | 0.15 KB | 225 MB | Workspaces × days × 2 sources |
consent_grants |
240,000 | 0.3 KB | 72 MB | One per grant per submission |
testimonial_replies |
45,000 | 2 KB | 90 MB | Follow-up adoption ≈ 25 % |
testimonial_flags |
3,000 | 0.4 KB | 1 MB | Rare by design |
suppressions |
60,000 | 0.3 KB | 18 MB | Unsubscribes and hard bounces |
overage_evaluations |
2.2 M | 0.5 KB | 1.1 GB | Nightly per workspace per metric; swept at 180 days |
usage_reconciliations |
5 M | 0.4 KB | 2 GB | Same shape, swept at 180 days |
invoices |
40,000 | 3 KB | 120 MB | Kept indefinitely |
| Total (bounded steady state) | ≈ 74 GB |
Object storage is the larger cost: at 40 % video share, 70,000 assets, an average 90-second 1080p take at roughly 15 MB original plus 8 MB of derivatives, expect ≈ 1.6 TB at month 12, reduced by roughly 45 % once the 30-day originals sweep reaches steady state.
Growth curve and the first thing that will hurt. Analytics raw ingest scales with impressions,
not customers, and is the first table to feel a viral host page. It is partitioned and
retention-bounded precisely so that a 10× traffic spike costs disk for 7 days and nothing more.
The second pressure point is audit_events; monthly partitions with archival at 24 months keep
it linear. Nothing in this schema requires sharding at the launch target, and the tenancy column
on every table means the eventual shard key already exists.
5.20 Retention schedule #
This table is the single retention schedule for the whole system. Every other section that mentions how long something is kept — Section 22's legal basis, Section 24's operational guidance, the job catalogue in Appendix 29.5 — refers to this table rather than restating a number, and every row here names both the column the clock runs from and the job that does the removing. Where a schedule row exists, a job exists; where a job removes data, a schedule row exists. A migration that adds a table holding personal data or unbounded history adds a row here in the same pull request.
Job names follow the convention stated in Section 4: domain.snake_case_verb, no hyphens. Every
sweep whose purpose is retention lives in the single retention.* family, so "what deletes old
data?" is answered by one prefix rather than by remembering which team owned which table.
| Data class | Period | Trigger column | Job name |
|---|---|---|---|
analytics_events_raw |
7 days | occurred_at (partition bound) |
retention.sweep_analytics_raw |
analytics_rollup_hourly |
35 days stored; the dashboard offers hourly granularity for 7 (Section 17.8) | bucket_hour |
retention.sweep_analytics_rollups |
analytics_rollup_daily |
Per plan: 30 d Free / 12 mo Starter / 24 mo Growth / 36 mo Scale | bucket_date |
retention.sweep_analytics_rollups |
audit_events |
24 months, then archived to object storage and dropped | occurred_at (partition bound) |
maintenance.partitions |
| Original video uploads | 30 days after ready, unless the workspace has "keep originals" |
media_assets.original_retain_until |
retention.sweep_originals |
media_derivatives |
Life of the media asset — derivatives are permanent | — | — (cascade) |
upload_sessions |
Aborted at 24 h, row deleted 7 days after a terminal state | expires_at |
retention.sweep_uploads |
submission_drafts |
30 days, or immediately on promotion to a submission | expires_at |
drafts.sweep |
submission_drafts (promoted) |
Deleted in the promoting transaction | promoted_submission_id |
drafts.purge_submitted |
submissions (abandoned) |
30 days | started_at |
retention.purge_soft_deleted |
Soft-deleted testimonials |
Row retained for the 30-day restore window, then hard-deleted; external assets purged when the delayed media.purge fires |
deleted_at |
retention.purge_soft_deleted |
Soft-deleted widgets, request_pages, showcase_pages, tags, contacts, custom_domains, inbox_saved_views |
30 days after deleted_at |
deleted_at |
retention.purge_soft_deleted |
ai_suggestions (not accepted) |
180 days | created_at |
retention.purge_ai_suggestions |
ai_suggestions (accepted) |
Life of the testimonial | — | — (cascade) |
transcript_revisions |
The 20 most recent revisions per transcript, and 12 months | created_at |
retention.prune_transcript_revisions |
| Caption/VTT versions superseded by a newer generation | 90 days | media_derivatives.generated_at |
retention.prune_caption_versions |
consents withdrawal tombstones |
24 months after the withdrawal, then the tombstone alone is purged | consents.created_at |
retention.purge_consent_tombstones |
consents (live evidence), consent_versions |
Never — they are the evidence | — | — |
consent_grants |
Life of the submission | — | — (cascade) |
suppressions |
Never, except a hard_bounce row carrying an explicit expires_at |
expires_at |
retention.purge_soft_deleted |
erasure_log |
6 years from erasure_completed_at, matching the minimized consent record; exempt from the erasure it records |
erasure_completed_at |
retention.purge_soft_deleted |
Soft-deleted workspaces |
30 days, then full hard purge; the export stays downloadable throughout | deleted_at |
retention.purge_workspaces |
| GDPR erasure | Hard delete within 30 days, target under 24 hours | data_subject_requests.due_at |
gdpr.erase |
| Data export artefacts | 7 days, then the object is deleted and the signed URL dies with it | data_subject_requests.artifacts->>'expiresAt' |
export.sweep |
job_runs (succeeded, canceled) |
30 days | finished_at |
retention.purge_job_runs |
job_runs (failed, dead-lettered) |
180 days | finished_at |
retention.purge_job_runs |
overage_evaluations, usage_reconciliations |
180 days | created_at |
retention.purge_job_runs |
abuse_blocks |
90 days after expires_at or released_at; indefinite blocks are never swept |
expires_at / released_at |
retention.purge_job_runs |
delivery_usage_days |
25 months | usage_date |
retention.purge_job_runs |
webhook_deliveries |
30 days | created_at |
maintenance.sweep |
outbox_events |
7 days after published_at |
published_at |
maintenance.sweep |
idempotency_keys |
24 hours | expires_at |
maintenance.sweep |
sessions |
7 days after absolute_expires_at |
absolute_expires_at |
session.sweep |
verification_tokens |
24 hours after expires_at |
expires_at |
session.sweep |
invitations (any terminal status: accepted, revoked, expired) |
90 days | status_changed_at |
invitation.expire |
import_runs.file_object_key (the uploaded CSV) |
30 days; the parsed rows persist | created_at |
imports.sweep |
email_deliveries, sms_deliveries |
180 days | created_at |
maintenance.sweep |
notifications |
90 days | created_at |
maintenance.sweep |
usage_notifications, overage_notices |
Life of the workspace | — | — (cascade) |
stripe_events |
12 months | received_at |
maintenance.sweep |
usage_counters, usage_credits |
Life of the workspace | — | — (cascade) |
invoices, refunds, trial_grants |
Indefinite; they survive the workspace with workspace_id nulled (Section 5.12) |
— | — |
cancellation_reasons |
Life of the workspace | — | — (cascade) |
Three entries a reader will want the reasoning for.
AI suggestions are kept 180 days, not a year. The record exists to support the rejection-rate alert in Section 12.4 and to answer "which prompt version produced this?" — both of which are answered within weeks. A proposal contains the testimonial's words and the model's paraphrase of them, so it is personal data with no ongoing purpose, and the shorter period is both cheaper and more defensible. Accepted proposals are the exception and live as long as the highlight that points at them.
Failed job runs are kept six times longer than successful ones. The question a retention window has to survive is "has this been failing quietly since March?", and a 30-day window cannot answer it. Successes have no such question.
Hourly analytics rollups are stored for 35 days but exposed for 7. The extra four weeks are operational: they let a rollup be repaired from a raw partition that has since been dropped, and they let an engineer investigate an incident three weeks later at the granularity it happened at. The customer-facing granularity picker stops at 7 days because an hourly chart spanning a month is neither readable nor cheap. Storing more than you display is a deliberate choice here, not an oversight, and Section 17.8 says the same thing from the other side.
6. Multi-Tenancy, Authentication, Authorization & Team Management #
6.1 The tenancy model #
A workspace is the tenancy boundary. It owns testimonials, request pages, widgets, domains,
billing, members, and analytics. Every domain row in the database carries a non-null
workspace_id, including rows that are only reachable through a parent — a testimonial_tags
join row carries it, a transcript_revisions row carries it, an upload_sessions row carries it.
The three categories of table without the column are enumerated in Section 5.1.6 and no fourth
category may be introduced.
There is no implicit "current organization" anywhere in the codebase. No module-level
variable, no async-local store consulted deep in a query builder, no getCurrentWorkspace()
helper that reaches into request context from inside a repository. The workspace id is an
explicit first parameter on every service function and every query helper:
// packages/core/testimonial/service.ts
export async function listTestimonials(
workspaceId: string, // always first, always explicit, never defaulted
actor: Actor,
filter: TestimonialFilter,
): Promise<Page<Testimonial>> { /* … */ }An ESLint rule (vouch/explicit-workspace-id, in packages/config/eslint) fails the build when
an exported function in packages/core or packages/db takes a filter/input object without a
sibling workspaceId: string parameter, and when a Drizzle query on a workspace-scoped table is
built without an eq(table.workspaceId, …) predicate.
Why this is non-negotiable, and what breaks without it. The failure mode of implicit tenancy
is not a crash; it is a customer seeing another customer's testimonials. Ambient context leaks
across an await, survives into a background job, is forgotten in a cache key, and defaults to
"the last workspace this process saw" under load. Each of those is silent. Making the id an
argument means a missing tenancy scope is a type error at compile time, not an incident. The
same reasoning drives the second, independent layer: even if application code gets it wrong, the
database refuses to return the row (Section 6.2). Two layers, because the cost of one failure is
the product's credibility.
Two more consequences of the model, stated so they are not rediscovered later:
- Composite indexes lead with
workspace_id(Section 5.13). RLS appends aworkspace_id = …predicate to every query; a leading tenancy column makes that predicate free rather than a filter applied after an index scan. - Cache keys are workspace-prefixed. Every Valkey key begins
ws:{workspaceId}:, every CDN purge tag includesworkspace:{id}, and every BullMQ job payload carriesworkspaceIdas a required field validated by a Zod schema before the job is accepted.
6.2 Row-Level Security #
6.2.1 Database roles #
| Role | Login | Bypasses RLS | Used by | Privileges |
|---|---|---|---|---|
vouch_app |
Yes | No (NOBYPASSRLS) |
The Next.js app and the worker | SELECT, INSERT, UPDATE, DELETE on domain tables; SELECT, INSERT only on the five append-only tables (Section 5.16.3) |
vouch_migrator |
Yes | Yes | Migrations and the audited erasure procedure only | Owns every object; full DDL |
vouch_readonly |
Yes | No | Analytics exploration and support read access | SELECT only, subject to the same policies |
CREATE ROLE vouch_migrator LOGIN PASSWORD :'migrator_password' BYPASSRLS;
CREATE ROLE vouch_app LOGIN PASSWORD :'app_password' NOBYPASSRLS;
CREATE ROLE vouch_readonly LOGIN PASSWORD :'readonly_password' NOBYPASSRLS;
ALTER DEFAULT PRIVILEGES FOR ROLE vouch_migrator IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO vouch_app;
ALTER DEFAULT PRIVILEGES FOR ROLE vouch_migrator IN SCHEMA public
GRANT SELECT ON TABLES TO vouch_readonly;vouch_app is explicitly not the table owner. A table owner is exempt from its own policies
unless FORCE ROW LEVEL SECURITY is set, and relying on that flag is a single-point failure. The
application connects as a role that could not bypass RLS even if someone forgot the flag — and
the flag is set as well.
6.2.2 The session setting #
Tenancy is carried in the Postgres session variable app.current_workspace_id, set with
SET LOCAL inside a transaction so it cannot leak to the next borrower of a pooled connection.
SET LOCAL is reverted on commit or rollback by the database itself; there is no cleanup code
that can be skipped.
// packages/db/src/tenant.ts
import { sql } from 'drizzle-orm';
import { db, type Tx } from './client';
/**
* The ONLY way to touch workspace-scoped data. Every route handler, server action,
* and job consumer enters through this function.
*/
export async function withWorkspace<T>(
workspaceId: string,
fn: (tx: Tx) => Promise<T>,
): Promise<T> {
if (!isUuid(workspaceId)) throw new AppError('WORKSPACE_CONTEXT_INVALID', 400);
return db.transaction(async (tx) => {
// Parameterized: set_config() takes a value, so no string interpolation reaches SQL.
await tx.execute(sql`SELECT set_config('app.current_workspace_id', ${workspaceId}, true)`);
return fn(tx);
});
}The true third argument is what makes it LOCAL. A helper that used SET (session-wide) would
poison every subsequent request on that pooled connection; set_config(..., true) is the only
form permitted, and a lint rule bans the literal string SET app.current_workspace_id outside
this file.
Reading it back inside a policy uses current_setting('app.current_workspace_id', true), whose
true means "return NULL instead of raising when unset". A NULL setting makes every policy
predicate NULL, which is not true, so an unset context returns zero rows rather than every
row — the safe direction.
6.2.3 Policies #
RLS is enabled and forced on every workspace-scoped table. The policy is identical everywhere
except on workspaces itself, where the tenancy column is id.
-- Representative: the same four statements are emitted for every workspace-scoped table.
ALTER TABLE testimonials ENABLE ROW LEVEL SECURITY;
ALTER TABLE testimonials FORCE ROW LEVEL SECURITY;
CREATE POLICY testimonials_tenant_isolation ON testimonials
FOR ALL
TO vouch_app, vouch_readonly
USING (workspace_id = current_setting('app.current_workspace_id', true)::uuid)
WITH CHECK (workspace_id = current_setting('app.current_workspace_id', true)::uuid);
-- The anchor table keys on its own primary key.
ALTER TABLE workspaces ENABLE ROW LEVEL SECURITY;
ALTER TABLE workspaces FORCE ROW LEVEL SECURITY;
CREATE POLICY workspaces_tenant_isolation ON workspaces
FOR ALL TO vouch_app, vouch_readonly
USING (id = current_setting('app.current_workspace_id', true)::uuid)
WITH CHECK (id = current_setting('app.current_workspace_id', true)::uuid);
-- Platform tables with a nullable tenant (Section 5.1.6): job_runs, stripe_events,
-- email_deliveries, abuse_blocks, and the three tenant-detached-on-purge tables
-- (invoices, refunds, trial_grants). erasure_log is NOT one of these: its workspace_id is
-- NOT NULL and takes the ordinary tenant-isolation policy, it simply carries no foreign key.
CREATE POLICY job_runs_tenant_isolation ON job_runs
FOR ALL TO vouch_app
USING (workspace_id IS NULL
OR workspace_id = current_setting('app.current_workspace_id', true)::uuid);WITH CHECK matters as much as USING: without it, a tenant could insert a row belonging to
another workspace even though they could never read it back. Both clauses are present on every
policy, and a CI assertion fails the build if any policy has a USING clause without a matching
WITH CHECK (except read-only policies on vouch_readonly).
Global identity tables (users, sessions, accounts, verification_tokens,
two_factor_secrets) do not carry workspace_id and are not protected by a workspace policy.
They are protected differently: vouch_app may read them only through the authentication module,
which never accepts a user id from request input — it derives it from the verified session
cookie. sessions additionally carries a policy restricting rows to the authenticated user id
carried in a second session setting, app.current_user_id, set by the same wrapper when a user
session exists.
A CI test asserts complete coverage, so a new table cannot ship unprotected:
-- packages/db/test/rls-coverage.test.sql — must return zero rows
SELECT c.relname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = 'public'
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attname = 'workspace_id' AND NOT a.attisdropped
WHERE c.relkind IN ('r','p')
AND (NOT c.relrowsecurity OR NOT c.relforcerowsecurity);6.2.4 Background jobs #
A worker has no HTTP request and no cookie, so it takes its workspace id from the job payload.
Every job payload schema in packages/validation/jobs.ts requires workspaceId, and the
consumer wrapper sets the context before the handler body runs:
// apps/worker/src/runner.ts
export function defineJob<S extends z.ZodType>(name: string, schema: S, handler: JobHandler<S>) {
return async (job: Job) => {
const payload = schema.parse(job.data); // workspaceId is required by every schema
return withWorkspace(payload.workspaceId, (tx) =>
handler({ tx, payload, jobId: job.id, attempt: job.attemptsMade + 1 }),
);
};
}Platform-wide maintenance jobs that must legitimately span tenants (partition maintenance,
retention sweeps) do not use withWorkspace. They run in a separate connection pool
authenticated as vouch_migrator, they are the only code permitted to do so, they live in
apps/worker/src/jobs/maintenance/** where a lint rule confines the elevated pool import, and
each one writes a job_runs row with workspace_id IS NULL so the elevated activity is visible
in the operational record.
6.2.5 The public, unauthenticated read path #
Widget data, showcase pages, request pages, and the oEmbed endpoint are read by people with no account. They still go through RLS; they simply derive the workspace id from the public identifier before opening the transaction.
// apps/web/src/app/api/v1/widgets/[widgetId]/data/route.ts (Edge-cached, unauthenticated)
export async function GET(req: Request, { params }: { params: { widgetId: string } }) {
// 1. Resolve public id -> (workspaceId, widgetId) via a single indexed probe on a
// deliberately narrow, non-tenant-scoped resolver. It returns ONLY these two ids.
const resolved = await resolveWidgetPublicId(params.widgetId);
if (!resolved) return notFound('WIDGET_NOT_FOUND');
if (resolved.deletedAt) return gone('WIDGET_DELETED');
// 2. Everything after this point runs inside the tenant context.
const payload = await withWorkspace(resolved.workspaceId, (tx) =>
buildWidgetPayload(tx, resolved.workspaceId, resolved.widgetId),
);
return cachedJson(payload, { sMaxAge: 60, staleWhileRevalidate: 300,
tags: [`widget:${resolved.widgetId}`, `workspace:${resolved.workspaceId}`] });
}Three rules make this safe, and they are the only exemptions in the system:
- The resolver is the single exception to "every query is tenant-scoped". It runs as a
separate, minimally-privileged database role
vouch_resolverwithSELECTon exactly two columns of three tables (widgets.public_id → workspace_id, id, deleted_at;custom_domains.hostname → workspace_id, state;request_pages.slug+workspaces.slug → workspace_id, id). It is physically incapable of reading testimonial content. Its code lives in one file,packages/db/src/resolvers.ts, and a lint rule forbids importing the resolver pool anywhere else. - The resolver never accepts a workspace id from the caller. The workspace id is always
derived from a public identifier, never supplied. There is no
?workspaceId=parameter on any public endpoint. - The payload builder filters to published content explicitly, in addition to RLS:
status = 'published' AND deleted_at IS NULL. RLS answers "which tenant?"; the builder answers "which of that tenant's rows are public?". Neither is trusted to do the other's job.
For a custom domain, step 1 resolves Host: → custom_domains.hostname and requires
state = 'active'; a domain that is not active serves the platform's generic page, never another
workspace's content.
6.2.6 The test that proves cross-tenant reads fail #
This runs on every pull request as part of the integration suite. It is not a unit test of
application logic — it opens a real connection as vouch_app and asserts the database's own
behaviour.
// packages/db/test/rls.integration.test.ts
import { describe, it, expect, beforeAll } from 'vitest';
describe('row-level security', () => {
let alpha: Seed, beta: Seed;
beforeAll(async () => { alpha = await seedWorkspace('alpha'); beta = await seedWorkspace('beta'); });
it('cannot read another workspace’s testimonials', async () => {
const rows = await withWorkspace(alpha.workspaceId, (tx) =>
tx.select().from(testimonials).where(eq(testimonials.id, beta.testimonialId)));
expect(rows).toHaveLength(0); // not an error — simply invisible
});
it('cannot update another workspace’s testimonials', async () => {
const res = await withWorkspace(alpha.workspaceId, (tx) =>
tx.update(testimonials).set({ isFeatured: true }).where(eq(testimonials.id, beta.testimonialId)));
expect(res.rowCount).toBe(0);
});
it('cannot insert a row belonging to another workspace', async () => {
await expect(withWorkspace(alpha.workspaceId, (tx) =>
tx.insert(testimonials).values({ ...validTestimonial(), workspaceId: beta.workspaceId }),
)).rejects.toThrow(/row-level security/i); // WITH CHECK rejects it
});
it('returns nothing when the context is unset', async () => {
const rows = await rawUnscopedQuery('SELECT * FROM testimonials LIMIT 10');
expect(rows).toHaveLength(0); // NULL setting -> no rows, never all rows
});
it('leaves no context behind on a pooled connection', async () => {
await withWorkspace(alpha.workspaceId, async () => {});
const [{ setting }] = await rawUnscopedQuery(
`SELECT current_setting('app.current_workspace_id', true) AS setting`);
expect(setting).toBeNull(); // SET LOCAL died with the transaction
});
it('covers every workspace-scoped table', async () => {
for (const table of workspaceScopedTables) {
const rows = await withWorkspace(alpha.workspaceId, (tx) =>
tx.execute(sql`SELECT count(*)::int AS n FROM ${sql.identifier(table)}
WHERE workspace_id = ${beta.workspaceId}`));
expect(rows[0].n).toBe(0); // parameterized over the full table list
}
});
});The last case is the important one: it iterates the generated list of every workspace-scoped table, so adding a table without a policy fails the suite automatically rather than waiting for someone to remember to write a test.
6.3 How a request acquires its workspace id #
Five entry points, five derivations. There is no sixth, and none of them accepts the id from a request body.
| Entry point | Derivation | Failure mode |
|---|---|---|
Dashboard (app.vouch.app) |
Session cookie → sessions.active_workspace_id, verified against a live workspace_members row on every request |
401 UNAUTHENTICATED if no session; 403 WORKSPACE_ACCESS_DENIED if membership is gone |
| Public REST API | Authorization: Bearer → api_keys.key_hash lookup → api_keys.workspace_id |
401 INVALID_API_KEY; 401 API_KEY_REVOKED |
| Widget data / oEmbed / analytics beacon | widgets.public_id → workspace_id (Section 6.2.5) |
404 WIDGET_NOT_FOUND; 410 WIDGET_DELETED |
| Request page / showcase page | Host + slug → workspaces.slug or custom_domains.hostname → workspace_id |
404 PAGE_NOT_FOUND; inactive page returns 200 with a closed-state payload, never an error |
Respondent link (/p/{token}) |
respondent_tokens.token_hash → workspace_id + testimonial_id |
404 RESPONDENT_LINK_INVALID; 410 RESPONDENT_LINK_REVOKED |
Membership is re-verified per request rather than trusted from the cookie, so removing a member
takes effect on their next request, not on their next sign-in. The check is a single indexed
lookup on workspace_members (workspace_id, user_id) WHERE deleted_at IS NULL, cached in Valkey
for 10 seconds under ws:{workspaceId}:member:{userId} and invalidated explicitly on any
membership write — so the worst-case staleness after a removal is ten seconds, and it is bounded
by an explicit invalidation rather than by expiry alone.
6.4 Deferred capability: multi-workspace membership and agency accounts #
This is a named commitment, not an afterthought. Two capabilities are deliberately deferred to post-v1 while their entire data seam ships in v1, so enabling them later requires no schema migration:
- One user belonging to many workspaces. A consultant with five clients holds five memberships under one login and switches between them.
- Agency / white-label parent accounts. A parent workspace administers child workspaces, owns their billing, and applies its own brand to their surfaces.
6.4.1 What already exists #
workspace_membersis a true many-to-many join with a surrogate key, a per-workspace role, and a partial unique on(workspace_id, user_id). It has never been a 1:1 table pretending to be one; there is nousers.workspace_idcolumn anywhere and there never will be.usersis a global principal with no tenancy column (Section 5.1.6).workspace_members_user_idxon(user_id) WHERE deleted_at IS NULLalready answers "which workspaces can this user open?" — the exact query a workspace switcher issues.sessions.active_workspace_idalready stores which workspace a session is looking at, andusers.default_workspace_idalready stores which one to open at sign-in.- Permissions are already evaluated per workspace:
can(actor, action, resource)takes the actor's role in the resource's workspace, so a user who is an Owner in one and a Viewer in another gets the right answer today. - Every cache key, job payload, CDN tag, and audit row is already workspace-scoped.
6.4.2 What v1 pins #
- Sign-up creates exactly one workspace, and the new user is its Owner. That workspace becomes
users.default_workspace_id. - The dashboard pins one active workspace per session.
sessions.active_workspace_idis set at sign-in fromusers.default_workspace_idand is not changeable through the UI — there is no switcher control in v1. - Accepting an invitation while already holding a membership is permitted at the data layer
and blocked at the API layer in v1 with
MULTI_WORKSPACE_NOT_AVAILABLE(409), with copy that explains the person should use a different email address for now. The block lives in exactly one function,assertSingleWorkspaceMembership()inpackages/core/membership, so lifting it is a one-line change plus its test. - Billing ownership is one workspace, one subscription.
subscriptionshas a partial unique onworkspace_id, which the agency model will relax rather than replace.
6.4.3 What a later version adds #
| Capability | Work required | Schema change |
|---|---|---|
| Multi-workspace membership | Remove assertSingleWorkspaceMembership(); add a workspace switcher to the dashboard shell; add PUT /api/v1/session/active-workspace; extend sign-in to land on default_workspace_id and offer a chooser when a user has more than one |
None |
| Agency / white-label parent | Add workspaces.parent_workspace_id uuid REFERENCES workspaces(id); add a billing-ownership rule that a child's subscription may be held by its parent; extend can() with a parent-role inheritance rule; add parent-level brand overrides to workspace_settings |
One nullable column and one can() rule — no table restructuring, no data backfill |
Section 27 lists both as post-v1 milestones with their sequencing. Recording the seam here means the future developer can see, from the schema alone, that the decision was made deliberately.
6.5 Authentication #
Dashboard authentication uses better-auth (version line in Section 3) with the email+password and magic-link strategies. Respondents never authenticate: request pages and respondent links are token-based and account-free, by design (Sections 8 and 22).
6.5.1 Configuration #
// apps/web/src/lib/auth.ts
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { twoFactor, magicLink } from 'better-auth/plugins';
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: 'pg', schema: authSchema }),
session: {
cookieName: '__Host-vouch_session',
expiresIn: 60 * 60 * 24 * 30, // 30-day rolling window
updateAge: 60 * 60 * 24, // refresh the window at most once a day
freshAge: 60 * 15, // re-auth required for sensitive actions after 15 min
cookieCache: { enabled: false }, // membership must be re-checked per request
},
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
minPasswordLength: 12,
maxPasswordLength: 256,
password: { hash: argon2idHash, verify: argon2idVerify },
},
advanced: {
cookiePrefix: '__Host-',
useSecureCookies: true,
defaultCookieAttributes: { httpOnly: true, secure: true, sameSite: 'lax', path: '/' },
generateId: () => newId(), // UUIDv7 from packages/db, per Section 5.1.1
},
plugins: [ twoFactor({ issuer: 'Vouch' }), magicLink({ expiresIn: 60 * 15 }) ],
});6.5.2 The session cookie #
| Property | Value | Reason |
|---|---|---|
| Name | __Host-vouch_session |
The __Host- prefix forces Secure, Path=/, and no Domain — a browser will reject the cookie if any of those is violated, so a misconfiguration fails visibly |
HttpOnly |
true | Not readable by script; an XSS cannot exfiltrate the session |
Secure |
true | HTTPS only |
SameSite |
Lax |
Blocks cross-site POST CSRF while allowing top-level navigation back into the dashboard from an email link |
| Rolling lifetime | 30 days, refreshed at most daily | |
| Absolute lifetime | 90 days, never extended (sessions.absolute_expires_at) |
A stolen long-lived session dies on a fixed schedule |
| Value | 32 bytes of CSPRNG randomness | Stored as SHA-256 in sessions.token_hash; the plaintext exists only in the cookie |
Additional CSRF defence: every state-changing route also requires either the Origin header to
match an allowed origin or a double-submit token for form posts, because SameSite=Lax is a
browser behaviour and not a guarantee across every client.
Sessions are revoked (all of them, for that user) on: password change, email change confirmation, 2FA enrolment or removal, and explicit "sign out everywhere". The current session may optionally be preserved on password change, which is the behaviour users expect; that choice is explicit in the handler, not incidental.
6.5.3 Password hashing #
argon2id, with parameters pinned here and reviewed annually:
| Parameter | Value |
|---|---|
| Algorithm | argon2id |
| Memory cost | 19,456 KiB (19 MiB) |
| Time cost (iterations) | 2 |
| Parallelism | 1 |
| Salt | 16 bytes, CSPRNG, per password |
| Output length | 32 bytes |
| Encoding | Standard PHC string, stored in accounts.password_hash |
These are the OWASP-recommended argon2id settings for a server-side workload and are chosen to
keep verification under roughly 100 ms on the deployment's instance class. Raising them is a
forward-only change: verifyPassword() checks the encoded parameters and, when they are below
the current policy, re-hashes the password transparently on the next successful sign-in and
updates accounts.password_changed_at without disturbing the session.
6.5.4 Password rules #
| Rule | Value | Failure code |
|---|---|---|
| Minimum length | 12 characters | PASSWORD_TOO_SHORT |
| Maximum length | 256 characters | PASSWORD_TOO_LONG |
| Composition requirements | None | — |
| Breached-password check | Required | PASSWORD_BREACHED |
| Similarity to email or workspace name | Rejected (normalized Levenshtein ≥ 0.8) | PASSWORD_TOO_SIMILAR |
| Trimming | Never — leading/trailing whitespace is significant | — |
| Unicode | NFKC-normalized before hashing, so the same typed password works across keyboards | — |
There are deliberately no character-class requirements. They demonstrably push people toward
Password1! while blocking strong passphrases; length plus a breach check is the stronger
control and is what is implemented.
The breached-password check uses a k-anonymity range query so the password never leaves the server in any recoverable form:
// packages/core/auth/breached.ts
export async function isBreached(password: string): Promise<boolean> {
const sha1 = createHash('sha1').update(password, 'utf8').digest('hex').toUpperCase();
const prefix = sha1.slice(0, 5); // 5 hex chars leave the server
const suffix = sha1.slice(5); // 35 hex chars never do
const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {
headers: { 'Add-Padding': 'true' }, // response padding hides the true bucket size
signal: AbortSignal.timeout(2000),
});
if (!res.ok) return false; // fail OPEN, and log — see below
const body = await res.text();
return body.split('\n').some((line) => line.split(':')[0].trim() === suffix);
}The service sees only a 5-character prefix shared by hundreds of thousands of passwords. The check fails open: if the range service is unreachable within 2 seconds, sign-up proceeds and a warning is logged with a counter that alerts if the failure rate exceeds 5% over 15 minutes. Failing closed would mean a third party's outage locks every new customer out of the product, which is a worse outcome than briefly accepting a password that later checks would reject. The check runs on sign-up, on password change, and on password reset — never on sign-in.
6.5.5 Sign-in, rate limiting and lockout #
Three independent limits, because each defends a different attack:
| Limit | Window | Scope | Response |
|---|---|---|---|
| 10 attempts | 15 minutes | Per IP | 429 with Retry-After |
| 5+ failed attempts | rolling | Per email address | Progressive server-side delay, never account lockout — see below |
| 100 attempts | 1 hour | Per /24 IPv4 or /48 IPv6 block |
429; blocks distributed credential stuffing from a single network |
There is no account lockout in this product, and that is a deliberate security decision, not an omission. A per-email lockout is a free denial-of-service: anybody who knows a customer's email address can keep them permanently signed out by failing their password on a loop, and the victim has no way to stop it. The attacker needs no credentials and takes no risk.
Instead, repeated authentication failures for the same account are met with a progressive
server-side delay — 0 s, 1 s, 2 s, 4 s, 8 s, capped at 15 s — applied before the response is
returned. This makes online guessing economically useless while leaving the legitimate owner able
to sign in the moment they type the right password. There is no locked_until state, no
"account temporarily locked" message, and no support path to unlock, because none is needed.
The three limits above are scoped so that exhausting one cannot starve the others; otherwise an attacker who saturates the per-IP limit would have reintroduced lockout by another name.
Behaviour details that matter:
- The response is identical for an unknown email and a wrong password — the same message, the same HTTP status, and an artificial delay padding the response to a constant 250 ms floor — so the endpoint cannot be used to enumerate accounts.
- A successful sign-in resets
failed_sign_in_countto 0 and clearslast_failed_sign_in_at, so the delay disappears the instant the real owner authenticates. - The delay is self-clearing and needs no operator action; there is no "contact support to unlock" state, because there is nothing locked.
- The account owner still receives a notification email ("we saw several failed sign-in attempts"), sent at most once per rolling hour so the notification itself cannot be used to spam them.
- Every failed attempt writes an
audit_eventsrow with actionauth.sign_in_failed, and every escalation of the delay writesauth.sign_in_throttled. - Magic-link requests are limited to 3 per email per 15 minutes and 20 per IP per hour.
6.5.6 Token lifetimes and the flows that use them #
All tokens are 32 bytes of CSPRNG randomness, transmitted base64url, stored as SHA-256 in
verification_tokens.token_hash, single-use, and invalidated when a newer token for the same
(identifier, purpose) is issued.
| Purpose | Lifetime | Notes |
|---|---|---|
email_verify |
24 hours | Re-sendable, max 5 per address per 24 h |
magic_link |
15 minutes | Single use; consuming it also verifies the email |
password_reset |
60 minutes | Consuming it revokes every session for that user |
email_change |
60 minutes | Sent to the new address; a notice goes to the old address immediately and cannot be cancelled by the attacker |
two_factor_recovery |
Until used | Ten codes, each single-use, stored hashed |
Password reset, precisely: the request endpoint always returns 200 with the same body whether
or not the address exists. If it exists, an email is sent. Consuming the token sets the new
password (subject to Section 6.5.4), revokes every session including the current one, writes
auth.password_reset to the audit log, and emails a confirmation to the address. A reset does
not bypass 2FA: if the account has confirmed 2FA, a valid TOTP code or a recovery code is
required in the same request as the new password.
Email change, precisely: the user submits the new address while authenticated and fresh
(re-authenticated within 15 minutes). users.pending_email is set, a token goes to the new
address, and a "someone requested a change of your email address" notice goes to the old address
with a one-click revoke link that is valid for 7 days. On confirmation, email is replaced,
pending_email is cleared, email_verified_at is set to now, every session is revoked, and both
addresses are notified.
6.5.7 Two-factor authentication #
- TOTP only in v1 (RFC 6238, SHA-1, 6 digits, 30-second period, ±1 step drift tolerance). WebAuthn is a documented post-v1 addition in Section 29.8.
- Enrolment: the server generates a 160-bit secret, stores it envelope-encrypted in
two_factor_secrets.secret_encrypted, and returns anotpauth://URI and QR code. 2FA is not active until the user submits a valid code —confirmed_atis the switch. Confirming issues ten single-use recovery codes, displayed once, stored as SHA-256 digests, with a "download" and a "regenerate" action. - Replay protection:
last_used_counterstores the accepted time-step counter and a code from the same or an earlier step is rejected. - Disabling 2FA requires a fresh session plus a current code or a recovery code, revokes all sessions, and emails the user.
- Workspace-wide enforcement: an Owner on Growth or Scale may set
workspaces.is_two_factor_enforced. When set, every member without confirmed 2FA is routed to a mandatory enrolment screen on their next request; they may still sign out and may still reach account settings, and nothing else. Existing sessions are not killed — they are gated, which is gentler and equally effective. The Owner cannot enable enforcement until their own 2FA is confirmed, which makes locking yourself out impossible. Turning it off is Owner-only and audited.
6.6 Authorization #
6.6.1 The four roles #
Exactly four roles exist. There are no custom roles, no per-resource ACLs, and no "team" concept in v1 — a fifth role is a schema change and a product decision, not a configuration toggle.
| Role | Summary |
|---|---|
| Owner | Everything, including billing, workspace deletion, ownership transfer, and 2FA enforcement. Exactly one per workspace. |
| Admin | Everything operational: members, invitations, brand, domains, API keys, integrations, content, analytics. No billing, no workspace deletion, no ownership transfer, no 2FA enforcement. |
| Editor | Collection and content: request pages, invitations to respondents, moderation, publishing, widgets, showcase content, imports that do not require connecting a credential. No members, no brand, no domains, no API keys, no billing. |
| Viewer | Read-only across content and analytics, with respondent contact details masked. |
6.6.2 The complete permission matrix #
Permission strings are resource:action. This is the whole list; a string not on it does not
exist, and can() throws PERMISSION_UNKNOWN (a 500, because it is a programming error) rather
than returning false for an unrecognized string.
| Permission | Owner | Admin | Editor | Viewer |
|---|---|---|---|---|
workspace:read |
✓ | ✓ | ✓ | ✓ |
workspace:update |
✓ | ✓ | — | — |
workspace:delete |
✓ | — | — | — |
workspace:transfer_ownership |
✓ | — | — | — |
workspace:enforce_2fa |
✓ | — | — | — |
workspace:export_data |
✓ | ✓ | — | — |
settings:read |
✓ | ✓ | ✓ | ✓ |
settings:update |
✓ | ✓ | — | — |
brand:update |
✓ | ✓ | — | — |
member:read |
✓ | ✓ | ✓ | ✓ |
member:invite |
✓ | ✓ | — | — |
member:update_role |
✓ | ✓ | — | — |
member:remove |
✓ | ✓ | — | — |
invitation:read |
✓ | ✓ | — | — |
invitation:create |
✓ | ✓ | — | — |
invitation:resend |
✓ | ✓ | — | — |
invitation:revoke |
✓ | ✓ | — | — |
billing:read |
✓ | — | — | — |
billing:manage |
✓ | — | — | — |
plan:change |
✓ | — | — | — |
request_page:read |
✓ | ✓ | ✓ | ✓ |
request_page:create |
✓ | ✓ | ✓ | — |
request_page:update |
✓ | ✓ | ✓ | — |
request_page:delete |
✓ | ✓ | ✓ | — |
contact:read |
✓ | ✓ | ✓ | — |
contact:create |
✓ | ✓ | ✓ | — |
contact:update |
✓ | ✓ | ✓ | — |
contact:delete |
✓ | ✓ | ✓ | — |
contact:import |
✓ | ✓ | ✓ | — |
invite:read |
✓ | ✓ | ✓ | ✓ |
invite:send |
✓ | ✓ | ✓ | — |
invite:revoke |
✓ | ✓ | ✓ | — |
submission:read |
✓ | ✓ | ✓ | ✓ |
testimonial:read |
✓ | ✓ | ✓ | ✓ |
testimonial:read_pii |
✓ | ✓ | ✓ | — |
testimonial:create |
✓ | ✓ | ✓ | — |
testimonial:update |
✓ | ✓ | ✓ | — |
testimonial:approve |
✓ | ✓ | ✓ | — |
testimonial:reject |
✓ | ✓ | ✓ | — |
testimonial:publish |
✓ | ✓ | ✓ | — |
testimonial:unpublish |
✓ | ✓ | ✓ | — |
testimonial:archive |
✓ | ✓ | ✓ | — |
testimonial:delete |
✓ | ✓ | — | — |
testimonial:restore |
✓ | ✓ | — | — |
testimonial:flag |
✓ | ✓ | ✓ | — |
testimonial:flag_clear |
✓ | ✓ | — | — |
testimonial:reply |
✓ | ✓ | ✓ | — |
testimonial:feature |
✓ | ✓ | ✓ | — |
testimonial:tag |
✓ | ✓ | ✓ | — |
testimonial:export |
✓ | ✓ | ✓ | — |
tag:create |
✓ | ✓ | ✓ | — |
tag:update |
✓ | ✓ | ✓ | — |
tag:delete |
✓ | ✓ | ✓ | — |
transcript:read |
✓ | ✓ | ✓ | ✓ |
transcript:edit |
✓ | ✓ | ✓ | — |
transcript:retry |
✓ | ✓ | ✓ | — |
highlight:create |
✓ | ✓ | ✓ | — |
highlight:update |
✓ | ✓ | ✓ | — |
highlight:delete |
✓ | ✓ | ✓ | — |
ai:suggest |
✓ | ✓ | ✓ | — |
ai:accept |
✓ | ✓ | ✓ | — |
media:read |
✓ | ✓ | ✓ | ✓ |
media:purge |
✓ | ✓ | — | — |
social_card:read |
✓ | ✓ | ✓ | ✓ |
social_card:generate |
✓ | ✓ | ✓ | — |
social_card:delete |
✓ | ✓ | ✓ | — |
widget:read |
✓ | ✓ | ✓ | ✓ |
widget:create |
✓ | ✓ | ✓ | — |
widget:update |
✓ | ✓ | ✓ | — |
widget:delete |
✓ | ✓ | ✓ | — |
showcase:read |
✓ | ✓ | ✓ | ✓ |
showcase:update |
✓ | ✓ | ✓ | — |
showcase:publish |
✓ | ✓ | ✓ | — |
domain:read |
✓ | ✓ | ✓ | ✓ |
domain:create |
✓ | ✓ | — | — |
domain:verify |
✓ | ✓ | — | — |
domain:set_primary |
✓ | ✓ | — | — |
domain:delete |
✓ | ✓ | — | — |
import:read |
✓ | ✓ | ✓ | ✓ |
import:connect |
✓ | ✓ | — | — |
import:disconnect |
✓ | ✓ | — | — |
import:run |
✓ | ✓ | ✓ | — |
import:upload_csv |
✓ | ✓ | ✓ | — |
analytics:read |
✓ | ✓ | ✓ | ✓ |
analytics:export |
✓ | ✓ | ✓ | — |
webhook:read |
✓ | ✓ | — | — |
webhook:create |
✓ | ✓ | — | — |
webhook:update |
✓ | ✓ | — | — |
webhook:delete |
✓ | ✓ | — | — |
webhook:replay |
✓ | ✓ | — | — |
integration:read |
✓ | ✓ | ✓ | ✓ |
integration:connect |
✓ | ✓ | — | — |
integration:disconnect |
✓ | ✓ | — | — |
api_key:read |
✓ | ✓ | — | — |
api_key:create |
✓ | ✓ | — | — |
api_key:revoke |
✓ | ✓ | — | — |
audit:read |
✓ | ✓ | — | — |
consent:read |
✓ | ✓ | ✓ | — |
dsr:read |
✓ | ✓ | — | — |
dsr:create |
✓ | ✓ | — | — |
dsr:fulfil |
✓ | ✓ | — | — |
notification:read |
✓ | ✓ | ✓ | ✓ |
Raising a flag and clearing one are two different permissions, deliberately. Section 11.9's
flagging workflow splits across testimonial:flag (Editor and above) and testimonial:flag_clear
(Admin and above), because the two actions are not symmetrical in consequence. Raising a flag is
safe: it takes a testimonial off the air and asks for a second opinion, and the worst outcome of
being too eager is a published quote that goes quiet for an afternoon. Clearing a flag is the
opposite — it dismisses a stated concern about abusive language, third-party personal data, or a
legal request, and it re-opens the path to publication. Under the Editor principle below, raising
is shaping and clearing is exposing, so the person who is allowed to say "this looks wrong" is
not automatically the person allowed to overrule it. Anyone who can raise a flag can therefore
always stop something; only an Admin can decide it was a false alarm.
Replies have their own permission because they leave the building. testimonial:reply
(Section 11.8) is Editor and above. It is deliberately not folded into testimonial:update,
because a reply is outbound contact with a real person rather than a change to a stored row, and
it carries the consent and suppression obligations that go with contacting them — a follow-up
that relies on the case_study_contact grant is refused with FOLLOW_UP_CONSENT_MISSING when
that grant is absent, and every send is checked against suppressions (Section 5.9.6) first. It
is equally not folded into invite:send, because revoking someone's ability to run invitation
campaigns should not silently revoke their ability to answer a respondent who wrote to them.
Saving an inbox view requires no permission at all. A saved view (Section 11.3.4) is a personal arrangement of data the actor can already see. Gating it would mean a Viewer could not organize their own screen, which is friction with no security benefit — the filter cannot reveal a row the underlying query would not already return.
The Editor line, stated as a principle rather than a list. An Editor may shape collection and curation, but may not destroy or expose. Everything an Editor can do is recoverable by another Editor; everything an Editor cannot do either removes something permanently, reveals something to the outside world, or spends money. Five grants in the table follow from that principle in ways a reader will want justified:
testimonial:deleteandtestimonial:restoreare Admin+. A testimonial is the thing the customer paid to collect, frequently the only copy of a video a respondent will ever record, and its deletion is the one content action with a 30-day fuse and an irreversible end. Restore sits with delete because they are two halves of the same decision.testimonial:flagis Editor-allowed buttestimonial:flag_clearis Admin+. Raising a flag only ever removes something from public view, which is the safe direction; clearing one dismisses a stated safety, privacy, or legal concern and re-opens the path to publication, which is exposure. The asymmetry is the point: an Editor can always stop something, and only an Admin can decide the concern was unfounded.tag:deleteis Editor-allowed. A tag is vocabulary, not content. Deleting one removes a label from testimonials that keep every word they had; the cost is an afternoon of re-tagging, not a loss. Refusing an Editor the ability to tidy the vocabulary they are asked to maintain makes the taxonomy rot.contact:deleteis Editor-allowed. An Editor already creates, imports, and invites contacts; being unable to remove a wrong address from a list they own is the kind of gap that gets worked around by giving Editors Admin. The destructive edge — erasure of a person's data under a data-subject request — isdsr:fulfil, which an Editor does not hold.social_card:deleteis Editor-allowed. A social card is a rendered artefact of a testimonial, not the testimonial. Deleting one destroys an image thatsocial_card:generatecan produce again from the same source; the testimonial, the highlight it quoted, and the alt text all survive untouched. That makes it curation rather than destruction, and it sits with the other two grants above rather than withtestimonial:delete. The complete social-card set issocial_card:read,social_card:generate,social_card:delete.
Two further judgement calls, because they are the ones a reader will question next:
import:connectis Admin+, butimport:runis Editor+. Connecting a source stores a third-party OAuth credential for the whole workspace; running a sync against an existing connection is content work. The credential and its use are separated on purpose.testimonial:read_piiis not held by Viewer. A Viewer sees testimonials, ratings, names as published, and analytics, butrespondent_emailandcontacts.phone_e164are replaced withj•••@e•••.comand+1•••••••89in every serializer. The masking happens inpackages/core/serialize/mask.ts, applied at the serialization boundary rather than at the query, so no code path can accidentally return the raw value.
This table is generated, not transcribed. The permission list and the four role columns are
exported from packages/core/authz/permissions.ts, and the copy of this matrix that appears in
Appendix 29.6 is regenerated from the same source with one extra column for the API scope. A CI
check (Section 6.6.3) asserts that the two renderings are byte-identical after normalization; a
hand-edit to either one fails the build. Two hand-maintained copies of an authorization matrix
diverge, and when they diverge the safe-looking one is not always the one that ships.
6.6.3 The can() function #
// packages/core/authz/can.ts
export type Actor =
| { type: 'user'; userId: string; workspaceId: string; role: MemberRole; twoFactorSatisfied: boolean }
| { type: 'api_key'; apiKeyId: string; workspaceId: string; scopes: ApiScope[] }
| { type: 'system'; jobName: string; workspaceId: string }
| { type: 'respondent'; testimonialId: string; workspaceId: string };
export interface Resource { workspaceId: string; kind: ResourceKind; id?: string; ownerUserId?: string }
/** The single authorization decision point. Every route and every server action calls it. */
export function can(actor: Actor, permission: Permission, resource: Resource): Decision {
if (!PERMISSIONS.has(permission)) throw new AppError('PERMISSION_UNKNOWN', 500);
// 1. Tenancy first. An actor can never act outside its own workspace, whatever its role.
if (actor.workspaceId !== resource.workspaceId) return deny('WORKSPACE_MISMATCH');
switch (actor.type) {
case 'user': {
if (!ROLE_PERMISSIONS[actor.role].has(permission)) return deny('ROLE_LACKS_PERMISSION');
if (SENSITIVE_PERMISSIONS.has(permission) && !actor.twoFactorSatisfied) return deny('TWO_FACTOR_REQUIRED');
return allow();
}
case 'api_key': {
const required = SCOPE_FOR_PERMISSION[permission];
if (!required) return deny('PERMISSION_NOT_AVAILABLE_TO_API_KEYS');
if (!actor.scopes.includes(required)) return deny('SCOPE_MISSING');
if (!API_KEY_PERMISSIONS.has(permission)) return deny('PERMISSION_NOT_AVAILABLE_TO_API_KEYS');
return allow();
}
case 'system':
return SYSTEM_PERMISSIONS.has(permission) ? allow() : deny('SYSTEM_LACKS_PERMISSION');
case 'respondent':
return RESPONDENT_PERMISSIONS.has(permission) && resource.id === actor.testimonialId
? allow() : deny('RESPONDENT_SCOPE');
}
}Rules around it:
- Every route handler and every server action calls
can()before doing work, as its first statement after input validation. A route module that exports a handler without acan()call fails thevouch/require-authzlint rule, which is an error, not a warning. - Denial returns 403 using the canonical error envelope defined in Section 7.3, with
code: "FORBIDDEN"and amessagethat does not disclose whether the resource exists. When the actor cannot even read the resource type, the response is 404NOT_FOUNDinstead, so authorization does not become an existence oracle. - The strings passed to
deny()are not error codes.WORKSPACE_MISMATCH,ROLE_LACKS_PERMISSION,TWO_FACTOR_REQUIRED,PERMISSION_NOT_AVAILABLE_TO_API_KEYS,SCOPE_MISSING,SYSTEM_LACKS_PERMISSION, andRESPONDENT_SCOPEare internal audit and log strings. They are written to the structured log line and toaudit_events.metadataso an operator can answer "why was this refused?", and they are never placed inerror.codeon the wire, because telling a caller which of seven internal reasons applied is telling them the shape of the authorization model. The one exception isTWO_FACTOR_REQUIRED, which is returned to the caller as a403code, because it is the only denial the user can act on: the remedy is "complete your second factor and retry", and answering it with a bareFORBIDDENstrands a user who has done nothing wrong.PERMISSION_UNKNOWNis likewise returned on the wire, as a500, because it is a programming error the caller should report rather than a refusal. - A CI check asserts that this matrix and Appendix 29.6 are the same table. Both are rendered
from
packages/core/authz/permissions.ts; the check re-renders each and fails on any difference, including a permission present in one and absent from the other, a differing grant cell, and a differing string. It runs on every pull request, not nightly, because a diverged authorization matrix is a security defect and the window between "merged" and "noticed" is the exposure. SENSITIVE_PERMISSIONS— requiring a session that has satisfied 2FA when the workspace enforces it, and a fresh session (re-authenticated within 15 minutes) regardless — are:workspace:delete,workspace:transfer_ownership,workspace:enforce_2fa,billing:manage,plan:change,api_key:create,api_key:revoke,member:remove,member:update_role,webhook:create,integration:connect,dsr:fulfil.can()is pure and synchronous. It receives an already-loadedActorandResource; it never queries. That makes it exhaustively unit-testable, and the test suite asserts the full cross-product of 4 roles × every permission — 380-odd assertions generated from the matrix above, so the table in this document and the code cannot diverge.
6.6.4 API keys as actors #
An API key is a workspace-level actor with a fixed capability ceiling plus its own scopes.
Effective permission = API_KEY_PERMISSIONS ∩ SCOPE_FOR_PERMISSION[permission] ∈ key.scopes.
The scope map below is complete: every permission an API key can ever exercise appears in it exactly once, and a permission absent from it is a permission no key can hold, whatever scopes it carries. There is no second list anywhere; if another section appears to grant a key something not in this table, that section is wrong.
| Scope | Grants |
|---|---|
testimonials:read |
workspace:read, testimonial:read, testimonial:read_pii, submission:read, transcript:read, media:read, social_card:read, consent:read, request_page:read, contact:read, invite:read, import:read |
testimonials:write |
testimonial:create, :update, :approve, :reject, :publish, :unpublish, :archive, :delete, :restore, :flag, :tag, :feature, :export, tag:create, tag:update, tag:delete, highlight:create, highlight:update, highlight:delete, transcript:edit, transcript:retry, ai:suggest, ai:accept, import:run, import:upload_csv, request_page:create, request_page:update, request_page:delete, contact:create, contact:update, contact:delete, contact:import, invite:send, invite:revoke, social_card:generate, social_card:delete, media:purge |
widgets:read |
widget:read, showcase:read |
widgets:write |
widget:create, widget:update, widget:delete, showcase:update, showcase:publish |
analytics:read |
analytics:read, analytics:export |
webhooks:manage |
webhook:read, webhook:create, webhook:update, webhook:delete, webhook:replay |
The ceiling is absolute and it is stated here, once. Permissions no API key can ever hold,
whatever its scopes and whatever any other section says: everything under billing:, member:,
invitation:, api_key:, plan:change, workspace:update, workspace:delete,
workspace:transfer_ownership, workspace:enforce_2fa, workspace:export_data,
settings:read, settings:update, brand:update, every domain: permission including
domain:read, import:connect, import:disconnect, integration:read,
integration:connect, integration:disconnect, notification:read, dsr:read, dsr:create,
dsr:fulfil, audit:read, testimonial:flag_clear, and testimonial:reply.
Why a key may raise a flag but not clear one, and may not reply at all.
testimonial:flag is in testimonials:write because an automated safety check — a profanity
scan, a PII detector, an integration watching for a legal notice — is exactly the kind of caller
that should be able to pull content off the air without waking anyone. Clearing a flag is the
reverse action and is excluded for the same reason it is Admin-only for humans: dismissing a
stated safety or legal concern is a judgement call, and a leaked key that could make it would be
able to re-publish content someone deliberately stopped. testimonial:reply is excluded because
it sends mail to a real respondent under that respondent's consent grants; a key that could send
it would be a spam vector pointed at the customer's own contacts, signed with the customer's
domain.
The domain:* exclusion is the one worth spelling out, because it is the one a convenience-minded
change would erode first. A custom domain is the customer's hostname: the ability to add, verify,
re-point, or delete one is the ability to serve arbitrary content from a domain the customer's
audience trusts. A leaked API key that could touch domains would be a phishing platform with the
customer's own TLS certificate. Domains are therefore a human-with-a-session action, always, and
domain:read is excluded alongside the writes so that a key cannot even enumerate the hostnames
worth attacking. integration:connect and integration:disconnect are excluded for the same
shape of reason: they move third-party OAuth credentials.
api_key actors are exempt from the 2FA and freshness checks — a machine has no second factor —
which is precisely why their ceiling excludes every sensitive permission.
Read-only keys on Free. The Free plan's API access is read-only at 30 requests per minute
(Section 19). This is enforced at key creation: a Free workspace may only create keys whose scopes
are a subset of {testimonials:read, widgets:read, analytics:read}, and requesting a write scope
returns 409 PLAN_FEATURE_UNAVAILABLE — the same code every other plan gate uses, because
this is a plan limitation and not an authorization failure. After a downgrade, existing
write-scoped keys are neither deleted nor silently narrowed: they are refused at authentication
time with 403 PLAN_DOWNGRADED_SCOPE_REVOKED, which is a genuinely different condition and
keeps its own code. The distinction matters to the integration on the other end: the first says
"you cannot ask for this", the second says "what you already had has stopped working, and here is
why". Deleting the keys instead would make a re-upgrade require re-integrating.
6.7 Team management #
6.7.1 Seats #
A seat is a non-deleted workspace_members row plus a pending invitation, so a workspace
cannot exceed its seat limit by holding invitations open:
SELECT (SELECT count(*) FROM workspace_members
WHERE workspace_id = $1 AND deleted_at IS NULL AND seat_state = 'active')
+ (SELECT count(*) FROM invitations
WHERE workspace_id = $1 AND status = 'pending' AND expires_at > now()) AS seats_used;Note the expires_at > now() predicate on the invitation half. The seat count is a gauge
recomputed on read (Section 19.5.2), so it must be correct at the instant it is read rather than
correct as of the last time a sweeper ran. Without the predicate, an invitation that expired
forty minutes ago would still occupy a seat until the hourly invitation.expire job noticed —
which is a customer watching a seat they have paid for stay unusable for no visible reason.
The check runs in packages/core/quota before an invitation is created and before an invitation
is accepted (the second check matters: a seat may have been filled while the invitation sat in an
inbox). Exceeding the limit returns 409 SEAT_LIMIT_REACHED with the plan's limit and current
usage in details. It is a 409 and not a 403 because the request is properly authorized and
correctly formed — it conflicts with the workspace's current state, and the remedy is to change
that state (remove a member, or upgrade), not to acquire a permission. Section 19.5.7 owns that
rule and every quota refusal in the system follows it.
On downgrade to a plan with fewer seats, nothing is deleted: no membership row is removed and
no role is changed. The workspace's limit_state becomes over_limit, which blocks new
invitations, and the excess members — newest membership first, never the Owner — move to
seat_state = 'seat_suspended' (Section 5.4.3). A suspended member keeps their row, their role,
and their history; they simply cannot open the workspace until a seat frees up or the plan is
raised, at which point Section 19.12.5 reactivates them oldest-membership-first. Because
suspension changes an actor's effective permissions without changing their role, entering or
leaving seat_suspended revokes that member's sessions immediately (Section 7.7.1) — a
suspended member holding a live session would otherwise keep working for up to the session's
remaining life.
This mirrors the rule for testimonials in Section 19 — a cap blocks growth, it never destroys what exists.
6.7.2 Changing a role #
member:update_role is Owner and Admin. Constraints:
- An Admin cannot promote anyone to Owner (that is
workspace:transfer_ownership, Owner-only). - An Admin cannot change the Owner's role.
- An Admin can demote another Admin, including a mutual demotion race — the database's single-Owner index means neither can become Owner, and the last-Owner rule in 6.7.4 means the workspace always retains one.
- Changing your own role downward is allowed except for the Owner, who must transfer first.
- Every change writes
member.role_changedtoaudit_eventswith before/after roles and emails the affected member.
6.7.3 Removing a member #
Removal is a soft delete: workspace_members.deleted_at = now(). What happens, exactly:
- Their sessions scoped to that workspace are revoked immediately, and the membership cache key is invalidated, so the removal takes effect within one request.
- Their audit history is retained and still attributed to them.
audit_eventsrows keepactor_user_idand the denormalizedactor_labelcaptured at the time, so "Priya published this testimonial" still reads correctly a year after Priya left. Audit rows are append-only (Section 5.16.3) and are never rewritten to anonymize a departure. - Content they created is untouched.
testimonials.created_by_user_idandapproved_by_user_idcontinue to point at the user row, which still exists globally. - API keys they created keep working — keys belong to the workspace, not the person (Section 5.4.9). This is deliberate: revoking a person must not silently break an integration at 3 a.m. The members screen shows which keys a departing member created so an Admin can make a decision explicitly.
- Their pending invitations, if any, are revoked.
- A
member.removedaudit row is written and the removed person is emailed.
If the user account itself is later erased under a GDPR request, the user row's PII is
replaced with tombstone values (Section 5.4.1) and actor_label on historical audit rows is
rewritten — by the audited erasure procedure only — to "Removed user". This is the single case in
which an audit row's content changes, and the change is itself recorded in
data_subject_requests.deleted_entity_counts.
6.7.4 Ownership transfer and last-Owner protection #
Last-Owner protection is enforced at three levels:
- Database:
workspace_members_one_owner_uqguarantees at most one Owner, andworkspace_members_owner_removal_ckforbids soft-deleting a row whose role isowner. An Owner must therefore be demoted or transferred before removal — there is no ordering of statements that leaves a workspace ownerless. - Foreign key:
workspaces.owner_user_idisON DELETE RESTRICT, so deleting the user is refused while they own a workspace. - Application:
transferOwnership()is the only function that writesowner_user_id, and it asserts the target is an existing, non-deleted, email-verified member.
Transfer flow:
- The Owner opens Settings → Members, chooses a member, and selects "Transfer ownership". The action requires a fresh session and, where 2FA is enrolled, a current code.
- A confirmation dialog requires typing the workspace slug — the same friction as deletion, because the consequence is comparable.
- In one transaction: the target's role becomes
owner, the previous Owner's becomesadmin,workspaces.owner_user_idis updated, and aworkspace.ownership_transferredaudit row is written. The unique index makes the intermediate two-Owner state impossible, so the update order is fixed: demote first, then promote. - Both parties are emailed. The new Owner inherits billing responsibility; the Stripe customer record's email is updated to the new Owner's address.
- If the workspace has 2FA enforced and the target has not confirmed 2FA, the transfer is
refused with
TRANSFER_TARGET_MISSING_2FA— the workspace would otherwise have an Owner who cannot sign in under its own policy.
6.8 Invitations #
6.8.1 State machine #
create
│
▼
┌─────────┐ accept (valid, unexpired, seat available) ┌──────────┐
│ pending │ ───────────────────────────────────────────► │ accepted │ (terminal)
└─────────┘ └──────────┘
│ │
revoke │ │ expires_at passes (sweeper, or lazily on use)
▼ ▼
┌─────────┐ ┌─────────┐
│ revoked │ │ expired │ (both terminal)
└─────────┘ └─────────┘Terminal states never transition. Re-inviting a revoked or expired address creates a new row;
the old row is retained for the audit trail (90 days, Section 5.20). Attempting to accept a
non-pending invitation returns 410 INVITATION_NOT_PENDING, never 404 — the distinction tells
the person their link was real but is no longer usable, which is a materially better experience
than "not found".
6.8.2 The token #
32 bytes of CSPRNG randomness, base64url-encoded in the link
(https://app.vouch.app/invite/{token}), stored only as SHA-256 in invitations.token_hash.
Single-use: acceptance is a conditional update
(UPDATE invitations SET status='accepted', … WHERE id=$1 AND status='pending') whose zero-row
result means someone else already used it. Expiry is 7 days from creation; a re-send extends
expires_at by another 7 days from the re-send and issues a new token, invalidating the old
one, so a forwarded old link cannot be used after a re-send.
6.8.3 Acceptance, with and without an existing account #
| Invitee state | Flow |
|---|---|
| No account | The link opens a sign-up form with the email pre-filled and locked (the invitation is bound to that address). On submit: create users, create accounts with the password hash, mark email_verified_at = now() because possession of the invitation token proves control of the mailbox, create workspace_members, mark the invitation accepted, sign them in, land on the workspace dashboard. |
| Has an account, signed out | The link opens a sign-in form with the email pre-filled and locked. After authentication (including 2FA if enrolled), the invitation is accepted and they land on the dashboard. |
| Has an account, signed in as the same email | A single "Join {workspace} as {role}?" confirmation screen showing who invited them and their role. One click accepts. |
| Has an account, signed in as a different email | An explicit screen: "This invitation is for priya@acme.com but you are signed in as sam@acme.com." Two actions: sign out and continue, or cancel. The invitation is never silently applied to the wrong account. |
| Already a member of this workspace | The invitation is marked accepted (idempotent, no duplicate membership) and they land on the dashboard with an informational toast. |
| Already a member of a different workspace | Blocked in v1 with 409 MULTI_WORKSPACE_NOT_AVAILABLE and copy explaining the single-workspace limit and that using a separate address works today. See Section 6.4.2. |
6.8.4 Sending, re-sending and rate limits #
| Limit | Value | Error |
|---|---|---|
| Invitation sends per workspace | 30 per minute | 429 RATE_LIMITED |
| Re-sends per invitation | 5 total (resend_count) |
429 INVITATION_RESEND_LIMIT |
| Re-send interval | No more than once per 60 seconds per invitation | 429 RATE_LIMITED with Retry-After |
| Pending invitations per workspace | Bounded by the seat limit (Section 6.7.1) | 409 SEAT_LIMIT_REACHED |
| One open invitation per address | Enforced by a partial unique index | 409 INVITATION_ALREADY_PENDING |
Free tier. The Free plan has one seat, the Owner. Any attempt to create an invitation returns 409 with the canonical envelope (Section 7.3) and:
{
"error": {
"code": "SEAT_LIMIT_REACHED",
"message": "Your plan includes 1 seat. Upgrade to Starter to invite up to 3 teammates.",
"details": [
{ "field": "plan", "issue": "Current plan 'free' allows 1 seat." },
{ "field": "seats", "issue": "1 of 1 seats in use." }
],
"docsUrl": "https://docs.vouch.app/errors/SEAT_LIMIT_REACHED"
},
"meta": { "requestId": "req_01JXXXXXXXXXXXXXXXXXXXXXXX" }
}The dashboard renders this as an inline upgrade prompt on the Members screen — the invite form is
visible and disabled with an explanatory note, not hidden, so the capability is discoverable. The
details array is present here because quota refusals carry the limit and the current usage as
structured fields, which is the shape Section 19.6 defines for every plan and quota error; that is
a deliberate, catalogued exception to the general rule that details accompanies validation
failures.
6.9 Onboarding #
Five steps. Each is resumable: a user who abandons at step 3 returns to step 3, driven by
workspaces.onboarding_completed_at and the presence of the artefacts each step creates.
| # | Step | Validation | On success |
|---|---|---|---|
| 1 | Sign up — email, password, name | Email is RFC-5322-valid, ≤ 254 chars, MX record present on the domain (soft check, 2 s timeout, failure does not block); password per Section 6.5.4; name 1–120 chars | users row created, verification email sent, session issued in an unverified state that may only reach steps 1–2 |
| 2 | Verify email | Token per Section 6.5.6 | email_verified_at set; full dashboard unlocked |
| 3 | Create workspace — name, slug | Name 2–80 chars; slug 3–48 chars matching ^[a-z0-9](?:[a-z0-9-]{1,46}[a-z0-9])$, not reserved, globally unique |
workspaces + workspace_settings + workspace_members (Owner) + Free subscriptions placeholder + system tags, all in one transaction |
| 4 | First request page | Name, headline, at least one of video/text enabled; slug unique within the workspace; a consent version is attached automatically | request_pages row; onboarding_completed_at set; the share link and QR code are shown |
| 5 | First invitation (skippable) | A valid email or E.164 phone; the send is subject to Section 6.8.4 | contacts + invites rows; the funnel begins |
Slug selection and reservation. The slug becomes {slug}.vouch.app and appears in every
request-page URL, so it is checked hard:
- Format:
^[a-z0-9](?:[a-z0-9-]{1,46}[a-z0-9])$— 3–48 characters, lowercase alphanumerics and single internal hyphens, no leading/trailing hyphen, no consecutive hyphens (checked separately withslug !~ '--'). - Uniqueness: globally unique among non-deleted workspaces, checked case-insensitively via
citext. A soft-deleted workspace's slug is not immediately reusable — it is released when the 30-day purge completes, so links from the old workspace cannot silently resolve to a new one. - Reservation: the slug is held for 15 minutes while the user is on the step, in Valkey under
slug:reserve:{slug}, so two people in a live signup race get a clear message rather than a constraint-violation error page. - Reserved words — rejected with 422
SLUG_RESERVED:about, account, accounts, admin, administrator, api, app, apps, assets, auth, billing, blog, cdn, careers, changelog, checkout, cname, community, compare, contact, cookies, dashboard, demo, dev, developer, developers, docs, download, downloads, e, edge, editor, email, embed, enterprise, events, examples, faq, features, feed, files, ftp, graphql, guides, help, home, host, hosting, i, id, images, img, integrations, internal, invite, jobs, legal, login, logout, m, mail, manage, marketing, media, mx, new, news, ns, ns1, ns2, oauth, onboarding, p, partners, pay, payment, payments, plans, press, pricing, privacy, profile, r, register, reset, resources, root, s, sales, security, server, service, settings, setup, signin, signup, sitemap, smtp, sso, staff, staging, static, status, store, support, system, team, terms, test, testing, tos, trust, undefined, upgrade, uploads, v, vouch, w, web, webhook, webhooks, widget, widgets, wgt, www, null, noneplus every two-letter string, every string matching^ns[0-9]+$, and a profanity list maintained inpackages/core/slug/reserved.ts. The list is data, not code, and adding to it never invalidates an existing slug — existing workspaces keep what they have.
What is created with the workspace, in one transaction, so a half-built workspace is
impossible: the workspaces row (plan free, billing_state = 'active', limit_state = 'ok',
and usage_anchor_day set from the creation date clamped to 28), the workspace_settings row
pointing at the current platform consent version, the Owner workspace_members row with
seat_state = 'active', the ten system tags from Section 5.18, one usage_counters row per
metric initialized to 0 with the Free limits snapshotted, and a workspace.created audit row.
6.10 Security and session events in the audit trail #
Every event below writes a row to audit_events (Section 5.11.1). Content events are catalogued
by Section 11.6; this list is the authentication, authorization, and membership set, and it is
exhaustive.
| Action | Written when | Notable fields |
|---|---|---|
auth.signed_up |
Account created | after.email, invitation id when applicable |
auth.email_verified |
Verification token consumed | |
auth.signed_in |
Successful sign-in | metadata.method = password | magic_link; metadata.twoFactorUsed |
auth.sign_in_failed |
Failed attempt | metadata.reason = unknown_user | bad_password | locked | bad_totp; never the attempted password |
auth.sign_in_throttled |
Progressive sign-in delay escalated (Section 6.5.5); there is no account-lockout state | metadata.delaySeconds |
auth.signed_out |
Explicit sign-out | metadata.scope = session | all |
auth.password_changed |
Password changed while signed in | |
auth.password_reset |
Reset token consumed | metadata.sessionsRevoked |
auth.email_change_requested |
New address submitted | after.pendingEmail |
auth.email_changed |
Change confirmed | before.email, after.email |
auth.email_change_revoked |
Old address clicked revoke | |
auth.two_factor_enrolled |
TOTP confirmed | |
auth.two_factor_disabled |
TOTP removed | |
auth.recovery_code_used |
A recovery code consumed | metadata.remaining |
auth.recovery_codes_regenerated |
New set issued | |
auth.session_revoked |
A session killed | metadata.reason |
authz.denied |
A can() denial on a state-changing route |
metadata.permission, metadata.reason; sampled at 100% for sensitive permissions, 10% otherwise to bound volume |
workspace.created |
||
workspace.updated |
Name, slug, region, or settings changed | changed_keys |
workspace.ownership_transferred |
before.ownerUserId, after.ownerUserId |
|
workspace.two_factor_enforcement_changed |
after.enforced |
|
workspace.deleted |
Soft delete initiated | metadata.purgeScheduledFor |
member.invited |
Invitation created | after.email, after.role |
member.invitation_resent |
metadata.resendCount |
|
member.invitation_revoked |
||
member.joined |
Invitation accepted | after.userId, after.role |
member.role_changed |
before.role, after.role |
|
member.seat_suspended |
A downgrade suspended this seat (Section 6.7.1) | metadata.reason = plan_downgrade; metadata.seatsAllowed, metadata.seatsUsed |
member.seat_reactivated |
A seat freed up or the plan was raised | metadata.reason = plan_upgrade | seat_freed |
member.removed |
before.userId, before.role |
|
api_key.created |
after.name, after.scopes, after.keyPrefix — never the key |
|
api_key.revoked |
||
api_key.used_after_revocation |
A revoked key was presented | metadata.keyPrefix, truncated IP — a real signal that a secret is loose |
Two rules: no secret, token, password, or key ever appears in an audit row, enforced by a
redaction pass over before/after that drops any key matching
/(password|secret|token|key_hash|hash|authorization)/i; and audit writes happen in the same
transaction as the change they describe, so an action that succeeded but was not recorded is
impossible.
6.11 Dashboard screens implied by this section #
Every screen below lives under apps/web/src/app/(dashboard)/. Each lists its route, the
permission the route guard requires, and its three mandatory states. "Error state" means the
component-level failure UI; transport failures render the canonical error envelope's message
(Section 7.3) with the requestId shown in small print for support.
| Screen | Route | Permission | Empty state | Loading state | Error state |
|---|---|---|---|---|---|
| Sign in | /sign-in |
public | — | Button spinner, inputs disabled, form still readable | Inline field errors; a single non-specific banner for bad credentials |
| Sign up | /sign-up |
public | — | Same | Inline validation, live password-strength and breach feedback after blur |
| Verify email | /verify-email |
authenticated, unverified | "We sent a link to {email}" with a resend button (60 s cooldown, live countdown) | Skeleton on the status line | Expired-token panel with a one-click resend |
| Magic link sent | /magic-link/sent |
public | Confirmation copy plus "use a password instead" | — | — |
| Accept invitation | /invite/[token] |
public | — | Full-page skeleton while the token resolves | Distinct panels for expired, revoked, already-accepted, and wrong-account (Section 6.8.3) |
| Forgot / reset password | /forgot-password, /reset-password/[token] |
public | — | Button spinner | Expired-token panel with a "request a new link" action |
| Two-factor challenge | /2fa |
authenticated, 2FA pending | — | Button spinner | "That code didn't work" plus a "use a recovery code" link |
| Onboarding: create workspace | /onboarding/workspace |
authenticated, verified, no workspace | Pre-filled name suggestion from the email domain | Slug availability check shows a spinner in the field affix | Inline SLUG_RESERVED / SLUG_TAKEN messages with three generated alternatives |
| Onboarding: first page | /onboarding/request-page |
request_page:create |
Template chooser with three presets | Skeleton form | Inline validation |
| Onboarding: first invite | /onboarding/invite |
invite:send |
"Invite your first customer" with a skip link | Button spinner | Per-row error in the recipient list |
| Home / overview | / |
workspace:read |
"No testimonials yet" with a share-your-link card and a QR code | Card skeletons preserving final layout height (no shift) | Retry banner per card; one failing card never blanks the page |
| Members | /settings/members |
member:read |
Only-you state with an invite CTA (disabled with an upgrade note on Free) | Table skeleton, 5 rows | Row-level error with retry |
| Invitations (within Members) | /settings/members |
invitation:read |
"No pending invitations" | Inline skeleton | Toast plus row error |
| Member detail / role change | /settings/members/[memberId] |
member:update_role |
— | Skeleton | Confirmation dialog surfaces the error and stays open |
| Transfer ownership | /settings/members/transfer |
workspace:transfer_ownership |
— | Button spinner | Blocking panel for TRANSFER_TARGET_MISSING_2FA naming the requirement |
| Account: profile | /account |
authenticated | — | Field skeletons | Inline field errors |
| Account: password | /account/password |
authenticated | — | Button spinner | Inline; breach failure explains the check and offers a passphrase suggestion |
| Account: two-factor | /account/two-factor |
authenticated | "Two-factor is off" with an enable CTA and a plan note when the workspace enforces it | QR skeleton | Wrong-code inline error; recovery-code panel shown exactly once with copy and download actions |
| Account: sessions | /account/sessions |
authenticated | — | Row skeletons | Row-level error; "sign out everywhere" confirms first |
| Workspace settings | /settings/workspace |
settings:read (settings:update to save) |
— | Field skeletons | Inline; slug change warns about link impact before saving |
| Brand | /settings/brand |
brand:update |
Placeholder logo with an upload target | Preview skeleton | Upload errors state the size and format limits |
| Security | /settings/security |
workspace:read (workspace:enforce_2fa to change) |
— | Skeleton | Enforcement toggle is disabled with an explanation on plans below Growth and when the Owner's own 2FA is unconfirmed |
| API keys | /settings/api-keys |
api_key:read |
"No API keys yet" with docs links | Table skeleton | Creation modal shows the plaintext key exactly once with a copy button and an explicit "you will not see this again" warning |
| Audit log | /settings/audit-log |
audit:read |
"No activity yet" | Row skeletons | Cursor-paginated; a failed page keeps prior rows and shows an inline retry |
| Billing | /settings/billing |
billing:read |
— | Skeleton | Portal-unreachable banner with a retry; plan state still renders from the local mirror |
Cross-cutting requirements for every screen in this table: a permission the actor lacks renders a
403 screen naming the required role, not a blank page; every destructive action requires typed
confirmation of the resource name; every loading state reserves the final layout's height so
nothing shifts; every error state is announced with role="alert"; and every state carries text,
not colour alone (Section 21).
7. API Design — Internal Contract & Public REST API #
7.1 Canonical status of this section #
This section is the single canonical definition of the HTTP contract for Vouch. Response envelopes, pagination, sorting, status codes, authentication, authorization wiring, idempotency, rate limiting, versioning, CORS, size limits, timeouts, and compression are defined here and nowhere else. Every other section of this document — the collection flows in Section 8, the media pipeline in Section 9, the moderation surfaces in Section 11, the widget data API in Section 13, the analytics beacon in Section 17, the integrations in Section 18 — references this section rather than restating it. If any other section appears to contradict this one, this section wins and the other section is the defect.
Three rules follow from that and are absolute:
- One envelope. Every JSON response from every Vouch HTTP surface uses the envelopes in Section 7.3. There is no endpoint anywhere that returns a bare array, a bare object, or a bare string.
- One pagination model. Cursor pagination, Section 7.4. There is no
page,offset,skip, orpageSizeparameter anywhere in the product, on any surface, ever. - One error shape. Section 7.3.3. Every non-2xx response, including those produced by middleware, rate limiters, and the edge, carries it.
The implementing agent should treat a violation of any of the three as a build-breaking bug, and Section 25 defines the contract tests that enforce them.
7.2 Surfaces, base URLs, and runtime placement #
Vouch exposes five HTTP surfaces. They differ in host, authentication, cacheability, and CORS, and the differences are deliberate.
| # | Surface | Base URL | Runtime | Auth | Cached at edge |
|---|---|---|---|---|---|
| 1 | Dashboard API (first-party) | https://app.vouch.app/api/v1 |
Node | Session cookie | No (private, no-store) |
| 2 | Public REST API | https://app.vouch.app/api/v1 |
Node | Authorization: Bearer vch_… |
No (private, no-store) |
| 3 | Respondent / submission API | https://v.vouch.app/api/v1 |
Node | Unauthenticated, draft token, or portal session | No |
| 4 | Widget data API | https://cdn.vouch.app/api/v1 |
Edge | None (public) | Yes |
| 5 | Analytics beacon | https://cdn.vouch.app/api/v1/e |
Edge | None (public) | No (write path) |
The Edge/Node split is one rule, not a list to memorise: does this route touch the database directly? Edge carries only the routes that never open a database connection: the widget data API, the embed loader and the oEmbed response, which serve a pre-rendered CDN-cacheable payload, and the analytics beacon ingest, which acknowledges and hands off without reading or writing Postgres in the request. Node carries everything that opens a transaction, because such a route must set the RLS session variable per Section 6 on its own connection, and because it needs the Postgres driver and the object-storage signing SDK, neither of which is available in an edge runtime. Any new route is placed by asking that one question, and a route that would need a database connection from the edge is a design error rather than a deployment decision.
Surface 3 is therefore Node, and unambiguously so: the public collection endpoints in Section 8.10 create draft rows, submission rows and upload sessions, and sign multipart parts. They also hash draft tokens, which uses the same Node crypto primitives the rest of the product's token handling relies on. Being database-touching is not incidental to what they do — it is what they do.
Node placement does not imply slow or single-region. The request-page shell is CDN-cached in front of the origin, so a respondent's first paint does not wait on a database, and custom-domain routing per Section 14.5 is unaffected by which runtime serves the request.
Surfaces 1 and 2 share a base URL and a route tree on purpose: the public REST API is not a second implementation of the dashboard's data access, it is the same route handlers with a different authentication principal. A route resolves a principal (session-user or api-key), then calls the single can(actor, action, resource) authorization function defined in Section 6, then calls the same packages/core service the dashboard uses. This eliminates the classic drift where the public API lags the product by six features.
Not every route is available to both principals. Each entry in the endpoint catalogue (Section 7.15) names its accepted principals explicitly in the Auth column, using these tokens:
| Token | Meaning |
|---|---|
session |
Dashboard session cookie only. Rejects API keys with 401 API_KEY_NOT_PERMITTED. |
key |
API key only. Rejects session cookies with 401 SESSION_NOT_PERMITTED. |
session | key |
Either principal. |
public |
No credentials. Anyone may call it. |
draft token |
The collection draft token in the URL path (/public/drafts/{draftToken}), which authorises exactly one in-progress submission and nothing else. Section 8.9 owns its lifetime, storage and revocation. |
portal-session |
The read-only bearer minted from the respondent link token described in Section 22 (https://v.vouch.app/p/{token}). Scope portal:read. Distinct from a draft token: it addresses testimonials already submitted. |
portal-session + step-up |
The same bearer after the one-time emailed code has been verified, carrying a portal_step_up claim. Required by every portal endpoint that changes state (Section 7.7.3). Without the claim: 401 STEP_UP_REQUIRED. |
signature |
Authenticated by a third-party signature (Stripe, Mux, Slack, Twilio, Resend, Deepgram callback). Never by a Vouch credential. |
Free-tier workspaces are additionally served on {workspace-slug}.vouch.app and paid workspaces on their own custom domains (Section 14); those hosts serve surface 3 and the showcase page only. They never serve the dashboard API or the public REST API, and a request to /api/v1/testimonials on a customer domain returns 404 NOT_FOUND rather than leaking the existence of the route.
7.3 Response envelopes #
7.3.1 Success — single resource #
{
"data": {
"id": "tst_01JBQ7Z4K9M2X7YV3C8N6P0R5T",
"status": "published",
"authorName": "Dana Okafor",
"createdAt": "2025-11-04T09:12:44.118Z"
},
"meta": { "requestId": "req_01JBQ8A2M4NKX9V3PT7ZQ0R5TC" }
}datais always an object for single-resource responses. Nevernullon a 2xx: if there is nothing to return, the endpoint returns204 No Contentwith no body at all.metaalways containsrequestIdand may contain endpoint-specific keys documented per endpoint (for examplemeta.replayedon an idempotent replay,meta.warningson a partially-degraded read).metanever contains business data that a client would need to function.- Unknown keys inside
datamay be added at any time and are not a breaking change (Section 7.11). Clients must ignore keys they do not recognise.
7.3.2 Success — collection #
{
"data": [
{ "id": "tst_01JBQ7Z4K9M2X7YV3C8N6P0R5T", "status": "published" },
{ "id": "tst_01JBQ7Y1H8F2C4WD9K3M6Q0Z2B", "status": "published" }
],
"pagination": {
"nextCursor": "eyJrIjpbIjIwMjUtMTEtMDRUMDk6MTI6NDQuMTE4WiIsInRzdF8wMUpCUTdZMUg4RjJDNFdEOUszTTZRMFoyQiJdfQ.9f2a1c",
"hasMore": true,
"limit": 25
},
"meta": { "requestId": "req_01JBQ8A2M4NKX9V3PT7ZQ0R5TC" }
}datais always an array, possibly empty. An empty result is200with"data": [], never404.paginationis present on every collection response, including the last page. On the last pagenextCursorisnullandhasMoreisfalse.pagination.limitechoes the effective limit actually applied after clamping, not the raw request value.- There is deliberately no
total. Counting rows across a tenant-filtered, soft-delete-filtered, RLS-protected table on every list request is a performance trap, and an approximate total is worse than none. Where the product genuinely needs a count (the moderation inbox badge, plan usage), a dedicated counting endpoint exists and is documented in the catalogue — for exampleGET /api/v1/testimonials/counts.
7.3.3 Error — every non-2xx without exception #
{
"error": {
"code": "TESTIMONIAL_LIMIT_REACHED",
"message": "Human-readable, safe to show an end user.",
"details": [ { "field": "email", "issue": "Must be a valid email address." } ],
"docsUrl": "https://docs.vouch.app/errors/TESTIMONIAL_LIMIT_REACHED"
},
"meta": { "requestId": "req_01JBQ8A2M4NKX9V3PT7ZQ0R5TC" }
}Rules:
| Field | Rule |
|---|---|
error.code |
SCREAMING_SNAKE_CASE, stable forever once shipped, machine-readable. Catalogued once in Appendix Section 29.3. Clients branch on this, never on message. |
error.message |
One sentence, plain language, safe to render directly to an end user. Never contains a stack trace, SQL, an internal hostname, a table name, a user id from another workspace, or a provider's raw error string. |
error.details |
Present on 422 validation failures and — as the single named exception — on 409 INVALID_STATUS_TRANSITION. On no other status, and on no other 409. Always an array of { field, issue }. field is a dot/bracket path into the request body (items[2].rating, theme.accentColor), or the literal _state on the status-transition case, whose issue names the current status and every legal next state so a client can render the real choice rather than a dead end. Absent — not null, not [] — everywhere else. The exception exists because a rejected lifecycle transition is the one conflict where the actionable information is structured (Section 11.2.3 relies on it); it is not a licence to attach details to other conflicts. |
error.docsUrl |
Always present. Deterministically https://docs.vouch.app/errors/{code}. |
meta.requestId |
Always present, identical to the X-Request-Id response header. |
Middleware-level failures (rate limiting, body-size rejection, malformed JSON, unsupported media type, CORS preflight rejection) produce the same envelope. The edge runtime carries a 40-line envelope helper so surfaces 4 and 5 are not exceptions.
7.3.4 requestId and correlation #
- Format:
req_+ a UUIDv7 encoded in Crockford base32, uppercase, unpadded — e.g.req_01JBQ8A2M4NKX9V3PT7ZQ0R5TC. Generation is application-side, on the very first middleware, before authentication. - The value is echoed in the
X-Request-Idresponse header on every response including 204s and redirects. - It is attached to the pino logger context (Section 3) so every log line for the request carries
requestId, and to the Sentry scope as a tag, and to the OpenTelemetry span as thevouch.request_idattribute. - If a client sends its own
X-Request-Idrequest header, the server does not adopt it. It records it asclientRequestIdin the log context for correlation and generates its own canonical id. Accepting client-supplied ids as canonical lets a hostile client poison log searches and collide with real ids. - Background jobs (Section 24) inherit the
requestIdof the request that enqueued them asparentRequestIdand generate their ownjobRunId, so an async chain is traceable end to end from a support ticket that quotes one id.
7.3.5 Date, number, and null conventions #
- All timestamps in JSON are RFC 3339 / ISO 8601 UTC strings with milliseconds and a
Zsuffix:2025-11-04T09:12:44.118Z. Never epoch numbers, never local times, never offsets. - All durations are numbers of seconds unless the key name ends in
Ms. - All money is an integer of minor units in a key ending
Cents, always accompanied by a siblingcurrencykey holding an ISO-4217 uppercase code. Never a float, never a formatted string. - A field that is genuinely absent is
null. A field that the caller lacks permission to see is omitted entirely rather than nulled, so a client cannot distinguish "no value" from "not allowed" and then probe. - Enum values are lowercase
snake_casestrings matching the Postgres enum values in Section 5 (pending,approved,published,unpublished,rejected,archived). - Booleans are never tri-state. If a third state is needed, it is an enum.
7.4 Pagination — cursor only #
7.4.1 Model #
Every collection endpoint in Vouch is cursor-paginated. There is no offset or page pagination anywhere in the product, on any surface, in any version. Offset pagination is rejected because it produces duplicate and skipped rows under concurrent inserts — which is the normal state of a testimonial inbox — and because OFFSET 20000 degrades linearly on tables that will reach millions of rows.
Request parameters, accepted on every collection endpoint:
| Param | Type | Default | Constraints | Notes |
|---|---|---|---|---|
limit |
integer | 25 |
1 ≤ limit ≤ 100 |
Values above 100 are clamped to 100, not rejected, and the effective value is echoed in pagination.limit. Values below 1, non-integers, or non-numeric strings are 422 VALIDATION_FAILED. |
cursor |
string | none | opaque | Must be a token previously returned in pagination.nextCursor. Anything else is 400 INVALID_CURSOR. |
sort |
string | per-endpoint | see 7.5 | Must be identical on every page of a traversal. |
7.4.2 Cursor format #
The cursor is opaque to clients and clients must never construct, parse, mutate, persist beyond a traversal, or reason about one. Its internal structure is documented here for implementers only:
<base64url( JSON )>.<tag>where the JSON payload is exactly:
{ "k": ["2025-11-04T09:12:44.118Z", "tst_01JBQ7Y1H8F2C4WD9K3M6Q0Z2B"] }k[0]is the value of the primary sort key on the last row of the page just returned, serialized per Section 7.3.5.k[1]is that row'sid, which is the universal tiebreaker. Because ids are UUIDv7 (Section 5) they are time-ordered, so the tiebreaker never reorders rows that share a sort key value in a surprising way.tagis the first 6 hex characters ofHMAC-SHA256(CURSOR_SECRET, base64urlPayload + "|" + querySignature), wherequerySignatureis the canonical, key-sorted serialization of the effectivesortplus every filter parameter plus the workspace id. It makes the cursor tamper-evident and detects the "client changed the filter mid-traversal" case deterministically.- Verification is constant-time. A cursor whose tag does not verify, whose payload is not valid base64url JSON, whose
kis not a two-element array, or which was issued for a different workspace, returns400 INVALID_CURSORwith the message "This page cursor is no longer valid. Start the list again from the first page." - Cursors do not expire on a timer. They become invalid only if the filter/sort signature changes or
CURSOR_SECRETis rotated.CURSOR_SECRETrotation supports two active secrets so an in-flight traversal survives a deploy.
7.4.3 Query shape #
The generated SQL uses a keyset predicate, never OFFSET:
SELECT *
FROM testimonials
WHERE workspace_id = current_setting('app.current_workspace_id')::uuid
AND deleted_at IS NULL
AND status = 'published'
AND (created_at, id) < ($1::timestamptz, $2::uuid) -- the cursor
ORDER BY created_at DESC, id DESC
LIMIT $3 + 1; -- fetch one extraThe handler fetches limit + 1 rows, returns the first limit, and sets hasMore from whether the extra row materialized. It never issues a second count query.
Every sortable column has a supporting composite index leading with workspace_id and ending with id, for example idx_testimonials_ws_created (workspace_id, created_at DESC, id DESC). Section 5 owns the index list; a sort key without a matching index is not an allowed sort key.
7.4.4 Worked example — first page #
GET /api/v1/testimonials?status=published&limit=2 HTTP/1.1
Host: app.vouch.app
Authorization: Bearer vch_live_7Qk2wRb9XmT4pL8sYc1VnZ0dHjE6uA3f{
"data": [
{ "id": "tst_01JBQ7Z4K9M2X7YV3C8N6P0R5T", "createdAt": "2025-11-04T11:02:10.004Z" },
{ "id": "tst_01JBQ7Y1H8F2C4WD9K3M6Q0Z2B", "createdAt": "2025-11-04T09:12:44.118Z" }
],
"pagination": {
"nextCursor": "eyJrIjpbIjIwMjUtMTEtMDRUMDk6MTI6NDQuMTE4WiIsInRzdF8wMUpCUTdZMUg4RjJDNFdEOUszTTZRMFoyQiJdfQ.9f2a1c",
"hasMore": true,
"limit": 2
},
"meta": { "requestId": "req_01JBQ8A2M4NKX9V3PT7ZQ0R5TC" }
}7.4.5 Worked example — follow-up page #
The client repeats every parameter and appends cursor:
GET /api/v1/testimonials?status=published&limit=2&cursor=eyJrIjpbIjIwMjUtMTEtMDRUMDk6MTI6NDQuMTE4WiIsInRzdF8wMUpCUTdZMUg4RjJDNFdEOUszTTZRMFoyQiJdfQ.9f2a1c HTTP/1.1
Host: app.vouch.app
Authorization: Bearer vch_live_7Qk2wRb9XmT4pL8sYc1VnZ0dHjE6uA3f{
"data": [ { "id": "tst_01JBQ7X0G7E1B3VC8J2L5P9Y1A", "createdAt": "2025-11-03T16:40:02.771Z" } ],
"pagination": { "nextCursor": null, "hasMore": false, "limit": 2 },
"meta": { "requestId": "req_01JBQ8B7Q1PZX2V4RT8YQ1S6DE" }
}Termination rule for clients: loop while pagination.hasMore is true, using pagination.nextCursor. Do not loop on "did I get fewer rows than limit" — a filtered page can legitimately return fewer rows than the limit while more pages exist.
7.5 Sorting and filtering conventions #
7.5.1 Sorting #
- Default sort everywhere:
created_at DESC, id DESC. This is the sort applied whensortis absent. - Syntax:
sort=field:direction, comma-separated for multi-key, e.g.sort=rating:desc,createdAt:desc. Direction isascordesc, required, lowercase. - Field names in
sortarecamelCaseand map to columns inpackages/db; a field not in the endpoint's allowlist is422 VALIDATION_FAILEDwithdetails[0].field = "sort". - The
idtiebreaker is appended by the server automatically and must not be supplied by the client. - A maximum of two client-supplied sort keys are accepted; a third is
422. - Allowed sort keys per endpoint are listed in the endpoint catalogue. Endpoints not listing any are default-sort-only, and supplying
sortreturns422 SORT_NOT_SUPPORTED.
7.5.2 Filtering #
- Filters are plain query parameters in
camelCase, e.g.status=published,tagId=tag_…,minRating=4. - Repeating a parameter expresses OR within that field:
status=approved&status=published. Different fields AND together. - Date-range filters use
{field}Afterand{field}Beforewith RFC 3339 values, both inclusive of the boundary instant, e.g.createdAfter=2025-10-01T00:00:00.000Z. - Free-text search is a single
qparameter, trimmed, 2–120 characters, matched with Postgres full-text search over the search vector defined in Section 5.qshorter than 2 characters after trimming is ignored rather than erroring, so a user typing into a search box does not see errors mid-keystroke. - Unknown query parameters are rejected with
422 UNKNOWN_QUERY_PARAMlisting the offending keys indetails. Silently ignoring a typo'd filter is how customers ship code that appears to filter and does not. Two exceptions are ignored rather than rejected: any parameter beginningutm_, and_(cache-buster).
7.6 HTTP status code policy #
Exactly these codes are used. A response with any other code is a bug.
| Code | Name | Used in this API when |
|---|---|---|
200 |
OK | A synchronous read or a synchronous mutation succeeded and there is a body. |
201 |
Created | A new resource was created. Location header carries its canonical URL; body carries the resource in the single-resource envelope. |
202 |
Accepted | The request was validated and a background job was enqueued; the outcome is not yet known. Body carries data.jobId and data.statusUrl. Used for transcription, imports, media purge, exports, social-card renders, and webhook replays. |
204 |
No Content | A mutation succeeded and there is deliberately nothing to return (deletes, unsubscribes, beacon writes). No body, no envelope. |
400 |
Bad Request | The request is malformed at the protocol/parsing level: unparseable JSON, invalid cursor, invalid Content-Encoding, malformed header. Not used for field-level validation — that is 422. |
401 |
Unauthorized | No credential, expired credential, revoked API key, malformed bearer token, unverified email where verification is required, or a credential that is valid but has not cleared the step-up a route demands (STEP_UP_REQUIRED, Section 7.7.3). Always includes WWW-Authenticate: Bearer realm="vouch" on the public API. |
403 |
Forbidden | The principal is authenticated but not permitted: role lacks the permission, API key lacks the scope, the workspace is read-only after cancellation, consent has not been granted, or the credential's context is refused (CSRF token, IP allowlist, stale re-authentication). Never used for a plan or quota refusal — those are 409. |
404 |
Not Found | The resource does not exist, is soft-deleted, or belongs to another workspace. The three are indistinguishable by design — a cross-tenant probe must not be able to tell "exists but not yours" from "does not exist". One deliberate exception: the public widget data API distinguishes a deleted widget with 410 WIDGET_DELETED, because a widget id is published in the customer's own HTML and so reveals nothing when confirmed (Section 7.15.22). |
409 |
Conflict | A uniqueness or state-machine conflict: slug taken, domain already claimed, illegal status transition, concurrent edit detected via If-Match, an idempotency key reused with a different body — and every plan or quota refusal, because the request is authorized and conflicts only with the workspace's current state (Section 19.5.6 owns the reasoning and Section 19.5.7 the code list). |
410 |
Gone | The resource existed and is permanently unavailable: an expired invitation token, a completed data-subject erasure, a sunset API version, a request page that has been deleted, a deleted widget on the public data API. |
413 |
Payload Too Large | Body exceeded the limit for that surface (Section 7.13). |
415 |
Unsupported Media Type | Content-Type is not one the endpoint accepts. |
422 |
Unprocessable Content | The request parsed but failed schema or business validation. Carries error.details, as does 409 INVALID_STATUS_TRANSITION and nothing else (Section 7.3.3). |
429 |
Too Many Requests | A rate-limit bucket was exhausted. Always carries Retry-After and the X-RateLimit-* headers (Section 7.10). |
500 |
Internal Server Error | An unhandled fault. error.code is always INTERNAL_ERROR and error.message is always the same generic string — never the exception text. The requestId is the customer's handle for support. |
503 |
Service Unavailable | A required dependency (Postgres, Valkey, Mux, Stripe, Deepgram, the AI provider) is unavailable or a circuit breaker is open. Carries Retry-After when a recovery estimate exists. |
Notes on codes deliberately not used: 402 (there is no payment-required status anywhere in this API, on any surface, in any version — plan and quota problems are 409 with a catalogued quota code from Section 19.5.7, because such a request is authorized and conflicts only with the workspace's current state, and 403 stays reserved for role, scope, read-only-workspace, consent, and credential-context refusals), 405 (a route that does not implement a method returns 404, so method probing does not enumerate routes), 301/302 inside /api/v1 (APIs do not redirect; the client would silently drop its Authorization header on a cross-origin hop), and 418.
7.7 Authentication #
7.7.1 Dashboard sessions (first-party) #
First-party routes authenticate with the session cookie issued by better-auth (Section 3 for the version line, Section 6 for the full auth configuration): __Host-vouch_session, HttpOnly, Secure, SameSite=Lax, 30-day rolling with a 90-day absolute maximum.
- Because
SameSite=Laxdoes not protect non-GET cross-site requests in every browser generation, every state-changing first-party route additionally requires a double-submit CSRF token: headerX-Vouch-Csrfmust equal the value of the non-HttpOnlyvouch_csrfcookie, compared in constant time. Mismatch or absence is403 CSRF_TOKEN_INVALID. - Every first-party request resolves an active workspace from the
vouch_wscookie, falling back to the user's sole membership. The resolved id is written into the transaction withSET LOCAL app.current_workspace_idper Section 6, so RLS is the backstop even if an application-level check is forgotten. - Requests carrying both a session cookie and an
Authorization: Bearerheader are rejected with400 AMBIGUOUS_CREDENTIALS. Ambiguous principals are how confused-deputy bugs happen. - A session belonging to a user whose email is unverified may read nothing but
/api/v1/meand may call onlyPOST /api/v1/auth/resend-verification; everything else is401 EMAIL_NOT_VERIFIED. - Sessions are revoked immediately on password change, on 2FA enrolment change, on role change to a lower privilege, on entering or leaving
seat_suspended(Section 19.4 E6 — a suspended seat changes effective permissions without changing the role), and on workspace removal. Revocation is a Valkey-backed denylist keyed by session id with a TTL matching the session's absolute expiry, checked on every request, so revocation is instant rather than eventually-consistent.
7.7.2 API keys (public REST API) #
Authorization: Bearer vch_live_7Qk2wRb9XmT4pL8sYc1VnZ0dHjE6uA3f| Property | Value |
|---|---|
| Format | vch_live_ or vch_test_ followed by 32 characters of base62 drawn from a CSPRNG (≈190 bits). |
| Ownership | A key belongs to a workspace, never to a user. Deleting the user who created a key does not revoke the key; the audit trail retains who created it. |
| Storage | SHA-256 of the full token, stored in api_keys.key_hash, plus a 12-character display prefix (vch_live_7Qk2) in api_keys.display_prefix for identification in the UI and logs. The plaintext is never written to disk, never logged, and never retrievable. |
| Display | Shown in full exactly once, at creation, in the creation response body and the UI modal. The UI copy states plainly that it cannot be shown again. |
| Lookup | Constant-time: the prefix selects candidate rows via a unique index, then the full hash is compared with crypto.timingSafeEqual. |
| Last used | api_keys.last_used_at is updated at most once per 60 seconds per key via a Valkey-throttled write, so a hot key does not write on every request. |
| Rotation | POST /api/v1/api-keys/{id}/rotate issues a new secret for the same key row and keeps the old secret valid for a caller-chosen overlap of 0, 1, 24, or 168 hours (gracePeriodHours). Both secrets authenticate during the overlap; the response includes previousSecretExpiresAt. |
| Revocation | DELETE /api/v1/api-keys/{id} is immediate and irreversible. Revoked keys are also pushed to a Valkey denylist so an edge-cached authorization decision cannot outlive the revocation. |
| Expiry | Optional expiresAt, maximum 2 years out. An expired key returns 401 API_KEY_EXPIRED. The Owner and Admins are emailed 14 days and 1 day before expiry. |
| Limit | 25 live keys per workspace, on every plan (the row is in the Section 19.2 matrix so Section 7 is not silently owning a plan limit). Creating the 26th is 409 API_KEY_LIMIT_REACHED. |
| IP allowlist | Optional per key: a list of up to 20 CIDR blocks. A request from outside them is 403 IP_NOT_ALLOWED. |
Test keys (vch_test_) address the same workspace and the same data but operate in test mode: outbound side effects are simulated rather than performed. Concretely, a test-mode request never sends an email or SMS, never calls Mux/Deepgram/Stripe/HubSpot/Slack, never consumes plan quota, and never dispatches a webhook to a live endpoint — webhook deliveries are recorded and visible in the delivery log with mode: "test". Reads return real data. This lets a customer build an integration against their real content without spending quota or emailing real people.
A missing, malformed, or unknown bearer token is 401 INVALID_API_KEY with a deliberately uniform message; the API does not distinguish "no such key" from "revoked key" from "wrong workspace", to avoid turning the endpoint into a key oracle.
7.7.3 Respondent tokens #
A respondent has no account (Section 22). Their long-lived link https://v.vouch.app/p/{token} carries a 32-byte random token stored SHA-256-hashed. Presenting it to POST /api/v1/public/portal/session exchanges it for a short-lived bearer (vch_rp_…, 30-minute TTL, bound to the testimonial ids that token covers). The long-lived token itself is never accepted as an Authorization value, so it cannot leak from a proxy log into a replayable credential.
The token alone mints a read-only session. That bearer carries the scope portal:read and nothing more: it can view the covered testimonials and their consent state. Every state-changing portal endpoint — withdraw, export, correct, erase — additionally requires a portal_step_up claim on the bearer, issued only after the one-time code emailed to the address captured at submission has been verified (Section 22.4.2 owns the code, its 10-minute TTL, its attempt limit, and the typed confirmation that accompanies erasure). A read-only session presenting itself to a state-changing endpoint returns 401 STEP_UP_REQUIRED, and the response names the verification endpoint so the client can start the step-up rather than guess.
This tiering is not ceremony, and it is the one place in this section where a weaker rule would be a serious defect. The portal link is long-lived by design — a respondent must be able to find it in an old email years later — which means it will inevitably be seen by parties who are not the respondent: a forwarded message, a shared or inherited device, a corporate mail-security scanner that follows links, a proxy or CDN access log. Reading a testimonial you already agreed to publish is an acceptable outcome of that exposure. Permanently destroying a stranger's testimonial, or exporting their personal data to whoever holds the URL, is not. The step-up requirement is what makes the difference, so no state-changing portal endpoint may be built without it, on any surface, for any caller, and there is no configuration flag that disables it.
7.7.4 Third-party signature authentication #
Inbound webhook receivers (Stripe, Mux, Slack, Twilio, Resend, Deepgram callbacks) authenticate by provider signature only. They accept no Vouch credential, are excluded from CSRF, read the raw request body before any JSON parsing, verify with constant-time comparison, enforce a timestamp tolerance, and are idempotent on the provider's event id. Their per-provider details live with the owning feature section; the shared receiver rules are here so no receiver invents its own.
7.8 Authorization #
Authorization is a single function, can(actor, action, resource), in packages/core (Section 6 defines it, the four roles, and the full permission matrix; Appendix Section 29.6 reproduces the matrix). This section defines only how HTTP principals reach it.
The two-gate rule. An API-key request must pass both gates:
- Scope gate. The key's scopes must include the scope the endpoint declares. Failure:
403 INSUFFICIENT_SCOPE, witherror.messagenaming the required scope. - Capability gate. The action must be permitted for an API-key actor at all. An API key is evaluated against the permission set of an Admin — deliberately never an Owner — so no API key can manage billing or transfer ownership regardless of scope. Failure:
403 FORBIDDEN.
A session request passes one gate: can(user, action, resource) against the member's role.
Scopes and what each grants:
| Scope | Grants |
|---|---|
testimonials:read |
List/get testimonials, tags, transcripts, consents (redacted), media metadata, submissions. |
testimonials:write |
Create manual testimonials, update, tag, approve, reject, publish, unpublish, archive, soft-delete, restore, edit transcripts, accept/reject AI suggestions, run imports, create upload sessions. |
widgets:read |
List/get widgets, their configuration, embed code, showcase settings, social-card templates and rendered cards. |
widgets:write |
Create/update/delete widgets, purge widget caches, update showcase settings, generate social cards. |
analytics:read |
Analytics summary, timeseries, per-testimonial, per-widget, and export endpoints. |
webhooks:manage |
Create/update/delete webhook endpoints, rotate secrets, read delivery logs, replay deliveries, read integration status. |
Notes:
- There is no
billing:*scope and no API-key path to Stripe operations. Billing is session-and-Owner only (403 FORBIDDENfor any key). - No scope reaches a custom domain or an integration connection. Section 6.6.4 sets an absolute ceiling: no API key may ever hold
domain:*,integration:connect, orintegration:disconnect, and no scope above grants them. A leaked key must not be able to repoint a customer's public hostname or hand a third party's OAuth tokens to a new destination, so those actions are session-only and are marked—in the scope column of the catalogue (Sections 7.15.16 and 7.15.17). The scope-to-permission map itself lives in Section 6.6.4; the table above is its HTTP-facing summary and may never widen it. - There is no wildcard or "full access" scope. A key must enumerate what it needs. The creation UI defaults to
testimonials:readonly. *:writedoes not imply*:read. A write-only key that lists resources gets403 INSUFFICIENT_SCOPE. The UI warns when a write scope is selected without its read partner.- Free-tier workspaces are restricted to read scopes; requesting a write scope at key creation returns
409 PLAN_FEATURE_UNAVAILABLEnaming the plan required, per Section 19. A key that already holds a write scope on a workspace that has since downgraded is a different condition and is refused at authentication time with403 PLAN_DOWNGRADED_SCOPE_REVOKED(Section 6.6.4). - A workspace in
canceledstate is read-only: any write, by any principal, returns403 WORKSPACE_READ_ONLY. Published widgets, the showcase page, the widget data API, and the analytics beacon continue to serve at full quality and full volume regardless — that guarantee is absolute and is restated in Section 19. - A workspace in
over_limitblocks only new collection. Reads, edits, publishing of already-collected testimonials, and every widget surface continue normally.
7.9 Idempotency #
7.9.1 Contract #
Every unsafe public-API endpoint (POST, PATCH, PUT, DELETE) accepts an Idempotency-Key request header.
| Aspect | Rule |
|---|---|
| Header value | Client-generated, 8–255 characters, [A-Za-z0-9_\-]. A UUIDv4 is the recommended choice. Violations are 422 VALIDATION_FAILED. |
| Scope | The key is unique per (workspace_id, api_key_id, method, path_template). The same key value may be reused safely against a different endpoint. |
| Store | idempotency_keys (Section 5) holds the key, a SHA-256 of the canonical request body, the response status, the response headers worth replaying, the full response body, and expires_at. |
| Retention | 24 hours from first receipt. After expiry the key is unknown and the request executes as new. |
| Replay | A repeat with the same key and the same body hash returns the stored status and body byte-for-byte, plus Idempotency-Replayed: true and meta.replayed: true. No side effect is repeated. |
| Conflict | A repeat with the same key and a different body hash returns 409 IDEMPOTENCY_KEY_REUSED. |
| In flight | A repeat arriving while the first is still executing returns 409 IDEMPOTENCY_IN_PROGRESS with Retry-After: 2. Concurrency is controlled by inserting the key row inside the same transaction as the work, so the unique index is the lock. |
| Failures | Responses with status 5xx and 429 are not stored — the client should retry and get a real attempt. 4xx other than 409/429 are stored, so a client that retries a validation failure gets the same deterministic answer without burning quota. |
7.9.2 Required versus accepted #
Endpoints where Idempotency-Key is required — omitting it is 422 IDEMPOTENCY_KEY_REQUIRED — are those whose side effects are externally visible, billable, or irreversible:
| Endpoint | Why required |
|---|---|
POST /api/v1/testimonials |
Consumes lifetime collection quota. |
POST /api/v1/request-pages/{id}/invites |
Sends email/SMS to a real human. |
POST /api/v1/imports/{id}/runs |
Can create hundreds of rows and consume quota. |
POST /api/v1/media/upload-sessions |
Allocates external storage. |
POST /api/v1/testimonials/{id}/transcript/regenerate |
Consumes paid transcription minutes. |
POST /api/v1/testimonials/{id}/ai-suggestions |
Consumes paid AI inference. |
POST /api/v1/social-cards |
Consumes render capacity and storage. |
POST /api/v1/webhooks/{id}/deliveries/{deliveryId}/replay |
Re-delivers to a third party. |
POST /api/v1/data-subject-requests |
Can trigger irreversible erasure. |
POST /api/v1/public/drafts/{draftToken}/submit |
Creates a testimonial and consumes lifetime collection quota. The one unauthenticated endpoint on the list, and the one where a retry-on-timeout is most likely, because it is called from a phone on a flaky connection at the end of a long form (Section 8.10.5). |
Everywhere else the header is accepted and honoured but optional. First-party dashboard routes accept it too; the dashboard client sends one on every mutation automatically, which makes the "user double-clicked Publish" case a non-event.
Naturally idempotent operations (PATCH a field to a value, DELETE an already-deleted row returning 204, POST /publish on an already-published testimonial returning 200 with the current state) behave correctly without a key. The key protects against duplicate creation and duplicate dispatch, which retry-on-timeout would otherwise cause.
7.9.3 Worked example #
curl -sS -X POST https://app.vouch.app/api/v1/testimonials \
-H "Authorization: Bearer $VOUCH_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 8f14e45f-ea6c-4d0e-9a1b-6bd3a2c77c01" \
-d '{
"authorName": "Dana Okafor",
"authorEmail": "dana@example.com",
"authorRole": "Head of Support",
"authorCompany": "Northwind",
"rating": 5,
"bodyText": "Vouch replaced three tools and a spreadsheet.",
"source": "manual",
"consent": { "publish": true, "advertising": false, "caseStudy": false }
}'The identical command run a second time returns the same 201 body with Idempotency-Replayed: true and creates nothing.
7.10 Rate limiting #
7.10.1 Implementation #
A token bucket in Valkey (Section 3), evaluated in a single Lua script so refill-and-consume is atomic. Key shape rl:{class}:{subject}. The script returns remaining tokens and the reset instant; the handler translates that into headers. Buckets refill continuously (tokens per second = limit ÷ 60), so a client is never forced to wait for a wall-clock minute boundary.
If Valkey is unavailable the limiter fails open for read traffic and fails closed for the submission and auth classes. Publishing must never break because a cache node restarted; abuse endpoints must never open because one did. A fail-open event increments a metric that alerts per Section 24.
7.10.2 Classes and limits #
| Class | Subject | Limit | Burst | Applies to |
|---|---|---|---|---|
api-standard |
workspace | 120 req/min | 240 | Public REST API on all paid plans. |
api-free-read |
workspace | 30 req/min | 60 | Public REST API on Free (read-only). |
dashboard |
session | 600 req/min | 900 | First-party dashboard routes. |
auth |
IP + email | 10 req/min, and 20/hour per email | 10 | Sign-in, password reset, magic link, verification resend, TOTP challenge. |
submission |
IP | 10 req/min | 10 | Public submission endpoints on v.vouch.app. |
invite-send |
workspace | 30 req/min | 60 | Invite dispatch (email and SMS). |
upload-part |
upload session | 600 req/min | 900 | Chunked media part signing and completion. |
export |
workspace | 5 req/hour | 5 | Analytics export, data export, DSR export. |
edge-public |
IP | 600 req/min | 1200 | Widget data API and analytics beacon at the edge. |
The widget data API is CDN-cached and is therefore exempt from per-workspace limits entirely — a customer's traffic spike must never be throttled by their plan. Only the per-IP edge-public limit applies, and it exists to stop a single abusive client, not to meter customers. This is a direct consequence of the rule in Section 19 that reaching a cap never degrades anything already live.
7.10.3 Headers and behaviour #
Every response on a limited class — 2xx included — carries:
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
X-RateLimit-Reset: 1762247400
X-RateLimit-Policy: 120;w=60;burst=240X-RateLimit-Reset is a Unix epoch second. On a 429:
HTTP/1.1 429 Too Many Requests
Retry-After: 7
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1762247407
Content-Type: application/json{
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests. Retry after 7 seconds.",
"docsUrl": "https://docs.vouch.app/errors/RATE_LIMITED"
},
"meta": { "requestId": "req_01JBQ8C9R2QAY3W5SU9ZR2T7EF" }
}A 429 never consumes idempotency state and never counts against plan quota. Clients should retry with exponential backoff plus full jitter, honouring Retry-After as a floor. The official quick-start snippets in Section 7.16 do this.
Sustained abuse — more than 10,000 429s from one IP in an hour on submission or auth — escalates to a 24-hour edge block recorded in abuse_blocks, with an operator runbook in Section 24.
7.11 Versioning and deprecation #
- The API is URL-versioned:
/api/v1/…. The version is in the path, never in a header, never negotiated. - Additive changes are not breaking and ship continuously within
v1: new endpoints, new optional request fields, new response fields, new enum values on fields documented as extensible, new error codes on new failure modes, relaxed validation. Clients must tolerate all of these — the SDK and the documented quick-starts model that tolerance. - Breaking changes — removing or renaming a field, changing a type, tightening validation, removing an endpoint, changing a default, changing the meaning of a status code, adding a required request field, or adding an enum value to a field a client is expected to switch on exhaustively — require
/api/v2/. - On the day
v2reaches GA,v1enters a 12-month support window. During the windowv1is fully functional and receives security fixes but no new features. - Throughout the window every
v1response carries:
Sunset: Wed, 31 Mar 2027 23:59:59 GMT
Deprecation: version="v1"
Link: <https://docs.vouch.app/api/v2/migration>; rel="deprecation"; type="text/html"- Owners and Admins of workspaces with
v1traffic are emailed at window start, 90 days, 30 days, and 7 days before sunset, and the dashboard shows a persistent banner in the final 30 days with the last-seenv1call time and the API keys involved. - After sunset,
v1returns410 API_VERSION_SUNSETwith aLinkto the migration guide. It never silently redirects tov2. - Individual endpoints may be deprecated inside a major version only if a superset replacement exists; they then carry the same three headers and remain functional for the same 12 months.
7.12 CORS policy per surface #
| Surface | Access-Control-Allow-Origin |
Credentials | Allowed methods | Allowed request headers | Exposed headers | Preflight cache |
|---|---|---|---|---|---|---|
Dashboard API (app.vouch.app) |
Exact match against an allowlist: https://app.vouch.app and, in non-production, http://localhost:3000. Never *. |
true |
GET, POST, PATCH, DELETE, OPTIONS |
Content-Type, X-Vouch-Csrf, Idempotency-Key, If-Match, X-Request-Id |
X-Request-Id, Idempotency-Replayed, ETag |
600 s |
| Public REST API | * |
false |
GET, POST, PATCH, DELETE, OPTIONS |
Authorization, Content-Type, Idempotency-Key, If-Match |
X-Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After, Idempotency-Replayed, Sunset, Deprecation |
86 400 s |
Respondent / submission (v.vouch.app) |
* for GET; for POST and PATCH the request page's configured allowedOrigins list, or * when the list is empty |
false |
GET, POST, PATCH, OPTIONS |
Content-Type, Idempotency-Key |
X-Request-Id |
3 600 s |
Widget data API (cdn.vouch.app) |
* |
false |
GET, OPTIONS |
Content-Type |
X-Request-Id, ETag, Age |
86 400 s |
Analytics beacon (cdn.vouch.app/api/v1/e) |
* |
false |
POST, OPTIONS |
Content-Type |
none | 86 400 s |
Hard rules:
Access-Control-Allow-Origin: *andAccess-Control-Allow-Credentials: trueare never combined. The public API deliberately refuses credentials so a browser page cannot ride a user's session into it.- Because the public API returns
*and accepts a bearer token, customers must not put a live API key in browser JavaScript. The documentation says so in bold at the top of the quick-start, and key creation shows the same warning. Avch_live_key seen in a request with a browser-shapedOriginandSec-Fetch-Mode: corsraises an operator signal and emails the workspace Owner with a rotation prompt; it is not blocked, because breaking a customer's working (if unwise) integration silently is worse than warning them loudly. - Preflight for a disallowed origin returns
403 CORS_ORIGIN_NOT_ALLOWEDin the standard error envelope with no CORS headers, so the browser reports a CORS failure and the response body is available to server-side debugging. Vary: Originis set on every response whose CORS headers depend on the request origin, so a CDN never serves one origin's allowance to another.
7.13 Size limits, timeouts, and compression #
7.13.1 Request size limits #
| Surface / endpoint | Limit | Exceeded → |
|---|---|---|
| Default JSON body, any endpoint | 1 MB | 413 PAYLOAD_TOO_LARGE |
| CSV import upload (multipart) | 10 MB, ≤ 5 000 data rows (Section 16.3.2 owns both) | 413 IMPORT_FILE_TOO_LARGE / 422 IMPORT_ROW_LIMIT |
Media upload part (PUT to pre-signed URL) |
8 MB per part; 10 000 parts is the multipart protocol maximum, not a size allowance — the per-upload ceiling is VIDEO_MAX_UPLOAD_BYTES (2 GiB) |
Storage provider rejects the part; the ceiling is enforced by the API per Section 9.6 |
| Image upload (logo, avatar, poster override) | 5 MB, ≤ 8 000 px on the long edge | 413 / 422 IMAGE_TOO_LARGE |
| Analytics beacon body | 32 KB, ≤ 50 events per beacon (Section 17.4 owns the transport and justifies the 32 KB against the keepalive ceiling) |
413 / 422 EVENT_BATCH_TOO_LARGE |
| Any single JSON string field | 100 000 characters unless narrower per field | 422 VALIDATION_FAILED |
| Request URL including query string | 8 KB | 414 is not used; the edge returns 400 URI_TOO_LONG |
| Total request headers | 32 KB | 400 HEADERS_TOO_LARGE |
| JSON nesting depth | 32 levels | 400 MALFORMED_JSON |
| Array length in any request body field | 1 000 unless narrower per field | 422 VALIDATION_FAILED |
7.13.2 Response size #
- A collection response is bounded by
limit ≤ 100; no single row is allowed to be unbounded. Transcript payloads, the largest rows, are excluded from list responses and available only on the single-resource transcript endpoint. - The widget data payload is capped at 60 KB for a 24-item wall. If the configured item set would exceed it, the pre-render step truncates the item list from the tail and sets
meta.truncated: truein the payload; it never emits an oversized payload. Section 13 owns the payload's shape.
7.13.3 Timeouts #
| Stage | Budget | On breach |
|---|---|---|
| Edge route (widget data, beacon, oEmbed) | 5 s hard | 503 DEPENDENCY_UNAVAILABLE; the beacon instead returns 204 and drops the event, because a beacon must never surface an error to a host page |
| Node API route, read | 10 s | 503 DEPENDENCY_UNAVAILABLE |
| Node API route, write | 30 s | 503 DEPENDENCY_UNAVAILABLE |
| Single Postgres statement | 5 s on the web role, 30 s on the worker role (statement_timeout, set at the role level) |
Query aborted → 503 |
| Postgres transaction | 15 s (idle_in_transaction_session_timeout 10 s) |
Aborted → 503 |
| Outbound call to Mux, Stripe, Deepgram, Resend, Twilio, HubSpot, Slack, the AI provider | 10 s connect+read, 2 retries with jittered backoff, circuit breaker opens after 5 consecutive failures in 60 s | 503 DEPENDENCY_UNAVAILABLE with Retry-After: 30 |
| Anything expected to exceed the write budget | — | Must be a job returning 202 with data.jobId, never a long-held HTTP request |
Clients should set their own timeout at 30 s and retry idempotently on 503 and 429 only. Retrying a 4xx other than 429 will never succeed.
7.13.4 Compression #
- Responses are compressed with brotli when the client advertises
br, otherwise gzip, for bodies above 1 KB.Content-EncodingandVary: Accept-Encodingare always set. - Request bodies may be sent gzipped with
Content-Encoding: gzip. The decompression guard rejects anything whose inflated size exceeds 10 MB or whose compression ratio exceeds 100:1 with413 PAYLOAD_TOO_LARGE— a decompression-bomb defence, not a courtesy limit. - JSON responses are not pretty-printed. Whitespace is a measurable share of the widget payload budget.
Content-Typeon every JSON response isapplication/json; charset=utf-8. Requests with a body must sendContent-Type: application/json(ormultipart/form-datawhere documented); anything else is415 UNSUPPORTED_MEDIA_TYPE. Acharsetparameter is accepted and ignored; only UTF-8 is supported.
7.13.5 Caching and concurrency headers #
- Dashboard and public REST responses:
Cache-Control: private, no-store. Authenticated JSON is never stored by an intermediary. - Widget data:
Cache-Control: public, max-age=60, stale-while-revalidate=300, plus a strongETagderived from the payload hash, plus surrogate keyswidget:{id}andworkspace:{id}for tag-based purge. Purge fires on publish, unpublish, testimonial edit, widget config change, and consent withdrawal. - Mutable single resources return a strong
ETag.PATCHandDELETEacceptIf-Match; a stale value returns409 RESOURCE_CONFLICTcarrying the currentETag. The dashboard sendsIf-Matchon every edit form, which is how two moderators editing one testimonial is resolved without silent overwrite. If-None-Matchon aGETreturns304 Not Modifiedwith no body.304is a cache negotiation outcome, not an error, and is therefore not in the status table in Section 7.6.
7.14 Validation and error mapping #
All request validation uses the shared Zod schemas in packages/validation (Section 3 for the version line, Section 4 for the layout). The same schema object validates the browser form, the API route, the worker job payload, and the generated OpenAPI document, so the three can never disagree.
The mapping from a Zod failure to the wire format is mechanical:
// packages/core/http/validation-error.ts
import { ZodError } from 'zod';
import { AppError } from './app-error';
export function fromZodError(err: ZodError): AppError {
return new AppError({
code: 'VALIDATION_FAILED',
httpStatus: 422,
publicMessage: 'One or more fields are invalid.',
details: err.issues.map((issue) => ({
field: issue.path.length ? issue.path.join('.').replace(/\.(\d+)/g, '[$1]') : '_root',
issue: humanize(issue),
})),
});
}Rules that make validation errors usable rather than decorative:
- Every failing field is reported in one response. Validation never short-circuits on the first error, so a form fills all its inline messages in a single round trip.
fielduses the request-body path, not the column name:theme.accentColor,items[3].rating,fields[0].options[2].label.issueis a complete sentence a human can act on — "Must be a whole number between 1 and 5." — never a raw Zod code such asinvalid_type.- Business-rule failures that are not schema failures use their own code and status rather than being crammed into
422:409 SLUG_TAKEN,409 INVALID_STATUS_TRANSITION,409 PLAN_FEATURE_UNAVAILABLE,409 TESTIMONIAL_LIMIT_REACHED. - Unknown keys in a request body are stripped, not rejected, for
PATCH(so a client sending back a whole resource it previously read does not break when the server adds a read-only field) and rejected with422 UNKNOWN_FIELDforPOST(so a typo in a create call is caught immediately rather than silently discarded). This asymmetry is intentional and documented in the OpenAPI description of each operation. - Read-only fields (
id,createdAt,updatedAt,workspaceId, computed counts) present in a write body are stripped silently onPATCHand rejected onPOST. - String fields are trimmed, Unicode-NFC-normalized, and stripped of C0/C1 control characters except
\nand\tbefore validation. Length limits are counted in Unicode code points, not UTF-16 units, so an emoji costs one character rather than two. - Rich-text bodies are sanitized server-side with DOMPurify at write time to the allowlist
p, br, strong, em, a[href], withrel="nofollow noopener"forced on links, and re-sanitized at serve time. The stored value is the sanitized value; the API never round-trips unsanitized HTML.
The AppError base class (Section 4) carries code, httpStatus, publicMessage, and details. A thrown AppError is serialized by one error middleware. Anything else that reaches the middleware is logged with its stack, reported to Sentry with the requestId tag, and returned as 500 INTERNAL_ERROR with a fixed message. No route ever formats its own error body.
7.15 Endpoint catalogue #
7.15.1 How to read this catalogue #
Unless a group states otherwise, every endpoint below:
- is rooted at
https://app.vouch.app/api/v1; - is workspace-scoped by the resolved active workspace and enforced by RLS per Section 6;
- returns the envelopes of Section 7.3;
- can return
401(bad or missing credential),403(role, scope, plan, or read-only workspace),404(unknown/other-tenant/soft-deleted),429(rate limit),500, and503, so those six are not repeated in each entry's error list — only endpoint-specific codes are; - accepts
Idempotency-Keyif it is unsafe, and requires it where the Idem. column saysrequired; - excludes soft-deleted rows from reads via the shared
notDeleted()helper (Section 5).
The Permission / scope column names the permission string checked by can() for session principals and, after a slash, the API-key scope required for key principals. — in the scope position means the endpoint is not reachable by an API key.
The Rate column names a class from Section 7.10.2.
7.15.2 Workspaces, profile, and settings #
| Method & path | Auth | Permission / scope | Idem. | Rate |
|---|---|---|---|---|
GET /workspaces/current |
session | key |
workspace:read / any scope |
— | api-standard |
PATCH /workspaces/current |
session |
workspace:update / — |
accepted | dashboard |
GET /workspaces/current/branding |
session | key |
workspace:read / widgets:read |
— | api-standard |
PATCH /workspaces/current/branding |
session | key |
workspace:update / widgets:write |
accepted | api-standard |
POST /workspaces/current/logo |
session |
workspace:update / — |
accepted | dashboard |
DELETE /workspaces/current/logo |
session |
workspace:update / — |
accepted | dashboard |
GET /workspaces/current/usage |
session | key |
workspace:read / analytics:read |
— | api-standard |
POST /workspaces/current/transfer-ownership |
session |
workspace:transfer / — |
accepted | dashboard |
DELETE /workspaces/current |
session |
workspace:delete / — |
accepted | dashboard |
GET /me |
session |
— | — | dashboard |
PATCH /me |
session |
— | accepted | dashboard |
POST /me/2fa/enroll |
session |
— | accepted | auth |
POST /me/2fa/verify |
session |
— | accepted | auth |
DELETE /me/2fa |
session |
— | accepted | auth |
GET /me/sessions |
session |
— | — | dashboard |
DELETE /me/sessions/{sessionId} |
session |
— | accepted | dashboard |
GET /me/notification-preferences |
session |
— | — | dashboard |
PATCH /me/notification-preferences |
session |
— | accepted | dashboard |
PATCH /workspaces/current body
| Field | Type | Validation |
|---|---|---|
name |
string | 2–80 characters after trim |
slug |
string | ^[a-z0-9-]{3,48}$, globally unique, not in the reserved list (app, api, cdn, v, www, admin, status, docs, mail, edge, support, blog) |
defaultLanguage |
enum | one of en, es, fr, de, pt (Section 10 owns the language set) |
timezone |
string | valid IANA zone name |
replyToEmail |
string | RFC 5322 address; must be verified before it is used (Section 20.3) |
enforce2fa |
boolean | 409 PLAN_FEATURE_UNAVAILABLE unless the plan includes it per Section 19 |
keepOriginals |
boolean | Growth and Scale only; see the retention rule in Section 9 |
Errors: 409 SLUG_TAKEN, 422 VALIDATION_FAILED, 409 PLAN_FEATURE_UNAVAILABLE, 409 RESOURCE_CONFLICT (stale If-Match).
GET /workspaces/current/usage returns every metered dimension with its cap and the enforcement state, and is the same data the in-product 80 % / 90 % / 95 % / 100 % warnings read (Section 19):
{
"data": {
"plan": "growth",
"billingState": "active",
"limitState": "ok",
"periodStart": "2025-11-01T00:00:00.000Z",
"periodEnd": "2025-12-01T00:00:00.000Z",
"metrics": {
"testimonials.collected": { "used": 412, "limit": 500, "unit": "count", "scope": "lifetime", "percent": 82.4, "state": "warn_80" },
"seats.active": { "used": 6, "limit": 10, "unit": "count", "scope": "current", "percent": 60.0, "state": "ok" },
"request_pages.active": { "used": 9, "limit": 25, "unit": "count", "scope": "current", "percent": 36.0, "state": "ok" },
"storage.bytes": { "used": 41231234567, "limit": 107374182400, "unit": "bytes", "scope": "current", "percent": 38.4, "state": "ok" },
"delivery.minutes": { "used": 12044, "limit": 50000, "unit": "minutes", "scope": "period", "percent": 24.1, "state": "ok" },
"transcription.minutes": { "used": 1442, "limit": 1500, "unit": "minutes", "scope": "period", "percent": 96.1, "state": "warn_95" },
"custom_domains.active": { "used": 1, "limit": 3, "unit": "count", "scope": "current", "percent": 33.3, "state": "ok" }
},
"collectionOpen": true,
"overLimitSince": null
},
"meta": { "requestId": "req_01JBQ8D1S3RBZ4X6TV0AS3U8FG" }
}The keys of metrics are the seven metric identifiers of the usage_metric enum in Section 5.2, used verbatim — the same strings Section 19.5.1 recomputes and the same strings a quota.threshold_reached webhook carries in data.metric. state is one of ok, warn_80, warn_90, warn_95, at_limit, over_limit. collectionOpen: false is what the request page reads to render the friendly closed state described in Section 19 — a 200 with a collectionClosed payload, never an error.
POST /workspaces/current/transfer-ownership takes { "targetMemberId": "mbr_…", "confirmWorkspaceSlug": "northwind" }. The slug must match exactly, the target must be an existing active member, and the caller must be the current Owner. On success the caller becomes an Admin, the target becomes Owner, both are emailed, an audit_events row is written, and every session for both users is refreshed so the new role takes effect immediately. Errors: 403 FORBIDDEN, 404, 409 ALREADY_OWNER, 422 CONFIRMATION_MISMATCH.
DELETE /workspaces/current soft-deletes the workspace and starts a 30-day grace period: the dashboard is read-only, widgets and the showcase page keep serving, and an Owner can restore from the emailed link. At day 30 the workspace is hard-erased by the job in Section 22, which destroys Mux assets, object-storage keys, and CDN entries. Requires { "confirmWorkspaceSlug": "…" } and a fresh password or TOTP re-authentication within the last 5 minutes; otherwise 403 REAUTHENTICATION_REQUIRED.
7.15.3 Members and invitations #
| Method & path | Auth | Permission / scope | Idem. | Rate |
|---|---|---|---|---|
GET /members |
session |
member:read / — |
— | dashboard |
GET /members/{memberId} |
session |
member:read / — |
— | dashboard |
PATCH /members/{memberId} |
session |
member:update / — |
accepted | dashboard |
DELETE /members/{memberId} |
session |
member:remove / — |
accepted | dashboard |
GET /invitations |
session |
member:read / — |
— | dashboard |
POST /invitations |
session |
member:invite / — |
accepted | invite-send |
POST /invitations/{invitationId}/resend |
session |
member:invite / — |
accepted | invite-send |
DELETE /invitations/{invitationId} |
session |
member:invite / — |
accepted | dashboard |
GET /public/invitations/lookup |
public |
— | — | auth |
POST /public/invitations/accept |
public |
— | accepted | auth |
GET /members — query: role (repeatable, one of owner, admin, editor, viewer), q (matches name or email), limit, cursor, sort (allowed keys createdAt, name). Each row carries id, userId, name, email, role, twoFactorEnabled, lastActiveAt, createdAt.
PATCH /members/{memberId} body { "role": "editor" }. Rules: the role must be one of the four in Section 6; the Owner's own membership cannot be changed here (409 CANNOT_DEMOTE_OWNER — use transfer-ownership); an Admin cannot change another Admin's role, only Owners can (403 FORBIDDEN); raising a seat count above the plan's seat limit is 409 SEAT_LIMIT_REACHED. Changing a role revokes and re-issues the target's sessions so a demotion is immediate.
DELETE /members/{memberId} removes the member. The Owner cannot be removed (409 CANNOT_REMOVE_OWNER). Content the member created is retained and its created_by reference is preserved — removal is not authorship erasure. The member's API keys are unaffected because keys belong to the workspace (Section 7.7.2). Returns 204.
POST /invitations body:
{ "email": "sam@northwind.example", "role": "editor", "message": "Joining to help with moderation." }| Field | Validation |
|---|---|
email |
RFC 5322, ≤ 254 characters, lowercased, not already a member (409 ALREADY_MEMBER), not already pending (409 INVITATION_PENDING), not on the workspace suppression list (422 EMAIL_SUPPRESSED) |
role |
admin, editor, or viewer. owner is rejected with 422 CANNOT_INVITE_OWNER |
message |
optional, ≤ 500 characters, plain text only, rendered escaped |
The token is 32 random bytes, stored SHA-256-hashed, single-use, 7-day expiry, delivered by the template in Section 20.4. Free-tier workspaces have one seat, so this endpoint returns 409 SEAT_LIMIT_REACHED with a message naming the plan required (Section 19).
GET /public/invitations/lookup?token=… returns only what an unauthenticated recipient needs to decide: workspace name, workspace logo URL, inviter display name, invited role, and expiresAt. It never returns the member list, the email, or any workspace content. Errors: 404 INVITATION_NOT_FOUND, 410 INVITATION_EXPIRED, 410 INVITATION_REVOKED, 410 INVITATION_ALREADY_USED.
POST /public/invitations/accept body { "token": "…", "name": "Sam Ruiz", "password": "…" } (password omitted when the email already has an account, in which case the caller must be signed in). Accepting creates the workspace_members row, marks the invitation accepted, retains the invitation row so the acceptance is observable in the audit trail (Section 6.8; swept 90 days after its terminal status per Section 5.20), and signs the user in. Errors as above plus 422 VALIDATION_FAILED, 409 SEAT_LIMIT_REACHED (the plan changed between send and accept — the invitation stays valid and the Owner is notified).
7.15.4 API keys #
| Method & path | Auth | Permission / scope | Idem. | Rate |
|---|---|---|---|---|
GET /api-keys |
session |
apikey:read / — |
— | dashboard |
POST /api-keys |
session |
apikey:create / — |
accepted | dashboard |
GET /api-keys/{keyId} |
session |
apikey:read / — |
— | dashboard |
PATCH /api-keys/{keyId} |
session |
apikey:update / — |
accepted | dashboard |
POST /api-keys/{keyId}/rotate |
session |
apikey:update / — |
accepted | dashboard |
DELETE /api-keys/{keyId} |
session |
apikey:delete / — |
accepted | dashboard |
API-key management is deliberately session-only: a key that can mint keys is a privilege-escalation primitive, so no scope grants it.
POST /api-keys body:
{
"name": "Marketing site build",
"mode": "live",
"scopes": ["testimonials:read", "widgets:read"],
"expiresAt": "2026-11-04T00:00:00.000Z",
"ipAllowlist": ["203.0.113.0/24"]
}| Field | Validation |
|---|---|
name |
2–60 characters, unique per workspace among live keys (409 API_KEY_NAME_TAKEN) |
mode |
live or test, default live |
scopes |
non-empty array, each value from the scope list in Section 7.8, deduplicated, max 6 |
expiresAt |
optional RFC 3339, between 1 hour and 2 years in the future |
ipAllowlist |
optional array of ≤ 20 valid IPv4/IPv6 CIDR strings |
Response 201 — the only time the secret is ever returned:
{
"data": {
"id": "key_01JBQ8E5T4SC05Y7UW1BT4V9GH",
"name": "Marketing site build",
"mode": "live",
"scopes": ["testimonials:read", "widgets:read"],
"displayPrefix": "vch_live_7Qk2",
"secret": "vch_live_7Qk2wRb9XmT4pL8sYc1VnZ0dHjE6uA3f",
"expiresAt": "2026-11-04T00:00:00.000Z",
"createdAt": "2025-11-04T12:00:00.000Z",
"createdByUserId": "usr_01JBQ7W9F6D0A2TB6H1K4N8X0Z"
},
"meta": { "requestId": "req_01JBQ8E5T4SC05Y7UW1BT4V9GJ" }
}Every other response for this resource omits secret entirely (not null — omitted, per Section 7.3.5). Errors: 422 VALIDATION_FAILED, 409 API_KEY_LIMIT_REACHED, 409 PLAN_FEATURE_UNAVAILABLE (write scopes on Free), 409 API_KEY_NAME_TAKEN.
POST /api-keys/{keyId}/rotate body { "gracePeriodHours": 24 } with allowed values 0, 1, 24, 168. Returns the new secret plus previousSecretExpiresAt. Both secrets authenticate until that instant; the old one then returns 401 INVALID_API_KEY. An audit event records the rotation and the Owner is emailed.
7.15.5 Request pages, fields, and prompts #
| Method & path | Auth | Permission / scope | Idem. | Rate |
|---|---|---|---|---|
GET /request-pages |
session | key |
requestpage:read / testimonials:read |
— | api-standard |
POST /request-pages |
session | key |
requestpage:create / testimonials:write |
accepted | api-standard |
GET /request-pages/{pageId} |
session | key |
requestpage:read / testimonials:read |
— | api-standard |
PATCH /request-pages/{pageId} |
session | key |
requestpage:update / testimonials:write |
accepted | api-standard |
DELETE /request-pages/{pageId} |
session | key |
requestpage:delete / testimonials:write |
accepted | api-standard |
POST /request-pages/{pageId}/duplicate |
session | key |
requestpage:create / testimonials:write |
accepted | api-standard |
GET /request-pages/{pageId}/fields |
session | key |
requestpage:read / testimonials:read |
— | api-standard |
POST /request-pages/{pageId}/fields |
session | key |
requestpage:update / testimonials:write |
accepted | api-standard |
PATCH /request-pages/{pageId}/fields/{fieldId} |
session | key |
requestpage:update / testimonials:write |
accepted | api-standard |
DELETE /request-pages/{pageId}/fields/{fieldId} |
session | key |
requestpage:update / testimonials:write |
accepted | api-standard |
POST /request-pages/{pageId}/fields/reorder |
session | key |
requestpage:update / testimonials:write |
accepted | api-standard |
GET /request-pages/{pageId}/prompts |
session | key |
requestpage:read / testimonials:read |
— | api-standard |
POST /request-pages/{pageId}/prompts |
session | key |
requestpage:update / testimonials:write |
accepted | api-standard |
PATCH /request-pages/{pageId}/prompts/{promptId} |
session | key |
requestpage:update / testimonials:write |
accepted | api-standard |
DELETE /request-pages/{pageId}/prompts/{promptId} |
session | key |
requestpage:update / testimonials:write |
accepted | api-standard |
GET /request-pages/{pageId}/qr |
session | key |
requestpage:read / testimonials:read |
— | api-standard |
Section 8 owns the semantics of request pages, field types, and prompts. This catalogue owns their wire contract only.
POST /request-pages body (abridged to the validated fields):
| Field | Type | Validation |
|---|---|---|
name |
string | 2–80 characters, internal label |
slug |
string | ^[a-z0-9-]{3,48}$, unique per workspace, defaults to a slugified name with a numeric suffix on collision |
headline |
string | ≤ 120 characters |
intro |
string | ≤ 1 000 characters, rich text sanitized per Section 7.14 |
collectText |
boolean | default true |
collectVideo |
boolean | default true; at least one of the two must be true (422 NO_COLLECTION_MODE) |
maxVideoSeconds |
integer | 15–the plan maximum from Section 19; above it is 422 VIDEO_LENGTH_EXCEEDED — a property of the submitted value, not a quota, so it is validation rather than a plan refusal |
requireRating |
boolean | default true |
theme |
object | { accentColor, logoUrl, backgroundStyle, fontScale }; accentColor is a 6-digit hex and is contrast-checked, returning 422 WIDGET_ACCENT_CONTRAST_INSUFFICIENT with the nearest passing colour in details[0].issue per Section 21 |
consentVersionId |
uuid | must reference an active consent version; defaults to the workspace's current version (Section 22) |
reminderSchedule |
object | { enabled, offsetsDays: [3, 7], maxReminders: 2 }; offsets 1–30, ascending, unique, max 2 entries — the respondent-protection ceiling of at most two reminders in Section 20.1 is absolute and Section 20.7 owns the cadence |
allowedOrigins |
string[] | ≤ 20 origins, each a valid scheme+host, used for the submission CORS rule in Section 7.12 |
redirectUrl |
string | optional https:// URL shown after submit |
isActive |
boolean | default true; inactive pages render the closed state |
Errors: 422 VALIDATION_FAILED, 409 SLUG_TAKEN, 409 REQUEST_PAGE_LIMIT_REACHED (request-page cap from Section 19), 409 PLAN_FEATURE_UNAVAILABLE.
POST /request-pages/{pageId}/fields body: { "type", "label", "helpText", "isRequired", "options", "position" }. type is one of the seven field types owned by Section 8.5.2: short_text, long_text, select, star_rating, url, file, boolean. They are the values of the request_field_type native Postgres enum in Section 5.2 — a closed set, because Section 8.5.2 defines per-type validation and mobile rendering rules that a runtime-extensible list could not satisfy. options is required and non-empty for select and rejected for every other type (422 OPTIONS_NOT_ALLOWED). A page may hold at most 20 fields (422 CUSTOM_FIELD_LIMIT_REACHED on the 21st, per Section 8.5.1).
POST /request-pages/{pageId}/fields/reorder body { "fieldIds": ["fld_…", "fld_…"] } — the array must be a permutation of the page's current field ids, or 422 REORDER_SET_MISMATCH. Positions are rewritten in one transaction.
GET /request-pages/{pageId}/qr — query format (png default, or svg), size (128–2048, default 512), margin (0–8, default 2). Returns the image with Cache-Control: private, max-age=3600, not the JSON envelope; this is one of the four documented non-JSON responses in the API (the others are the VTT download in Section 7.15.11, the social card image in Section 7.15.13, and the analytics CSV export in Section 7.15.18). Each of them still emits X-Request-Id, and each still returns the JSON error envelope on failure.
The public read of a request page is not in this group. It is GET /api/v1/public/request-pages/resolve, specified in Section 8.10.1 and listed in Section 7.15.7; the endpoints above are the workspace-facing management API for the same resource, and the two must not be confused. The management routes never serve a respondent and the public route never returns testimonial content, member data, or plan names.
Two facts about that public read bind this section, because they are the ones a reader of the endpoint catalogue will look for here:
- The closed state is a payload, not an error. A page that cannot accept a submission still returns
200with acollectionClosedobject. The reason vocabulary is owned by Section 8.2.4, which defines exactly seven values —paused,archived,not_yet_open,window_closed,quota_reached,workspace_over_limit,billing_inactive— together with the condition that enters each and thereopensAtfield. This section does not restate that table and no endpoint anywhere may return a reason outside it.openis not a value: an accepting page omits the object entirely. - The only public-read failures are
404 REQUEST_PAGE_NOT_FOUNDand410 REQUEST_PAGE_GONE(a page that was soft-deleted, which is distinguished from "never existed" because that URL was public and may be printed on something). Refusing an actual submission into a closed page is the separate409 COLLECTION_CLOSEDof Section 8.10.5 — a different question, asked by a different caller, deserving a different answer.
7.15.6 Contacts, invites, and suppression #
| Method & path | Auth | Permission / scope | Idem. | Rate |
|---|---|---|---|---|
GET /contacts |
session | key |
contact:read / testimonials:read |
— | api-standard |
POST /contacts |
session | key |
contact:create / testimonials:write |
accepted | api-standard |
GET /contacts/{contactId} |
session | key |
contact:read / testimonials:read |
— | api-standard |
PATCH /contacts/{contactId} |
session | key |
contact:update / testimonials:write |
accepted | api-standard |
DELETE /contacts/{contactId} |
session | key |
contact:delete / testimonials:write |
accepted | api-standard |
POST /contacts/import |
session |
contact:create / — |
accepted | dashboard |
GET /invites |
session | key |
invite:read / testimonials:read |
— | api-standard |
POST /request-pages/{pageId}/invites |
session | key |
invite:send / testimonials:write |
required | invite-send |
GET /invites/{inviteId} |
session | key |
invite:read / testimonials:read |
— | api-standard |
POST /invites/{inviteId}/resend |
session | key |
invite:send / testimonials:write |
required | invite-send |
POST /invites/{inviteId}/cancel |
session | key |
invite:send / testimonials:write |
accepted | api-standard |
GET /suppressions |
session | key |
contact:read / testimonials:read |
— | api-standard |
POST /suppressions |
session | key |
contact:update / testimonials:write |
accepted | api-standard |
DELETE /suppressions/{suppressionId} |
session |
contact:update / — |
accepted | dashboard |
GET /public/unsubscribe |
public |
— | — | submission |
POST /public/unsubscribe |
public |
— | accepted | submission |
POST /request-pages/{pageId}/invites is the send endpoint. Body:
{
"channel": "email",
"recipients": [
{ "email": "dana@example.com", "name": "Dana Okafor", "variables": { "product": "Northwind Cloud" } },
{ "contactId": "cnt_01JBQ7V2E5C9Z1SA5G0J3M7W9Y" }
],
"scheduleAt": null,
"reminderScheduleOverride": { "enabled": true, "offsetsDays": [2, 6], "maxReminders": 2 }
}| Field | Validation |
|---|---|
channel |
email or sms. sms requires a verified sending configuration and a phone on every recipient (Section 20.6) |
recipients |
1–500 entries. Each entry supplies either contactId or (email/phone plus optional name), never both (422 AMBIGUOUS_RECIPIENT) |
recipients[].variables |
optional flat object, ≤ 10 keys, values ≤ 200 characters, merged into the template per Section 20.4 |
scheduleAt |
optional RFC 3339 ≤ 90 days out; omitted or null sends immediately |
reminderScheduleOverride |
optional; same shape and validation as the page's reminderSchedule |
Behaviour: recipients on the workspace suppression list are skipped, not failed — the response reports them so the caller learns why nothing was sent. Duplicate addresses within one call are collapsed. Sending is asynchronous, so the response is 202:
{
"data": {
"jobId": "job_01JBQ8G7V6UE27A9WY3DV6XBJM",
"statusUrl": "https://app.vouch.app/api/v1/invites?jobId=job_01JBQ8G7V6UE27A9WY3DV6XBJM",
"accepted": 1,
"skipped": [ { "email": "dana@example.com", "reason": "suppressed_complaint" } ],
"inviteIds": ["inv_01JBQ8G7V6UE27A9WY3DV6XBJN"]
},
"meta": { "requestId": "req_01JBQ8G7V6UE27A9WY3DV6XBJP" }
}Errors: 422 VALIDATION_FAILED, 422 IDEMPOTENCY_KEY_REQUIRED, 409 TESTIMONIAL_LIMIT_REACHED (invite sending is blocked at the collection cap per Section 19), 409 PLAN_FEATURE_UNAVAILABLE (SMS below the required plan), 422 SMS_COUNTRY_UNSUPPORTED, 422 SMS_QUOTA_EXCEEDED, 429 on invite-send.
GET /invites — filters: status (repeatable, the eleven values of the invite_status enum: queued, sent, delivered, opened, clicked, started, submitted, bounced, failed, revoked, expired), requestPageId, contactId, channel, jobId, createdAfter, createdBefore, q (matches recipient email/phone/name). Sort keys: createdAt, sentAt, status. The status vocabulary is the invite_status enum defined once in Section 5.2; Section 20.7 gives each value its operational meaning, and the same values appear in the dashboard and in the invite.* webhooks of Section 18.2.
POST /suppressions body { "email": "…", "reason": "manual" } or { "phone": "+15551234567", "reason": "manual" }. reason is one of hard_bounce, complaint, unsubscribe, manual. Suppressions are workspace-scoped and permanent until removed by DELETE, which is Owner/Admin-only, audited, and possible only for the manual reason. Enforcement happens in the messaging service layer, never in the UI (Section 20.8).
7.15.7 The public collection surface — pointer #
Section 8.10 is the specification for this group. It owns the endpoints, the draft-token model, the request and response bodies, the per-endpoint error tables, and the edge cases; Section 8.10.6 catalogues every code they return. This catalogue does not restate any of it, because two descriptions of one collection flow is exactly the defect that makes an implementer pick whichever they read first.
What this section contributes is the same thing it contributes to every other group: these endpoints are not exempt from the contract. They use the envelopes of Section 7.3, the cursor model of Section 7.4 where they paginate, the status policy of Section 7.6, the idempotency rules of Section 7.9, and the submission rate class of Section 7.10.2 applied per IP. They are rooted at https://v.vouch.app/api/v1 and are also served on a workspace's active custom domains.
The catalogue lines, for completeness of the endpoint index only:
| Method & path | Auth | Idem. | Specified in |
|---|---|---|---|
GET /public/request-pages/resolve |
public |
— | 8.10.1 |
POST /public/request-pages/{pageId}/drafts |
public |
accepted | 8.10.2 |
GET /public/drafts/{draftToken} |
draft token | — | 8.10.2 |
PATCH /public/drafts/{draftToken} |
draft token | accepted | 8.10.3 |
POST /public/drafts/{draftToken}/uploads |
draft token | accepted | 8.10.4 |
POST /public/drafts/{draftToken}/submit |
draft token | required | 8.10.5 |
POST /public/drafts/{draftToken}/verify-email |
draft token | accepted | 8.10 |
POST /public/drafts/{draftToken}/verify-email/confirm |
draft token | accepted | 8.10 |
GET /public/submissions/{submissionToken} |
submission token | — | 8.10 |
POST /public/invitations/{inviteToken}/open |
invite token | accepted | 8.10 |
Three consequences of that flow bind the rest of this section and are stated here so no reader has to infer them:
- The draft token travels in the path, not in a header. Nothing in this group requires a custom request header, which is what keeps the surface reachable from a constrained client and keeps the CORS allowance in Section 7.12 narrow.
POST /public/drafts/{draftToken}/submitrequiresIdempotency-Key(Section 7.9.2), because it is the one unauthenticated call in the product with an irreversible, quota-consuming side effect.- A page read is always
200even when collection is closed — the closed state is a payload, never an error (Section 8.2.4) — while a submit into a closed page is409 COLLECTION_CLOSED. The two behaviours are deliberately different: a respondent who followed a link deserves a page, and a client that tries to write deserves an unambiguous refusal.
7.15.8 Testimonials #
| Method & path | Auth | Permission / scope | Idem. | Rate |
|---|---|---|---|---|
GET /testimonials |
session | key |
testimonial:read / testimonials:read |
— | api-standard |
GET /testimonials/counts |
session | key |
testimonial:read / testimonials:read |
— | api-standard |
POST /testimonials |
session | key |
testimonial:create / testimonials:write |
required | api-standard |
GET /testimonials/{id} |
session | key |
testimonial:read / testimonials:read |
— | api-standard |
PATCH /testimonials/{id} |
session | key |
testimonial:update / testimonials:write |
accepted | api-standard |
POST /testimonials/{id}/approve |
session | key |
testimonial:moderate / testimonials:write |
accepted | api-standard |
POST /testimonials/{id}/reject |
session | key |
testimonial:moderate / testimonials:write |
accepted | api-standard |
POST /testimonials/{id}/publish |
session | key |
testimonial:publish / testimonials:write |
accepted | api-standard |
POST /testimonials/{id}/unpublish |
session | key |
testimonial:publish / testimonials:write |
accepted | api-standard |
POST /testimonials/{id}/archive |
session | key |
testimonial:moderate / testimonials:write |
accepted | api-standard |
DELETE /testimonials/{id} |
session | key |
testimonial:delete / testimonials:write |
accepted | api-standard |
POST /testimonials/{id}/restore |
session | key |
testimonial:delete / testimonials:write |
accepted | api-standard |
POST /testimonials/bulk |
session | key |
varies by action / testimonials:write |
accepted | api-standard |
GET /testimonials/{id}/tags |
session | key |
testimonial:read / testimonials:read |
— | api-standard |
PUT /testimonials/{id}/tags |
session | key |
testimonial:update / testimonials:write |
accepted | api-standard |
DELETE /testimonials/{id}/tags/{tagId} |
session | key |
testimonial:update / testimonials:write |
accepted | api-standard |
GET /testimonials/{id}/audit-events |
session | key |
audit:read / testimonials:read |
— | api-standard |
GET /testimonials/{id}/consents |
session |
consent:read / — |
— | dashboard |
GET /testimonials query parameters
| Param | Type | Validation |
|---|---|---|
status |
enum, repeatable | pending, approved, published, unpublished, rejected, archived |
mediaType |
enum, repeatable | text, video |
source |
enum, repeatable | collected, manual, csv, google, g2, capterra, x |
tagId |
uuid, repeatable | OR within, AND across other filters |
tagMode |
enum | any (default) or all — with all, a row must carry every listed tag |
minRating / maxRating |
integer | 1–5 |
requestPageId |
uuid | — |
hasTranscript |
boolean | — |
hasHighlight |
boolean | — |
isFeatured |
boolean | — |
language |
enum | en, es, fr, de, pt |
createdAfter / createdBefore |
RFC 3339 | inclusive |
publishedAfter / publishedBefore |
RFC 3339 | inclusive |
q |
string | 2–120 characters, full-text over author name, company, body, and transcript text |
include |
enum, repeatable | transcript, media, tags, highlights, consent. Omitted by default to keep list payloads small; transcript is rejected on list endpoints with 422 INCLUDE_NOT_ALLOWED_ON_LIST |
limit, cursor, sort |
— | Section 7.4. Sort keys: createdAt, publishedAt, rating, authorName |
Testimonial resource shape (published example, include=media,tags):
{
"data": {
"id": "tst_01JBQ7Z4K9M2X7YV3C8N6P0R5T",
"workspaceId": "wsp_01JBQ7S1C3A7X9QY3E8G1K5U7W",
"status": "published",
"source": "collected",
"mediaType": "video",
"requestPageId": "rqp_01JBQ7U0D2B6W8PX2D7F0J4T6V",
"authorName": "Dana Okafor",
"authorEmail": "dana@example.com",
"authorRole": "Head of Support",
"authorCompany": "Northwind",
"authorAvatarUrl": "https://cdn.vouch.app/a/8f2c…/avatar.jpg",
"authorLocation": "Leeds, UK",
"rating": 5,
"bodyText": "Vouch replaced three tools and a spreadsheet.",
"bodyHtml": "<p>Vouch replaced three tools and a spreadsheet.</p>",
"language": "en",
"isFeatured": true,
"customFields": { "plan_used": "Growth", "months_customer": "14" },
"sourceUrl": null,
"sourceRatingRaw": null,
"media": {
"id": "med_01JBQ7Z4K9M2X7YV3C8N6P0R5V",
"state": "ready",
"durationSeconds": 63.4,
"width": 1920,
"height": 1080,
"playbackId": "aBcD1234EfGh5678",
"posterUrl": "https://cdn.vouch.app/m/aBcD1234EfGh5678/poster.jpg",
"captionsUrl": "https://cdn.vouch.app/m/aBcD1234EfGh5678/en.vtt"
},
"tags": [ { "id": "tag_01JBQ7R4B2Z5V7NW1C6E9H3S5U", "name": "onboarding", "color": "#2F5BEA" } ],
"consentSummary": { "publish": true, "advertising": false, "caseStudy": true, "withdrawnAt": null },
"submittedAt": "2025-11-04T09:12:44.118Z",
"approvedAt": "2025-11-04T10:01:03.552Z",
"publishedAt": "2025-11-04T10:01:41.907Z",
"createdAt": "2025-11-04T09:12:44.118Z",
"updatedAt": "2025-11-04T10:01:41.907Z"
},
"meta": { "requestId": "req_01JBQ8H9W7VF38B0XZ4EW7YCKN" }
}authorEmail is omitted entirely for API-key principals holding only testimonials:read on a Free workspace, and is always omitted from any public surface. Section 22 owns the field-level exposure rules; the API implements them by omission, never by masking with asterisks, which leaks length.
bodyHtml is the stored sanitized rich text (testimonials.body_html_sanitized, Section 5.6.1, allowlist p, br, strong, em, a[href]) and it exists only on this authenticated server-to-server surface. It is never placed in a widget payload, an oEmbed response, a social card, or any other browser-executed surface: those carry bodyNodes, the token array in Section 13.8.2, precisely so that no HTML string ever reaches a parser on a customer's origin (Section 7.15.22). A consumer that renders bodyHtml into a page of its own is responsible for sanitizing it again on its own side — trusting a remote API's sanitizer is not a defence a receiving application should rely on.
POST /testimonials creates a manual testimonial (a quote a customer sent by email, a note taken on a call). Required: authorName, and at least one of bodyText or mediaId. Optional: authorEmail, authorRole, authorCompany, authorAvatarUrl, authorLocation, rating (1–5), language, customFields, tagIds, submittedAt (may be backdated up to 10 years, never into the future), consent (object with the three grants), sourceUrl. Validation highlights: bodyText 1–20 000 characters (the column bound in Section 5.6.1); customFields ≤ 30 keys, keys ^[a-z0-9_]{1,40}$, values ≤ 500 characters; tagIds must all exist in the workspace (422 UNKNOWN_TAG). New rows land in pending — manual creation does not skip moderation, so one pipeline governs everything (Section 11). Consumes one unit of the lifetime collection quota, so 409 TESTIMONIAL_LIMIT_REACHED is possible. Errors also include 422 IDEMPOTENCY_KEY_REQUIRED, 422 MEDIA_NOT_OWNED, 409 MEDIA_ALREADY_ATTACHED.
PATCH /testimonials/{id} may change authorName, authorRole, authorCompany, authorLocation, authorAvatarUrl, rating, bodyText/bodyHtml, language, isFeatured, customFields. It may not change status (use the action endpoints), source, workspaceId, or any timestamp. Imported testimonials are text-immutable: any attempt to change bodyText, bodyHtml, rating, or authorName on a row whose source is google, g2, capterra, or x returns 409 IMPORTED_CONTENT_IMMUTABLE. The moderator may still tag, feature, hide, approve, publish, or unpublish it (Section 16). Accepts If-Match; a stale ETag is 409 RESOURCE_CONFLICT.
Action endpoints and the state machine. Section 11 owns the state machine; this catalogue owns its HTTP surface. Legal transitions:
| Endpoint | From | To | Also does |
|---|---|---|---|
POST …/approve |
pending |
approved |
Sets approved_at, writes an audit event, fires testimonial.approved |
POST …/reject |
pending |
rejected |
Body { "reason": "…" } ≤ 500 characters, stored and shown in the audit trail; fires testimonial.rejected |
POST …/publish |
approved, unpublished |
published |
Sets published_at on first publish only, purges CDN tags widget:*/workspace:*, fires testimonial.published |
POST …/unpublish |
published |
unpublished |
Retains published_at, purges CDN within 60 seconds, fires testimonial.unpublished |
POST …/archive |
rejected, unpublished, approved |
archived |
Removes from working views, retains the row |
DELETE … |
any | soft-deleted | Sets deleted_at, records pre_delete_status, enqueues media.purge (Section 9), fires testimonial.deleted. Does not release a lifetime collection slot (Section 19.5.3) |
POST …/restore |
soft-deleted within 30 days | pre_delete_status |
Cancels the pending media.purge; no quota change, because the slot was never released. Fails with 410 TESTIMONIAL_PURGED once media purge has completed |
Any other transition returns 409 INVALID_STATUS_TRANSITION whose message names the current status and the legal next states. Calling an action that is already satisfied — publishing a published testimonial — returns 200 with the current resource and no side effect, so retries are safe. Publishing a testimonial whose consentSummary.publish is false or whose withdrawnAt is set returns 409 CONSENT_MISSING; there is no override flag, on any plan, for any role.
POST /testimonials/bulk body { "action": "approve", "ids": ["tst_…"], "reason": "…" } with action in approve, reject, publish, unpublish, archive, delete, addTags, removeTags, feature, unfeature; ids 1–200. Returns 207-style semantics inside a 200 envelope — each id gets a per-item result, because partial success is the normal outcome of a bulk moderation sweep:
{
"data": {
"succeeded": ["tst_01JBQ7Z4K9M2X7YV3C8N6P0R5T"],
"failed": [ { "id": "tst_01JBQ7Y1H8F2C4WD9K3M6Q0Z2B", "code": "INVALID_STATUS_TRANSITION", "message": "Cannot publish a testimonial that is pending." } ]
},
"meta": { "requestId": "req_01JBQ8J2X8WG49C1YA5FX8ZDLP" }
}GET /testimonials/counts returns { "pending": 12, "approved": 4, "published": 380, "unpublished": 3, "rejected": 9, "archived": 4 } honouring the same filters as the list endpoint minus status. This is the one place a count is available, and it is a single grouped query against the status index.
GET /testimonials/{id}/audit-events is cursor-paginated, sorted occurredAt DESC, and returns actorId, actorType (user | api_key | system), action, before, after, ip, userAgent, occurredAt. The table is append-only and the API exposes no write path to it at all (Section 11.6).
7.15.9 Tags #
| Method & path | Auth | Permission / scope | Idem. | Rate |
|---|---|---|---|---|
GET /tags |
session | key |
tag:read / testimonials:read |
— | api-standard |
POST /tags |
session | key |
tag:create / testimonials:write |
accepted | api-standard |
PATCH /tags/{tagId} |
session | key |
tag:update / testimonials:write |
accepted | api-standard |
DELETE /tags/{tagId} |
session | key |
tag:delete / testimonials:write |
accepted | api-standard |
POST /tags/{tagId}/merge |
session | key |
tag:update / testimonials:write |
accepted | api-standard |
Tags are a table, not an enum, because customers extend them (Section 5). name is 1–40 characters, unique per workspace case-insensitively (409 TAG_NAME_TAKEN), and normalized to NFC with collapsed internal whitespace. color is a 6-digit hex, defaulted from a 12-colour palette by hashing the name so new tags look intentional without a colour picker. A workspace may hold 200 tags; the 201st is 409 TAG_LIMIT_REACHED. A single testimonial may carry 25 tags; the 26th is 422 TESTIMONIAL_TAG_LIMIT_REACHED on PUT /testimonials/{id}/tags. They are two codes because they are two limits, and a client that receives one must be able to tell which ceiling it hit without parsing prose. GET /tags supports q, sort=name:asc|usageCount:desc, and returns usageCount per tag. Deleting a tag removes its join rows (a hard delete of child rows per Section 5) and is not reversible; the UI confirms with the usage count. POST /tags/{tagId}/merge body { "intoTagId": "tag_…" } re-points every join row, deletes the source tag, and writes one audit event per affected testimonial. Merging a tag into itself is 422 CANNOT_MERGE_INTO_SELF.
7.15.10 Media and upload sessions #
Section 9 owns the pipeline; this is its wire contract. The routes below are the dashboard and API-key variants on app.. A respondent never calls them: their entry point is POST /api/v1/public/drafts/{draftToken}/uploads (Section 8.10.4), authenticated by the draft token, which opens the same upload session and then follows the identical part-signing, completion and abort shapes below.
| Method & path | Auth | Permission / scope | Idem. | Rate |
|---|---|---|---|---|
POST /media/upload-sessions |
session | key |
media:create / testimonials:write |
required | upload-part |
GET /media/upload-sessions/{sessionId} |
owner of the session | media:read / testimonials:read |
— | upload-part |
POST /media/upload-sessions/{sessionId}/parts |
owner of the session | media:create / testimonials:write |
accepted | upload-part |
POST /media/upload-sessions/{sessionId}/complete |
owner of the session | media:create / testimonials:write |
accepted | upload-part |
POST /media/upload-sessions/{sessionId}/abort |
owner of the session | media:create / testimonials:write |
accepted | upload-part |
GET /media/{mediaId} |
session | key |
media:read / testimonials:read |
— | api-standard |
PATCH /media/{mediaId}/poster |
session | key |
media:update / testimonials:write |
accepted | api-standard |
DELETE /media/{mediaId} |
session | key |
media:delete / testimonials:write |
accepted | api-standard |
POST /media/upload-sessions body:
{
"kind": "video",
"mimeType": "video/mp4;codecs=avc1,mp4a",
"estimatedBytes": 48210432,
"durationSecondsEstimate": 63
}| Field | Validation |
|---|---|
kind |
video, audio, image |
mimeType |
must be in the negotiated allowlist: video/mp4;codecs=avc1,mp4a, video/webm;codecs=vp9,opus, video/webm;codecs=vp8,opus, video/webm, audio/webm;codecs=opus, image/jpeg, image/png, image/webp. Anything else is 415 UNSUPPORTED_MEDIA_TYPE. The negotiated value is stored on the row so a playback bug is diagnosable months later |
estimatedBytes |
optional integer, 0–VIDEO_MAX_UPLOAD_BYTES (Section 9.6 owns the field and the constant). Above VIDEO_MAX_UPLOAD_BYTES returns 413 UPLOAD_TOO_LARGE; above the workspace's remaining storage headroom plus 20 % returns 409 STORAGE_LIMIT_EXCEEDED |
durationSecondsEstimate |
must not exceed the plan's maximum video length (Section 19) or 422 VIDEO_LENGTH_EXCEEDED |
A session opened here is bound to the workspace and, once complete, is attached to a testimonial by mediaId. A session opened through Section 8.10.4 is bound to the draft instead, which is what lets a respondent resume on another device and re-attach to the parts already uploaded; Section 8.10.4 owns those binding rules and this section does not restate them.
Response 201 returns { uploadSessionId, storageKey, partSizeBytes: 8388608, maxParts: 10000, expiresAt }. expiresAt is 24 hours out; abandoned sessions are swept after 24 hours per Section 9.
POST /media/upload-sessions/{sessionId}/parts body { "partNumbers": [1,2,3] }, 1–100 part numbers per call, each 1–10 000. Returns pre-signed PUT URLs valid 900 seconds: { "parts": [ { "partNumber": 1, "url": "https://…", "expiresAt": "…" } ] }. Clients upload directly to storage — no media byte ever passes through the API — and retry a failed part up to 5 times with jittered exponential backoff capped at 30 seconds.
POST /media/upload-sessions/{sessionId}/complete body { "parts": [ { "partNumber": 1, "etag": "\"9f2a…\"" } ] }, ordered ascending, complete with no gaps (422 PART_SEQUENCE_INVALID), every ETag non-empty. On success the multipart upload is completed, a media_assets row is created in state uploaded, a Mux asset is created, and the response is 202 with { mediaId, state: "processing", jobId }. Errors: 409 UPLOAD_ALREADY_COMPLETED, 410 UPLOAD_SESSION_EXPIRED, 422 PART_SEQUENCE_INVALID, 503 DEPENDENCY_UNAVAILABLE.
POST /media/upload-sessions/{sessionId}/abort aborts the multipart upload immediately, reclaiming storage. This is what a retake calls, and retakes are free and unlimited — nothing is charged to quota until submit (Section 9). Returns 204. Aborting an already-completed session is 409 UPLOAD_ALREADY_COMPLETED.
GET /media/{mediaId} returns state (uploading, uploaded, processing, ready, errored), errorCode and errorMessage when errored, durationSeconds, width, height, mimeType, bytes, playbackId, posterUrl, captionsUrl, originalRetainedUntil, and purgedAt. Clients poll this at 2-second intervals with a 10-minute ceiling, or subscribe to media.ready / media.failed webhooks (Section 18.2), which is the recommended path.
PATCH /media/{mediaId}/poster body { "timestampSeconds": 4.5 } (0 ≤ value ≤ duration) or { "uploadedImageMediaId": "med_…" }. Regenerates the poster, writes it to object storage, serves it from cdn. so widget posters do not depend on Mux availability, and purges the affected widget cache tags. Returns 202.
7.15.11 Transcripts and captions #
| Method & path | Auth | Permission / scope | Idem. | Rate |
|---|---|---|---|---|
GET /testimonials/{id}/transcript |
session | key |
transcript:read / testimonials:read |
— | api-standard |
PATCH /testimonials/{id}/transcript |
session | key |
transcript:update / testimonials:write |
accepted | api-standard |
POST /testimonials/{id}/transcript/regenerate |
session | key |
transcript:update / testimonials:write |
required | api-standard |
GET /testimonials/{id}/transcript/vtt |
session | key |
transcript:read / testimonials:read |
— | api-standard |
The transcript is the single source of truth for captions, clip suggestions, pull-quote extraction, and search indexing (Section 10). Its stored JSONB shape is owned by Section 10; the API returns it verbatim plus envelope, adding transcriptVersion, provider, model, editedByUserId, and editedAt.
PATCH /testimonials/{id}/transcript accepts a full segment array, not a patch language, because merging and splitting segments cannot be expressed as field patches. Validation:
segments1–5 000 entries; every entry hasid,start,end,text.start ≥ 0,end > start,end ≤ media.durationSeconds + 0.5(tolerance for the final frame).- Segments must be ordered by
startand must not overlap (422 SEGMENT_OVERLAP). text0–2 000 characters per segment; an empty segment is allowed and is rendered as a caption gap.wordsis optional per segment; if present, every word must lie within its segment's bounds (422 WORD_OUT_OF_BOUNDS).languagemust be one of the five supported at launch (Section 10).
Saving bumps transcriptVersion, writes an audit event with a before/after snapshot, regenerates the WebVTT file into object storage, purges the CDN tags, and fires transcript.edited. Concurrency is guarded by If-Match on the transcript's own ETag; a stale save returns 409 RESOURCE_CONFLICT and the editor reloads rather than clobbering another moderator's work.
Critically, editing a transcript does not change the testimonial's published pull-quotes retroactively without revalidation: any existing highlight whose text no longer matches the transcript by exact substring is marked stale and hidden from public surfaces until a human re-confirms it. That is the code-level enforcement of the verbatim rule owned by Section 12.
POST /testimonials/{id}/transcript/regenerate body { "language": "es" } (optional; omit for auto-detection). Consumes paid transcription minutes, hence the required idempotency key. Returns 202 with { jobId }. Errors: 409 NO_MEDIA, 409 MEDIA_NOT_READY, 409 TRANSCRIPTION_MINUTES_EXCEEDED (soft — see Section 19: the job still completes if the workspace is within the 20 %/7-day tolerance, and this error is returned only past it), 503 DEPENDENCY_UNAVAILABLE.
GET /testimonials/{id}/transcript/vtt returns text/vtt; charset=utf-8 with Content-Disposition: inline and a strong ETag, not the JSON envelope. Errors still use the JSON envelope. A testimonial with no transcript returns 404 TRANSCRIPT_NOT_FOUND — video is never blocked on transcription (Section 10), so this is a routine, non-alarming state.
7.15.12 Highlights and AI suggestions #
| Method & path | Auth | Permission / scope | Idem. | Rate |
|---|---|---|---|---|
GET /testimonials/{id}/highlights |
session | key |
highlight:read / testimonials:read |
— | api-standard |
POST /testimonials/{id}/highlights |
session | key |
highlight:create / testimonials:write |
accepted | api-standard |
PATCH /highlights/{highlightId} |
session | key |
highlight:update / testimonials:write |
accepted | api-standard |
DELETE /highlights/{highlightId} |
session | key |
highlight:delete / testimonials:write |
accepted | api-standard |
POST /testimonials/{id}/ai-suggestions |
session | key |
ai:request / testimonials:write |
required | api-standard |
GET /testimonials/{id}/ai-suggestions |
session | key |
ai:read / testimonials:read |
— | api-standard |
POST /ai-suggestions/{suggestionId}/accept |
session | key |
highlight:create / testimonials:write |
accepted | api-standard |
POST /ai-suggestions/{suggestionId}/reject |
session | key |
ai:read / testimonials:write |
accepted | api-standard |
A highlight is a published pull-quote or a video clip. Its body is either { "kind": "quote", "parts": [ { "startChar": 12, "endChar": 96 } ] } for text or { "kind": "clip", "startSeconds": 14.2, "endSeconds": 31.8 } for video, plus an optional label ≤ 60 characters.
The API enforces the verbatim rule owned by Section 12 at write time, on every highlight, whether AI-proposed or hand-created: the resolved text of a quote highlight must be an exact substring of the stored body or transcript after Unicode NFC normalization and whitespace collapsing. A multi-part quote is an ordered list of exact substrings that render joined by an ellipsis. A highlight that fails the check is rejected with 422 HIGHLIGHT_NOT_VERBATIM and its details name the first divergent character offset. There is no flag, plan, or role that disables this check.
Clip highlights validate 0 ≤ start < end ≤ duration, minimum length 2 seconds, maximum 90 seconds, and a maximum of 3 clip highlights per testimonial.
POST /testimonials/{id}/ai-suggestions is Growth-and-Scale only; on lower plans it returns 409 PLAN_FEATURE_UNAVAILABLE naming the required plan. Body { "kinds": ["quote", "clip", "tags", "altText", "title"] } — defaults to all five. Returns 202 with { jobId }. The job produces at most 5 quote candidates, 3 clip candidates, tag suggestions drawn only from the workspace's existing tag vocabulary, alt text for social cards, and a platform-authored draft title. Every quote candidate that fails the verbatim substring check is discarded before a human sees it, and the discard is counted in a metric that alerts above 2 % of proposals (Section 12.4).
GET /testimonials/{id}/ai-suggestions returns each suggestion with id, kind, payload, rationale (one line, for clips), promptVersion, model, status (the ai_suggestion_state values from Section 5.2: proposed, accepted, dismissed, expired, discarded — the reject endpoint sets dismissed, and discarded is what a candidate that failed the verbatim check before any human saw it carries), createdAt. promptVersion is stored on every row for auditability (Section 12).
POST /ai-suggestions/{suggestionId}/accept is the only path from a suggestion to a published artefact, and it always requires this explicit human call — nothing AI-proposed is ever published automatically, on any plan. Accepting a quote creates a highlight and re-runs the verbatim check server-side (belt and braces: the transcript may have been edited since the suggestion was produced), returning 422 HIGHLIGHT_NOT_VERBATIM if the source text has changed. Accepting a tag suggestion attaches an existing tag and never creates one. Accepting a title suggestion stores it in a field explicitly labelled platform-authored, so it is never rendered as the customer's words.
7.15.13 Social image cards #
| Method & path | Auth | Permission / scope | Idem. | Rate |
|---|---|---|---|---|
GET /social-card-templates |
session | key |
social_card:read / widgets:read |
— | api-standard |
GET /social-cards |
session | key |
social_card:read / widgets:read |
— | api-standard |
POST /social-cards |
session | key |
social_card:generate / widgets:write |
required | api-standard |
GET /social-cards/{cardId} |
session | key |
social_card:read / widgets:read |
— | api-standard |
DELETE /social-cards/{cardId} |
session | key |
social_card:delete / widgets:write |
accepted | api-standard |
GET /public/social-cards/{cardToken}.{ext} |
public |
— | — | edge-public |
Section 15 owns the template catalogue and the rendering pipeline. Wire contract: POST /social-cards takes { "testimonialId", "templateId", "size", "theme", "highlightId", "altText" }. size is one of og (1200×630), square (1080×1080), story (1080×1920). theme overrides accent colour, background, and font scale, each validated exactly as the widget theme is, including the contrast check. highlightId selects which pull-quote the card features and must belong to the same testimonial. altText is 10–420 characters and is required — a card without alt text cannot be created, because it will be posted to social networks where alt text is the only accessible representation (Section 21). Returns 202 with { cardId, jobId }; the render runs in the worker with satori and resvg and writes PNG plus WebP derivatives to object storage.
GET /public/social-cards/{cardToken}.png (also .webp, .jpg) serves the rendered image from cdn. with Cache-Control: public, max-age=31536000, immutable, because the token embeds a content hash. Deleting a card purges the CDN tag and the storage keys.
7.15.14 Widgets #
| Method & path | Auth | Permission / scope | Idem. | Rate |
|---|---|---|---|---|
GET /widgets |
session | key |
widget:read / widgets:read |
— | api-standard |
POST /widgets |
session | key |
widget:create / widgets:write |
accepted | api-standard |
GET /widgets/{widgetId} |
session | key |
widget:read / widgets:read |
— | api-standard |
PATCH /widgets/{widgetId} |
session | key |
widget:update / widgets:write |
accepted | api-standard |
DELETE /widgets/{widgetId} |
session | key |
widget:delete / widgets:write |
accepted | api-standard |
POST /widgets/{widgetId}/duplicate |
session | key |
widget:create / widgets:write |
accepted | api-standard |
GET /widgets/{widgetId}/embed-code |
session | key |
widget:read / widgets:read |
— | api-standard |
GET /widgets/{widgetId}/preview |
session |
widget:read / — |
— | dashboard |
POST /widgets/{widgetId}/purge |
session | key |
widget:update / widgets:write |
accepted | api-standard |
Widgets are unlimited on every plan, including Free, and widget views are unmetered on every plan. There is no widget count limit and no view limit to return an error about.
POST /widgets body
| Field | Type | Validation |
|---|---|---|
name |
string | 2–80 characters |
type |
enum | wall_of_love, carousel, single_quote, video_reel, badge, popup — the six values of the widget_type enum in Section 5.2, catalogued in Section 13 |
layout |
enum | masonry, grid, list (wall_of_love only; 422 LAYOUT_NOT_VALID_FOR_TYPE otherwise) |
theme.mode |
enum | light, dark, auto, custom |
theme.accentColor |
string | 6-digit hex; contrast-checked against the resolved background, 422 WIDGET_ACCENT_CONTRAST_INSUFFICIENT with the nearest passing colour offered (Section 21) |
theme.backgroundColor, theme.textColor |
string | 6-digit hex, required when mode is custom |
theme.fontScale |
enum | sm, md, lg |
theme.borderRadius |
integer | 0–32 px |
maxItems |
integer | 1–100, default 12. Values above 24 emit meta.warnings noting the 60 KB payload cap in Section 7.13.2 |
filters.tagIds |
uuid[] | ≤ 25, must exist |
filters.tagMode |
enum | any (default), all |
filters.minRating |
integer | 1–5 |
filters.mediaTypes |
enum[] | subset of text, video |
filters.language |
enum[] | subset of the five supported languages |
filters.testimonialIds |
uuid[] | ≤ 100; when present it pins an exact, ordered set and every other filter is ignored |
sortOrder |
enum | newest, oldest, rating_desc, featured_first, random, manual |
showBadge |
boolean | Forced true and rejected as 409 BADGE_REQUIRED_ON_PLAN if set to false on Free — the badge is rendered server-side into the payload and re-asserted on every refresh (Section 19) |
showRating, showAvatar, showCompany, showDate, showSourceBadge |
boolean | display toggles |
autoplayCarousel |
boolean | Advisory only: the runtime never auto-advances when prefers-reduced-motion is set (Section 21) |
popup.frequencyCapHours |
integer | 1–168, default 24 (popup only) |
popup.delaySeconds |
integer | 5–120, default 8; values below 5 are rejected, because the popup never appears in the first 5 seconds |
popup.position |
enum | bottom_left, bottom_right, top_left, top_right |
The widget id is returned as wgt_ + Crockford base32 UUIDv7, which is exactly the token that appears in the paste-once snippet.
GET /widgets/{widgetId}/embed-code returns the snippet the customer pastes, unchanged for the life of the widget:
{
"data": {
"widgetId": "wgt_01JXQ7Z4K9M2X7YV3C8N6P0R5T",
"html": "<script async src=\"https://cdn.vouch.app/e.js\" data-vouch=\"wgt_01JXQ7Z4K9M2X7YV3C8N6P0R5T\"></script>\n<div data-vouch-widget=\"wgt_01JXQ7Z4K9M2X7YV3C8N6P0R5T\"></div>",
"oembedUrl": "https://v.vouch.app/api/v1/widgets/wgt_01JXQ7Z4K9M2X7YV3C8N6P0R5T/oembed",
"noscriptFallbackUrl": "https://v.vouch.app/w/wgt_01JXQ7Z4K9M2X7YV3C8N6P0R5T"
},
"meta": { "requestId": "req_01JBQ8K4Y9XH5AD2ZB6GY9AEMQ" }
}The snippet carries only the widget id. It hard-codes no workspace host, no custom domain, no plan, and no layout, so adding, changing, or removing a custom domain never requires re-pasting it (Section 13). cdn.vouch.app is a permanent, platform-owned host.
POST /widgets/{widgetId}/purge forces an immediate CDN purge of the widget:{id} tag and returns 202. It is rate-limited to 10 calls per widget per hour beyond the standard class (429 PURGE_RATE_LIMIT) because purging is a shared, expensive operation; ordinary publish/unpublish/edit purges are automatic and are not subject to this sub-limit.
GET /widgets/{widgetId}/preview returns the exact JSON payload the edge would serve, bypassing the CDN and including pending/approved rows when includeUnpublished=true, so the widget editor previews an unpublished arrangement without touching live traffic. It is session-only and never cached.
7.15.15 Showcase page #
| Method & path | Auth | Permission / scope | Idem. | Rate |
|---|---|---|---|---|
GET /showcase |
session | key |
showcase:read / widgets:read |
— | api-standard |
PATCH /showcase |
session | key |
showcase:update / widgets:write |
accepted | api-standard |
POST /showcase/purge |
session | key |
showcase:update / widgets:write |
accepted | api-standard |
One showcase page per workspace (Section 14 owns its rendering, SEO, and domain resolution). Editable fields: isEnabled, slug (used at {workspace-slug}.vouch.app when no custom domain is primary), headline ≤ 120 characters, subheadline ≤ 240 characters, logoUrl, theme (validated exactly as widget themes are, contrast check included), filters (same shape as widget filters), sortOrder, showRatingSummary, ctaLabel ≤ 40 characters, ctaUrl (https:// only), metaTitle ≤ 60 characters, metaDescription ≤ 160 characters, ogImageCardId, noindex (default false). Disabling the page returns 410 SHOWCASE_DISABLED to public visitors rather than a 404, so a customer who linked to it gets an honest signal.
7.15.16 Custom domains #
| Method & path | Auth | Permission / scope | Idem. | Rate |
|---|---|---|---|---|
GET /domains |
session |
domain:read / — |
— | dashboard |
POST /domains |
session |
domain:create / — |
accepted | dashboard |
GET /domains/{domainId} |
session |
domain:read / — |
— | dashboard |
GET /domains/{domainId}/status |
session |
domain:read / — |
— | dashboard |
POST /domains/{domainId}/verify |
session |
domain:update / — |
accepted | dashboard |
POST /domains/{domainId}/primary |
session |
domain:update / — |
accepted | dashboard |
DELETE /domains/{domainId} |
session |
domain:delete / — |
accepted | dashboard |
This group is session-only, without exception. No API-key scope grants domain:* — that is the hard ceiling in Section 6.6.4, restated in Section 7.8 — because a leaked key that could repoint or delete a customer's public hostname would turn a credential leak into a domain takeover. An API key calling any route here receives 403 PERMISSION_NOT_AVAILABLE_TO_API_KEYS.
POST /domains body { "hostname": "stories.northwind.com" }. Validation: a valid DNS hostname, ≤ 253 characters, lowercase, punycode-encoded for IDNs (the original Unicode form is echoed as hostnameUnicode), not a vouch.app subdomain (422 RESERVED_HOSTNAME), not on the public-suffix list as a bare entry, and not already claimed by another workspace (409 DOMAIN_ALREADY_CLAIMED — the challenge record is what proves ownership, so one domain belongs to exactly one workspace). Plan gating per Section 19: Free returns 409 PLAN_FEATURE_UNAVAILABLE; Starter requires the paid add-on and returns 409 DOMAIN_ADDON_REQUIRED with a purchase URL when it is absent; a workspace already at its plan's domain count returns 409 CUSTOM_DOMAIN_LIMIT_REACHED.
Response 201 includes the exact records to create:
{
"data": {
"id": "dom_01JBQ8M6ZAYJ6BF3AC7HZAFNRS",
"hostname": "stories.northwind.com",
"isApex": false,
"state": "pending_dns",
"isPrimary": false,
"requiredRecords": [
{ "type": "CNAME", "name": "stories.northwind.com", "value": "edge.vouch.app", "ttl": 300 },
{ "type": "CNAME", "name": "_vouch-challenge.stories.northwind.com", "value": "v-9f2a1c7e4b.challenge.vouch.app", "ttl": 300 }
],
"observedRecords": [],
"lastCheckedAt": null,
"certificate": null,
"createdAt": "2025-11-04T13:20:00.000Z"
},
"meta": { "requestId": "req_01JBQ8M6ZAYJ6BF3AC7HZAFNRT" }
}For an apex domain the response instead requires an ALIAS/ANAME to edge.vouch.app where the registrar supports it, or A records to the documented anycast addresses where it does not, plus the same _vouch-challenge CNAME.
GET /domains/{domainId}/status is the endpoint the live status UI polls. It returns the seven domain_state values — pending_dns, dns_detected, verifying, issuing_tls, active, failed, and suspended (entered only by the billing downgrade path and left only by upgrade, per Section 14.3) — with stateLabel (an accessible text label, never colour alone), nextAction (a plain-English instruction), lastCheckedAt, observedRecords as actually seen at the authoritative nameservers, and recordDiff, a per-record plain-English comparison:
{
"data": {
"state": "pending_dns",
"stateLabel": "Waiting for DNS",
"nextAction": "Add the CNAME record shown below at your DNS provider. Changes can take up to an hour to appear.",
"lastCheckedAt": "2025-11-04T13:24:31.882Z",
"nextCheckAt": "2025-11-04T13:24:46.882Z",
"observedRecords": [ { "type": "CNAME", "name": "stories.northwind.com", "value": "northwind.example.net" } ],
"recordDiff": [
{ "name": "stories.northwind.com", "expected": "edge.vouch.app", "observed": "northwind.example.net", "ok": false,
"explanation": "This name currently points to northwind.example.net. Change it to edge.vouch.app." },
{ "name": "_vouch-challenge.stories.northwind.com", "expected": "v-9f2a1c7e4b.challenge.vouch.app", "observed": null, "ok": false,
"explanation": "This record has not been created yet." }
],
"certificate": null,
"failureReason": null
},
"meta": { "requestId": "req_01JBQ8N8AB0K7CG4BD8JAB0PST" }
}Client polling cadence: every 5 seconds for the first 2 minutes, every 15 seconds to 10 minutes, every 60 seconds to 24 hours (Section 14). Independently, a repeatable server job checks every 5 minutes for 72 hours. nextCheckAt tells the client when the server will next look, so the UI can show a truthful countdown instead of spinning.
POST /domains/{domainId}/verify forces an immediate check, limited to 10 per domain per hour (429). POST /domains/{domainId}/primary makes the domain the canonical host for the showcase page and for link targets inside widget payloads; exactly one domain per workspace may be primary, and switching is atomic. It never changes the loader origin, so no embed breaks. DELETE releases the claim, revokes the certificate, purges the CDN configuration, and — if the deleted domain was primary — falls back to {workspace-slug}.vouch.app in the same transaction so no public URL 404s.
7.15.17 Review import #
| Method & path | Auth | Permission / scope | Idem. | Rate |
|---|---|---|---|---|
GET /import-connections |
session | key |
import:read / testimonials:read |
— | api-standard |
POST /import-connections |
session |
import:connect / — |
accepted | dashboard |
GET /import-connections/{connectionId} |
session | key |
import:read / testimonials:read |
— | api-standard |
PATCH /import-connections/{connectionId} |
session |
import:connect / — |
accepted | dashboard |
DELETE /import-connections/{connectionId} |
session |
import:disconnect / — |
accepted | dashboard |
POST /import-connections/{connectionId}/runs |
session | key |
import:run / testimonials:write |
required | api-standard |
GET /import-runs |
session | key |
import:read / testimonials:read |
— | api-standard |
GET /import-runs/{runId} |
session | key |
import:read / testimonials:read |
— | api-standard |
POST /csv-imports |
session | key |
import:upload_csv / testimonials:write |
accepted | api-standard |
PATCH /csv-imports/{csvImportId}/mapping |
session | key |
import:upload_csv / testimonials:write |
accepted | api-standard |
POST /csv-imports/{csvImportId}/commit |
session | key |
import:run / testimonials:write |
required | api-standard |
GET /csv-imports/{csvImportId} |
session | key |
import:read / testimonials:read |
— | api-standard |
Section 16 owns source semantics, the no-scraping rule, deduplication, and rating normalization. Wire contract highlights.
Import permissions are deliberately split by consequence rather than lumped into one string, and the split decides which principal can reach which route:
| Permission | Routes | Reachable by an API key? |
|---|---|---|
import:connect |
POST /import-connections, PATCH /import-connections/{id} |
No |
import:disconnect |
DELETE /import-connections/{id} |
No |
import:run |
POST /import-connections/{id}/runs, POST /csv-imports/{id}/commit |
Yes, with testimonials:write |
import:upload_csv |
POST /csv-imports, PATCH /csv-imports/{id}/mapping |
Yes, with testimonials:write |
import:read |
every GET in this group |
Yes, with testimonials:read |
Connecting, reconfiguring, and disconnecting a provider are session-only. import:connect and import:disconnect sit under the same ceiling as integration:connect and integration:disconnect in Section 6.6.4, for the same reason domain:* does — a key that could disconnect and re-point an OAuth connection could quietly redirect a customer's review feed to a source they do not control. Running an import and uploading a CSV carry no such power: they only create pending rows that a human still has to moderate, so they are ordinary write actions.
POST /import-connections body { "provider": "google" } with provider in google, g2, capterra, x. Returns 201 with { connectionId, authorizationUrl, state, expiresAt }; the dashboard opens authorizationUrl, and the provider redirects back to GET /oauth/callback/{provider} which completes the exchange, encrypts the tokens (Section 18.9), and marks the connection connected. The state parameter is a signed, single-use, 10-minute value bound to the workspace and the user; a mismatch is 403 OAUTH_STATE_INVALID.
For G2 and Capterra the customer may have no syndication entitlement. That is a first-class flow, not an error: POST /import-connections for those providers returns 201 with { "entitlement": "unknown", "fallback": "file_upload" } and the UI routes the customer to the file-upload path, which accepts the customer's own export in CSV or JSON. The API never returns an error merely because a contract-gated feed is unavailable.
POST /import-connections/{connectionId}/runs body { "mode": "incremental" | "full", "since": "2025-10-01T00:00:00.000Z" }. Returns 202 with { runId, jobId }. Errors: 409 TESTIMONIAL_LIMIT_REACHED (import is blocked at the collection cap per Section 19), 409 IMPORT_ALREADY_RUNNING (one run per connection at a time), 409 CONNECTION_REAUTH_REQUIRED, 409 IMPORT_NOT_AVAILABLE_ON_PLAN, 409 IMPORT_SOURCE_NOT_ENTITLED, 503 DEPENDENCY_UNAVAILABLE.
GET /import-runs/{runId} reports progress and outcome:
{
"data": {
"id": "imr_01JBQ8P0BC1M8DH5CE9KBC1QTU",
"connectionId": "imc_01JBQ8M6ZAYJ6BF3AC7HZAFNRS",
"provider": "google",
"mode": "incremental",
"state": "succeeded",
"startedAt": "2025-11-04T13:40:00.000Z",
"finishedAt": "2025-11-04T13:41:12.441Z",
"counts": { "fetched": 214, "created": 31, "duplicates": 180, "nearDuplicates": 2, "skipped": 1, "failed": 0 },
"errors": [],
"nextCursorToken": "AoJ…",
"rateLimitedUntil": null
},
"meta": { "requestId": "req_01JBQ8P0BC1M8DH5CE9KBC1QTV" }
}state is one of the import_run_status values in Section 5.2: queued, running, succeeded, partial, failed, canceled. A run waiting out a provider rate limit stays running with rateLimitedUntil set — waiting is not a terminal state and does not get one. Imported rows land in pending exactly like collected ones, so one moderation pipeline governs everything. Deduplication is on (source, sourceReviewId) unique per workspace; near-duplicates are flagged by a normalized text hash and surfaced as a moderator warning, never auto-merged.
CSV import is a three-step flow so a customer never commits a bad mapping to hundreds of rows. POST /csv-imports uploads the file (multipart/form-data, ≤ 10 MB, ≤ 5 000 data rows — Section 16.3.2 owns both numbers — UTF-8 or UTF-8-BOM, comma or semicolon delimiter auto-detected) and returns detected headers plus the first 10 parsed rows and a proposed mapping. PATCH …/mapping sets the column-to-field map and per-column transforms (trim, titlecase, rating_scale_10_to_5, date_format), returning a fresh dry-run preview with per-row validation results. POST …/commit performs the import and returns 202. CSV import is available on every plan including Free (Section 19). Errors: 413 IMPORT_FILE_TOO_LARGE, 422 IMPORT_ROW_LIMIT, 422 CSV_MISSING_REQUIRED_COLUMN, 422 CSV_ENCODING_UNSUPPORTED, 409 CSV_ALREADY_COMMITTED, 409 TESTIMONIAL_LIMIT_REACHED.
7.15.18 Analytics #
| Method & path | Auth | Permission / scope | Idem. | Rate |
|---|---|---|---|---|
GET /analytics/summary |
session | key |
analytics:read / analytics:read |
— | api-standard |
GET /analytics/timeseries |
session | key |
analytics:read / analytics:read |
— | api-standard |
GET /analytics/testimonials |
session | key |
analytics:read / analytics:read |
— | api-standard |
GET /analytics/widgets |
session | key |
analytics:read / analytics:read |
— | api-standard |
POST /analytics/exports |
session | key |
analytics:export / analytics:read |
accepted | export |
GET /analytics/exports/{exportId} |
session | key |
analytics:export / analytics:read |
— | api-standard |
Shared query parameters: from and to (RFC 3339, to exclusive, maximum span 400 days, both required on timeseries), widgetId (repeatable), testimonialId (repeatable), eventType (repeatable, from the event vocabulary in Section 17), country (ISO 3166-1 alpha-2, repeatable), deviceClass (mobile, tablet, desktop), and granularity on timeseries (hour or day; hour is rejected for spans over 31 days with 422 GRANULARITY_TOO_FINE).
A range extending before the plan's analytics retention window is clamped, not rejected: the response returns the available window and sets meta.warnings: [{ "code": "RANGE_CLAMPED_TO_RETENTION", "message": "Data before 2024-11-04 is outside your plan's 12-month retention." }]. Rejecting a dashboard's default 12-month range because a workspace downgraded would be a pointless failure.
GET /analytics/summary returns impressions, unique-ish views, interactions, interaction rate, clicks, CTR, video starts, and video completion at 25/50/75/100 %, each with the absolute value and the delta versus the immediately preceding equal-length window. Deltas carry a direction string (up, down, flat) alongside the number so the UI never conveys change by colour alone (Section 21).
POST /analytics/exports body { "format": "csv", "dataset": "events_rollup", "from": "…", "to": "…" } with format in csv, json and dataset in events_rollup, per_testimonial, per_widget. Returns 202 with { exportId, jobId }; the finished export is a time-limited signed URL valid 24 hours, returned by GET /analytics/exports/{exportId} and also emailed (Section 20.4). Export is limited to 5 per hour per workspace.
7.15.19 Billing #
| Method & path | Auth | Permission / scope | Idem. | Rate |
|---|---|---|---|---|
GET /billing/subscription |
session |
billing:read / — |
— | dashboard |
POST /billing/checkout-sessions |
session |
billing:manage / — |
accepted | dashboard |
POST /billing/portal-sessions |
session |
billing:manage / — |
accepted | dashboard |
POST /billing/subscription/preview |
session |
billing:manage / — |
accepted | dashboard |
PATCH /billing/subscription |
session |
billing:manage / — |
accepted | dashboard |
GET /billing/invoices |
session |
billing:read / — |
— | dashboard |
GET /billing/usage |
session |
billing:read / — |
— | dashboard |
Billing is Owner-only and session-only. There is no API-key path and no billing:* scope (Section 7.8). Every other role receives 403 FORBIDDEN.
Stripe is the source of truth for subscription state (Section 19). These endpoints are thin: POST /billing/checkout-sessions body { "plan": "growth", "interval": "monthly" | "annual", "addons": [{ "code": "custom_domain", "quantity": 2 }] } returns a Stripe Checkout URL; POST /billing/portal-sessions returns a Billing Portal URL for every subsequent change. POST /billing/subscription/preview returns the proration amount Stripe would charge, in minor units with a currency, so the confirmation dialog quotes a real number rather than an estimate. PATCH /billing/subscription applies a plan change with Stripe's default proration behaviour.
A downgrade that leaves the workspace above the new caps is allowed and returns 200 with meta.warnings explaining the consequence: nothing is deleted, nothing is unpublished, and the workspace enters over_limit where it can no longer collect new testimonials until it is under the cap or upgrades (Section 19). The API never blocks a downgrade to protect revenue.
GET /billing/subscription returns plan, billingState (trialing, active, past_due, canceled) and limitState (ok, over_limit) — two orthogonal columns on workspaces, because a payment problem and a capacity problem are genuinely different situations and Section 19.8 owns both — plus interval, currentPeriodEnd, cancelAtPeriodEnd, seatsIncluded, addons, amountCents, currency, and dunning ({ attempt, nextAttemptAt, gracePeriodEndsAt }) when past due. A canceled workspace is read-only in the dashboard, and its widgets and showcase page continue serving published testimonials indefinitely — data export stays available forever.
7.15.20 Webhook endpoints #
| Method & path | Auth | Permission / scope | Idem. | Rate |
|---|---|---|---|---|
GET /webhooks |
session | key |
webhook:read / webhooks:manage |
— | api-standard |
POST /webhooks |
session | key |
webhook:create / webhooks:manage |
accepted | api-standard |
GET /webhooks/{endpointId} |
session | key |
webhook:read / webhooks:manage |
— | api-standard |
PATCH /webhooks/{endpointId} |
session | key |
webhook:update / webhooks:manage |
accepted | api-standard |
DELETE /webhooks/{endpointId} |
session | key |
webhook:delete / webhooks:manage |
accepted | api-standard |
POST /webhooks/{endpointId}/rotate-secret |
session | key |
webhook:update / webhooks:manage |
accepted | api-standard |
POST /webhooks/{endpointId}/test |
session | key |
webhook:update / webhooks:manage |
accepted | api-standard |
GET /webhooks/{endpointId}/deliveries |
session | key |
webhook:read / webhooks:manage |
— | api-standard |
GET /webhooks/{endpointId}/deliveries/{deliveryId} |
session | key |
webhook:read / webhooks:manage |
— | api-standard |
POST /webhooks/{endpointId}/deliveries/{deliveryId}/replay |
session | key |
webhook:update / webhooks:manage |
required | api-standard |
Section 18.2 through 18.5 own the event catalogue, payloads, retry schedule, and delivery-log semantics. Wire contract: POST /webhooks body { "url", "events", "description", "isEnabled" } where url must be https:// (plain http:// is rejected with 422 INSECURE_WEBHOOK_URL, with a documented exception for http://localhost and http://127.0.0.1 in development environments only), must not resolve to a private, loopback, link-local, or metadata address in production (422 WEBHOOK_URL_NOT_ALLOWED — this is SSRF defence and the check is re-run at delivery time, not only at registration), and must be ≤ 2 000 characters. events is a non-empty array of event names from the catalogue, or the single value ["*"] to subscribe to all current and future events. The number of endpoints a workspace may register is a plan limit from the Section 19.2 matrix, not a constant owned here; exceeding it is 409 WEBHOOK_ENDPOINT_LIMIT_REACHED. Webhooks are a paid feature; Free returns 409 PLAN_FEATURE_UNAVAILABLE.
7.15.21 Respondent portal and data-subject requests #
| Method & path | Auth | Permission / scope | Idem. | Rate |
|---|---|---|---|---|
POST /public/portal/session |
public (portal token in body) |
— | accepted | auth |
POST /public/portal/verify-code |
portal-session | — | accepted | auth |
GET /public/portal/testimonials |
portal-session | portal:read / — |
— | submission |
POST /public/portal/withdraw |
portal-session + step-up | — | accepted | submission |
POST /public/portal/correct |
portal-session + step-up | — | accepted | submission |
POST /public/portal/exports |
portal-session + step-up | — | accepted | export |
GET /public/portal/exports/{exportId} |
portal-session | portal:read / — |
— | submission |
POST /public/portal/erasure |
portal-session + step-up | — | accepted | export |
GET /data-subject-requests |
session |
dsr:read / — |
— | dashboard |
POST /data-subject-requests |
session | key |
dsr:create / testimonials:write |
required | api-standard |
GET /data-subject-requests/{dsrId} |
session |
dsr:read / — |
— | dashboard |
These endpoints are rooted at https://v.vouch.app/api/v1 for the portal group and https://app.vouch.app/api/v1 for the request group. Section 22 owns consent, withdrawal, erasure, and the 30-day SLA.
POST /public/portal/session exchanges the long-lived link token for a 30-minute bearer bound to the covered testimonial ids, carrying portal:read and nothing else (Section 7.7.3).
POST /public/portal/verify-code is the step-up. The client first asks for a code — the session endpoint sends one on request — and then posts { "code": "123456" }; on success the same session is re-issued with a portal_step_up claim valid for the remainder of its 30 minutes. Section 22.4.2 owns the code's length, TTL, attempt limit and lockout. Errors: 422 VALIDATION_FAILED, 401 VERIFICATION_CODE_INVALID, 429 on auth.
Every state-changing row above requires that claim. Without it the endpoint returns 401 STEP_UP_REQUIRED before doing anything else — before validating the body, before touching a row, before enqueuing a job — so a caller holding only a leaked URL cannot cause a side effect of any kind:
POST /public/portal/withdrawunpublishes every covered testimonial within 60 seconds, triggering an immediate CDN purge, settingconsents.withdrawn_at, notifying the workspace, and firingconsent.withdrawn. Returns202with{ withdrawnCount, effectiveBy }.POST /public/portal/correctsubmits a correction to the respondent's own attributed details for the workspace to review.POST /public/portal/exportsproduces the respondent's data export.POST /public/portal/erasurestarts the full hard-delete path — provider assets destroyed, every object-storage key deleted, CDN purged, database rows hard-deleted — and returns202with a reference code the respondent can quote. It additionally requires the typed confirmation described in Section 22.4.2, because it is irreversible and noIdempotency-Keycan undo it.
No account is required for any of it: the code is sent to the address captured at submission, which is the only identity a respondent ever has here.
POST /data-subject-requests body { "type": "export" | "erasure", "subjectEmail": "…", "note": "…" } lets a workspace initiate the same flows on a respondent's behalf when the request arrives by another channel. Erasure is irreversible; the endpoint requires an idempotency key and, for session callers, re-authentication within the last 5 minutes.
7.15.22 Public edge endpoints #
These four endpoints are the only ones a random visitor's browser calls. They are unauthenticated, Access-Control-Allow-Origin: *, and served from the edge.
| Method & path | Host | Cache | Notes |
|---|---|---|---|
GET /api/v1/widgets/{widgetId}/data |
cdn.vouch.app |
public, max-age=60, stale-while-revalidate=300 |
Pre-rendered widget payload, ≤ 60 KB |
GET /api/v1/widgets/data?ids=wgt_a,wgt_b |
cdn.vouch.app |
same | Batched form; 1–10 ids; the loader uses this when a page holds multiple widgets |
POST /api/v1/e |
cdn.vouch.app |
no-store |
Analytics beacon |
GET /api/v1/widgets/{widgetId}/oembed |
v.vouch.app |
public, max-age=300 |
oEmbed for crawlable/SSR host pages |
Widget data. Returns the standard single-resource envelope with the pre-rendered payload in data; Section 13.8.2 owns that payload's shape and this section does not reproduce it.
One property of it is restated here, and only because getting it wrong on this endpoint would be the highest-blast-radius defect in the product: the widget payload never contains an HTML string. There is no bodyHtml, no html, and no field of any other name holding markup. Rich text travels as a closed token array that the runtime inserts with textContent:
"bodyNodes": [
{ "t": "p", "children": [
{ "t": "text", "text": "The migration took " },
{ "t": "strong", "text": "one afternoon" },
{ "t": "text", "text": "." }
] }
]The widget runtime contains no HTML parser at all (Section 13.10.1) — no innerHTML, no DOMParser, no insertAdjacentHTML, no setHTMLUnsafe — so a payload string could not be rendered even if one were emitted. That is the entire defence: this script executes on every embedding customer's origin at once, where a single parsed attacker-controlled string is stored cross-site scripting on all of them simultaneously, from content a stranger typed into a public form. An implementer who finds an HTML string anywhere in a widget payload has found a bug in the payload builder, not a field to render.
The endpoint is otherwise purged by tag (widget:{id}, workspace:{id}) on publish, unpublish, edit, consent withdrawal, and widget configuration change.
An unknown widget and a deleted widget are different answers, and this is the one public surface where that distinction is deliberate:
| Condition | Status | Code |
|---|---|---|
| Unknown or mistyped widget id | 404 |
WIDGET_NOT_FOUND |
| Widget that existed and was deleted, or belongs to a deleted workspace | 410 |
WIDGET_DELETED |
Both carry Cache-Control: public, max-age=300, so a typo'd id in a snippet on a high-traffic page cannot become an origin load problem. Section 13.8.3 owns the full condition table for this endpoint, including the batched form; these two rows are repeated here only because they are the ones that contradict the general rule in Section 7.6.
The distinction earns its exception because it is actionable and leaks nothing. A widget id is public by construction — it is pasted into the customer's HTML — so telling the caller that it once existed reveals nothing a visitor cannot already see, and the cross-tenant probing that Section 7.6's indistinguishability rule defends against does not apply. What it buys is honesty at the point of failure: 404 tells a customer "check your snippet" and generates a support ticket for a widget they deliberately deleted, while 410 tells them exactly what happened. The loader treats them identically on the page — placeholder collapsed, nothing drawn, no error shown to a visitor (Section 13.2) — so the difference is visible only to the person debugging.
A workspace that is past_due, canceled, over_limit, or at any cap still receives a complete, full-quality payload — this endpoint has no degraded mode, on any plan, in any billing state.
The badge object is written into the payload server-side on Free and re-asserted on every refresh, so removing it client-side does not remove it from the source of truth (Section 19). Section 13.8.2 owns its shape; the key is badge and its four fields are:
"badge": {
"show": true,
"label": "Powered by Vouch",
"href": "https://vouch.app/?ref=widget&w=wgt_01JQY7K2M3N4P5Q6R7S8T9V0W1",
"accessibleName": "Powered by Vouch — opens vouch.app in a new tab"
}show, not required; &w=, not &wid=; and accessibleName is mandatory, because the badge is a link and a link with no accessible name is a defect under Section 21.
Analytics beacon. POST /api/v1/e accepts a batched body sent by navigator.sendBeacon, at most one beacon per 5 seconds per page, ≤ 32 KB, ≤ 50 events:
{
"v": 1,
"viewId": "3f9c1a7e2b8d4c60",
"widgetId": "wgt_01JXQ7Z4K9M2X7YV3C8N6P0R5T",
"events": [
{ "t": "impression", "ts": 1762247401123 },
{ "t": "interaction", "ts": 1762247404881, "a": "play", "tid": "tst_01JBQ7Z4K9M2X7YV3C8N6P0R5T" }
]
}viewId is a random value held in JavaScript memory only; it is never persisted to a cookie, localStorage, or sessionStorage, and it ceases to exist on navigation (Section 17). The endpoint always responds 204 with an empty body — even for a malformed payload, an unknown widget id, or a rejected bot event — because a beacon must never surface an error to a host page. Rejected events are counted server-side under a rejected metric so they are visible to operators rather than silently dropped. The IP is used at the edge to derive country and device class and is then discarded; it is never written to the events table.
oEmbed. Served on the v.vouch.app host but an edge route by the rule in Section 7.2 — it reads the same pre-rendered payload the widget data API serves and opens no database connection. Host and runtime are independent axes, and this is the one place they differ.
GET /api/v1/widgets/{widgetId}/oembed?maxwidth=800&maxheight=600&format=json returns an oEmbed rich response whose html is a self-contained, crawlable block of sanitized markup with no script, suitable for a host page that needs server-rendered HTML. The discovery form GET https://v.vouch.app/api/v1/oembed?url=https://v.vouch.app/w/wgt_… is also supported for consumers that follow <link rel="alternate" type="application/json+oembed">. format=xml returns 501-free behaviour by responding 422 UNSUPPORTED_OEMBED_FORMAT; only JSON is supported and the documentation says so.
Health. GET https://cdn.vouch.app/api/v1/health and GET https://app.vouch.app/api/v1/health return { "data": { "status": "ok", "version": "2025.11.04-1", "region": "…" } } with no authentication and no tenant data, for uptime monitoring. A degraded dependency returns 503 with data.checks naming the failing dependency without exposing hostnames.
7.15.23 Inbound provider webhooks #
These routes receive third-party callbacks. They authenticate by provider signature only (Section 7.7.4), are exempt from CSRF and from Vouch rate-limit classes (they use a per-provider limiter), read the raw body before parsing, and respond 2xx fast — heavy work is enqueued.
| Path | Provider | Verification | Idempotency key |
|---|---|---|---|
POST /api/v1/hooks/stripe |
Stripe | Stripe-Signature, 5-minute tolerance |
stripe_events.event_id |
POST /api/v1/hooks/mux |
Mux | Mux webhook signature, 5-minute tolerance | mux_asset_id + event id |
POST /api/v1/hooks/deepgram |
Deepgram | Shared-secret callback token + job id match | transcription job id |
POST /api/v1/hooks/resend |
Resend | Provider signature | provider message id + event type |
POST /api/v1/hooks/twilio |
Twilio | X-Twilio-Signature over the full URL and params |
message SID + status |
POST /api/v1/hooks/slack/events |
Slack | X-Slack-Signature v0, 5-minute tolerance |
Slack event id |
POST /api/v1/hooks/slack/interactivity |
Slack | X-Slack-Signature v0, 5-minute tolerance |
Slack interaction id |
POST /api/v1/hooks/hubspot |
HubSpot | X-HubSpot-Signature-v3, 5-minute tolerance |
HubSpot event id |
An unverifiable signature returns 401 with the standard envelope and increments a security metric. A verified but duplicate event returns 200 immediately without re-processing. A verified event for an unknown workspace returns 200 and logs — returning an error would make the provider retry forever for a workspace that no longer exists.
7.16 Public API quick start #
The four calls below are the complete "hello world" for the public API. They are copy-pasteable and use only documented behaviour.
Never put a vch_live_ key in browser JavaScript. The public API returns Access-Control-Allow-Origin: * precisely so server-side and native clients can call it; a key shipped to a browser is readable by every visitor. Call the API from your server, or use the public widget data endpoint (Section 7.15.22), which needs no key at all.
1 — Authenticate and confirm the workspace
export VOUCH_API_KEY="vch_live_7Qk2wRb9XmT4pL8sYc1VnZ0dHjE6uA3f"
curl -sS https://app.vouch.app/api/v1/workspaces/current \
-H "Authorization: Bearer $VOUCH_API_KEY"2 — List published testimonials, following every page
CURSOR=""
while : ; do
RESP=$(curl -sS "https://app.vouch.app/api/v1/testimonials?status=published&limit=100${CURSOR:+&cursor=$CURSOR}" \
-H "Authorization: Bearer $VOUCH_API_KEY")
echo "$RESP" | jq -r '.data[] | "\(.authorName): \(.bodyText)"'
HAS_MORE=$(echo "$RESP" | jq -r '.pagination.hasMore')
[ "$HAS_MORE" = "true" ] || break
CURSOR=$(echo "$RESP" | jq -r '.pagination.nextCursor')
done3 — Create a widget
curl -sS -X POST https://app.vouch.app/api/v1/widgets \
-H "Authorization: Bearer $VOUCH_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"name": "Homepage wall",
"type": "wall_of_love",
"layout": "masonry",
"maxItems": 12,
"sortOrder": "featured_first",
"theme": { "mode": "auto", "accentColor": "#2F5BEA", "fontScale": "md", "borderRadius": 12 },
"filters": { "minRating": 4, "mediaTypes": ["text", "video"] }
}'4 — Fetch analytics for the last 30 days
FROM=$(date -u -d '30 days ago' +%Y-%m-%dT00:00:00.000Z)
TO=$(date -u +%Y-%m-%dT00:00:00.000Z)
curl -sS "https://app.vouch.app/api/v1/analytics/summary?from=$FROM&to=$TO" \
-H "Authorization: Bearer $VOUCH_API_KEY"The same four steps in TypeScript, with the retry policy the API expects (retry only 429 and 503, honour Retry-After, full jitter):
// examples/vouch-client.ts — no dependencies beyond the standard library.
const BASE = 'https://app.vouch.app/api/v1';
const KEY = process.env.VOUCH_API_KEY!;
type Envelope<T> = {
data: T;
pagination?: { nextCursor: string | null; hasMore: boolean; limit: number };
meta: { requestId: string };
};
class VouchApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
readonly requestId: string,
readonly details?: Array<{ field: string; issue: string }>,
) {
super(message);
}
}
async function request<T>(path: string, init: RequestInit = {}, attempt = 0): Promise<Envelope<T>> {
const res = await fetch(`${BASE}${path}`, {
...init,
headers: {
Authorization: `Bearer ${KEY}`,
'Content-Type': 'application/json',
...(init.headers ?? {}),
},
});
if (res.status === 204) return { data: undefined as T, meta: { requestId: res.headers.get('x-request-id') ?? '' } };
const body = await res.json();
if (!res.ok) {
const retryable = res.status === 429 || res.status === 503;
if (retryable && attempt < 5) {
const hinted = Number(res.headers.get('retry-after') ?? '0') * 1000;
const backoff = Math.min(30_000, 500 * 2 ** attempt);
const delay = Math.max(hinted, Math.random() * backoff); // full jitter, Retry-After as a floor
await new Promise((r) => setTimeout(r, delay));
return request<T>(path, init, attempt + 1);
}
throw new VouchApiError(res.status, body.error.code, body.error.message, body.meta.requestId, body.error.details);
}
return body as Envelope<T>;
}
/** Cursor pagination is the only pagination model — loop on hasMore, never on row count. */
async function* listAll<T>(path: string): AsyncGenerator<T> {
let cursor: string | null = null;
do {
const qs = new URLSearchParams({ limit: '100', ...(cursor ? { cursor } : {}) });
const page = await request<T[]>(`${path}?${qs}`);
for (const row of page.data) yield row;
cursor = page.pagination?.hasMore ? page.pagination.nextCursor : null;
} while (cursor);
}
// 1 — workspace
const ws = await request<{ id: string; name: string; plan: string }>('/workspaces/current');
console.log(`Connected to ${ws.data.name} (${ws.data.plan})`);
// 2 — published testimonials
for await (const t of listAll<{ authorName: string; bodyText: string }>('/testimonials?status=published')) {
console.log(`${t.authorName}: ${t.bodyText}`);
}
// 3 — create a widget (idempotent)
const widget = await request<{ id: string }>('/widgets', {
method: 'POST',
headers: { 'Idempotency-Key': crypto.randomUUID() },
body: JSON.stringify({
name: 'Homepage wall',
type: 'wall_of_love',
layout: 'masonry',
maxItems: 12,
sortOrder: 'featured_first',
theme: { mode: 'auto', accentColor: '#2F5BEA', fontScale: 'md', borderRadius: 12 },
filters: { minRating: 4, mediaTypes: ['text', 'video'] },
}),
});
// 4 — analytics
const from = new Date(Date.now() - 30 * 86_400_000).toISOString();
const to = new Date().toISOString();
const summary = await request(`/analytics/summary?from=${from}&to=${to}`);
console.log(summary.data);7.17 OpenAPI specification #
- The OpenAPI 3.1 document is generated, never hand-written. Every route registers its Zod request and response schemas in a route registry; a build step converts them to JSON Schema and emits
openapi.json. A route that fails to register a schema fails the build — there is no path to an undocumented endpoint. - It is served at
GET https://app.vouch.app/api/v1/openapi.json(public, cached 5 minutes) and rendered as human documentation athttps://docs.vouch.app/api. - It is written to
docs/openapi.jsonin the repository on every release so the file is reviewable in a pull request diff, which is where API changes are actually noticed. - CI runs a breaking-change check against the previous release's
openapi.json. Removing an endpoint, removing a response field, adding a required request field, narrowing a type, removing an enum value from a response, or tightening a validation bound fails the build. The only way past it is a/api/v2/route tree. Additive changes pass. - The error catalogue in Appendix Section 29.3 is generated from the same source as the
AppErrorsubclasses, so a code cannot exist in the runtime and be missing from the docs. - Example values in the specification come from the same fixtures the contract tests use (Section 25), so a documented example that stopped working fails a test rather than misleading a reader.
7.18 Webhook signature verification #
Every outbound webhook Vouch sends carries:
Vouch-Signature: t=1762247400,v1=6f0c1e4d9a2b7c3e5f8a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e
Vouch-Event-Id: evt_01JBQ8Q2CD2N9EJ6DF0LCD2RUV
Vouch-Event-Type: testimonial.published
Vouch-Delivery-Id: dlv_01JBQ8Q2CD2N9EJ6DF0LCD2RUW
Vouch-Api-Version: v1
Content-Type: application/json
User-Agent: Vouch-Webhooks/1.0The signature is HMAC-SHA256 over the exact string "{t}.{rawBody}" using the endpoint secret (whsec_…), hex-encoded. Three rules make verification safe:
- Verify against the raw body, before any JSON parsing. Re-serializing changes bytes and breaks the signature.
- Reject outside a 5-minute tolerance, comparing
tagainst the receiver's clock. This is the replay defence. - Compare in constant time. A
===comparison on a hex digest leaks the digest one byte at a time under timing analysis.
During a secret rotation the header carries multiple v1= values, one per active secret (Section 18.4). A receiver must accept the request if any of them verifies.
// examples/verify-vouch-webhook.ts
import crypto from 'node:crypto';
const TOLERANCE_SECONDS = 300;
export function verifyVouchSignature(rawBody: string, header: string, secret: string): boolean {
// header: "t=1762247400,v1=6f0c…,v1=9b3d…" (multiple v1 values during rotation)
const parts = header.split(',').map((p) => p.trim());
const timestamp = parts.find((p) => p.startsWith('t='))?.slice(2);
const signatures = parts.filter((p) => p.startsWith('v1=')).map((p) => p.slice(3));
if (!timestamp || signatures.length === 0) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false; // replay defence
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`, 'utf8')
.digest();
return signatures.some((sig) => {
let provided: Buffer;
try {
provided = Buffer.from(sig, 'hex');
} catch {
return false;
}
if (provided.length !== expected.length) return false; // timingSafeEqual throws on length mismatch
return crypto.timingSafeEqual(provided, expected); // constant time
});
}
// Express-style receiver. Note express.raw — the parsed body is useless here.
app.post('/vouch-webhook', express.raw({ type: 'application/json', limit: '1mb' }), (req, res) => {
const raw = req.body.toString('utf8');
const header = req.header('Vouch-Signature') ?? '';
if (!verifyVouchSignature(raw, header, process.env.VOUCH_WEBHOOK_SECRET!)) {
return res.status(401).send('invalid signature');
}
const event = JSON.parse(raw);
// Delivery is at-least-once. Deduplicate on event.id before acting.
if (alreadyProcessed(event.id)) return res.status(200).send('duplicate');
enqueue(event); // acknowledge fast, work asynchronously
res.status(200).send('ok');
});A receiver should acknowledge within 5 seconds; Vouch treats a slower response as a failure and retries per the schedule in Section 18.5. Any 2xx is success; 3xx is treated as failure and redirects are not followed, because a redirect to an attacker-controlled host would leak the payload.
8. Collection — Request Pages, Custom Fields & Invitations #
8.1 Scope, Objectives and Non-Negotiables #
Collection is the funnel that turns a happy customer into a stored, consented, moderatable testimonial. Everything in this section is respondent-facing except where explicitly marked "dashboard". The respondent is not a user of the product: they have no account, no password, no cookie, and in the overwhelming majority of cases they are holding a phone, standing in a kitchen, with one hand free and a 4G connection. They will not retry if the page fails.
This section owns:
- The request page — its configuration model, URL scheme, branding, and lifecycle.
- The respondent flow — seven screens, every state of each screen.
- Custom fields — the seven types, their validation, storage and rendering.
- Case-study fields — a named preset group with defined downstream behaviour.
- Star ratings — the input, the storage, the "no rating" representation.
- Invitations — single, bulk, CSV mapping, personalization, scheduling, attribution.
- Sharing surfaces — copy link, QR code, email-signature snippet, short link.
- Spam, abuse and quality controls on both sides of the transaction.
- The dashboard management surface and the conversion funnel report.
This section does not own: the recorder and media pipeline (Section 9), the transcript (Section 10), moderation and the testimonial state machine (Section 11), widgets (Section 13), custom domains (Section 14), consent storage and privacy law (Section 22), notification templates and reminder cadence configuration (Section 20), plan limits (Section 19), the database schema (Section 5), or the HTTP contract (Section 7). Where those boundaries touch, this section references the owning section by number and never restates its rules.
Five non-negotiables that govern every decision below.
| # | Rule | Consequence in this section |
|---|---|---|
| N1 | Video and text are equally first-class | Both paths get equal visual weight, equal keyboard order priority, identical field sets after capture, and identical downstream handling. Neither is a fallback for the other. |
| N2 | No cookies, no localStorage, no sessionStorage, no fingerprinting on the request page |
Draft resumption uses a server-side row keyed by an opaque token carried in the URL (Section 8.9). Analytics on the request page follow the cookie-free model in Section 17. |
| N3 | Mobile-first at 360 × 640 CSS px | Every screen is designed at 360 px and scaled up. Nothing requires a viewport wider than 320 px. Total request-page JavaScript is budgeted at ≤ 140 KB gzip (Section 23). |
| N4 | WCAG 2.2 Level AA in full | Every control named in this section is bindable by Section 21's test matrix. No colour-only meaning, ever. |
| N5 | Hitting a plan cap never breaks anything already live | A capped workspace's request page returns a friendly closed state, HTTP 200 with a collectionClosed payload — never an error page. Published testimonials, widgets and showcase pages keep serving. Section 19 owns the enforcement rules. |
Definition of done for Section 8. A respondent on an iPhone SE on a congested 4G cell, who has never heard of this product, can open a link, understand within four seconds who is asking and why, choose video or text, complete the flow, and see a confirmation — with no account, no app install, and no more than one page of scrolling per screen. A workspace Owner can create a request page, invite 2,000 contacts from a CSV with per-contact attribution, and see exactly where respondents drop off.
8.2 The Request Page — Identity, URLs and Slugs #
8.2.1 URL scheme #
A request page is the unit of collection. One page per campaign, per audience, per event, or per product line — whatever the workspace decides. Pages are cheap; plan limits on the number of pages are in Section 19.
| Surface | URL | Notes |
|---|---|---|
| Shared platform host | https://v.vouch.app/r/{workspaceSlug}/{pageSlug} |
Always available on every plan, always works, never disabled. |
| Workspace subdomain | https://{workspaceSlug}.vouch.app/r/{pageSlug} |
Convenience alias served by the same edge application. 308 redirect to the canonical URL unless the workspace has set this as canonical. |
| Custom domain | https://{customerDomain}/r/{pageSlug} |
Active only when the workspace has a verified custom domain (Section 14). The {workspaceSlug} segment is dropped because the domain already identifies the workspace. |
| Short link | https://v.vouch.app/s/{shortCode} |
302 to the canonical URL, Cache-Control: private, max-age=0. Section 8.12.4. |
| Invitation link | https://v.vouch.app/i/{inviteToken} |
Resolves the contact, then 302s to the canonical page URL carrying an attribution token. Section 8.11.7. |
| Draft resume link | canonical URL + ?d={draftToken} |
Section 8.9. |
| Preview (dashboard only) | https://app.vouch.app/request-pages/{id}/preview |
Server-rendered inside the dashboard shell, authenticated, never indexed, never counts toward funnel metrics. |
Canonicalization. Exactly one URL per page is canonical at any moment, chosen by the
workspace setting canonicalHost (platform | subdomain | custom). The canonical URL is
emitted as <link rel="canonical"> on every rendered variant and is the URL returned by the
API, the copy-link button, the QR code and every email. Non-canonical variants respond 200
with the canonical link tag (not a redirect) when the request carries a draft token or an
invitation attribution token, because redirecting would risk dropping the query string on
strict email clients; in every other case they respond 308 Permanent Redirect.
Trailing slash. Never. /r/acme/spring-2026/ 308-redirects to /r/acme/spring-2026.
Case. Slugs are lowercase. A request with any uppercase character in either slug segment 308-redirects to the lowercased form before any lookup happens.
Indexing. Request pages emit <meta name="robots" content="noindex, nofollow"> by
default, because a collection form is not content and indexing it wastes crawl budget and
leaks campaign names. The workspace can opt a page in to indexing with isIndexable, which
switches the tag to index, follow and adds the page to the workspace's sitemap. Archived
and closed pages are always noindex regardless of the setting.
8.2.2 Slug rules and uniqueness #
request_pages.slug follows the platform slug convention stated in Section 5: lowercase,
pattern ^[a-z0-9](?:[a-z0-9-]{1,46}[a-z0-9])$, 3–48 characters, no leading or trailing
hyphen, no consecutive hyphens.
| Rule | Specification |
|---|---|
| Character set | a–z, 0–9, - only. Unicode input is transliterated (see below), never stored raw. |
| Length | 3–48 characters inclusive. |
| Uniqueness scope | Unique per workspace among non-deleted rows: partial unique index on (workspace_id, slug) WHERE deleted_at IS NULL. Two workspaces may both own spring-2026. |
| Reuse after delete | A soft-deleted page's slug is released immediately for reuse. The deleted row keeps its slug value; the partial index permits the collision. Requests for a soft-deleted page's URL resolve to the live page if one now owns the slug, otherwise 410 Gone. |
| Auto-generation | Derived from the page name: NFKD-normalize, strip combining marks, transliterate common Latin-1/Latin Extended characters (ä→a, ø→o, ß→ss, ł→l, æ→ae), lowercase, replace every run of non-[a-z0-9] with a single hyphen, trim hyphens, truncate to 48. If the result is shorter than 3 characters or empty (for example a name written entirely in a non-Latin script), fall back to page-{6 lowercase base32 chars derived from the row id}. |
| Collision on auto-generation | Append -2, then -3, … up to -99, truncating the base to keep the total ≤ 48. Beyond -99, append a 4-character random base32 suffix. |
| Manual edit | Allowed at any time by Owner, Admin or Editor. Changing a slug breaks existing links; the UI states this in a confirmation dialog with the exact count of invitations already sent to the old URL, and offers to keep a redirect. |
| Redirect on rename | When "keep a redirect" is accepted, a row is written to request_page_slug_aliases (workspace_id, page_id, old_slug, created_at, expires_at = +180 days). Alias hits 308 to the current canonical URL and increment alias_hit_count. Aliases are unique per workspace and block new pages from claiming that slug while they live. |
Reserved slugs. The following are rejected for both request_pages.slug and
workspaces.slug, case-insensitively, with error code SLUG_RESERVED:
about, account, accounts, admin, administrator, api, app, apps, assets, auth, billing,
blog, cdn, careers, changelog, checkout, cms, community, config, contact, cookies, css,
dashboard, dev, developer, developers, discover, doc, docs, download, downloads, e, edge,
embed, enterprise, error, events, explore, faq, favicon, features, feed, files, fonts,
forum, ftp, graphql, help, home, hooks, i, images, img, imprint, integrations, internal,
invite, invites, js, legal, login, logout, mail, marketing, media, mobile, new, news, null,
oauth, onboarding, p, partners, password, pay, payment, payments, press, pricing, privacy,
public, r, register, reset, resources, root, rss, s, search, security, settings, setup,
server, service, services, showcase, signin, signup, sitemap, slack, sso, staging, static,
status, store, styleguide,
subscribe, support, system, terms, test, testimonial, testimonials, tos, undefined, unsub,
unsubscribe, upload, uploads, user, users, v, vouch, w, webhook, webhooks, widget, widgets,
www, xmlThe list also rejects: any slug beginning vouch-, any slug matching ^(wgt|req|tst|ws)_,
any pure-numeric slug (reserved for future numeric routing), and any slug that would collide
with a top-level route added later — the route table in apps/web is the source of truth and
a build-time check asserts every static top-level route segment appears in this list. The
list lives in packages/validation/src/reserved-slugs.ts and is exported so the dashboard
validates client-side before submit (advisory only; the server re-validates and is the
enforcement point).
Homoglyph and confusable protection. After transliteration, the slug is checked against
existing slugs in the same workspace using a confusable-skeleton comparison (Unicode
confusables mapping restricted to the ASCII output set, plus 0↔o, 1↔l↔i, 5↔s, rn↔m).
A near-collision produces a 422 with SLUG_TOO_SIMILAR and the conflicting slug in
details. This is a per-workspace check only — cross-workspace similarity is irrelevant
because the workspace slug already disambiguates.
8.2.3 Custom-domain behaviour #
When a workspace has an active custom domain (Section 14 owns the verification lifecycle),
request pages become reachable at https://{domain}/r/{pageSlug} immediately, with no
per-page configuration. The workspace chooses which host is canonical. Three consequences
specified here:
- No re-invitation is required. Already-sent invitation links point at
https://v.vouch.app/i/{token}, which resolves server-side and redirects to whatever the canonical host is at click time. Adding, changing or removing a custom domain therefore never invalidates an invitation already in someone's inbox. - Branding follows the host. On a custom domain the "Powered by" badge state still obeys the plan rule in Section 19 — a custom domain does not remove the badge on a plan where the badge is locked.
- Removing a domain is non-destructive. If a custom domain is removed or its certificate
lapses,
canonicalHostsilently falls back toplatformand every URL keeps working. The Owner is notified (Section 20). No 404 is ever served because of a domain problem.
8.2.4 Page states #
request_pages.status is a Postgres enum request_page_status with exactly these values.
The column is separate from deleted_at; a page can be soft-deleted from any status.
| Status | Respondent sees | Appears in dashboard list | Accepts submissions | Appears in funnel report |
|---|---|---|---|---|
draft |
404 on the public URL (page has never been published) |
Yes, "Draft" pill | No | No |
active |
The live page | Yes | Yes | Yes |
paused |
Closed state, 200 + collectionClosed, reason paused |
Yes, "Paused" pill | No | Yes (historical) |
scheduled |
Closed state before opensAt; behaves as active between opensAt and closesAt; closed state after closesAt |
Yes, with the window shown | Only inside the window | Yes |
archived |
Closed state, reason archived, with the workspace's archive copy |
Only under "Archived" filter | No | Yes (read-only) |
Transitions: draft → active (publish), active ↔ paused, active → archived,
paused → archived, archived → active (restore), and draft|active|paused|archived →
soft delete. scheduled is not a manual state — it is derived whenever opensAt or
closesAt is set on an active page, and the dashboard displays it as such. Every
transition writes an audit event per Section 11.6.
The closed state. Whenever a page cannot accept a submission — paused, archived, outside
its schedule, or the workspace has reached its collected-testimonial cap, or is over_limit
or canceled — the page still renders. (past_due is deliberately not in that list: a
workspace inside the dunning window keeps collecting normally. See the note under the reason
table below.) It renders the workspace's branding, the
headline, and a single clear message. The HTTP status is 200, and the page-data endpoint
returns:
{
"data": {
"id": "rqp_01J8Z2M6K7Q0X4V9N3TAB5CDEF",
"slug": "spring-2026",
"status": "paused",
"collectionClosed": {
"closed": true,
"reason": "paused",
"headline": "We're not collecting right now",
"body": "Thanks for stopping by — this page has paused collecting testimonials. Please check back soon.",
"reopensAt": null
},
"branding": { "...": "..." }
},
"meta": { "requestId": "req_01J8Z2M6K7Q0X4V9N3TAB5CDEF" }
}This subsection owns the collectionClosed.reason vocabulary. It has exactly seven values
and every other section that surfaces a closed page uses these names verbatim:
reason |
Entered when | reopensAt |
|---|---|---|
paused |
status = 'paused' — a human paused collection |
null |
archived |
status = 'archived' |
null |
not_yet_open |
status = 'scheduled' and now < opensAt |
The scheduled open instant |
window_closed |
status = 'scheduled' and now > closesAt |
null |
quota_reached |
The workspace has reached its lifetime collected-testimonial cap (Section 19) | null |
workspace_over_limit |
workspaces.limit_state = 'over_limit' (Section 19) |
null |
billing_inactive |
workspaces.billing_state is canceled (Section 19). Not past_due — see the note below |
null |
open is not a reason — when the page is accepting submissions the collectionClosed object
is absent entirely, not present with closed: false.
The respondent-facing copy never mentions billing, quotas or plan names: quota_reached,
workspace_over_limit and billing_inactive all render the same neutral "not accepting
submissions right now" message, and the reason string exists only so the workspace's own
dashboard and support tooling can tell the three apart. reopensAt is populated only for
not_yet_open, and is rendered in the respondent's local timezone by the client.
billing_inactive is entered at canceled and never at past_due. A past_due
workspace is inside the fourteen-day dunning window described in Section 19.7, during which it
remains fully functional — a card expires far more often than a customer leaves, and closing
someone's collection page on the first failed charge, before a single retry has run, would
punish an administrative accident with lost testimonials the customer can never recover. It
would also break the rule in Section 19 that a payment problem never reaches a public surface.
Collection closes only once the subscription has actually ended.
A page read is always 200 when a reason applies — a respondent who followed a link
deserves a page, not an error. The only public-read failures are 404 REQUEST_PAGE_NOT_FOUND
and 410 REQUEST_PAGE_GONE (Section 8.10.1). An attempt to submit into a page in any of
these seven states is the separate refusal 409 COLLECTION_CLOSED (Section 8.10.5).
8.3 Request Page Configuration Model #
A request page's configuration is a first-class row, not a blob. Columns that drive routing, quota and lifecycle are real columns; presentation settings live in typed JSONB validated by a shared Zod schema so the dashboard, the public renderer and the API all agree. Section 5 owns the physical schema; what follows is the semantic contract and every default.
8.3.1 Identity and lifecycle fields #
| Field | Type | Default | Validation |
|---|---|---|---|
id |
UUIDv7 | generated | Section 5. |
workspaceId |
UUID | required | Section 6 tenancy. |
name |
string | required | 1–120 chars after trim. Internal label; never shown to respondents. |
slug |
string | derived from name |
Section 8.2.2. |
status |
enum | draft |
Section 8.2.4. |
opensAt |
timestamptz | null | null |
Must be in the future when set on a draft page; may be in the past on an active page. |
closesAt |
timestamptz | null | null |
Must be > opensAt when both set, and > now() when set. |
isIndexable |
boolean | false |
Section 8.2.1. |
locale |
string | workspace default (en) |
BCP-47, restricted to the five supported UI locales: en, es, fr, de, pt. Controls the respondent UI chrome only, never the workspace's own copy. |
submissionCount |
integer | 0 |
Maintained by trigger; denormalized for list views. |
createdBy / updatedBy |
UUID | actor | Section 11.6 audit. |
8.3.2 Branding #
All branding lives under branding JSONB. Every colour is a 6-digit hex string
(^#[0-9a-f]{6}$, lowercased on write; 3-digit and 8-digit input is expanded/rejected at the
validation layer — alpha is not accepted because the contrast checker cannot reason about it).
| Key | Type | Default | Rules |
|---|---|---|---|
logoMediaId |
UUID | null | workspace logo | Rendered at max 160 × 48 CSS px, object-fit: contain. Uploaded through the image pipeline in Section 9 (EXIF stripped, re-encoded, derivatives generated). |
logoAltText |
string | workspace name | 1–120 chars. If the logo is purely decorative the workspace may set "", which renders alt="" — the UI explains the difference. |
coverMediaId |
UUID | null | null |
Rendered as a 16:9 banner on desktop and 3:1 on mobile, object-position: center. Never carries text-critical content (a warning is shown; the alt text is required if coverAltText is non-empty). |
coverAltText |
string | "" |
0–200 chars. Empty means decorative. |
accentColor |
hex | #1f4fd8 |
Used for primary buttons, focus rings, the active step indicator and the rating stars. Runs through the contrast checker (Section 8.3.5). |
backgroundColor |
hex | #ffffff |
Page background. |
surfaceColor |
hex | #f6f7f9 |
Card/panel background layered on backgroundColor. |
textColor |
hex | #101418 |
Body text. |
mutedTextColor |
hex | #5a6472 |
Helper text, timestamps, character counters. |
borderColor |
hex | #d8dde5 |
Input borders, dividers. |
errorColor |
hex | #b3261e |
Error text and borders. Never the sole error signal — every error also carries an icon and text (Section 21). |
theme |
light | dark | auto |
auto |
auto follows prefers-color-scheme. In dark, the workspace supplies a parallel colour set under darkOverrides with the same keys; unspecified keys are derived by the platform's documented dark-mode transform (lightness inversion in OKLCH with chroma preserved, then contrast-corrected). |
fontScale |
sm | md | lg |
md |
Multiplies the base type ramp by 0.9375 / 1 / 1.125. The base ramp is 16 px body on all viewports; sm never goes below 15 px. |
fontFamily |
system | serif | mono |
system |
Three system stacks only. No web fonts are ever downloaded on a request page — this is a hard performance rule (Section 23), and it is stated in the branding UI. |
cornerRadius |
none | sm | md | lg |
md |
0 / 4 / 8 / 16 px. |
buttonStyle |
solid | outline |
solid |
Outline buttons still meet 3:1 non-text contrast against the background. |
showPoweredBy |
boolean | plan-dependent | On Free the value is forced true and the control is disabled with an explanatory tooltip; Section 19 owns the gating and the server-side enforcement. The badge is rendered server-side into the page HTML. |
faviconMediaId |
UUID | null | platform favicon | 32 × 32 and 180 × 180 derivatives generated. |
8.3.3 Copy #
All copy fields live under copy JSONB. Every string is plain text except introBody and
thankYouBody, which accept a restricted rich-text subset. Rich text is sanitized
server-side on write with the allowlist p, br, strong, em, a[href] and rel="nofollow noopener" forced on links (the same allowlist used for testimonial bodies, stated in Section
13), and re-sanitized at serve time.
| Key | Type | Default | Limits |
|---|---|---|---|
headline |
plain | "Share your experience with {{workspaceName}}" |
1–90 chars. |
subheadline |
plain | "It takes about two minutes." |
0–160 chars. |
introBody |
rich | "" |
0–1,200 chars of source text; max 12 block elements. |
videoCtaLabel |
plain | "Record a video" |
1–40 chars. |
textCtaLabel |
plain | "Write it instead" |
1–40 chars. |
promptsIntro |
plain | "A few things you could talk about — pick whichever you like." |
0–200 chars. |
detailsIntro |
plain | "Tell us who you are so we can credit you properly." |
0–200 chars. |
consentIntro |
plain | "One last thing — your permission." |
0–200 chars. |
submitLabel |
plain | "Send it" |
1–40 chars. |
thankYouHeadline |
plain | "Thank you — we got it." |
1–90 chars. |
thankYouBody |
rich | default paragraph explaining review, publication and the management link | 0–1,200 chars. |
closedHeadline |
plain | "We're not collecting right now" |
1–90 chars. |
closedBody |
plain | neutral message per Section 8.2.4 | 0–400 chars. |
archivedBody |
plain | "This page is no longer collecting testimonials." |
0–400 chars. |
redirectUrl |
URL | null | null |
Section 8.3.4. |
redirectDelaySeconds |
integer | 5 |
0–30. 0 means redirect immediately after the thank-you screen paints; the screen is still rendered for one frame so the confirmation is announced to assistive technology before navigation. |
sharePromptEnabled |
boolean | true |
Section 8.8.7. |
sharePromptText |
plain | "Know someone else who'd have something to say?" |
0–160 chars. |
Merge tokens in copy. headline, subheadline, introBody, thankYouHeadline and
thankYouBody support the token set in Section 8.11.4 ({{firstName}}, {{company}}, …).
Tokens resolve against the invitation contact when the respondent arrived via an invitation
link, and against the documented fallbacks otherwise. Token substitution happens
server-side, after sanitization, with HTML-escaping of the substituted value — a contact
whose first name is <script> renders as literal text, never as markup.
8.3.4 Redirect-after-submit #
redirectUrl sends the respondent somewhere else after the thank-you screen. Rules:
- Scheme must be
https.http,javascript:,data:,mailto:and protocol-relative URLs are rejected withINVALID_REDIRECT_URL. - Maximum length 2,000 characters. Must parse as an absolute URL.
- The host must not be
v.vouch.app,app.vouch.apporcdn.vouch.app(prevents building an open redirector on the platform's own hosts). Custom domains belonging to the same workspace are permitted. - The redirect is a client-side
location.replace()afterredirectDelaySeconds, never a server 30x, so the thank-you confirmation is guaranteed to have been rendered and announced. - The respondent's management link (Section 8.8.7) is shown before the redirect fires and is also emailed, so a redirect never costs the respondent their link.
- Query parameters the workspace adds are preserved verbatim. The platform appends nothing — no respondent identifiers are ever leaked into a third-party URL. If the workspace wants attribution it can put its own static parameters in the URL.
- A
rel="noopener noreferrer"equivalent is achieved by usinglocation.replacerather thanwindow.open; noRefereris suppressed, because the referring URL is the request page, which the workspace already owns.
8.3.5 Contrast checking #
Every colour pair a workspace can produce is validated in the editor against the thresholds Section 21 owns (4.5:1 body text, 3:1 large text and non-text UI components). The checker:
- Computes the WCAG 2.x relative-luminance contrast ratio for each of these pairs:
textColor/backgroundColor,textColor/surfaceColor,mutedTextColor/surfaceColor, accent-button foreground/accentColor,accentColor/backgroundColor(focus ring and star fill, 3:1),borderColor/surfaceColor(3:1),errorColor/backgroundColor. - Chooses the accent button's foreground automatically as whichever of
#ffffffor#101418scores higher againstaccentColor, and reports the resulting ratio. - On failure, shows a non-colour-only warning (icon + text + the numeric ratio) naming the exact pair, and offers "use nearest accessible colour", which walks OKLCH lightness in 0.01 steps away from the background, preserving hue and clamping chroma to keep the colour in sRGB gamut, until the threshold is met, then presents the hex for confirmation.
- Runs identically in
lightanddarkwhenthemeisauto— both must pass. - Is advisory in the UI and enforced on save: a page cannot be moved to
activewhile a body-text pair is below 4.5:1. The save returns422withCONTRAST_BELOW_MINIMUMand adetailsentry per failing pair. Non-text failures (3:1) block publish as well; the product does not ship an inaccessible page and then apologize.
// packages/core/src/branding/contrast.ts
export interface ContrastFinding {
pair: string; // "textColor/backgroundColor"
ratio: number; // 3.87
required: 3 | 4.5;
passes: boolean;
suggestion?: string; // "#0e1116"
}
export function auditPageContrast(
branding: PageBranding,
scheme: 'light' | 'dark',
): ContrastFinding[];8.4 Prompts #
Prompts are the ordered questions a workspace wants the respondent to address. They are suggestions, not a form — the respondent is never forced to answer them one by one, and their text is not stored per-prompt for the video path. They exist to defeat the blank-page problem, which is the single largest cause of abandonment in testimonial collection.
request_page_prompts (Section 5 owns the schema):
| Field | Type | Default | Rules |
|---|---|---|---|
id |
UUIDv7 | generated | |
requestPageId |
UUID | required | |
position |
integer | append | Dense, 0-based, re-packed on every reorder inside one transaction. |
text |
string | required | 1–200 chars. Plain text only. |
helpText |
string | null | null |
0–200 chars, rendered smaller beneath. |
isRequired |
boolean | false |
Only meaningful on the text path, where it makes a per-prompt answer box mandatory. On the video path isRequired is ignored and the UI says so in the editor. |
appliesTo |
both | video | text |
both |
Lets a workspace show different prompting for the two paths. |
Limits: 1–10 prompts per page. Zero prompts is permitted and renders the capture screen with
the workspace's subheadline only. Attempting to add an eleventh returns 422
PROMPT_LIMIT_REACHED.
Default prompt set seeded on every new request page (the workspace can edit or delete any of them):
- "What problem were you trying to solve before you found us?"
- "What made you choose us?"
- "What's changed since?"
- "Who would you recommend us to?"
Rendering.
- Mobile (< 640 px): one prompt at a time in a horizontally swipeable, keyboard-navigable
list with a dot indicator (
role="tablist"semantics are not used; it is arole="group"witharia-roledescription="prompt carousel", previous/next buttons, and anaria-live="polite"region announcing "Prompt 2 of 4"). Swipe always has a button equivalent, per Section 21's dragging-movements rule. On the video path the prompts remain visible above the recorder and can be collapsed to a single line. - Desktop (≥ 640 px): all prompts as a plain ordered list, no carousel, no JavaScript required to read them.
- Text path: if any prompt has
isRequired = true, the text path renders one labelled textarea per prompt (appliesTofiltered), each with its own character counter, and the concatenated result becomes the testimonial body with prompts rendered as bold lead-ins. Otherwise the text path renders a single body textarea with the prompts shown alongside as inspiration. - "These are just suggestions" affordance: a persistent, non-dismissible line of muted text directly under the prompt block reading "These are just suggestions — say whatever feels true." It is part of the rendered page, not a tooltip, so it is reachable by screen readers in document order.
Teleprompter linkage. On the video path, the concatenated prompt text is what the optional teleprompter scrolls (Section 9 owns the teleprompter control). The teleprompter is off by default.
8.5 Custom Fields #
Custom fields let a workspace collect structured data alongside the testimonial. Seven types, no more — the type list is deliberately closed so that widgets, the showcase page, the CSV export and the public API can render every value without per-workspace code.
A workspace extends its fields, never its types. Field definitions therefore live in a
table (request_page_custom_fields, Section 8.5.1); the seven types are the native
Postgres enum request_field_type declared in Section 5.2 — not a lookup table, because every
type carries fixed validation and rendering rules (Section 8.5.2) that no runtime-added row
could supply.
Section 8.5.2 is the owning definition of the type vocabulary. Wherever any other section names a custom-field type — the schema, the dashboard API, the public API, the export, the glossary — it uses one of those seven names exactly.
8.5.1 The field definition #
request_page_custom_fields:
| Field | Type | Default | Rules |
|---|---|---|---|
id |
UUIDv7 | generated | |
workspaceId, requestPageId |
UUID | required | Tenancy per Section 6. |
key |
string | derived from label |
^[a-z][a-z0-9_]{0,39}$. Unique per page. This is the JSON key in the stored answer object and in the public API, so it is immutable after the first submission is received — the API returns 409 CUSTOM_FIELD_KEY_LOCKED on an attempt to change it. Before the first submission it is freely editable. |
type |
enum | required | One of the seven below. Immutable after creation — changing a type would invalidate stored answers. The UI offers "delete and recreate" instead. |
label |
string | required | 1–80 chars, plain text. Rendered as the <label>; never placeholder-only. |
helpText |
string | null | null |
0–200 chars, rendered beneath the control and wired with aria-describedby. |
placeholder |
string | null | null |
0–60 chars. Never a substitute for the label. |
isRequired |
boolean | false |
Enforced server-side. |
position |
integer | append | Dense, 0-based. |
isPublic |
boolean | false |
If true, the value may be rendered publicly (widgets, showcase, public API). If false, it is internal-only and is stripped from every public payload — including the widget JSON API and the oEmbed response. Defaults to false so nothing leaks by accident. |
config |
JSONB | {} |
Type-specific, validated by a discriminated Zod union. |
deletedAt |
timestamptz | null | null |
Soft delete. Deleting a field hides it from new submissions and from the dashboard form editor but never removes stored answers; historical answers remain readable and exportable, labelled with the field's last-known label. |
Limits: 20 custom fields per request page (CUSTOM_FIELD_LIMIT_REACHED at 21). The
case-study preset group (Section 8.6) counts as one against this limit regardless of how
many sub-fields it contains.
8.5.2 The seven types #
The seven values of request_field_type, in enum order, are:
short_text · long_text · select · star_rating · url · file · boolean
No other spelling of any of them is valid anywhere in this specification. In particular there
is no text, rating, email, number, date, multiselect or checkbox type: an email
address is collected by the standard Details-screen field (Section 8.8.4) rather than by a
custom field, a single tick box is boolean, a multiple-choice answer is select with
allowMultiple: true, and a numeric answer is short_text with a pattern. Adding an eighth
type is a schema migration plus a row in the table below plus a rendering path in every public
surface — deliberately expensive, so that the export, the widgets and the public API can
render any workspace's fields with no per-workspace code.
| Type | Control | config keys |
Validation | Mobile rendering |
|---|---|---|---|---|
short_text |
<input type="text"> |
minLength (0–500, default 0), maxLength (1–500, default 120), pattern (optional ECMAScript regex string, ≤ 200 chars, compiled with a 50 ms timeout guard and rejected at save if it exhibits catastrophic backtracking on a fuzz probe), patternMessage (0–160 chars) |
Trimmed. Length checked after trim. Pattern checked against the trimmed value. Control characters other than nothing are stripped. | Full-width, 44 px min height, autocomplete mapped from autocompleteHint (name, organization, organization-title, email, tel, url, off), enterkeyhint="next". |
long_text |
<textarea> |
minLength (0–5000, default 0), maxLength (1–5000, default 1000), rows (2–12, default 4) |
Trimmed. Newlines preserved, \r\n normalized to \n. Max 40 consecutive newlines collapsed to 2. |
Auto-growing to a max of 12 rows then scrolls. Live character counter appearing at 80% of maxLength, announced politely, never blocking input beyond the max. |
select |
Radio group (≤ 5 options) or <select> (> 5 options); multi-select renders as a checkbox group |
options (2–50 items, each { value, label }, value ^[a-z0-9_-]{1,40}$ unique, label 1–80 chars), allowMultiple (default false), maxSelections (1–50, default 1 or options.length), allowOther (default false), otherLabel (default "Other") |
Submitted value(s) must be in options or, when allowOther, may include one other entry carrying a free-text string ≤ 120 chars. Duplicate selections rejected. |
Radio/checkbox groups are stacked, 44 px targets, wrapped in a <fieldset> with a <legend> = label. Native <select> on mobile so the platform picker is used. |
star_rating |
Keyboard-operable radio group of 5 (Section 8.7) | allowHalf is not offered (integers only), requiredMessage (0–160 chars) |
Integer 1–5. Absent when not required and not answered. | 44 × 44 px targets, horizontal, labelled "1 star" … "5 stars". |
url |
<input type="url" inputmode="url"> |
allowedSchemes (["https"] default, ["https","http"] permitted), requireHostSuffix (optional string, e.g. linkedin.com, matched against the registrable domain) |
Parsed with the WHATWG URL parser. Scheme must be allowed. Host must have a dot and a valid TLD-shaped label. Max 2,000 chars. Userinfo (user:pass@) rejected. Rendered publicly only with rel="nofollow noopener" and target="_blank". |
enterkeyhint="next", no autocapitalize, no autocorrect. A visible "https://" prefix hint rather than a forced prefix. |
file |
<input type="file"> with drag-and-drop on pointer devices |
accept (subset of the allowlist below, default ["image/png","image/jpeg","image/webp","application/pdf"]), maxSizeBytes (1–26214400, default 10485760 = 10 MB), maxFiles (1–3, default 1) |
Server-side MIME sniffing on the first 512 bytes; the declared Content-Type is never trusted. Allowlist is exactly: image/png, image/jpeg, image/webp, image/avif, image/gif, application/pdf, text/plain, text/csv. Anything else → 415 UNSUPPORTED_FILE_TYPE. Images go through the image pipeline (Section 9): EXIF stripped, orientation normalized, GPS removed, re-encoded. PDFs are stored as-is, served with Content-Disposition: attachment and X-Content-Type-Options: nosniff, and are never rendered inline in the dashboard. Files are stored under a per-workspace prefix and served only through signed URLs with a 15-minute TTL. Files count toward the workspace's storage quota (Section 19). |
A single large 44 px "Choose file" button, camera capture offered on mobile for image-only fields via capture="environment", per-file progress, per-file remove, and a clear "PDF, PNG or JPEG, up to 10 MB" hint. |
boolean |
Single checkbox with a visible label | trueLabel (default "Yes"), falseLabel (default "No"), renderAs (checkbox default, or switch) |
Stored as true/false. When isRequired, only true satisfies the requirement, and the UI says so ("You'll need to tick this to continue"). |
44 × 44 px hit area including the label; the label is the click target. |
Cross-type rules.
- Every field renders a real
<label for>; placeholders are never the accessible name. - Required fields are marked with the word "Required" in text next to the label — never an
asterisk alone (Section 21, no colour- or glyph-only meaning), and
aria-required="true". - Errors are rendered inline beneath the control, referenced by
aria-describedby, witharia-invalid="true", and the first invalid control receives focus on failed submit. An error summary at the top of the step lists every failure with in-page anchors. - Client-side validation is advisory. The server re-validates every field against the stored
definition on submit and is the only enforcement point. A
422returns onedetailsentry per failing field withfield=customFields.{key}. - Autofill is encouraged, never required:
autocompletehints are set on standard fields (Section 8.8.4) and onshort_text/urlfields viaautocompleteHint. Section 21's redundant-entry criterion is satisfied because nothing already supplied is asked for twice.
8.5.3 Stored answer shape #
Answers are stored once, on the submission — in submissions.custom_field_values, a JSONB
column whose DDL Section 5 owns — keyed by field key. They are not stored on the
testimonial: there is no custom_field_values column on testimonials, and no copy is made at
moderation time, so there is exactly one row to read and exactly one row to correct.
A testimonial reaches its answers by joining through testimonials.submission_id:
select t.id, s.custom_field_values
from testimonials t
left join submissions s on s.id = t.submission_id
where t.workspace_id = current_setting('app.workspace_id')::uuid;The join is a left join on purpose. A testimonial that never came through a request page —
one typed in by a moderator, or one created by an import (Section 16) — has
submission_id IS NULL and therefore no custom-field answers at all. Every reader treats that
as an empty answer set, never as an error, and no serializer may assume the join produced a
row. submission_id is indexed, and the moderation inbox's custom-field filters push down to
the GIN index on submissions.custom_field_values described below.
The stored document's internal shape is owned here. Each entry records the value and a snapshot of the field definition at submission time, so a later edit or deletion of the field never rewrites history.
{
"version": 1,
"values": {
"team_size": {
"type": "select",
"label": "How big is your team?",
"isPublic": true,
"value": "11_50",
"valueLabel": "11–50 people"
},
"seat_count": {
"type": "short_text",
"label": "How many seats do you use?",
"isPublic": false,
"value": "34"
},
"onboarding_rating": {
"type": "star_rating",
"label": "How was onboarding?",
"isPublic": true,
"value": 5
},
"linkedin": {
"type": "url",
"label": "LinkedIn profile",
"isPublic": true,
"value": "https://www.linkedin.com/in/example"
},
"would_refer": {
"type": "boolean",
"label": "Happy to be a reference?",
"isPublic": false,
"value": true
},
"logo_file": {
"type": "file",
"label": "Company logo",
"isPublic": true,
"value": [
{
"mediaAssetId": "01J8Z3A1B2C3D4E5F6G7H8J9K0",
"fileName": "acme-logo.png",
"byteSize": 48211,
"contentType": "image/webp",
"width": 512,
"height": 512
}
]
},
"context": {
"type": "long_text",
"label": "Anything else?",
"isPublic": false,
"value": "We migrated from a spreadsheet in about a week."
}
}
}Rules for this structure:
versionis an integer, currently1. Any future shape change increments it; readers switch on it and the old shape is never rewritten in place.- Keys absent from
valuesmean "not answered".nullis never stored for an unanswered optional field. valueLabelis denormalized forselectonly, so exports and widgets can render a human-readable value without joining to a possibly-deleted definition.isPublic: falseentries are stripped by a single serializer inpackages/coreused by the widget JSON API, the showcase renderer, the oEmbed endpoint and the public REST API. There is exactly one such serializer, and a unit test asserts that every public surface routes through it.- A submission with no custom fields stores
{"version": 1, "values": {}}, never SQLNULL, so every reader can index intovalueswithout a null check. - A GIN index on
submissions.custom_field_valuessupports filtering the moderation inbox by custom-field value. - Case-study answers are not part of this document: they live in
case_study_fieldson the testimonial (Section 8.6), which is the one place the two structures differ.
8.6 Case-Study Fields #
The case-study group is a named preset — one toggle on the request page that adds a structured block designed for B2B case studies. It exists because "company size / industry / before / after / metric" is the same five questions on every SaaS testimonial page in the world, and hand-rolling it as six custom fields loses the semantics that widgets and the showcase page need.
Enabling it sets caseStudyEnabled = true on the request page and reveals a
caseStudyConfig object. The group counts as one field against the 20-field limit.
| Sub-field | Key | Type | Required by default | Validation | Public by default |
|---|---|---|---|---|---|
| Company size | companySize |
select | No | One of 1, 2_10, 11_50, 51_200, 201_1000, 1001_5000, 5000_plus |
Yes |
| Industry | industry |
select with allowOther |
No | One of a 24-entry curated list (software, ecommerce, agency, finance, healthcare, education, manufacturing, nonprofit, real_estate, hospitality, legal, logistics, media, gaming, construction, retail, travel, energy, automotive, telecom, government, fitness, food_beverage, other), plus free text ≤ 60 chars when other |
Yes |
| Role | role |
short_text | No | 1–80 chars. Distinct from the respondent's role on the Details screen only in that this one is optional context; when both are present the Details value wins for display and this one is dropped at save with no error. |
Yes |
| The problem before | problemBefore |
long_text | No | 1–800 chars | Yes |
| The result after | resultAfter |
long_text | No | 1–800 chars | Yes |
| Headline metric — value | metricValue |
short_text | No | 1–24 chars, must match ^-?\d{1,12}(\.\d{1,4})?$ after stripping thousands separators (,, ., thin space, ' — the parser is locale-tolerant on input and stores a canonical decimal string) |
Yes |
| Headline metric — unit | metricUnit |
select | No | One of percent, x, currency, hours, days, weeks, months, count, points, custom |
Yes |
| Headline metric — custom unit | metricUnitCustom |
short_text | Only when metricUnit = custom |
1–12 chars | Yes |
| Headline metric — direction | metricDirection |
select | No | increase, decrease, absolute. Default increase. Controls the arrow glyph and the accompanying text "up"/"down"/none — never an arrow alone. |
Yes |
| Headline metric — label | metricLabel |
short_text | No | 1–60 chars, e.g. "faster onboarding" | Yes |
| Currency (when unit = currency) | metricCurrency |
select | Only when metricUnit = currency |
ISO-4217 from a 20-currency shortlist plus free entry validated against the full ISO-4217 code list | Yes |
| Permission to name metrics | metricsPublishable |
boolean | Yes when any metric sub-field is filled | Must be explicitly ticked. See below. | n/a — governs publication, never displayed |
metricsPublishable is the important one. Numbers are the most commercially sensitive
thing a respondent can give, and a customer's legal team will care. Behaviour:
- The toggle appears only when at least one of
metricValue,problemBeforeorresultAfteris non-empty. It is never pre-checked. - Its label is fixed platform copy that the workspace cannot edit: "You may publish these figures alongside my name and company." Editable copy here would let a workspace misrepresent what is being agreed to.
- Changing it later from the respondent portal is a state-changing consent operation and
therefore requires the step-up verification in Section 22.4.2, not the portal link alone.
A read-only portal session attempting it receives
401 STEP_UP_REQUIRED(Section 7.7.3). It is a consent field, and consent must not be flippable by anyone who happens to see a URL. - If it is not ticked, the metric sub-fields and the before/after narrative are stored with
isPublicforced tofalsefor that submission, are stripped from every public serializer, and the moderation inbox shows a persistent "metrics not cleared for publication" flag with a text label (not a colour) on the testimonial. - A moderator cannot override the flag in the dashboard. The only path to publishing the
figures is to ask the respondent, who can re-consent from their management link (Section
8.8.7) — which flips the stored value and writes an audit event with actor type
respondent. - The consent text actually rendered is stored verbatim with the submission, per the consent model in Section 22.
Storage. Case-study answers live in a dedicated case_study_fields JSONB column on the
testimonial (not mixed into custom_field_values), so downstream consumers can rely on the
key names:
{
"version": 1,
"companySize": "51_200",
"industry": "software",
"problemBefore": "Support tickets were tracked in a shared inbox and things fell through.",
"resultAfter": "Every request is triaged the same day and nothing gets lost.",
"metric": {
"value": "62",
"unit": "percent",
"unitCustom": null,
"currency": null,
"direction": "decrease",
"label": "fewer missed tickets"
},
"metricsPublishable": true
}Downstream surfacing.
| Surface | Behaviour |
|---|---|
| Wall of Love / Carousel / Single Quote widgets | When metricsPublishable is true and a metric exists, the widget renders a compact metric chip above the quote: direction glyph + accessible text ("down 62% — fewer missed tickets"), styled with the widget accent colour and meeting 3:1 non-text contrast. Section 13 owns the widget layout system; the chip is a documented sub-component of the testimonial card. |
| Badge / Rating Summary widget | Metrics are not shown (the badge is an aggregate). |
| Showcase page | Renders a dedicated case-study layout when problemBefore and resultAfter are both present and publishable: metric hero, "Before" and "After" headed blocks, company size and industry as metadata chips. Otherwise renders the standard testimonial card. Section 14 owns the showcase templates. |
| Social image cards | The metric becomes the card's headline when present and publishable; otherwise the pull-quote does. Section 15 owns card rendering. |
| Public REST API | Exposed under caseStudy on the testimonial resource, with non-publishable fields omitted entirely (not nulled), consistent with the single public serializer in Section 8.5.3. |
| CSV export | One column per sub-field, always exported for the workspace's own use (the workspace owns its data), with a metrics_publishable column so the workspace knows what it may use externally. |
8.7 Star Ratings #
A request page has an optional page-level star rating, independent of any star_rating
custom field. It is the rating that feeds the Badge / Rating Summary widget aggregate and the
showcase page's average.
| Setting | Type | Default | Notes |
|---|---|---|---|
ratingEnabled |
boolean | true |
|
ratingRequired |
boolean | false |
When true, submission fails validation without a rating. |
ratingLabel |
string | "How would you rate us?" |
1–80 chars. |
ratingLowLabel |
string | "Not great" |
0–24 chars, shown beneath star 1. |
ratingHighLabel |
string | "Excellent" |
0–24 chars, shown beneath star 5. |
Storage. testimonials.rating is smallint NULL constrained to 1..5. NULL means "no
rating given", which is semantically distinct from zero — zero is not a valid rating and is
rejected.
Input implementation. The control is a native radio group, never a hover-only star strip:
<fieldset class="v-rating" aria-describedby="rating-help">
<legend>How would you rate us? <span class="v-optional">Optional</span></legend>
<div class="v-rating__stars">
<input type="radio" id="r1" name="rating" value="1" />
<label for="r1"><svg aria-hidden="true" focusable="false">…</svg><span class="v-sr-only">1 star — Not great</span></label>
<input type="radio" id="r2" name="rating" value="2" />
<label for="r2"><svg aria-hidden="true" focusable="false">…</svg><span class="v-sr-only">2 stars</span></label>
<input type="radio" id="r3" name="rating" value="3" />
<label for="r3"><svg aria-hidden="true" focusable="false">…</svg><span class="v-sr-only">3 stars</span></label>
<input type="radio" id="r4" name="rating" value="4" />
<label for="r4"><svg aria-hidden="true" focusable="false">…</svg><span class="v-sr-only">4 stars</span></label>
<input type="radio" id="r5" name="rating" value="5" />
<label for="r5"><svg aria-hidden="true" focusable="false">…</svg><span class="v-sr-only">5 stars — Excellent</span></label>
</div>
<p id="rating-help" class="v-help">Pick a number of stars, or leave it blank.</p>
</fieldset>- Arrow keys move between stars (native radio behaviour),
Home/Endjump to 1 and 5, and the group is a single tab stop. - Each star's hit area is at least 44 × 44 CSS px on touch and 24 × 24 on pointer, per Section 21's target-size criterion.
- The filled state is conveyed by shape and fill together (an outlined star becomes solid), not by colour alone, and the current value is always mirrored in visible text beside the strip: "4 out of 5".
- A "Clear rating" button appears once a value is chosen, so an optional rating can be undone — a radio group otherwise cannot be un-set. The button is focusable and labelled.
- Hover previews are progressive enhancement only; with JavaScript disabled the radios still work and the form still submits.
prefers-reduced-motionsuppresses the fill animation.
Display of "no rating". A testimonial with rating = NULL never renders an empty or
greyed star strip anywhere — the strip is simply absent from the card. Aggregates exclude it
from both numerator and denominator, and the Badge widget's count reads "based on 143 rated
testimonials" so the number is never misleading. The moderation inbox shows an em dash with
the accessible text "No rating".
8.8 The Respondent Flow, Screen by Screen #
Seven screens. Each is a distinct URL step so back-navigation is native, and each is independently server-renderable so the first paint never waits on JavaScript.
Routing. The request page is a single Next.js route segment with a step in the query string rather than the path, because the path must stay stable for the canonical URL and for invitation links:
/r/{workspaceSlug}/{pageSlug} → step 1 (landing)
/r/{workspaceSlug}/{pageSlug}?step=prompts&d=…
/r/{workspaceSlug}/{pageSlug}?step=capture&d=…&mode=video|text
/r/{workspaceSlug}/{pageSlug}?step=details&d=…
/r/{workspaceSlug}/{pageSlug}?step=consent&d=…
/r/{workspaceSlug}/{pageSlug}?step=review&d=…
/r/{workspaceSlug}/{pageSlug}?step=done&s={submissionToken}Steps are pushed with history.pushState so the browser back button moves back one step
rather than leaving the page. A popstate handler restores the previous step's state from the
in-memory store, falling back to the server draft if the tab was restored from bfcache after
eviction.
The shell. Every step renders inside one shell containing: the workspace logo, the
headline (steps 1–2 only), a progress indicator, the step content, and the "Powered by" badge
when required. The progress indicator is a labelled <ol> with aria-current="step" on the
active item and visible text "Step 4 of 6" — never a bare progress bar. The number of steps
shown adapts: the prompts step is skipped when the page has no prompts, and the review step is
never skipped.
Performance. First paint of the landing screen is server-rendered HTML with inlined critical CSS ≤ 9 KB. The capture step's recorder chunk is loaded only when the video path is chosen. Total JavaScript for the text path is ≤ 42 KB gzip; for the video path ≤ 140 KB gzip including the recorder (Section 23 owns the budget table and the CI enforcement).
Global states. Every screen implements four states beyond its normal one.
| State | Trigger | Behaviour |
|---|---|---|
| Loading | Any step transition that needs the network | The primary action button enters a busy state (aria-busy="true", spinner + the word "Saving…"), inputs stay enabled and readable, and the step does not visually collapse. A skeleton is used only for the initial page load, never mid-flow. If a transition exceeds 8 seconds, a polite live-region message appears: "Still working — this is taking longer than usual." |
| Error | Any non-2xx, or a client-side validation failure | An error summary appears at the top of the step content, receives focus, is role="alert", lists each problem as a link to the offending control, and states what to do. The technical code from the error envelope in Section 7.3 is shown in small muted text as "Reference: VALIDATION_FAILED · req_01J…" so support can trace it. Nothing the respondent typed is ever cleared by an error. |
| Offline | navigator.onLine === false, or two consecutive network failures |
A persistent, non-modal banner: "You're offline. Everything you've written is saved on this device and will send when you're back." The primary button becomes "Retry". Draft-save requests queue in memory and flush on online. On the capture step during recording, the recorder keeps recording and buffers parts (Section 9 owns the upload resume behaviour). |
| Closed mid-flow | The page transitions to a closed state, or the workspace hits its cap, while a respondent is partway through | The respondent is allowed to finish. A submission that began before the page closed is accepted for 30 minutes after closure, because losing someone's recorded video to a quota boundary is unacceptable. The overage is recorded and surfaced in the dashboard. After 30 minutes the review step returns 409 COLLECTION_CLOSED with copy explaining that the page stopped collecting, plus a "download what you wrote" button. |
Back-navigation contract. Going back never destroys work. Specifically: leaving the capture step with a recorded-but-unsubmitted take keeps the take (its multipart upload stays open); leaving the details step keeps every field; leaving the consent step keeps the ticked boxes; and returning to a step re-renders it from the draft, not from defaults. Navigating back from the thank-you step lands on a read-only "already submitted" confirmation, never on an editable review step — the submission token is single-use for that purpose.
8.8.1 Screen 1 — Landing #
Purpose: answer who is asking, why, how long, and what are my two options, in under four seconds, above the fold at 360 × 640.
Layout at 360 px, top to bottom: cover image (if set, 3:1, aspect-ratio reserved so there
is zero layout shift) → logo (max 48 px tall) → headline → subheadline → the time estimate
→ two large buttons → introBody → "Powered by" badge if applicable.
- Time estimate is computed, not typed:
ceil(30s + 20s × promptCount + 15s × requiredCustomFieldCount)rounded to the nearest half-minute and rendered as "About 2 minutes". It is capped at "About 5 minutes" and never says "less than a minute". - The two buttons are the same size, the same visual weight, stacked on mobile and
side-by-side ≥ 640 px. The video button is first in DOM order (it converts better) but both
are
<button>elements in onerole="group"with an accessible group label "How would you like to share it?". The text button is never styled as a link or a secondary action — Section 8.1 N1. - Under the video button: "You'll need to allow camera access. Nothing records until you press the button." Under the text button: "No camera needed."
- If the respondent's browser cannot record (feature-detected: no
navigator.mediaDevices, noMediaRecorder, or no supported mime type — Section 9 owns the detection), the video button is replaced with a short explanatory line and the text path becomes the single primary action. It is never rendered as a disabled button. - Personalization: when an invitation token is present, the headline may address the respondent by name via merge tokens (Section 8.11.4), and a line reads "Requested by {sender name}" with the sender's avatar.
- Draft creation happens here, on first interaction, not on page load — see Section 8.9.
- Analytics: a
request_page_viewevent is recorded server-side at render (cookie-free, per Section 17), and arequest_page_path_chosenevent fires on button press withmode: video|text.
States: loading is a server-rendered page so there is none; error is a full-page render of the platform error shell with the workspace's branding if the workspace resolved, otherwise neutral; offline shows the banner and both buttons stay usable (the draft is created on reconnect); closed renders the closed state from Section 8.2.4 with no buttons at all.
8.8.2 Screen 2 — Prompts #
Skipped entirely when the page has zero prompts.
Renders the prompt block described in Section 8.4, the "these are just suggestions" affordance, a "Continue" primary button, and a "Back" link. On the video path it additionally offers a checkbox: "Show these on screen while I record" which enables the teleprompter (Section 9), default unchecked, persisted on the draft.
- On mobile, prompts render in the swipeable group; "Continue" is always visible without swiping to the last prompt — the respondent is never forced to page through them.
- A "Skip" text button is present and is not visually hidden. Skipping is normal.
- Reading the prompts requires no JavaScript: the server renders all prompts as a list and the carousel is a progressive enhancement applied on hydrate.
Loading/error/offline follow the global contract. Back returns to landing and preserves the chosen mode.
8.8.3 Screen 3 — Capture #
Two variants selected by mode.
Video variant. Owned by Section 9 (permissions, device pickers, recorder controls, teleprompter, chunked upload, retakes). This section specifies only the surrounding contract:
- The step cannot be advanced until a take exists and its upload has reached at least the "all parts queued" state. The Continue button is labelled "Use this take".
- Switching to the text path from here is always offered ("Rather write it?"), and doing so aborts the in-progress multipart upload and clears the take after a confirmation dialog.
- The maximum recording length shown to the respondent is the workspace's plan maximum (Section 19), fetched with the page payload so the recorder never has to guess.
- If the page has
ratingEnabled, the rating is not asked here — it belongs on Details, so the recorder screen has exactly one job.
Text variant.
| Element | Specification |
|---|---|
| Body textarea | Required. 40–5,000 characters after trim. Auto-growing from 6 rows to 18, then scrolls. Character counter appears at 3,200 characters and turns into a hard limit indicator at 5,000; typing beyond the limit is prevented and announced once, politely. |
| Minimum length rationale | 40 characters, because "Great!" is not a testimonial and rejecting it at moderation wastes everyone's time. The error copy explains warmly: "A little more, please — even one sentence about what changed." |
| Per-prompt textareas | Rendered instead of the single body when any prompt has isRequired = true (Section 8.4). Each is 20–1,500 characters; the assembled body is **{prompt}**\n\n{answer} joined by blank lines, then sanitized. |
| Formatting | No rich-text editor. Plain textarea. Line breaks are preserved. Markdown is not parsed — a respondent typing **bold** gets literal asterisks, which is the honest outcome. The workspace can add emphasis at moderation (Section 11 owns editing rules, and imported reviews are never editable). |
| Paste handling | Paste is allowed and unmodified except that \r\n is normalized and zero-width characters (U+200B–U+200D, U+FEFF) are stripped. |
| Autosave | Every 2 seconds of idle after a change, and on blur, into the draft (Section 8.9). A subtle "Saved" indicator with a timestamp appears in the muted text style, announced politely at most once per 30 seconds. |
| Spellcheck | spellcheck="true", autocapitalize="sentences", autocorrect="on", enterkeyhint="enter". |
| Empty-state help | Beneath the textarea: "Two or three sentences is plenty." |
8.8.4 Screen 4 — Details #
Collects who the respondent is, plus the page's custom fields and case-study block.
| Field | Required | Validation | autocomplete |
Notes |
|---|---|---|---|---|
| Name | Yes | 1–80 chars after trim. Must contain at least one letter (Unicode \p{L}). Emoji-only and digit-only names rejected with INVALID_NAME. |
name |
Split into given/family is not attempted — one field, because name structure is not universal. |
| Role / job title | No | 0–80 chars | organization-title |
|
| Company | No | 0–80 chars | organization |
|
| Yes | RFC 5322-shaped, ≤ 254 chars, single address, no display name, lowercased for storage of the local part only where the domain is known case-insensitive (the address is stored as typed and compared case-insensitively on the domain). MX presence is not required at submit time (Section 8.13.5 covers optional verification). | email |
Used for: the management link, the thank-you email, and moderator follow-up. Never published, never sold, never rendered in any public surface. The UI says so in one line: "We'll only use this to send you your link." | |
| Website / social URL | No | Same rules as the url custom field type |
url |
Optional page setting detailsUrlEnabled, default false. |
| Avatar | No | Image, ≤ 10 MB, ≥ 96 × 96 px, ≤ 8,000 × 8,000 px. Processed by the image pipeline in Section 9. | — | See below. |
| Company logo | No | Image, ≤ 10 MB, ≤ 8,000 × 8,000 px, transparency preserved. | — | Optional page setting detailsCompanyLogoEnabled, default false. |
| Star rating | Per ratingRequired |
Section 8.7 | — | |
| Custom fields | Per field | Section 8.5 | per autocompleteHint |
Rendered in position order after the standard fields. |
| Case-study block | Per config | Section 8.6 | — | Rendered last, under its own <h2> heading with the workspace's optional intro copy. |
Avatar handling. Three sources, offered in this order on mobile: "Take a photo" (camera
capture), "Choose a photo", and the initials fallback. The initials fallback is not a
degraded option — it is a first-class choice, rendered as a coloured disc with 1–2 initials
derived from the name, the background colour chosen deterministically from a 12-entry palette
by hashing the normalized name so the same person always gets the same colour, and the
foreground chosen for ≥ 4.5:1 contrast against it. The disc is generated at render time, not
stored as an image. If the respondent supplies no avatar and their name yields no letters, a
neutral silhouette glyph with aria-hidden="true" and an accessible name of the respondent's
name is used.
Uploaded avatars are cropped to a square by the respondent using a simple crop control that is
fully keyboard-operable (arrow keys pan, +/- zoom, with visible numeric readouts) — never
drag-only, per Section 21's dragging-movements criterion. The crop is applied server-side; the
original is processed and retained per Section 9's retention rules.
Layout at 360 px. One column, 16 px gutters, 44 px minimum control height, 24 px vertical
rhythm between fields, labels above controls always (never floating labels — they fail
zoom-to-200% and confuse voice control). The keyboard's "next" affordance moves through the
fields in DOM order and the last field's enterkeyhint is done.
Validation timing. On blur for the field that lost focus, plus on submit for everything. Never on every keystroke — that punishes people mid-typing. The email field is an exception: it validates on blur only, and its disposable-domain warning (Section 8.14.2) appears as a non-blocking note.
8.8.5 Screen 5 — Consent #
The consent screen renders the three permissions described below. Section 22 owns the storage model, the versioning, the hashing, and the legal framing; this section owns the interface.
| # | Permission | Required | Default |
|---|---|---|---|
| 1 | Publish the testimonial, including name, role, company and photo/video likeness as provided, on the workspace's website and marketing surfaces | Yes | Unchecked |
| 2 | Use the testimonial in paid advertising | No | Unchecked |
| 3 | Be contacted about a follow-up case study | No | Unchecked |
Interface rules, all non-negotiable:
- Nothing is pre-checked. Not the required one either. The submit button is disabled —
visibly, with an explanatory line, and with
aria-describedbypointing at that line — until permission 1 is ticked. A disabled submit button is still focusable so a screen-reader user can discover why. - Each permission is a real
<input type="checkbox">with a<label>whose full text is the consent text. The label is the click target and is at least 44 px tall. - The rendered consent text is the exact string stored with the submission. The client
sends back the
consentVersionand a SHA-256 of the rendered text; the server recomputes the hash from its own copy of that version and rejects a mismatch with409 CONSENT_TEXT_MISMATCH. This makes it impossible for a stale client to record agreement to text the platform cannot reproduce. - Optional permissions are visually grouped under a heading "Optional — entirely up to you" and never nag. Declining them has zero effect on the flow.
- A link "What happens to what I send?" opens an in-page disclosure (a
<details>element, no modal, no new tab) summarizing: who publishes it, where it may appear, how to withdraw, and how to get a copy. It is written at roughly a grade-8 reading level. - The workspace's name — not the platform's — is the subject of every sentence. The respondent is consenting to the workspace publishing, with the platform named as the processor.
- Under-16 handling: the consent screen carries a plain statement that the form is not intended
for people under 16 and a required-by-default-off page setting
requireAgeConfirmationwhich, when enabled, adds a fourth non-pre-checked checkbox "I'm 16 or older". Section 22 owns the lawful-basis reasoning.
8.8.6 Screen 6 — Review and submit #
Shows exactly what will be sent, in the order it will appear, with an Edit control for every part. Nothing is hidden behind a "show more".
Blocks, in order:
- The testimonial itself — the video with a poster and a play control (playback here does not count as a widget view; Section 17 owns event taxonomy), or the full text body. Edit → capture step.
- You — avatar/initials, name, role, company, email. Edit → details step.
- Rating — "4 out of 5" plus the star strip, or "No rating". Edit → details step.
- Extra details — every answered custom field as label/value pairs; unanswered optional fields are omitted, not shown as empty. Edit → details step.
- Case study — the before/after and the metric chip, with a clear note when
metricsPublishableis false: "These figures will be kept private." Edit → details step. - Permissions — each of the three with a tick or a dash and the word "Yes"/"No". Edit → consent step.
The submit button is the only primary action. Pressing it:
- Disables itself immediately and enters the busy state (double-submit is also prevented
server-side by the idempotency mechanism in Section 7.5 — the client sends an
Idempotency-Keyderived from the draft id). - Runs the honeypot and timing checks (Section 8.13).
- POSTs the submission (Section 8.10.5).
- On
422, returns the respondent to the first offending step with the error summary focused and the review step's scroll position remembered. - On
409 COLLECTION_CLOSEDpast the 30-minute grace, shows the closed message plus a "Download what you wrote" button producing a.txtfile client-side, and — if a video take exists — a note that the recording will be discarded, with a "Send it to yourself" option that emails a download link valid for 24 hours. - On
5xxor a network failure, retries automatically twice with 2 s and 6 s backoff, then offers a manual "Try again". The draft is untouched, so nothing is lost.
8.8.7 Screen 7 — Thank-you #
Rendered at ?step=done&s={submissionToken}. The submission token is a 32-byte random value,
single-use for the purpose of rendering this screen, valid 24 hours, stored hashed.
Contents:
- Confirmation —
thankYouHeadlineandthankYouBody, with arole="status"live region announcing "Your testimonial was sent" on mount. - What happens next — three fixed lines the workspace cannot edit, because they describe platform behaviour: "Someone at {workspaceName} will review it." · "If they publish it, you'll see it on their site." · "You can view, download or withdraw it any time using your link below."
- The management link — the respondent's long-lived link
https://v.vouch.app/p/{token}(Section 22 owns the token, the hashing and the capabilities behind it). The link alone grants read-only access; withdrawing, exporting, correcting or erasing additionally requires a one-time code emailed to the address captured at submission, per the tiering in Section 22.4.2 and the step-up rule in Section 7.7.3. This is deliberate: a long-lived link will be seen by forwarded mail, shared devices, mail-security scanners and CDN logs, and reading a testimonial you already agreed to publish is an acceptable consequence of that exposure — destroying it, or exporting the personal data behind it, is not. Shown in full, selectable, with a "Copy link" button, and stated to be private. It is also emailed immediately (Section 20 owns the template). If the respondent's email bounces, the link is still valid — the page is the primary delivery. - Video processing note — when a video was submitted: "Your video is still processing — that's normal and takes a couple of minutes. Nothing else is needed from you." The screen does not poll for processing state; it is not the respondent's problem.
- Optional share prompt — when
sharePromptEnabled, one line of copy plus native share (navigator.sharewhere available, falling back to copy-link) for the request page's public URL. Never auto-triggered, never modal, never social-network-specific buttons that load third-party scripts (zero third-party scripts run on a request page — Section 23). - Redirect — if configured, a visible countdown ("Taking you to acme.com in 5…") with a "Stay here" button that cancels it. An automatic redirect with no escape hatch fails Section 21's timing criteria; the cancel button satisfies them.
Refreshing this screen re-renders it from the submission token. Navigating back from it shows the read-only "already submitted" state. Submitting the same draft again returns the original response via the idempotency record rather than creating a duplicate.
8.9 Draft Persistence Without Cookies #
The constraint. The request page sets no cookie, writes no localStorage, writes no
sessionStorage, and creates no cross-site identifier of any kind (Section 8.1 N2, and the
cookie-free stance in Section 17). Yet a respondent who refreshes, rotates their phone into a
crash, or follows a link away and comes back must not lose a five-minute recording or three
paragraphs of typing.
The mechanism. A server-side draft row keyed by an opaque token that lives in the URL.
8.9.1 The draft token #
| Property | Value |
|---|---|
| Generation | 32 cryptographically random bytes, base64url-encoded → 43 characters. |
| Storage | SHA-256 hash in submission_drafts.token_hash; the plaintext exists only in the URL and in the client's memory. A leaked database dump does not yield working draft URLs. |
| Transport | Query parameter d on every step URL after the first interaction. Never a path segment (keeps it out of most default access-log path grouping), never a fragment (must reach the server). |
| Lifetime | 24 hours from creation, refreshed to 24 hours from last write on every draft save, with a hard ceiling of 72 hours from creation. |
| Scope | One draft is bound to exactly one request page and, if present, one invitation contact. It cannot be replayed against a different page. |
| Referrer safety | Every request page sends Referrer-Policy: same-origin, so the token is never leaked in a Referer header to a third party — including via the redirect-after-submit, which uses location.replace from a page carrying that policy. |
| Link safety | The token is stripped from any URL the respondent can copy: the "copy link" affordance on the thank-you screen copies the canonical page URL, and the share prompt shares the canonical URL. |
| Robots | Steps beyond the landing page send X-Robots-Tag: noindex, nofollow unconditionally. |
8.9.2 Creation #
The draft is created lazily, on the first meaningful interaction — pressing a path button, typing into the body, or opening the recorder — never on page load. This means a bot crawling the page creates nothing, and a respondent who bounces in two seconds leaves no row.
POST /api/v1/public/request-pages/{pageId}/drafts
Content-Type: application/json
{
"mode": "video",
"inviteToken": "9c2f…", // optional, from the invitation link
"startedAtClientMs": 1771483920123 // used only for the timing check in Section 8.13.2
}{
"data": {
"draftToken": "s1Yp7T2kQ0m9c4Xb3nR6uZv8LdWfHgJ4KpQsTvYw2A0",
"expiresAt": "2026-08-20T14:32:11.482Z",
"mode": "video"
},
"meta": { "requestId": "req_01J8Z2M6K7Q0X4V9N3TAB5CDEF" }
}The client immediately history.replaceStates the token into the URL so a refresh at any
moment resumes.
8.9.3 The draft row #
submission_drafts (Section 5 owns the DDL):
| Column | Type | Notes |
|---|---|---|
id |
uuid (v7) | |
workspace_id |
uuid | Tenancy; RLS applies. |
request_page_id |
uuid | |
invitation_contact_id |
uuid | null | Attribution (Section 8.11.7). |
token_hash |
bytea | SHA-256, unique. |
mode |
enum video | text | audio |
|
payload |
jsonb | Everything typed so far: body, details, custom field values, case-study values, rating, teleprompter preference, current step. Never contains consent grants. |
upload_session_id |
uuid | null | Links to the in-progress multipart upload (Section 9). |
client_started_at |
timestamptz | For the timing check. |
last_seen_ip_hash |
bytea | HMAC-SHA-256 of the IP with a rotating server key, used only for the per-IP rate limit and duplicate detection. The raw IP is never stored. |
created_at, updated_at, expires_at |
timestamptz | |
submitted_at |
timestamptz | null | Set when the draft converts. |
Consent is deliberately excluded from the draft payload. A consent grant is an act, not a form value; it is recorded only at submit, from the state on the consent screen at that moment, with the exact text hash. Persisting ticked consent boxes across a 24-hour gap would record agreement the respondent may not remember giving.
8.9.4 Saving and resuming #
- Save cadence: debounced 2 seconds after the last change, plus on every step transition,
plus on
visibilitychangetohidden, plus onpagehide(vianavigator.sendBeaconso the final save survives the tab closing). - Endpoint:
PATCH /api/v1/public/drafts/{draftToken}with a partial payload; the server deep-merges at the top level ofpayloadand replaces arrays wholesale. - Concurrency: a monotonically increasing
revisioninteger on the draft. The client sends the revision it last saw; a mismatch returns409 DRAFT_REVISION_CONFLICTwith the current payload, and the client — which is the only writer in practice — takes the server's copy for any field it has not touched since. Two tabs on the same draft therefore converge instead of clobbering. - Resume:
GET /api/v1/public/drafts/{draftToken}returns the payload plus the page configuration in one round trip, so a refresh restores in a single request. An expired, unknown or already-submitted token returns410 DRAFT_EXPIREDand the client starts a fresh flow with a friendly explanation rather than an error page. - Video takes survive a refresh because the upload session is server-side: the resumed client fetches the upload session state (Section 9) and re-attaches to the already-uploaded parts. A take that was fully uploaded but not submitted is re-offered as "Your recording is still here."
8.9.5 Cleanup #
| Job | Schedule | Behaviour |
|---|---|---|
drafts.sweep |
Every 15 minutes | Hard-deletes submission_drafts where expires_at < now() and submitted_at IS NULL, in batches of 500. For each, if upload_session_id is set and the underlying multipart upload is still open, enqueues media.abort_upload so no orphaned object-storage parts are billed. |
drafts.purge_submitted |
Hourly | Hard-deletes drafts where submitted_at < now() - interval '48 hours'. The submissions row is the durable record; the draft is transient working state. |
| Idempotency interaction | — | The 24-hour idempotency record (Section 7.9) outlives the draft, so a retried submit after draft purge still returns the original response rather than creating a duplicate. |
Draft rows are hard-deleted, not soft-deleted, consistent with the deletion policy in Section 5 (drafts are ephemeral child data, never subject to audit).
8.10 The Collection API #
All endpoints below are public and unauthenticated — they are called by the respondent's
browser. They live under /api/v1/public/ to make that explicit, are served by the Node
runtime on v.vouch.app and on active custom domains (they open database transactions and
sign multipart uploads, and Section 7.2 places every database-touching route on Node; the page
shell in front of them is CDN-cached, so this costs nothing in latency), and are exempt from the workspace
bearer-token requirement. They follow the envelope, status-code, idempotency (Section 7.9) and
pagination (Section 7.4) rules in Section 7 without exception. Rate limits are the submission
class defined in Section 7.10.2, applied per IP.
This subsection is the behavioural specification for the collection surface: what each endpoint returns, and — the part that actually costs build time — what it does when something goes wrong. Subsections 8.10.1 to 8.10.4 cover the four endpoints a respondent hits before submitting, 8.10.5 covers the submit itself, and 8.10.6 catalogues every code any of them can return.
Three rules hold across all six subsections, so they are stated once here rather than repeated:
- A public read never leaks existence. Any token that is unknown, expired, revoked, bound
to a different page, or already consumed produces the same response as one that never
existed. There is no
DRAFT_NOT_FOUNDdistinct fromDRAFT_EXPIRED, and no invitation code distinguishes "wrong token" from "typo" — an attacker holding a token list must learn nothing from the difference. The one exception is deliberate: a soft-deleted request page returns410 REQUEST_PAGE_GONErather than404, because that URL was public, was printed on things, and the distinction helps the workspace, not an attacker. - A quota or plan refusal is
409, never402and never403;403on these endpoints would be a lie, since the caller is not authenticated and has no permissions to lack.422means the request was well-formed and its contents failed validation. - Nothing the respondent has typed is destroyed by an error. Every failure below leaves the draft row intact and resumable, so a retry — automatic or human — continues rather than restarts. This is why the draft is server-side at all (Section 8.9).
| Method & path | Purpose | Auth | Notes |
|---|---|---|---|
GET /api/v1/public/request-pages/resolve?host={host}&workspaceSlug={s}&pageSlug={p} |
Resolve a URL to a page id and its full public configuration | None | Section 8.10.1. Cached at the edge public, max-age=30, stale-while-revalidate=300, keyed including host. Purged on any page or branding change via the tag request-page:{id}. |
POST /api/v1/public/request-pages/{pageId}/drafts |
Create a draft | None | Sections 8.9.2 and 8.10.2. Rate limited per IP. |
GET /api/v1/public/drafts/{draftToken} |
Resume a draft | Draft token | Section 8.10.2. Never cached (Cache-Control: no-store). |
PATCH /api/v1/public/drafts/{draftToken} |
Save draft progress | Draft token | Sections 8.9.4 and 8.10.3. |
POST /api/v1/public/drafts/{draftToken}/uploads |
Begin a media upload session | Draft token | Section 8.10.4. The upload protocol itself is Section 9's. |
POST /api/v1/public/drafts/{draftToken}/submit |
Convert a draft into a testimonial | Draft token | Idempotency-Key required. Section 8.10.5. |
POST /api/v1/public/drafts/{draftToken}/verify-email |
Send the respondent an email verification code | Draft token | Only when the page has requireEmailVerification. Rate limited to 3 sends per draft per hour. |
POST /api/v1/public/drafts/{draftToken}/verify-email/confirm |
Confirm the code | Draft token | 6-digit numeric code, 10-minute TTL, 5 attempts then the code is invalidated. |
GET /api/v1/public/submissions/{submissionToken} |
Render the thank-you screen data | Submission token | Single-purpose, 24-hour TTL. |
POST /api/v1/public/invitations/{inviteToken}/open |
Record an invitation open/click and resolve attribution | Invite token | Returns the canonical page URL and an attribution token. |
8.10.1 The public request-page read #
GET /api/v1/public/request-pages/resolve?host={host}&workspaceSlug={s}&pageSlug={p}
The first request the browser makes, and the only one that runs before any respondent interaction. It turns a URL into a page id plus the complete public configuration needed to render every one of the seven screens — branding, copy, prompts, field definitions, consent text and version, star-rating settings, locale set, and the media constraints. One round trip, because a second one would be visible on a phone on a train.
Resolution order. The host decides the tenancy: an active custom domain (Section 14)
resolves to its workspace and workspaceSlug is ignored if supplied; otherwise the host must
be the platform host and workspaceSlug is required. pageSlug is then matched against live
slugs first and historical aliases second (Section 8.2.2). A hit on an alias returns the
canonical URL in data.canonicalUrl and the client issues a 301-equivalent
history.replaceState, so an old printed link keeps working without splitting analytics.
What it never returns. No testimonial content, no member or workspace-user data, no plan name, no quota number, no billing state, no counts. The closed-state reason (Section 8.2.4) is the only piece of internal state that crosses this boundary, and it is a coarse enum precisely so that it cannot be used to infer a workspace's plan or usage.
Caching. public, max-age=30, stale-while-revalidate=300 at the edge, keyed on the full
host and both slugs, and tagged request-page:{id}. The tag is purged on any change to the
page, its fields, its prompts, its consent version or the workspace's branding. Thirty seconds
is short enough that a workspace editing copy sees its change almost immediately and long
enough that a link shared to fifty thousand people does not become an origin load. A closed
page is cached the same way; a page that has just reopened is therefore visibly closed for up
to thirty seconds, which is stated in the dashboard next to the reopen control.
Failure and edge conditions.
| Condition | Response |
|---|---|
| No live page and no alias matches | 404 REQUEST_PAGE_NOT_FOUND |
| The slug resolves only to a soft-deleted page | 410 REQUEST_PAGE_GONE, with the workspace's archive copy and a link to the workspace's other live pages if any exist |
status = 'draft' (never published) |
404 REQUEST_PAGE_NOT_FOUND — an unpublished page must not be discoverable by guessing its slug |
| Page is live but not accepting submissions | 200 with the collectionClosed object (Section 8.2.4) |
Host is a custom domain that is not active |
404 REQUEST_PAGE_NOT_FOUND on that host; the page remains reachable on the canonical platform URL, and Section 14 owns the domain state that caused it |
| Malformed or missing query parameters | 422 INVALID_REQUEST_BODY with one details entry per parameter |
| Per-IP limit exceeded | 429 RATE_LIMITED with Retry-After |
Every response beyond the landing page carries X-Robots-Tag: noindex, nofollow; the landing
page itself is indexable only when the page is active and the workspace has not opted out.
8.10.2 Draft creation and resume #
POST /api/v1/public/request-pages/{pageId}/drafts → create (Section 8.9.2)
GET /api/v1/public/drafts/{draftToken} → resume (Section 8.9.4)Creation happens on the first meaningful interaction, never on page load, so crawlers and
two-second bounces write nothing. Creation re-checks collection eligibility server-side rather
than trusting the eligibility flag the client read up to thirty seconds earlier: a page that
closed inside the cache window refuses here with 409 COLLECTION_CLOSED, before the respondent
has invested any effort.
An inviteToken or attribution token supplied at creation is exchanged for
invitation_contact_id (Section 8.11.7). An attribution token that is expired, already
consumed, or bound to a different page is ignored rather than fatal — the draft is created
anonymously and the flow continues. Losing attribution is a reporting inconvenience; refusing
the submission would cost the workspace a testimonial, and the respondent did nothing wrong.
Resume returns the draft payload and the page configuration together, so a refresh restores in one request. Three edge conditions matter and are specified rather than left to the implementer:
- The page changed while the draft was open. Resume always returns the current field
definitions. Answers to fields that have since been deleted or had their type changed are
retained in the payload but are not rendered and are dropped at submit; the response lists
their keys in
data.staleFieldKeysso the client can say "one question was removed since you started" instead of silently losing text. Answers to fields that merely became required are kept and re-validated at submit. - The page closed while the draft was open. Resume still succeeds and the flow continues to the end, because the 30-minute grace in Section 8.8 exists exactly for this. It is the submit in Section 8.10.5 that refuses if the grace has lapsed, at which point the respondent is offered their text for copying rather than shown a dead end.
- The token is unknown, expired, purged, or already submitted. All four return
410 DRAFT_EXPIRED— indistinguishably, per rule 1 above — and the client starts a fresh flow with a plain explanation. An already-submitted draft additionally offers to re-send the respondent's management link by email rather than displaying it, so a forwarded URL cannot expose someone else's testimonial.
| Condition | Response |
|---|---|
| Page not accepting submissions at creation time | 409 COLLECTION_CLOSED |
| Unparseable request body | 400 MALFORMED_JSON |
Parseable body of the wrong shape, or an unknown mode |
422 INVALID_REQUEST_BODY |
| Draft-creation limit for the IP exceeded (Section 8.13.3) | 429 RATE_LIMITED with Retry-After |
| Unknown, expired, purged or submitted draft token on resume | 410 DRAFT_EXPIRED |
Neither endpoint is ever cached: both send Cache-Control: no-store.
8.10.3 Draft save #
PATCH /api/v1/public/drafts/{draftToken} — a partial payload, deep-merged at the top level of
payload, arrays replaced wholesale (Section 8.9.4).
Concurrency. The client sends the revision it last saw. A mismatch returns
409 DRAFT_REVISION_CONFLICT carrying the server's current payload and revision, and the
client merges the server's copy into any field it has not touched since. Two tabs on the same
draft converge instead of clobbering.
The pagehide save is special. It travels by navigator.sendBeacon, which cannot set
custom headers and whose response nobody can read, so: the revision rides in the body; the
save is idempotent; and a beacon save that loses the revision race is discarded silently
rather than answered with a 409. There is no tab left to show a conflict to, and the durable
copy is the one the surviving writer holds.
What the server refuses to store. Consent grants are stripped from any payload that contains them (Section 8.9.3 — consent is an act recorded at submit, never a persisted form value), as are any keys not present in the page's current field set. Both are dropped without an error, because a client sending them is out of date, not hostile, and failing the save would lose the respondent's typing.
| Condition | Response |
|---|---|
| Revision mismatch | 409 DRAFT_REVISION_CONFLICT with the current payload and revision |
| Draft expired or purged mid-session | 410 DRAFT_EXPIRED; the client keeps its in-memory copy on screen and offers "copy your answer" before anything is discarded |
| Body exceeds the JSON request-size limit in Section 7.13.1 | The refusal catalogued there; the client stops auto-saving the offending field and warns |
| Unparseable body | 400 MALFORMED_JSON |
| Save limit for the draft exceeded (Section 8.13.3) | 429 RATE_LIMITED; the client backs off to a ten-second cadence and keeps working from memory |
A successful save returns the new revision and the refreshed expiresAt and nothing else —
the response is deliberately tiny because it is sent every two seconds of typing.
8.10.4 Media attach #
POST /api/v1/public/drafts/{draftToken}/uploads → open an upload sessionSection 9 owns the chunked-upload protocol in full — part sizes, signing, completion, retries, integrity verification. This subsection specifies only the collection-side envelope around it: what binds an upload to a draft, and what happens when the upload cannot proceed.
Binding rules.
- One upload session is active per draft at a time. Starting a new take aborts the previous
session (enqueuing
media.abort_uploadso no orphaned object-storage parts are billed) and replacessubmission_drafts.upload_session_id. - The session is bound to the draft, not to an IP or a browser: a respondent who resumes on a different device with the same draft URL re-attaches to the parts already uploaded.
- Sessions outlive individual requests but never outlive the draft.
drafts.sweepaborts the session of any draft it hard-deletes. - Custom-field
fileuploads (Section 8.5.2) use the same endpoint with afieldKey, and are additionally bound to that field'saccept,maxSizeBytesandmaxFilesconfig.
Quota is checked before the first byte, not after the last. The workspace's remaining
storage headroom is evaluated at session open and refused there, so a respondent never records
four minutes of video and then discovers there was no room for it. The refusal is 409,
consistent with every other quota refusal in this specification, and the respondent sees the
same neutral closed-state copy as any other capacity condition — never a plan name, never a
number.
| Condition | Response |
|---|---|
| Workspace is out of storage headroom (Section 19) | 409 STORAGE_LIMIT_EXCEEDED |
Declared duration exceeds the page's maxVideoSeconds |
422 VIDEO_LENGTH_EXCEEDED — a property of the submitted file, not a quota, so 422 and not 409 |
| Declared size exceeds the platform upload ceiling (Section 9.6) | 413 UPLOAD_TOO_LARGE |
| A part is the wrong size for the negotiated protocol | 422 PART_SIZE_INVALID |
| Custom-field file outside the allowlist | 415 UNSUPPORTED_FILE_TYPE |
Custom-field file above the field's maxSizeBytes |
413 FILE_TOO_LARGE |
| Media provider or object storage failing | 503 MEDIA_PIPELINE_UNAVAILABLE, Retry-After set; the draft and every uploaded part survive, and the client retries automatically |
| Draft expired or purged | 410 DRAFT_EXPIRED; any open session is aborted |
| Upload limit for the draft exceeded (Section 8.13.3) | 429 RATE_LIMITED |
The page closing mid-upload does not stop the upload. An upload that is already in flight runs to completion even if collection closes underneath it, because aborting it would destroy a recording that may be the only take the respondent is willing to give. The eligibility decision happens once, at submit (Section 8.10.5, step 4), where the 30-minute grace applies. If the grace has lapsed by then the media is discarded by the normal sweep and nothing partial is ever attached to a testimonial.
8.10.5 The submit endpoint #
POST /api/v1/public/drafts/s1Yp7T2kQ0m9c4Xb3nR6uZv8LdWfHgJ4KpQsTvYw2A0/submit
Content-Type: application/json
Idempotency-Key: draft_s1Yp7T2kQ0m9c4Xb3nR6uZv8LdWfHgJ4KpQsTvYw2A0
{
"mode": "video",
"body": null,
"uploadSessionId": "01J8Z3C4D5E6F7G8H9J0K1L2M3",
"respondent": {
"name": "Priya Raman",
"role": "Head of Support",
"company": "Northwind Tools",
"email": "priya@northwindtools.example",
"websiteUrl": null,
"avatarMediaId": "01J8Z3D5E6F7G8H9J0K1L2M3N4",
"companyLogoMediaId": null
},
"rating": 5,
"customFields": {
"team_size": "11_50",
"onboarding_rating": 5,
"would_refer": true
},
"caseStudy": {
"companySize": "51_200",
"industry": "software",
"problemBefore": "Support tickets were tracked in a shared inbox.",
"resultAfter": "Everything is triaged the same day.",
"metric": { "value": "62", "unit": "percent", "direction": "decrease", "label": "fewer missed tickets" },
"metricsPublishable": true
},
"consents": [
{ "type": "publish", "granted": true, "consentVersion": 3, "consentTextSha256": "b1c9…" },
{ "type": "advertising", "granted": false, "consentVersion": 3, "consentTextSha256": "7f0a…" },
{ "type": "case_study", "granted": true, "consentVersion": 3, "consentTextSha256": "d43e…" }
],
"antiAbuse": {
"honeypot": "",
"elapsedMs": 94211,
"captchaToken": null
}
}Server-side sequence, in order, all inside one transaction except where noted:
- Resolve the draft by token hash. Not found or expired →
410 DRAFT_EXPIRED. - Already submitted → return the stored idempotent response with
Idempotency-Replayed: true. - Open the tenancy transaction and set the workspace context per Section 6.
- Evaluate collection eligibility: page status, schedule window, workspace quota and billing
state, plus the 30-minute grace described in Section 8.8. Ineligible →
409 COLLECTION_CLOSED. - Anti-abuse gate (Section 8.13): honeypot non-empty → accept the request, return
201with a fabricated-looking success, and write nothing (silent tarpit; a bot must not learn it was caught). Timing below threshold or CAPTCHA failure →422with the specific code. - Validate the payload against the page's current field definitions with the shared Zod
schema. Any failure →
422 VALIDATION_FAILEDwith onedetailsentry per field. - Validate consent: permission 1 must be granted; every
consentTextSha256must match the server's rendering of thatconsentVersion. Mismatch →409 CONSENT_TEXT_MISMATCH. Missing required grant →422 CONSENT_REQUIRED. - For
mode = videooraudio: verify the upload session belongs to this draft and is in a completable state; complete the multipart upload and create the provider asset (Section 9 owns every step). A failure here →503 MEDIA_PIPELINE_UNAVAILABLE, the draft is left intact, and the client's automatic retry succeeds once the dependency recovers. - Insert the
submissionsrow — the durable record of this act of submitting: the request page, the mode, the locale, theinvitation_contact_idwhen present and the anti-abuse signals — then inserttestimonialswithstatus = 'pending', the resolvedsource = 'collected',submission_idreferencing that row, therequest_page_id, theinvitation_contact_idwhen present, and the sanitized body. - Insert
consentsrows (one per permission) with the verbatim text, hash, truncated IP and user agent, per Section 22. - Write
submissions.custom_field_values(the shape and the join are in Section 8.5.3) andtestimonials.case_study_fields(Section 8.6). Custom-field answers are written to the submission and never copied onto the testimonial. - Mint the respondent management token, store its hash, and mint the single-use submission token.
- Set
submitted_aton the draft. - Write the audit event (
testimonial.created, actor typerespondent) per Section 11.6. - Enqueue, via the transactional outbox so nothing fires on a rolled-back transaction:
notify.respondent_receipt,notify.workspace_new_submission,analytics.funnel_event, and — when media exists —media.finalize. - Commit, then return
201.
{
"data": {
"submissionToken": "Vq7…",
"testimonialId": "tst_01J8Z4F6G7H8J9K0L1M2N3P4Q5",
"submissionId": "sub_01J8Z4F6G7H8J9K0L1M2N3P4Q6",
"mode": "video",
"mediaState": "processing",
"managementUrl": "https://v.vouch.app/p/8Kd…",
"redirectUrl": null
},
"meta": { "requestId": "req_01J8Z4F6G7H8J9K0L1M2N3P4Q5" }
}8.10.6 Error codes introduced by this section #
Every code below is registered once in the catalogue in Section 29.3; this table is the
authoritative source for their meaning within collection, and every code named anywhere in
Section 8 appears here. Codes that belong to the whole API rather than to collection —
VALIDATION_FAILED, MALFORMED_JSON, INVALID_REQUEST_BODY, RATE_LIMITED,
IDEMPOTENCY_KEY_REQUIRED, IDEMPOTENCY_IN_PROGRESS and the request-size refusals — are used
by these endpoints with exactly the meanings Section 7 gives them and are not redefined here.
| Code | HTTP | When |
|---|---|---|
REQUEST_PAGE_NOT_FOUND |
404 | No live page matches the host + slugs. |
REQUEST_PAGE_GONE |
410 | The slug resolved to a soft-deleted page and no live page claims it. |
SLUG_RESERVED |
422 | Slug is in the reserved list. |
SLUG_TAKEN |
409 | Slug already used by a live page or an active alias in this workspace. |
SLUG_TOO_SIMILAR |
422 | Confusable-skeleton collision with an existing slug. |
SLUG_INVALID |
422 | Fails the pattern or length rules. |
CONTRAST_BELOW_MINIMUM |
422 | Publishing a page whose colours fail Section 21's thresholds. |
INVALID_REDIRECT_URL |
422 | Redirect URL fails the rules in Section 8.3.4. |
PROMPT_LIMIT_REACHED |
422 | More than 10 prompts. |
CUSTOM_FIELD_LIMIT_REACHED |
422 | More than 20 fields. |
CUSTOM_FIELD_KEY_LOCKED |
409 | Key change after the first submission. |
CUSTOM_FIELD_TYPE_IMMUTABLE |
409 | Type change after creation. |
DRAFT_EXPIRED |
410 | Unknown, expired or purged draft token. |
DRAFT_REVISION_CONFLICT |
409 | Concurrent draft write. |
COLLECTION_CLOSED |
409 | Page or workspace is not accepting submissions. |
CONSENT_REQUIRED |
422 | Required publication permission not granted. |
CONSENT_TEXT_MISMATCH |
409 | Client's consent text hash does not match the server's. |
SUBMISSION_TOO_FAST |
422 | Timing check failed (Section 8.13.2). |
CAPTCHA_FAILED |
422 | CAPTCHA token invalid or expired. |
EMAIL_VERIFICATION_REQUIRED |
422 | Page requires verification and the code was not confirmed. |
EMAIL_VERIFICATION_INVALID |
422 | Wrong or expired code. |
UNSUPPORTED_FILE_TYPE |
415 | File field received a type outside the allowlist. |
FILE_TOO_LARGE |
413 | File exceeds the field's maxSizeBytes. |
MEDIA_PIPELINE_UNAVAILABLE |
503 | The media provider or object storage is failing; the draft is preserved. |
INVITATION_NOT_FOUND |
404 | Invite token unknown. |
INVITATION_REVOKED |
410 | Invite was revoked or its campaign was deleted. |
INVITATION_ALREADY_USED |
409 | Single-use invite already converted and the page disallows repeats. |
INVITATION_ALREADY_PENDING |
409 | An unexpired, unconverted invitation for the same contact and page already exists. |
INVITE_LIMIT_REACHED |
409 | Send exceeds the per-workspace daily invitation cap (Section 8.11.3). |
CONTACT_LIMIT_REACHED |
409 | The workspace's stored-contact ceiling is reached (Section 8.11.1). |
PLAN_FEATURE_UNAVAILABLE |
409 | The workspace's plan does not include the collection feature being used — invitation sending (Section 8.11) or the SMS channel (Section 8.11.8). Section 19 owns the gating. |
CSV_PARSE_FAILED |
422 | Contact CSV could not be parsed. |
IMPORT_FILE_TOO_LARGE |
413 | Contact CSV exceeds the file-size limit in Section 16.3.2. |
IMPORT_ROW_LIMIT |
422 | Contact CSV exceeds the row limit in Section 16.3.2. |
STORAGE_LIMIT_EXCEEDED |
409 | No storage headroom for the upload the respondent is starting (Section 8.10.4). |
VIDEO_LENGTH_EXCEEDED |
422 | Declared recording length exceeds the page's maxVideoSeconds. |
UPLOAD_TOO_LARGE |
413 | Declared upload size exceeds the platform ceiling in Section 9.6. |
PART_SIZE_INVALID |
422 | A chunked-upload part is the wrong size for the negotiated protocol. |
Three codes are deliberately absent. There is no DRAFT_NOT_FOUND — an unknown token and
an expired one are indistinguishable by design (Section 8.10, rule 1), and a single
DRAFT_EXPIRED is what both produce. There is no 402 anywhere in collection: every capacity
and plan refusal above is a 409. And there is no 403 on any public endpoint in this
section, because an unauthenticated respondent has no permissions to lack — 403 is reserved
for role, scope and read-only-workspace refusals on the authenticated surfaces in Section 7.
8.11 Invitations #
An invitation is a request sent to a named contact, carrying a unique link, so the resulting testimonial is attributed to that contact automatically. Invitations are how workspaces actually collect at volume; a shareable link alone converts poorly because nobody feels personally asked.
Invitations are gated by plan: Free has one seat and no invitation sending. Section 19 owns the
gating; a blocked send returns 409 PLAN_FEATURE_UNAVAILABLE with an upgrade path in the
message, never a silent no-op and never a 402.
8.11.1 The model #
Three tables, described semantically here; Section 5 owns the DDL.
| Table | Purpose |
|---|---|
invitation_contacts |
A person the workspace may invite: email (required), optional phone, first name, last name, company, role, plus a customData JSONB for merge tokens. Unique per workspace on lowercased email. Soft-deleted with the workspace's contact list; never shared across workspaces. A workspace may store 100,000 contacts; the 100,001st returns 409 CONTACT_LIMIT_REACHED. This is a platform constant, identical on every paid plan — it is an abuse ceiling, not a thing the plan matrix sells — and it is an order of magnitude above the largest daily send cap so that it is never the limit a real workspace meets first. |
invitation_campaigns |
A batch: a name, the target request page, the channel (email, sms, both), the message template reference (Section 20 owns templates), the scheduled send time and timezone, the reminder policy reference, the status, and aggregate counters. |
invitations |
One row per contact per campaign: the hashed token, the state, the timestamps for sent/delivered/opened/clicked/started/submitted/bounced, the reminder count, and the resulting testimonial_id when converted. |
Invite state machine (invite_status enum — fourteen values). Note the naming carefully:
this is invite_status on invites, which tracks a testimonial request sent to a contact
and has a full delivery funnel. It is a different enum from invitation_status on
invitations, which tracks a workspace seat invitation and has only four values
(pending, accepted, expired, revoked — Section 6.8 owns it). A seat invitation has no
delivery funnel and must never be given one.
queued → sending → sent → delivered → opened → clicked → started → submitted
↘ bounced
↘ complained
queued|sending|sent|delivered|opened|clicked|started → canceled
any non-terminal → expired| State | Meaning | Set by |
|---|---|---|
queued |
Row created, not yet dispatched | Campaign creation |
sending |
Handed to the delivery worker | invites.send job |
sent |
Accepted by the email/SMS provider | Provider API 2xx |
delivered |
Provider confirmed delivery | Provider webhook |
opened |
Email open pixel fired (email only, best-effort) | Provider webhook |
clicked |
The unique link was resolved | POST /invitations/{token}/open |
started |
A draft was created from the link | Draft creation |
submitted |
A testimonial was created from the link | Submit endpoint |
bounced |
Hard or soft bounce | Provider webhook |
complained |
Spam complaint | Provider webhook |
canceled |
Campaign canceled before send, or invitation revoked | Owner/Admin/Editor action |
expired |
Token TTL elapsed without submission | invites.expire job |
opened is explicitly best-effort and is never presented as a reliable number: image blocking
and privacy proxies make open tracking noisy, and the dashboard labels it "Opened
(approximate)". Open tracking is a per-campaign toggle, default off, because a tracking
pixel is a third-party-ish artefact the platform would rather not ship by default. Click
resolution is server-side and exact, so clicked is the metric the funnel actually uses.
Token rules. 32 cryptographically random bytes, base64url (43 characters), stored as a SHA-256 hash, single-use for conversion but re-openable any number of times before conversion. TTL is a campaign setting, default 30 days, minimum 1 day, maximum 180 days. An expired token resolves to a friendly page offering the public request-page link, so a late click is never a dead end.
Repeat submissions. A per-page setting allowRepeatSubmissions (default false) controls
what happens when an already-converted invitation link is opened again. When false, the link
shows "You've already sent yours — thank you" plus the respondent's management link (re-sent by
email rather than displayed, so a forwarded link cannot expose someone else's data). When
true, a new draft is created and the second testimonial is attributed to the same contact.
8.11.2 Single invitation #
The dashboard's "Invite someone" form: email (required), first name, last name, company, role,
optional phone for SMS, the request page, an optional personal note (0–500 characters, plain
text, injected into the template as {{personalNote}}), and send-now-or-schedule.
Sending creates a one-contact campaign so that every invitation, single or bulk, flows through
identical machinery — there is no second code path. The campaign is named
"{contact email} · {date}" automatically and is hidden from the campaign list by default
under a "Single invites" grouping.
POST /api/v1/invitations (authenticated, scope testimonials:write, permission
invitation:create):
{
"requestPageId": "rqp_01J8Z2M6K7Q0X4V9N3TAB5CDEF",
"channel": "email",
"contact": {
"email": "priya@northwindtools.example",
"firstName": "Priya",
"lastName": "Raman",
"company": "Northwind Tools",
"role": "Head of Support"
},
"personalNote": "You mentioned the triage change made a real difference — would you say that publicly?",
"sendAt": null,
"reminderPolicyId": "01J8Z2P7R8S9T0U1V2W3X4Y5Z6"
}Responses: 201 with the invitation resource; 422 VALIDATION_FAILED for a malformed email;
409 INVITATION_ALREADY_PENDING when an unexpired, unconverted invitation for the same
contact and page already exists (with the existing invitation in details, and the UI offering
"Send a reminder instead"); 409 PLAN_FEATURE_UNAVAILABLE when the workspace's plan does not
include invitation sending.
8.11.3 Bulk invitation by CSV #
Upload. POST /api/v1/invitation-imports accepts multipart/form-data with one file.
| Constraint | Value |
|---|---|
| Max file size | The platform CSV file-size limit stated in Section 16.3.2 (413 IMPORT_FILE_TOO_LARGE) |
| Max rows | The platform CSV row limit stated in Section 16.3.2 (422 IMPORT_ROW_LIMIT). A larger contact list is uploaded as consecutive files into the same campaign, which the campaign editor offers explicitly; the daily send cap below, not the file limit, is what actually governs how fast a large list goes out |
| Accepted types | text/csv, text/plain, application/vnd.ms-excel (sniffed, not trusted) |
| Encoding | UTF-8, UTF-8 with BOM (stripped), UTF-16LE/BE (detected by BOM and transcoded), and Windows-1252 as a fallback when UTF-8 decoding fails. The detected encoding is shown in the preview so the workspace can catch mojibake before sending. |
| Delimiter | Auto-detected from the first 5 lines among ,, ;, \t, ` |
| Quoting | RFC 4180. Embedded newlines inside quotes are supported. |
| Line endings | \n, \r\n, \r all accepted. |
Header detection. The first row is treated as a header when at least half of its cells are non-numeric, non-empty, and unique after normalization. The UI shows the decision and a "First row is data, not headers" toggle. With no headers, columns are labelled "Column A", "Column B", …
Column mapping UI. A two-column mapper: detected source columns on the left, target fields on the right. Auto-mapping runs first, matching normalized header names (lowercased, non-alphanumerics stripped) against a synonym table:
| Target | Required | Synonyms matched automatically |
|---|---|---|
email |
Yes | email, emailaddress, e-mail, mail, workemail, contactemail, primaryemail |
firstName |
No | firstname, first, givenname, fname, forename |
lastName |
No | lastname, last, surname, familyname, lname |
fullName |
No | name, fullname, contactname, displayname — split on the last space into first/last when firstName is unmapped |
company |
No | company, companyname, organization, organisation, account, accountname, employer |
role |
No | role, title, jobtitle, position |
phone |
No | phone, mobile, cell, phonenumber, telephone, msisdn |
locale |
No | locale, language, lang — validated against the five supported locales, ignored otherwise |
customData.* |
No | Any unmapped column may be mapped to a custom merge key ^[a-z][a-z0-9_]{0,31}$, becoming {{custom.key}} in templates |
| (ignore) | — | Explicitly discard a column |
The mapping is remembered per workspace (invitation_import_mappings, keyed by a hash of the
sorted normalized header names) so re-uploading next quarter's export from the same CRM
auto-maps without any clicking.
Validation report — shown before anything is sent. Parsing produces a preview of the first 20 rows plus a full validation pass over every row, categorized:
| Category | Rule | Default action |
|---|---|---|
| Valid | Parseable, email valid, not a duplicate, not suppressed | Include |
| Invalid email | Fails RFC-shape check, or has a domain with no dot, or exceeds 254 characters | Exclude, listed with the row number and the raw value |
| Duplicate in file | Same lowercased email appears more than once in the upload | Keep the first occurrence, merging non-empty values from later ones into empty fields of the first; the merge is shown explicitly |
| Duplicate against existing contacts | Email already in invitation_contacts |
Include, and update the stored contact's empty fields from the CSV; never overwrite a non-empty stored value without an explicit "let the file win" toggle |
| Already invited to this page | An unexpired, unconverted invitation exists | Excluded by default with a "Send a reminder to these instead" action |
| Already submitted | The contact already converted for this page | Excluded, not offerable — a per-campaign override exists only when allowRepeatSubmissions is on |
| Suppressed | On the workspace's suppression list (unsubscribed, hard-bounced, complained) | Excluded, non-overridable. Section 20 owns the suppression list. This is the one exclusion the workspace cannot override, because sending to it damages deliverability for every workspace on the platform. |
| Disposable domain | Domain matches the disposable list | Included with a warning flag (Section 8.14.2) |
| Role-account | Local part is info, sales, support, admin, noreply, hello, contact, billing, office, team |
Included with a warning — these convert poorly and often reach a shared inbox |
| Missing name | No firstName and no fullName |
Included with a warning, because {{firstName}} will fall back (Section 8.11.4) |
The report renders as counts plus an expandable list per category, and is downloadable as a
CSV with an added vouch_status and vouch_reason column so the workspace can fix the source
data. Nothing is sent until the workspace explicitly confirms on a summary screen that
states: "You're about to email 1,842 people. 63 rows were excluded." The confirm button is
disabled for two seconds after the screen appears, which prevents the muscle-memory
double-click that sends a batch nobody reviewed.
Import states. invitation_imports.status: uploaded → parsing → mapping →
validating → ready → confirmed → dispatching → completed, plus failed and
canceled. Parsing and validation run in a worker job (invites.parse_csv) for files over
500 rows, with progress polled by the UI; smaller files parse synchronously. An import
abandoned in any pre-confirmed state is deleted, along with its uploaded file, after 7 days.
Send caps. To protect deliverability, sends are throttled and capped:
| Cap | Value | Behaviour when exceeded |
|---|---|---|
| Per-workspace invite send rate | The invite-send rate-limit class in Section 7.10.2 |
Excess is queued, not rejected; the campaign simply takes longer and the UI shows the estimated completion time. |
| Per-workspace daily invitations | 2,000 on Starter, 10,000 on Growth, 50,000 on Scale | 409 INVITE_LIMIT_REACHED at confirm time, with the remaining allowance and the reset time in details. A campaign larger than the daily cap is accepted and spread across days automatically when spreadAcrossDays is enabled (default true), so this is a pacing limit rather than a refusal for anyone who leaves the default on. |
| New-workspace warm-up | First 7 days: 200/day, regardless of plan | Same behaviour; the UI explains it is temporary and automatic. |
| Bounce-rate circuit breaker | A campaign whose hard-bounce rate exceeds 8% over the first 200 sends | The campaign auto-pauses, the Owner and Admins are emailed, and the remaining invitations stay queued until a human resumes or cancels. |
8.11.4 Personalization tokens #
Tokens are {{token}} with optional whitespace inside the braces. They are resolved in
message templates (Section 20 owns the templates themselves) and in the request page copy
fields listed in Section 8.3.3.
| Token | Source | Fallback when empty |
|---|---|---|
{{firstName}} |
Contact first name | "", and the surrounding sentence is rewritten by the template's conditional block (see below) |
{{lastName}} |
Contact last name | "" |
{{fullName}} |
firstName + " " + lastName, trimmed |
Contact email local part, title-cased |
{{email}} |
Contact email | — (always present) |
{{company}} |
Contact company | "your team" |
{{role}} |
Contact role | "" |
{{personalNote}} |
Campaign personal note | "" |
{{workspaceName}} |
Workspace display name | — (always present) |
{{senderName}} |
The user who created the campaign | Workspace name |
{{senderRole}} |
That user's role text, workspace-configurable | "" |
{{requestPageName}} |
Request page name |
Workspace name |
{{requestUrl}} |
The unique per-contact invitation URL | — (always present) |
{{unsubscribeUrl}} |
One-click unsubscribe URL | — (always present, mandatory in every email) |
{{custom.<key>}} |
invitation_contacts.customData[key] |
"" |
Conditional blocks. Templates support exactly one control structure, deliberately minimal:
{{#if firstName}}Hi {{firstName}},{{else}}Hi there,{{/if}}#if tests non-empty after trim. No loops, no expressions, no nesting beyond two levels, no
arbitrary code. The template compiler rejects anything else at save time with a precise
error and a caret pointing at the offending character.
Escaping rule — stated once and enforced everywhere. Every substituted value is escaped for its output context by the renderer, never by the template author:
| Context | Escaping applied |
|---|---|
| HTML email body | HTML-escape & < > " ' → entities. A contact named O'Brien & Sons <Ltd> renders literally. |
| Plain-text email body | No escaping; newlines in values collapsed to spaces. |
| Email subject line | Newlines and control characters stripped (header-injection defence); the result is RFC 2047-encoded by the mail library. |
| SMS body | Newlines preserved, GSM-7 vs UCS-2 detected for segment counting; no escaping. |
| URL query values | encodeURIComponent. |
| Request page HTML copy | HTML-escape, applied after the rich-text sanitizer so a value can never introduce markup. |
| CSV export | Values beginning =, +, -, @, tab or carriage return are prefixed with ' to defeat spreadsheet formula injection. |
Unknown tokens are left as literal text rather than rendered empty, so a typo like
{{firstname}} is visible in the preview instead of silently producing "Hi ,". The template
editor lints for unknown tokens at save and warns.
Preview. The campaign editor renders the email against a real contact chosen from the
import (or a synthetic one for a single invite), in HTML and plain text, at 360 px and desktop
widths, plus a "send me a test" button that delivers to the current user's address with a
[TEST] subject prefix. Test sends do not create invitation rows and do not consume the daily
cap, but are rate limited to 10 per user per hour.
8.11.5 Scheduling and timezones #
| Setting | Rules |
|---|---|
sendAt |
null = send now. Otherwise an ISO-8601 instant, must be ≥ 5 minutes and ≤ 180 days in the future. |
sendTimezone |
IANA zone identifier (e.g. Europe/Berlin), defaulting to the creating user's profile timezone, which itself defaults to the browser's Intl.DateTimeFormat().resolvedOptions().timeZone at signup. The UI always displays both the local time and the resolved UTC instant, e.g. "Tue 3 Mar, 09:00 Europe/Berlin (08:00 UTC)". |
respectRecipientTimezone |
Boolean, default false. When true and the contact has a timezone in customData, the send is scheduled for the same wall-clock time in the contact's zone; contacts without a zone use sendTimezone. |
| Business-hours guard | Optional sendWindow of { startHour, endHour, days[] } in the effective timezone, default { 8, 18, [1,2,3,4,5] } and off unless enabled. A send that would land outside the window is deferred to the next window opening, and the campaign UI states the deferral explicitly rather than silently moving it. |
| DST | Scheduling stores the local wall-clock time plus the zone, and the UTC instant is computed at dispatch time. For a time that does not exist on a spring-forward day, the send moves to the first valid instant after the gap. For an ambiguous time on a fall-back day, the first (earlier) occurrence is used. Both behaviours are stated in the UI's help text. |
| Cancellation | A scheduled campaign can be canceled up to the moment dispatch begins. Once dispatching, cancellation stops the remaining queued rows but cannot recall what has already been handed to the provider — the UI says exactly that and shows the count already sent. |
Dispatch is a BullMQ delayed job per campaign that fans out into per-invitation jobs at the throttle rate; the job catalogue is in Section 29.5.
8.11.6 Reminders #
Reminder cadence, templates and copy are owned by Section 20. This section specifies only how invitations bind to them:
- A campaign references a
reminderPolicyId. The default policy shipped with every workspace is: reminder 1 at +3 days, reminder 2 at +7 days, then stop. Two is also the maximum — no policy, custom or default, may schedule a third, and the editor refuses to add one. This is the respondent-protection principle in Section 20.1 ("one invite, at most two reminders, nothing else, ever"), and it is enforced by a database constraint onreminder_countas well as in the scheduler, so no code path can exceed it. - Reminders are sent only to invitations in
sent,delivered,openedorclicked— never tostarted(they are mid-flow; a reminder would be rude),submitted,bounced,complained,canceledorexpired. - An invitation that reaches
startedand then goes idle for 24 hours receives at most one "you left something half-finished" message containing the draft resume URL, and only if the draft has not expired. This is a distinct template from the reminder series and does not count against the reminder maximum. - Each reminder increments
reminder_countand stampslast_reminded_at. The scheduler skips any invitation whose contact hit the workspace-wide frequency cap (default: no more than one message per contact per 48 hours across all campaigns, configurable 24–168 hours). - Every reminder carries the same one-click unsubscribe (
List-UnsubscribeandList-Unsubscribe-Postheaders) as the original.
8.11.7 Attribution and the anonymous case #
Attributed path. The contact clicks https://v.vouch.app/i/{token}. The edge resolves the
token, records clicked, mints a short-lived attribution token (10 minutes, single use,
bound to the invitation), and 302s to the canonical request-page URL with ?a={attributionToken}.
The landing page exchanges the attribution token for the draft's invitation_contact_id at
draft-creation time, then drops it from the URL via replaceState. From that point the draft
— and the resulting testimonial — carry the contact id.
The two-token dance exists so that the long-lived invitation token never sits in the address bar of a page the respondent might screenshot, share or leave open on a shared device.
Consequences of attribution:
- The testimonial's respondent details are pre-filled from the contact (name, role, company, email) and are fully editable — pre-filling that cannot be corrected is worse than no pre-filling. Section 21's redundant-entry criterion is satisfied.
- The moderation inbox shows "Invited by {senderName} · {campaignName}".
- The campaign's conversion metrics attribute the submission.
- Reminders for that invitation stop immediately.
- The contact record's
lastSubmittedAtis stamped.
Anonymous path. Any submission arriving without an attribution token —
from the shared link, a QR code, an email signature, a social post, or a forwarded invitation
opened after conversion — has invitation_contact_id = NULL. It is a first-class,
fully-supported case, not a degraded one. The moderation inbox labels it "Direct link" and the
funnel counts it in a separate "Not invited" series.
Forwarded-invitation heuristic. If a submission arrives with an attribution token but the
submitted email differs from the contact's email (case-insensitive, after normalizing
gmail.com dots and +tags), the platform:
- Attributes the testimonial to the submitted email, creating a new contact if needed.
- Records
forwardedFrom = {originalContactId}on the invitation. - Leaves the original invitation in
clickedso the intended recipient still gets reminders. - Flags the testimonial in the inbox with "Submitted by a different address than invited", which is information, not suspicion.
8.11.8 SMS invitations #
Available on Starter and above where a phone number exists, using the SMS provider named in Section 3.
- The body is capped at 300 characters after token substitution, and the composer shows live segment counting with the GSM-7/UCS-2 encoding indicator (one emoji turns a 1-segment message into a 3-segment one, and the workspace should see that before sending).
- The link is always the short form
https://v.vouch.app/i/{token}— never a shortener owned by anyone else. - Every SMS ends with the platform-enforced suffix
Reply STOP to opt out.which cannot be removed. InboundSTOP/UNSTOPhandling writes to the same suppression list as email. - Phone numbers are validated and normalized to E.164 at import; unparseable numbers are
excluded from the SMS channel with a warning but still receive email when the channel is
both. - Country-level opt-outs, quiet-hours rules and sender-id requirements vary by destination; the platform enforces a hard quiet-hours window of 21:00–08:00 in the recipient's country (derived from the E.164 country code) and defers sends that would land inside it.
8.12 One-Click Sharing Surfaces #
Not every testimonial comes from an invitation. These four surfaces exist so a workspace can ask in person, in a footer, on a receipt, or on a conference badge.
8.12.1 Copy link #
A single button beside every request page in the dashboard and at the top of the page editor.
It copies the canonical URL (Section 8.2.1) using the async clipboard API, falling back to
a selected read-only input with a "press ⌘C" hint when the clipboard permission is denied. The
button's accessible name changes to "Link copied" for 3 seconds and the change is announced in
a polite live region. The copied URL carries no tracking parameters — the workspace can add its
own UTM parameters via a "Add campaign parameters" disclosure that appends utm_source,
utm_medium, utm_campaign, utm_content and utm_term (each validated as
^[A-Za-z0-9_.\-]{1,64}$) and are preserved through to the funnel report as a
source dimension.
8.12.2 QR code #
For in-person collection: events, receipts, table tents, packaging inserts, the back of a business card.
| Property | Specification |
|---|---|
| Encoded payload | The canonical page URL, or the short link when the canonical URL exceeds 80 characters (shorter payloads mean lower QR versions, larger modules, and far better scanning at distance). |
| Generation | Server-side, in the worker, using a pure TypeScript QR encoder in packages/core/src/qr. No third-party image service is ever called — the URL of a request page is not something to hand to a free QR site. |
| Error correction level | Q (25%) by default. Rationale: L is too fragile for print that will be handled, and H inflates the module count for long URLs. The workspace may override to L, M, Q, H; the UI explains the trade-off in one sentence each. |
| Version | Automatically the smallest version that fits the payload at the chosen error-correction level; capped at version 10 (57 × 57 modules) — beyond that, the platform silently switches to the short link to keep the code scannable. |
| Quiet zone | 4 modules, always, non-configurable. |
| Output formats | SVG (vector, the default for print), PNG at 512, 1024 and 2048 px, and PDF at 300 dpi sized for a 50 mm square. |
| Minimum print size | The UI states: at least 25 mm square for a payload scanned from ~30 cm, at least 100 mm for 1 m, with the rule of thumb "scanning distance ÷ 10". |
| Colour | Foreground defaults to #000000, background #ffffff. Custom colours are allowed but the generator refuses any pair below 4:1 contrast (a lower ratio breaks scanners before it breaks eyes) and says why. Inverted (light-on-dark) codes are permitted only with the explicit "some older scanners will fail" acknowledgement. |
| Logo overlay | Optional, the workspace logo centred at a maximum of 20% of the code's area, permitted only at error-correction level Q or H, with a white 2-module padding ring. The preview shows a scan-test reminder. |
| Caching | Generated SVG/PNG are cached in object storage under qr/{workspaceId}/{pageId}/{settingsHash}.{ext} and served from the CDN with public, max-age=31536000, immutable. Changing settings changes the hash, so nothing is ever stale. |
| Accessibility | The dashboard renders the QR with role="img" and an accessible name "QR code for {page name}", and always shows the plain URL beneath it as selectable text — a QR code alone is unusable to someone who cannot point a camera at it. |
| Analytics | QR scans arrive as ordinary page views. When the workspace enables campaign parameters, the QR payload includes them, so scans are distinguishable in the funnel's source dimension. |
8.12.3 Email-signature snippet #
A small, copy-pasteable HTML block for a team member's mail signature:
<a href="https://v.vouch.app/r/acme/spring-2026"
style="display:inline-block;font:14px -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#1f4fd8;text-decoration:none">
<img src="https://cdn.vouch.app/sig/01J8Z2M6K7Q0X4V9N3TAB5CDEF/star.png"
width="16" height="16" alt="" style="vertical-align:-3px;border:0" />
Happy with Acme? Leave us a testimonial →
</a>Rules: inline styles only (mail clients strip <style>), absolute HTTPS URLs, an explicit
width/height on the image, alt="" because the adjacent text carries the meaning, a
system font stack, no web fonts, no background images, no <table> unless the workspace picks
the "table-safe" variant offered for Outlook. Three presets are offered — text-only, text +
star glyph, and a compact badge — each with a one-click copy of both the HTML and a
"paste-into-Gmail" rendered version. The snippet's link is the canonical URL plus optional
campaign parameters; it is never per-user, so a signature never needs updating when someone
leaves.
8.12.4 Short link #
https://v.vouch.app/s/{shortCode} where shortCode is 7 characters from the alphabet
23456789abcdefghjkmnpqrstuvwxyz (Crockford-style: no 0/o, 1/l/i, u removed to avoid
accidental profanity), giving ~34 bits of space. Codes are generated randomly and checked for
collision, retrying up to 5 times, and are screened against a profanity list in the five
supported locales.
- One short link per request page, created on demand and stable thereafter. Regeneration is possible but warns that printed materials will break.
- Resolution is a
302 FoundwithCache-Control: private, no-storeso a changed canonical host takes effect immediately. - Short links resolve only to request pages and showcase pages within the platform. There is no general-purpose URL shortener here, ever — that would make the domain an open redirector and a phishing asset.
- Scans and clicks are counted in the funnel as
source = short_link.
8.13 Spam and Abuse Controls on the Respondent Side #
The design rule: real respondents must feel nothing. Every control below is either invisible or explicitly opt-in.
8.13.0 Framing control — allowedFrameOrigins #
This one comes first because it protects the most sensitive capture surface in the product, and because the obvious reasoning about it is backwards.
A request page invites a member of the public to switch on their camera and microphone. It is
tempting to argue that framing is safe because camera access requires the framing page to grant
allow="camera; microphone" — but in a UI-redress attack the attacker is the framing page
and grants that permission freely. An attacker who can frame a live request page can embed a
real workspace's recorder inside "Record a 30-second clip to claim your prize", or overlay it
transparently beneath a decoy button, and harvest a stranger's face, voice, name, email and
employer — submitted with a genuine consent record, against a workspace that never solicited it.
Therefore a request page is never framable by an arbitrary origin. Each request page carries
an allowedFrameOrigins setting on its configuration model (Section 8.3):
| Value | Meaning |
|---|---|
[] (default) |
The page may be framed only by its own origin. |
| A list of origins | Each must be a hostname the workspace has already proved it controls, using the same DNS-challenge verification as a custom domain (Section 14.2). An unverified origin is rejected at save time with 422 FRAME_ORIGIN_NOT_VERIFIED. |
The response's Content-Security-Policy sets frame-ancestors 'self' plus the workspace's
verified entries, emitted per request. frame-ancestors https: — allowing any HTTPS origin —
must never be used on a request page, and Section 22.17 carries the same rule for the surface
table.
When a request page is framed by a permitted ancestor, it renders a provenance interstitial
before getUserMedia may be called: the real workspace's name, logo and canonical request-page
URL, plus a "this recording will be sent to {workspace}" line and a continue control. The
interstitial is not dismissible by the framing page and is keyboard-focusable per Section 21.
A respondent should never be able to record a testimonial without knowing who receives it.
8.13.1 Honeypot #
Every submission form renders one decoy field:
<div class="v-hp" aria-hidden="true">
<label for="v-company-website">Company website</label>
<input type="text" id="v-company-website" name="companyWebsite"
tabindex="-1" autocomplete="off" />
</div>- Hidden with
position:absolute; left:-9999px; width:1px; height:1px; overflow:hidden— notdisplay:none, because some bots skipdisplay:nonefields. aria-hidden="true"plustabindex="-1"so screen-reader and keyboard users never reach it. A honeypot that traps assistive-technology users is an accessibility failure, not a spam control.- The field name is plausible (
companyWebsite) and is rotated among five plausible names, chosen deterministically per request page so a bot cannot learn one global name but the server always knows which to expect. - Behaviour when filled: the request is accepted, returns a normal-looking
201with a synthesized submission token that resolves to a generic thank-you, and nothing is written to the database. A counterhoneypot_triggered_totalincrements with the page id. Telling a bot it failed just teaches it to try again.
8.13.2 Timing check #
The draft records client_started_at server-side at draft creation. At submit, the server
computes elapsed time from its own clock (the client-reported elapsedMs is advisory and
logged for analysis, never trusted).
| Condition | Action |
|---|---|
| Elapsed < 4 seconds for a text submission | 422 SUBMISSION_TOO_FAST with copy "That was quick — please take another look and send again." A real respondent who somehow triggers it simply presses submit again, and the second attempt has a fresh elapsed time. |
| Elapsed < 4 seconds for a video submission | Impossible in practice (a recording takes longer), so it is treated as a hard bot signal: silent tarpit, same as the honeypot. |
| Elapsed > 24 hours | The draft has expired; 410 DRAFT_EXPIRED. |
The threshold is deliberately low. The goal is to catch scripted posts, not to punish fast typists, and a 4-second floor has never been reached by a human filling in a name, an email and 40 characters of prose.
8.13.3 Rate limits #
Per-IP submission limits are the numbers stated in Section 7.10 and are enforced at the edge before any database work. The IP is used only for the limit and is stored, if at all, as a keyed hash (Section 8.9.3). Additional collection-specific limits, all token-bucket in the cache layer:
| Bucket | Limit | Response on exhaustion |
|---|---|---|
| Draft creation per IP | 10 per 10 minutes | 429 with Retry-After; the landing page shows "Too many attempts — try again in a moment." |
| Draft saves per draft | 120 per 10 minutes | 429; the client backs off to a 10-second save cadence and keeps working from memory. |
| Submissions per IP | The submission class in Section 7.10.2 |
429 RATE_LIMITED with Retry-After. |
| Email-verification sends per draft | 3 per hour | 429 with the exact wait time. |
| File uploads per draft | 10 per hour | 429. |
Shared-IP realism: an office or a conference NAT will legitimately produce many submissions
from one address. The per-IP submission bucket therefore refills continuously rather than
resetting hourly, and a workspace can request a per-page allowlist entry (request_page_ip_allowlist,
CIDR, max 20 entries, Owner/Admin only) for a known event network — the entry raises the
limit tenfold and expires automatically after 72 hours.
8.13.4 Optional CAPTCHA #
Off by default. Documented as a toggle. Never enabled silently.
captchaMode on the request page: off (default), on_suspicion, always.
| Mode | Behaviour |
|---|---|
off |
No CAPTCHA script is loaded. Zero third-party requests. |
on_suspicion |
The challenge widget is loaded lazily and shown only after a submission attempt is rejected for a soft signal (rate limit hit, near-duplicate content, disposable-domain plus role-account, or three failed validations in 60 seconds). The respondent sees "Quick check that you're human" and continues. |
always |
The widget renders on the consent step, before submit. |
Provider: a privacy-respecting, cookie-free challenge that issues a verifiable token
(Cloudflare Turnstile as the shipped default, with an hCaptcha adapter behind the same
CaptchaProvider interface in packages/core/src/anti-abuse). Requirements the interface
enforces regardless of provider: no cookies, no cross-site identifiers, no behavioural
profiling beyond the challenge itself, a documented accessible alternative, and a keyboard-only
path. Verification happens server-side against the provider's siteverify endpoint with a
5-second timeout; a provider outage fails open (the submission proceeds, the event is
logged and counted) because losing a real testimonial to a CAPTCHA vendor's downtime is a worse
outcome than accepting a rare bot.
The dashboard's toggle carries a plain-language note: "CAPTCHAs cost you real submissions. Leave this off unless you're actually seeing spam."
8.13.5 Respondent email verification #
requireEmailVerification on the request page, default false.
When on, the details step gains a "Verify" affordance next to the email field. Pressing it
sends a 6-digit numeric code (crypto-random, no ambiguous formatting, 10-minute TTL, 5 attempts
then invalidated, at most 3 sends per draft per hour). The code entry is a single
<input inputmode="numeric" autocomplete="one-time-code" maxlength="6"> — one field, not six
boxes, because six boxes break paste, break screen readers and break autofill. On success, the
draft is stamped emailVerifiedAt and the field becomes read-only with a "Verified" text
indicator plus an icon and a "Change" button.
Submitting without verification when the page requires it returns 422 EMAIL_VERIFICATION_REQUIRED. Verification survives a refresh because it lives on the draft.
The email itself is sent through the transactional provider named in Section 3 with the
template owned by Section 20.
8.14 Quality Controls for the Workspace #
These do not block the respondent; they inform the moderator.
8.14.1 Duplicate-submission detection #
Three independent signals, evaluated at submit and recorded on the testimonial as
quality_flags (a JSONB array of { code, severity, detail }):
| Signal | Rule | Severity |
|---|---|---|
| Same email, same page | An existing non-deleted testimonial for this page has the same normalized email (lowercased, gmail-style dots and +tags stripped for known providers) |
info when the earlier one was more than 90 days ago, warning otherwise |
| Near-duplicate text | The normalized body (NFC, lowercased, punctuation stripped, whitespace collapsed) produces a SimHash within a Hamming distance of 3 of any existing body in the workspace, or an exact SHA-256 match | warning for near, high for exact |
| Same IP hash within 10 minutes | Two submissions to the same workspace from the same keyed IP hash inside 10 minutes | info — this is normal at events and must not be treated as fraud |
Flags are advisory. No submission is ever silently rejected for being a possible duplicate, because a genuine second testimonial from a returning customer is valuable and the platform cannot tell the difference. The moderation inbox surfaces flags as filterable, text-labelled chips (Section 11 owns the inbox).
8.14.2 Disposable-email detection — a warning, never a block #
A maintained list of disposable/temporary email domains ships in packages/core and is
refreshed by a weekly job (maintenance.refresh_disposable_domains) from a pinned public
dataset, with the previous list retained so a bad refresh can be rolled back.
- A matching domain adds a
disposable_emailquality flag atwarning. - The respondent sees nothing. They are not told, not blocked, not asked to use another address. Some people use forwarding services for entirely reasonable privacy reasons.
- The moderator sees a text-labelled chip and can filter by it.
- The list is also applied at CSV import time (Section 8.11.3) as a report warning.
MX-record lookups are performed asynchronously after submission, never inline, and a
domain with no MX record adds an undeliverable_email flag at warning so a moderator knows
the management link probably bounced.
8.14.3 Suspicious-submission flags #
Additional flags raised at submit:
| Flag | Trigger |
|---|---|
link_heavy_body |
The text body contains more than 2 URLs, or URLs make up more than 15% of its characters |
body_language_mismatch |
The body's detected language differs from the request page locale with confidence > 0.8 |
profanity_detected |
The body matches the profanity list for the detected language (informational; the platform does not moralize about a customer's own testimonials, but a moderator should know before it lands on a homepage) |
all_caps_body |
More than 60% of alphabetic characters are uppercase across a body longer than 60 characters |
role_account_email |
The email local part is a role account (list in Section 8.11.3) |
rating_text_mismatch |
A 1- or 2-star rating alongside a body whose sentiment scores strongly positive, or a 5-star rating with a strongly negative body — surfaced because it usually means the rating control was misread |
honeypot_adjacent |
The submission passed the honeypot but arrived with a user agent on the known-bot list from Section 17 |
Every flag is text-labelled, never colour-only, carries a one-line plain-English explanation in the inbox, and can be dismissed by a moderator (which writes an audit event). Flags never affect publication automatically; they are information for a human.
8.15 The Dashboard Side #
Permissions throughout follow the role matrix in Section 6: Owner, Admin and Editor may create and edit request pages; Viewer has read-only access to pages and their funnel reports.
8.15.1 The request-page list #
Columns: name, status pill (text + icon, never colour alone), submissions in the last 30 days, total submissions, conversion rate, last submission time, and an actions menu. Filters: status, created-by, has-invitations, date range. Sort: last activity (default), name, submissions, conversion rate. Cursor-paginated per Section 7.4 — there is no page-number control anywhere in the product.
Empty state: a single primary action ("Create your first request page") plus a three-line explanation and a link to a pre-built example page the workspace can duplicate.
8.15.2 The editor #
A three-pane layout on desktop (≥ 1024 px): a settings navigator on the left, the settings form in the middle, and a live preview on the right. Below 1024 px the preview collapses into a "Preview" tab.
Settings groups, in order: Basics (name, slug, status, schedule) · Branding · Copy · Prompts · Fields (custom fields and the case-study toggle) · Collection settings (rating, video/text availability, max length reminder, repeat submissions) · Anti-abuse · Sharing · Notifications (who gets told about a new submission — Section 20 owns delivery).
Editor behaviour:
- Autosave to a working draft of the configuration every 2 seconds of idle, with an
explicit "Publish changes" action for
activepages. Anactivepage's live version never changes until publish, so a half-edited headline is never served to a respondent. - The unpublished-changes state is shown as a persistent bar with "You have unsaved changes" and both "Publish" and "Discard".
- Optimistic concurrency on a
configVersioninteger: a second editor saving over a stale version gets409 REQUEST_PAGE_VERSION_CONFLICTwith a diff dialog listing exactly which keys differ, and the choice to keep theirs, take the other, or merge per key. - Every publish writes an audit event and stores the full previous configuration in
request_page_versions, keeping the last 30 versions per page. Any stored version can be restored in one action, which itself creates a new version rather than rewinding history. - Preview renders the real respondent page inside an iframe pointing at the authenticated
preview route, with a device toggle (360 px, 768 px, 1280 px), a theme toggle
(light/dark/auto), and a "with an invitation" toggle that fills the merge tokens with a
sample contact. Preview never creates drafts, never fires analytics, and never counts in the
funnel. A
data-preview="true"attribute on the root element makes that assertable in tests. - The preview's contrast audit runs live, showing the findings from Section 8.3.5 inline beside the colour controls.
8.15.3 Duplicating a page #
"Duplicate" copies: branding, copy, prompts, custom field definitions, case-study config,
collection settings and anti-abuse settings. It does not copy: submissions, invitations,
campaigns, the slug (a new one is derived with a -copy suffix, then de-duplicated per Section
8.2.2), the short link, the QR cache, or the funnel history. The duplicate is created in
draft status regardless of the source's status. A confirmation dialog states exactly what
carries over.
8.15.4 Archiving and deleting #
- Archive sets
status = 'archived'. Existing testimonials are untouched and stay published. The URL keeps working and renders the archived closed state. This is the recommended way to retire a campaign. - Delete is a soft delete (
deleted_at), permitted only fromdraftorarchived, and requires typing the page name to confirm. Testimonials collected through the page are not deleted — they lose nothing but their link back to a live page, and the inbox shows the page's last-known name. Deleting a page with active invitations cancels thequeuedones and leaves already-sent links resolving to a friendly "this page is closed" screen. - A soft-deleted page can be restored within 30 days from a "Recently deleted" view; after 30 days a maintenance job hard-deletes the row and its child configuration (prompts, field definitions, versions, aliases), leaving testimonials intact with their denormalized page name.
8.15.5 Contact and campaign management #
- Contacts list with search (email, name, company), filters (has submitted, bounced, suppressed, invited to page X), bulk delete, CSV export, and a per-contact detail drawer showing every invitation, its state, and the resulting testimonial.
- Campaigns list with status, recipient count, and the five funnel counters. A campaign detail view shows the per-invitation table with state, timestamps and a per-row "resend" (subject to the frequency cap) and "revoke".
- Suppression list view, read-only except for the ability to remove a manual entry; bounce- and complaint-derived entries cannot be removed, and the UI explains that this protects deliverability.
8.16 The Conversion Funnel #
Every request page has a funnel report. It is the single most useful screen in the dashboard for a workspace that is not getting testimonials, because it says where people stop.
8.16.1 The five steps #
| # | Step | Counted when | Notes |
|---|---|---|---|
| 1 | Viewed | The landing screen is served (server-side, once per view id — the cookie-free view identifier described in Section 17) | Bot-filtered using the same maintained list as widget analytics. Preview renders excluded. |
| 2 | Started | A draft is created (a path button was pressed) | Carries the mode dimension. |
| 3 | Captured | A video take reaches "all parts uploaded", or the text body first exceeds the 40-character minimum | This is the step that separates "opened the recorder" from "actually said something". |
| 4 | Details completed | The consent step is reached with details validation passing | |
| 5 | Submitted | A testimonial row is created |
Drop-off between consecutive steps is shown as both an absolute count and a percentage, with the largest drop highlighted by rank and labelled in text ("Biggest drop-off"), never by colour alone.
8.16.2 Dimensions #
Each step can be broken down by: mode (video / text / audio), source (invitation / direct
link / short link / QR / email signature / campaign parameters), device class (mobile / tablet
/ desktop), country (derived from IP then discarded, per Section 17), and campaign.
8.16.3 Diagnostics #
Beyond the raw funnel, the report surfaces named diagnostics with plain-English copy, each with a recommended action:
| Diagnostic | Condition | Suggested action shown |
|---|---|---|
| Camera permission wall | Video starts where permission was denied > 25% | "Add a line to your intro explaining why you need the camera." |
| Recorder incompatibility | Unsupported-browser rate > 10% of video starts | "A lot of your visitors can't record. Consider making the write option more prominent." |
| Long-form fatigue | Details-step drop-off > 40% and the page has more than 6 required fields | "You're asking for a lot. Try making some fields optional." |
| Consent hesitation | Consent-step drop-off > 15% | "People are stopping at permissions. Shorten your intro copy and make it clear where it will be used." |
| Upload failure | Media sessions failing to complete > 5% | "Uploads are failing for some respondents — check the media health panel in the operations view." |
| Invitation fatigue | Reminder-driven submissions < 2% of reminders sent | "Reminders aren't landing. Try a shorter first message instead of more reminders." |
| Mobile-only drop | Mobile conversion less than half of desktop conversion | "Your page converts much worse on phones. Preview it at 360 px." |
8.16.4 Retention and export #
Funnel events are stored as rollups under the same retention rules as widget analytics (Section 19's per-plan analytics retention, Section 17's rollup model). The report exports to CSV with one row per (day, step, dimension combination). Raw per-event data is not exposed, because it does not exist beyond the 7-day debugging window described in Section 17.
8.17 Internationalization of the Respondent Experience #
The respondent UI chrome ships in the five launch locales: English, Spanish, French, German and Portuguese. Selection order:
- The request page's
localesetting, when the workspace has pinned one. - Otherwise,
Accept-Languagenegotiation against the supported set. - Otherwise, English.
A visible language switcher appears in the page footer whenever more than one locale is
available; changing it sets a ?lang= parameter (never a cookie) and re-renders server-side.
The workspace's own copy — headline, prompts, consent text, field labels — is never machine-translated. It renders exactly as written, in whatever language the workspace wrote it. Translating a workspace's copy would change what the respondent is agreeing to, and consent must be recorded against text the platform can reproduce verbatim (Section 22). Multi-locale request pages are a deferred capability listed in Section 29.8; the workaround today is one request page per language, which is fully supported and costs nothing extra.
Formatting: dates and numbers use the respondent's locale via Intl; the recording timer is
locale-formatted; RTL is not in the launch locale set but the layout uses logical CSS
properties (margin-inline-start, padding-block) throughout so an RTL locale is a
translation task, not a rebuild.
8.18 Acceptance Criteria for Section 8 #
An implementation of this section is complete when every statement below is demonstrably true in an automated test or a documented manual check.
| # | Criterion | How it is verified |
|---|---|---|
| A1 | A request page renders its landing screen with zero client JavaScript and both paths usable | Playwright with JavaScript disabled: the text path completes a full submission |
| A2 | No cookie, localStorage or sessionStorage write occurs anywhere in the respondent flow |
Playwright assertion on document.cookie, and on Storage.prototype.setItem instrumented to throw |
| A3 | A refresh at any step restores every value entered so far | Playwright: fill each step, reload, assert values, for all seven steps |
| A4 | A draft expires exactly 24 hours after its last write and is hard-deleted by the sweep | Integration test with a controlled clock; assert row absence and multipart abort |
| A5 | Every custom field type validates identically on client and server | Property-based Vitest suite over the shared schemas, 1,000 generated cases per type |
| A6 | A 422 returns one details entry per failing field, matching the envelope in Section 7.3 |
Contract test against the submit endpoint |
| A7 | Consent is never pre-checked and submit is blocked without permission 1 | Playwright + a server test that rejects a payload with granted: false |
| A8 | A stale consent text hash is rejected | Integration test posting a hash from a previous consent version |
| A9 | The honeypot returns a success-shaped response and writes nothing | Integration test asserting a 201 and zero new rows |
| A10 | The reserved-slug list covers every static top-level route | Build-time assertion over the route manifest |
| A11 | Publishing a page whose colours fail contrast is rejected | Integration test per failing pair |
| A12 | The page renders a 200 closed state, never an error, when the workspace is at its cap |
Integration test with a quota-exhausted fixture |
| A13 | A submission in flight when the page closes still succeeds within the 30-minute grace | Integration test with a controlled clock |
| A14 | CSV import maps a file at the row limit in Section 16.3.2, reports every category, and sends nothing before confirmation | Integration test with a fixture file containing every error category, plus one file a single row over the limit asserting 422 IMPORT_ROW_LIMIT |
| A15 | Personalization values are escaped per output context | Unit tests with <script>, O'Brien & Sons, =cmd(), and a newline in every token |
| A16 | An invitation link resolves, attributes, and drops its token from the URL | Playwright asserting the final URL and the stored invitation_contact_id |
| A17 | Suppressed contacts cannot be sent to by any path | Integration test attempting single send, bulk send and reminder |
| A18 | The QR code encodes the canonical URL, uses level Q, and scans at 25 mm | Unit test on the encoder output plus a documented manual scan check |
| A19 | Every interactive control meets the 24 × 24 (44 × 44 touch) target size | Playwright bounding-box assertion across all seven steps |
| A20 | axe-core reports zero serious or critical violations on all seven steps in both themes | axe in CI per Section 21 |
| A21 | Total request-page JavaScript stays inside the budget in Section 23 | size-limit check in CI, hard fail |
| A22 | The funnel counts each of the five steps exactly once per view | Integration test replaying a full flow and asserting rollup counts |
| A23 | Deleting a request page leaves its testimonials published and readable | Integration test asserting widget output before and after |
| A24 | Every error condition in Section 8.10.6 is reachable and returns its documented code | One integration test per code |
| A25 | Custom-field answers are readable only from submissions.custom_field_values, and a testimonial resolves them through submission_id |
Schema assertion that testimonials has no custom_field_values column, plus an API test over a testimonial with a submission and one without |
| A26 | A recording already uploading when the page closes still completes, and the submit refuses only after the 30-minute grace lapses | Integration test with a controlled clock closing the page mid-upload |
| A27 | No public collection endpoint returns 402 or 403, and every capacity refusal is 409 |
Contract test sweeping every endpoint in Section 8.10 against a quota-exhausted, past-due and over-limit fixture |
9. Video Capture, Chunked Upload & Media Pipeline #
9.1 What this section is for, and the one user it is built around #
Everything in this section exists to serve a single person: a customer of the workspace, standing in a kitchen or an office corridor, holding a mid-tier Android phone or an iPhone on a patchy 4G connection, who was sent a link and has agreed — right now, in this moment — to say something nice on camera. They have no account. They will not install anything. They have never heard of this product and will never see it again. If the recorder stutters, if the permission dialog appears without explanation, if the upload bar sticks at 40%, or if they lose a good take because a call came in, they will close the tab and the testimonial is gone forever. There is no retry. There is no support ticket. The workspace simply never finds out why the response rate is low.
Every decision below is therefore biased in the same direction:
| Principle | Consequence in this section |
|---|---|
| Never lose bytes the respondent already produced | Upload starts while recording is still running (9.6); a finished take is durable before the respondent is asked anything else |
| Never make the respondent wait for a machine | Post-processing is fully asynchronous (9.7); submit returns in under a second |
| Never surprise the browser permission prompt | An in-page pre-prompt always precedes getUserMedia (9.2.2) |
| Always offer a way forward | Every failure state has a named recovery action; there is no dead end (9.6.10, 9.7.5) |
| Retaking must feel free | Unlimited retakes, instant, nothing counted until submit (9.5) |
| Deleting must actually delete | media.purge is a verified, retried, alerting job with an operator-runnable proof query (9.10) |
The recorder is implemented in apps/web as a route-level client component tree under the
public request-page route, using the framework, language and build versions stated in
Section 3. It ships no video framework and no third-party recorder library: browser capture
is getUserMedia + MediaRecorder only. Playback in the dashboard and in widgets uses the
video provider's player, dynamically imported (9.8.4).
The complete accessible-name inventory of every recorder control is given in 9.13 so that Section 21 can bind its WCAG 2.2 AA requirements to concrete element names. Section 21 owns the accessibility acceptance criteria; this section owns the controls' existence, behaviour and state.
9.2 Browser capture #
9.2.1 Capability detection and the pre-flight gate #
Before the record path is offered at all, the client runs a synchronous capability probe.
The probe never calls getUserMedia and therefore never triggers a permission prompt.
// apps/web/src/features/recorder/capability-probe.ts
export type RecorderCapability =
| { supported: true; mimeType: string; container: 'mp4' | 'webm' }
| { supported: false; reason: RecorderUnsupportedReason };
export type RecorderUnsupportedReason =
| 'NO_MEDIA_DEVICES' // navigator.mediaDevices is undefined
| 'INSECURE_CONTEXT' // page is not HTTPS / not localhost
| 'NO_MEDIA_RECORDER' // window.MediaRecorder is undefined
| 'NO_SUPPORTED_CODEC' // MediaRecorder exists but supports none of our mime types
| 'IN_APP_BROWSER_BLOCKED'; // known-broken embedded webview (9.3.5)
const CANDIDATE_MIME_TYPES = [
'video/mp4;codecs=avc1.42E01E,mp4a.40.2',
'video/mp4',
'video/webm;codecs=vp9,opus',
'video/webm;codecs=vp8,opus',
'video/webm',
] as const;
export function probeRecorder(): RecorderCapability {
if (!window.isSecureContext) return { supported: false, reason: 'INSECURE_CONTEXT' };
if (!navigator.mediaDevices?.getUserMedia) return { supported: false, reason: 'NO_MEDIA_DEVICES' };
if (typeof window.MediaRecorder === 'undefined') return { supported: false, reason: 'NO_MEDIA_RECORDER' };
if (isBlockedInAppBrowser(navigator.userAgent)) return { supported: false, reason: 'IN_APP_BROWSER_BLOCKED' };
for (const mimeType of CANDIDATE_MIME_TYPES) {
if (MediaRecorder.isTypeSupported(mimeType)) {
return { supported: true, mimeType, container: mimeType.startsWith('video/mp4') ? 'mp4' : 'webm' };
}
}
return { supported: false, reason: 'NO_SUPPORTED_CODEC' };
}The probe result drives the landing screen (Section 8 owns that screen's layout):
| Probe result | What the respondent sees |
|---|---|
supported: true |
"Record a video" and "Write it instead" side by side, equal visual weight |
INSECURE_CONTEXT |
Record path hidden; a one-line notice "Recording needs a secure connection." plus the write path. This state is unreachable in production and exists for local development honesty |
NO_MEDIA_DEVICES, NO_MEDIA_RECORDER, NO_SUPPORTED_CODEC |
Record path replaced by an upload-a-video control (<input type="file" accept="video/*" capture="user">), which on mobile opens the native camera app. The write path is unchanged |
IN_APP_BROWSER_BLOCKED |
A single-tap "Open in your browser" card with the full URL and a copy button, plus the native-camera upload control and the write path |
The native-camera fallback is a first-class path, not an apology. The file it produces enters
the same upload pipeline at 9.6 as a single logical take with capture_method = 'file_upload'
and skips only the in-browser recording UX. Section 8's funnel counts it as captured.
9.2.2 The permission pre-prompt #
getUserMedia is never called from a page load, a route transition, or any effect. It is
called only from the click handler of one specific button. Immediately before that button is
shown, the respondent sees an in-page explanation card. This exists because a browser
permission dialog that appears without context is denied at a far higher rate, and on iOS and
Android a denial is sticky and expensive to reverse.
Pre-prompt card contents, fixed:
- Heading: "We'll need your camera and microphone."
- Body: "Your browser will ask for permission next. Nothing is recorded until you press Record, and you can retake as many times as you like."
- A privacy line: "Your video is sent only to {workspace name} through {product name}."
- Primary button: Allow camera & mic (this is the element whose click handler calls
getUserMedia). - Secondary button: Write it instead (switches to the text path, never disabled).
The pre-prompt is shown once per page session. If the browser reports the permission is
already granted via navigator.permissions.query({ name: 'camera' }), the card is skipped
and the preview starts immediately; if permissions.query is unsupported or throws (Safari
historically does not expose camera/microphone descriptors), the card is shown — a
redundant card is a far smaller cost than an unexplained prompt.
9.2.3 getUserMedia constraints per plan tier and device class #
Constraints are computed server-side into the request page's bootstrap payload so that the client never decides its own ceiling. The plan's maximum resolution and maximum length are stated in the plan table in Section 19; this section states how they translate into media constraints.
// packages/core/src/media/capture-constraints.ts
export type DeviceClass = 'mobile_low' | 'mobile_high' | 'desktop';
export interface CaptureProfile {
maxHeight: 720 | 1080;
targetFrameRate: 30;
maxDurationSeconds: 120 | 300;
videoBitsPerSecond: number;
audioBitsPerSecond: 128_000;
}
export function buildConstraints(profile: CaptureProfile, facing: 'user' | 'environment'): MediaStreamConstraints {
return {
video: {
facingMode: { ideal: facing },
width: { ideal: Math.round((profile.maxHeight * 16) / 9), max: Math.round((profile.maxHeight * 16) / 9) },
height: { ideal: profile.maxHeight, max: profile.maxHeight },
frameRate: { ideal: 30, max: 30 },
aspectRatio: { ideal: 16 / 9 },
},
audio: {
channelCount: { ideal: 1 },
sampleRate: { ideal: 48_000 },
echoCancellation: { ideal: true },
noiseSuppression: { ideal: true },
autoGainControl: { ideal: true },
},
};
}Rules:
- No constraint is
exact. Every dimension usesideal/max. Anexactconstraint that a device cannot satisfy throwsOverconstrainedErrorand produces a black screen; a respondent cannot debug that. We accept whatever the device gives and normalize during transcode. - Plan ceiling is a maximum, never a floor. A Free-plan capture is capped at 720p; a device that can only do 480p records at 480p and is accepted.
facingModeisideal, notexact, so laptops without a rear camera do not fail.- Audio is forced to mono at capture intent level. Testimonials are one person speaking; mono halves the audio bitrate and improves transcription consistency.
- Device class is derived on the client from
navigator.hardwareConcurrency,navigator.deviceMemory(where exposed) and pointer/coarse media queries, and is used only to pick the bitrate row in 9.3.2 — never to reduce the plan ceiling.
Device-class thresholds:
| Class | Detection rule |
|---|---|
desktop |
(pointer: fine) matches and viewport width ≥ 1024 px |
mobile_high |
Not desktop, and hardwareConcurrency >= 6 or deviceMemory >= 4 |
mobile_low |
Everything else, including when both signals are unavailable |
9.2.4 Denied permission — the recovery path #
When getUserMedia rejects, the error name determines the screen. The recorder never shows a
raw browser error string.
error.name |
Meaning | Screen shown | Actions offered |
|---|---|---|---|
NotAllowedError |
User denied, or a permission policy blocks it | "Camera access is off" | Per-browser instructions (below), Try again, Write it instead |
NotFoundError |
No camera or no microphone present | "We couldn't find a camera" | Record audio only (if a mic exists), Upload a video, Write it instead |
NotReadableError |
Hardware in use by another app, or OS-level failure | "Your camera is busy" | "Close other apps using the camera (video calls, other tabs), then Try again", Try again, Write it instead |
OverconstrainedError |
Constraints unsatisfiable | Transparent to the respondent: the client automatically retries once with { video: true, audio: true } and only surfaces a screen if that also fails |
— |
SecurityError |
Insecure context / policy | Same as INSECURE_CONTEXT in 9.2.1 |
Write it instead |
AbortError |
OS interrupted acquisition | "Something interrupted the camera" | Try again, Write it instead |
| Any other | Unknown | "We couldn't start the camera" | Try again, Upload a video, Write it instead |
Per-browser recovery instructions are selected by parsing the user-agent into a small enum
(safari_ios, safari_macos, chrome_android, chrome_desktop, firefox_desktop,
firefox_android, edge_desktop, samsung_internet, other) and rendering static copy plus a
labelled illustration. The copy is maintained in apps/web/src/features/recorder/permission-help.ts
and is plain text, never a screenshot of a browser UI that will age. Examples of the exact copy:
safari_ios— "Tap the AA or ⋯ icon in the address bar → Website Settings → set Camera and Microphone to Allow → then tap Try again. If you don't see those options, open Settings → Safari → Camera and choose Ask or Allow."chrome_android— "Tap the lock icon next to the address bar → Permissions → turn on Camera and Microphone → then tap Try again."chrome_desktop/edge_desktop— "Click the camera icon at the right of the address bar → Always allow → Done, then reload this page."firefox_desktop/firefox_android— "Click the permissions icon to the left of the address bar → remove the Blocked entry for Camera and Microphone → then tap Try again."other— "Open your browser's site settings for this page and allow Camera and Microphone, then tap Try again."
Try again re-invokes getUserMedia from a user gesture. It is never automatic and never on
a timer: a browser that has hard-blocked the origin will otherwise silently reject forever and
the respondent will watch a spinner. After two consecutive NotAllowedErrors the write path is
promoted above the retry button, because at that point the highest-value outcome is a written
testimonial, not a lost one.
Every denial writes one analytics event (recorder_permission_denied, with error name, browser
enum, device class — no IP, no identifier) so the workspace's funnel in Section 8 shows where
respondents are lost. Section 17 owns the cookie-free analytics transport.
9.2.5 Device enumeration and mid-session switching #
After the first successful getUserMedia, device labels become available. The recorder then
calls navigator.mediaDevices.enumerateDevices() and builds two pickers:
| Picker | Population | Behaviour when only one device exists |
|---|---|---|
| Camera | All videoinput devices, labelled by device.label, falling back to "Camera 1", "Camera 2" |
Picker hidden; the flip control is hidden |
| Microphone | All audioinput devices, labelled by device.label, falling back to "Microphone 1" |
Picker hidden |
On mobile, the primary control is not a dropdown but a Flip camera icon button that toggles
facingMode between user and environment. The dropdown remains available in the settings
sheet for devices that expose more than two cameras (modern phones expose ultra-wide and
telephoto as separate videoinput entries; the flip button cycles only between the first
front-facing and the first rear-facing entry, and the sheet exposes all).
Switching rules:
- Switching is permitted before recording starts and during a pause; it is refused
while actively recording, because changing the track mid-
MediaRecorder-session produces containers that many decoders reject. The control isdisabledwith an accessible explanation ("Pause first to switch camera") rather than hidden, so its absence is never mysterious. - A switch stops only the affected track (
track.stop()on the oldvideoinputoraudioinput), acquires the new one, and replaces it in the previewMediaStream. The other track is untouched, so switching the mic does not restart the camera and vice versa. - If acquiring the new device fails, the previous device is re-acquired and a toast is shown: "Couldn't switch — we kept your previous camera." Never leave the preview dark.
- Selections live in React state for the page session only. Nothing is written to cookies,
localStorage, orsessionStorage— Section 22 forbids respondent-side persistent identifiers, and a device id is an identifier. navigator.mediaDevices.ondevicechangere-enumerates and, if the currently active device has disappeared (a Bluetooth headset walked out of range), the recorder pauses if recording, keeps every byte already captured, switches to the system default, and announces "Your microphone disconnected. We saved your recording — press Resume when you're ready."
9.2.6 Live audio level meter #
Bad audio kills more testimonials than bad video, and the respondent cannot hear themselves. A meter is therefore mandatory and always visible during preview and recording.
- Implementation: a
MediaStreamAudioSourceNodeinto anAnalyserNodewithfftSize = 256,smoothingTimeConstant = 0.6, sampled onrequestAnimationFrame, converted to RMS then to a 0–100 scale with a −60 dBFS floor. - Render: 12 discrete bars. Bars are not colour-coded alone — the meter has a text status line
underneath with exactly four states: "No sound detected" (peak < 5 for 3 consecutive
seconds), "Too quiet" (rolling 3-second peak < 20), "Sounds good" (20–90), "Too loud"
(peak ≥ 95 more than twice in 3 seconds). The status line has
aria-live="polite"and only announces on state change, never on every frame. - If "No sound detected" persists for 5 seconds during preview, an inline warning appears with a Check microphone action that opens the mic picker. If it persists for 5 seconds during recording, the same warning appears but recording is never interrupted — the respondent decides.
- The
AnalyserNodegraph is torn down (context.close()) whenever the preview stops, to avoid holding the audio hardware awake and draining battery.
9.2.7 Framing guide, mirroring, and orientation #
- Framing guide: a subtle overlay showing a head-and-shoulders oval plus a rule-of-thirds
eye line, with the caption "Line your eyes up with the top line." It is on by default, has a
Hide guide toggle, is drawn in an overlay element with
pointer-events: none, and is never burned into the recorded stream. - Mirroring: the preview is mirrored (
transform: scaleX(-1)) only for front-facing cameras, because an unmirrored self-view feels wrong to everyone. The recorded stream is never mirrored — mirroring is a CSS transform on the preview<video>element and touches no track. Rear-facing preview is not mirrored. Playback of a completed take is never mirrored, and the transition from mirrored preview to unmirrored playback is explained once with a small note: "Your video is saved the way others see you, not mirrored." - Orientation: portrait and landscape are both fully supported and neither is rejected. The
recorder captures whatever the device produces and stores
capture_orientation(portrait|landscape|square) and the rawwidth/heighton the media asset. On orientation change during preview the layout reflows; on orientation change during recording the recorder does nothing at all — rotating mid-take produces a rotated segment, and a mid-recording restart would lose the take. A one-time hint appears the first time the device rotates while armed: "Pick portrait or landscape before you start — rotating while recording looks odd on playback." - Safe-area insets (
env(safe-area-inset-*)) are respected on all four edges so controls are never under a notch or a home indicator. The recorder is usable at a 320 px viewport width.
9.3 MediaRecorder configuration #
9.3.1 Codec negotiation order #
The recorder selects the first mime type in this fixed order that MediaRecorder.isTypeSupported
accepts. The order prefers MP4/H.264 because it is the only combination that every downstream
consumer — the transcoder, the browsers that will play the raw file back in the review step,
and native share sheets — handles without re-muxing.
| Rank | Mime type | Typical browsers | Container stored |
|---|---|---|---|
| 1 | video/mp4;codecs=avc1.42E01E,mp4a.40.2 |
Safari 17+, Chrome 130+, Edge 130+ | mp4 |
| 2 | video/mp4 |
Safari 14.1–16 (unparameterized acceptance) | mp4 |
| 3 | video/webm;codecs=vp9,opus |
Chrome, Edge, Firefox, Android WebView | webm |
| 4 | video/webm;codecs=vp8,opus |
Older Chromium, Firefox ESR | webm |
| 5 | video/webm |
Last-resort Chromium builds | webm |
The exact string returned by recorder.mimeType after construction — not the requested
string — is persisted on the upload session and copied to the media asset, because some engines
normalize or extend it. It is stored in upload_sessions.mime_type and
media_assets.source_mime_type, and it is echoed in the transcode request so the provider never
has to guess. Section 5 owns both columns.
If no candidate is supported, the flow degrades per 9.2.1 (NO_SUPPORTED_CODEC → native camera
file upload). It never shows an error and never dead-ends.
9.3.2 Bitrate targets #
MediaRecorder is constructed with explicit bitrates. Left to defaults, Chromium picks ~2.5 Mbps
regardless of resolution and Safari picks values that vary by device — both waste the
respondent's mobile data and inflate upload time.
| Effective capture height | Device class | videoBitsPerSecond |
audioBitsPerSecond |
Approx. bytes/second | 5-minute take |
|---|---|---|---|---|---|
| ≤ 480p | any | 900_000 | 128_000 | ~129 KB/s | ~38 MB |
| 720p | mobile_low |
1_800_000 | 128_000 | ~241 KB/s | ~72 MB |
| 720p | mobile_high, desktop |
2_500_000 | 128_000 | ~328 KB/s | ~98 MB |
| 1080p | mobile_low |
3_000_000 | 128_000 | ~391 KB/s | ~117 MB |
| 1080p | mobile_high, desktop |
4_000_000 | 128_000 | ~516 KB/s | ~155 MB |
The effective height is read from stream.getVideoTracks()[0].getSettings().height after
acquisition, not from the requested constraint, so a device that gave us 540p is not billed the
720p bitrate.
Hard byte ceilings, enforced both client-side (stop the take, keep it, tell the respondent) and
server-side (reject further parts with 413 and the error envelope from Section 7.3):
| Plan max length | Max upload bytes (VIDEO_MAX_UPLOAD_BYTES) |
|---|---|
| 2 minutes (Free) | 157_286_400 (150 MiB) |
| 5 minutes (Starter, Growth, Scale) | 629_145_600 (600 MiB) |
These ceilings are ~4× the expected size at the matching bitrate row, which absorbs high-motion scenes and devices that ignore the requested bitrate, while still stopping a pathological stream long before it becomes an availability problem.
9.3.3 timeslice and the data flow out of the recorder #
recorder.start(5000) — a 5-second timeslice. Each dataavailable event delivers a Blob of
roughly one timeslice of media, which is pushed into the upload buffer described in 9.6.5. This
is the single most important number in the section: it is what makes a 5-minute take
approximately 96% uploaded at the instant the respondent presses Stop.
Why 5 seconds and not smaller or larger:
- Smaller (1–2 s) multiplies event overhead and, on
mobile_low, causes visible frame drops in the preview because the encoder flush competes with the compositor. - Larger (10–15 s) means more unsent data is resident in memory when a tab is killed.
- At 5 s, the worst case unsent-at-crash window is 5 seconds of media plus the unflushed part buffer, and the buffer is checkpointed to the server on every part (9.6.7).
recorder.stop() triggers a final dataavailable with the remainder. The client must wait for
onstop, not onpause or the last dataavailable, before flushing the final part.
MediaRecorder is created once per take. Retake constructs a new recorder and a new upload
session; recorders are never reused.
9.3.4 Browser support matrix and the exact fallback for each gap #
| Browser | getUserMedia |
MediaRecorder |
Preferred mime type | Pause/resume | Behaviour |
|---|---|---|---|---|---|
| Chrome desktop (last 2) | Yes | Yes | mp4/avc1, else webm/vp9 | Yes | Full recorder |
| Edge desktop (last 2) | Yes | Yes | mp4/avc1, else webm/vp9 | Yes | Full recorder |
| Firefox desktop (last 2) | Yes | Yes | webm/vp9 | Yes | Full recorder |
| Safari macOS 16+ | Yes | Yes | mp4 | Yes (17+); on 16 the pause control is hidden | Full recorder, pause conditional |
| Safari iOS 16+ | Yes | Yes | mp4 | Yes (17+); hidden on 16 | Full recorder, see 9.3.5 |
| Safari iOS 15 and below | Yes | Unreliable | — | — | Native camera upload fallback |
| Chrome Android (last 2) | Yes | Yes | mp4/avc1, else webm/vp9 | Yes | Full recorder |
| Firefox Android (last 2) | Yes | Yes | webm/vp9 | Yes | Full recorder |
| Samsung Internet (last 2) | Yes | Yes | webm/vp9 | Yes | Full recorder |
| Android WebView (in-app) | Varies | Often absent | — | — | Native camera upload fallback + "open in browser" card |
| iOS in-app webviews (Instagram, Facebook, TikTok, LinkedIn) | Varies by iOS version | Frequently broken | — | — | "Open in Safari" card first, native camera upload second, write path always |
| Any browser with JavaScript disabled | — | — | — | — | The write path renders as a plain server-rendered <form>; Section 8 owns it |
isBlockedInAppBrowser matches these user-agent fragments: FBAN, FBAV, Instagram,
Line/, MicroMessenger, TikTok, LinkedInApp, Snapchat, Pinterest. The match is a
hint, not a hard block: the card offers "Try recording anyway" beneath the primary
"Open in your browser" action, so a webview that has since been fixed is not permanently
punished.
9.3.5 iOS Safari particulars #
A large share of respondents are on iPhones, and iOS Safari has behaviours that will silently destroy takes if not handled explicitly.
getUserMediarequires a user gesture and a secure context. Both are guaranteed by the pre-prompt design in 9.2.2.- Only one page may hold the camera. If another tab holds it, acquisition throws
NotReadableError. The screen in 9.2.4 names this case explicitly ("Close other tabs that are using the camera"). - Backgrounding stops the tracks. When the user switches apps or answers a call, iOS mutes
and may end the media tracks. The recorder listens for
visibilitychange,pagehide, andtrack.onmute/track.onended, and on any of them finalizes the take rather than losing it — see 9.6.10 rows App backgrounded and Incoming phone call. This is the single most important iOS behaviour in the product. MediaRecorder.pause()exists on iOS 17+ but not iOS 16. Feature-detect withtypeof recorder.pause === 'function'and a runtime guard, and hide the pause control when unavailable rather than showing a control that throws.- Low Power Mode throttles timers and can suppress
requestAnimationFrame. The audio meter degrades to a 4 HzsetIntervalsampler whenrAFcallbacks are observed at under 10 Hz. Recording itself is unaffected. Upload concurrency drops to 1 (9.6.5) when battery status is available and reportscharging === falseandlevel < 0.2. - Playback of the just-recorded blob requires
playsinline. Every<video>element in the recorder and review steps carriesplaysinline,mutedon the live preview, andpreload="metadata"on the review player. Withoutplaysinline, iOS takes the review playback fullscreen and the respondent loses the surrounding UI. BlobURLs must be revoked.URL.revokeObjectURLis called on every take discard and on unmount; iOS is aggressive about memory pressure and an un-revoked 150 MB blob URL causes tab reloads that lose the take.- The MP4 that iOS
MediaRecorderproduces is fragmented (fMP4). It is valid input for the transcoder but some desktop players scrub poorly. The review-step player therefore plays from the local blob (always seekable in practice for our lengths) and the dashboard always plays the transcoded rendition, never the original.
9.4 Recording UX #
9.4.1 The armed state #
After permission is granted the recorder is armed: live preview, audio meter, framing guide, device controls, teleprompter toggle, and a single large Record button. The prompt text the workspace configured (Section 8 owns prompts) is visible above the preview in a collapsed form with a Show prompt expander.
9.4.2 Countdown #
Pressing Record starts a 3-2-1 countdown before recorder.start() is called.
- Rendered as a large numeral over the preview, one second apart, with a short tick sound at 3,
2, 1 and a distinct tone at "go". Sound is suppressed when the device is muted (we cannot
detect this reliably, so the tick uses the
AudioContextalready created for the meter and is automatically silent when the context is suspended) and whenprefers-reduced-motion: reduceis set the numerals do not animate but still change. - The countdown is announced once per number through the recorder's
aria-live="assertive"region: "3", "2", "1", "Recording". - A Cancel control is present during the countdown and returns to armed without creating an upload session.
recorder.start(5000)and thePOST /api/v1/uploadsinitiate call (9.6.3) are fired in parallel at the start of the countdown, not at its end, so the three seconds are spent doing the network round-trip. If initiate has not returned by "go", recording still starts and chunks buffer in memory; they flush as soon as the session id arrives. If initiate fails, 9.6.10 row Initiate failed applies.
9.4.3 Live timer and remaining-time warnings #
- A monospace
MM:SSelapsed timer sits adjacent to a recording indicator that is a filled dot plus the word "Recording" — never colour alone. - A thin progress bar shows elapsed time against the plan maximum, with
role="progressbar",aria-valuemin="0",aria-valuemax="{maxSeconds}",aria-valuenow, andaria-valuetext="1 minute 20 seconds of 5 minutes". - Announcements are made at exactly 60 s, 30 s, and 15 s remaining through the polite live region — never on every tick, which would make screen-reader use impossible. Text: "One minute left", "Thirty seconds left", "Fifteen seconds left".
- A visible banner appears at 15 s remaining: "15 seconds left — start wrapping up." At 5 s it becomes a countdown numeral.
- For a 2-minute plan maximum the 60 s announcement still fires; for maxima under 90 seconds the 60 s announcement is suppressed to avoid firing immediately after start.
9.4.4 Pause and resume #
Where MediaRecorder.pause() is available:
- Pause calls
recorder.pause(), freezes the timer, keeps the preview live (tracks are not stopped), keeps the upload of already-buffered parts running, and announces "Paused". - Resume calls
recorder.resume()and announces "Recording resumed". - Device switching is allowed only while paused (9.2.5).
- Paused time does not count toward the plan maximum; recorded duration does.
- A pause longer than 120 seconds shows "Still there? Your recording is paused." and nothing else — it never auto-stops, because auto-stopping is a way to lose a take.
Where pause is unavailable (iOS 16), the control is not rendered and the help text under the Stop button reads "You can retake as many times as you like."
9.4.5 Hard stop at the plan maximum, and the grace behaviour #
At maxDurationSeconds the recorder calls recorder.stop() itself. It is a hard stop with a
soft landing:
- At
max - 15 sthe warning banner appears (9.4.3). - At
maxexactly, recording stops and the take is kept, complete, and uploaded. It is never discarded for hitting the limit. - The respondent sees: "That's the {2|5}-minute limit — we saved everything up to here." with Use this take, Retake, and (only when the workspace's plan allows a longer limit on a higher tier) a discreet note the workspace will see in the dashboard, never an upsell shown to the respondent. Respondents are never marketed to.
- A 2-second grace window past
maxis tolerated in the duration validation on the server (duration_seconds <= maxDurationSeconds + 2) to absorb encoder flush latency. Beyond that the asset is accepted but flaggedduration_exceededfor the moderator; it is never rejected after the respondent has already submitted, because rejecting at that point destroys work that was done in good faith.
9.4.6 Teleprompter #
Off by default. Toggle labelled Teleprompter, remembered in page-session state only.
- Renders the workspace's prompt text as large, high-contrast type scrolling over the top 45% of the preview with a scrim behind it (contrast is checked against the scrim, not the video).
- Speed control: five discrete steps, 60 / 90 / 120 / 150 / 180 words per minute, default 120, changeable during recording without interrupting it, with the current value announced politely on change.
- Manual override: dragging or arrow-keying the text scrolls it and pauses auto-scroll; a
Resume scrolling button restarts it. Per WCAG 2.5.7, the drag has a keyboard equivalent
(
ArrowUp/ArrowDownscroll by one line,PageUp/PageDownby one screen). - Auto-scroll respects
prefers-reduced-motion: reduceby defaulting to manual scroll only, with auto-scroll available behind an explicit opt-in in that case. - The teleprompter is an overlay element. It is never composited into the recorded stream.
- Because reading from a teleprompter makes people look slightly off-camera, the text column is narrow and centred directly under the front camera position, and a one-line hint says "Glance at the words, then look at the lens."
9.5 Retake — free, unlimited, and instantly reclaimed #
Retake is the pressure valve that makes recording safe to attempt. It must be visibly free.
- The Retake control is present in the review step and, as a secondary control, while recording (labelled "Start over").
- Retaking during recording shows a confirmation dialog only if more than 10 seconds have been recorded: "Start over? The {N} seconds you've recorded will be deleted." with Start over and Keep recording. Under 10 seconds it starts over immediately — a confirmation dialog for a 3-second false start is friction with no upside.
- Retaking from the review step always confirms: "Retake? Your current video will be deleted."
- On confirm, in this order:
recorder.stop()if running; ignore the finaldataavailable.URL.revokeObjectURLon the local preview blob; drop all in-memory buffers.- Fire
POST /api/v1/uploads/{uploadSessionId}/abort(9.6.9). It is fired withkeepalive: trueso it survives the component teardown, and its result is not awaited — the UI returns to armed immediately. The server-side sweeper (9.10.3) is the backstop if the call never lands. - Reset the recorder state machine to armed with the same device selections, same teleprompter setting, same framing-guide setting.
- What is kept across a retake: device selections, teleprompter on/off and speed, framing guide on/off, the prompt the respondent is answering, and every field they have already typed on other screens.
- What is destroyed: the take's blobs, its object-storage multipart upload and every part
already written to it, and its
upload_sessionsrow (transitioned toaborted, retained 24 h for support then hard-deleted by the sweep in 9.10.3). - Nothing is charged to quota until submit. Storage bytes are attributed at the moment
media_assetsis created in 9.7.1, never at part-upload time. Ten discarded takes cost the workspace nothing. Section 19 owns quota semantics; this is the attribution point. - There is no cap on retake count. A soft telemetry counter (
recorder_retakes) is recorded so the workspace can see, in aggregate, whether their prompt is hard to answer.
9.6 Chunked resumable upload #
9.6.1 The model in one paragraph #
The moment recording starts, the client asks the server to open an S3-compatible multipart
upload and receives an upload-session id plus a bearer token. As MediaRecorder emits blobs
every 5 seconds, the client appends them to a buffer; whenever the buffer reaches exactly one
part size (8 MiB), the client slices off a part, asks for a pre-signed PUT URL for that
part number, uploads it directly to object storage, and reports the returned ETag back to the
server. When recording stops, at most one under-sized final part remains. The server holds the
authoritative part list, so a dropped connection resumes from the last acknowledged part and
nothing already uploaded is re-sent. On submit, the server completes the multipart upload and
hands the resulting object to the video provider.
9.6.2 Constants #
| Constant | Value | Rationale |
|---|---|---|
PART_SIZE_BYTES |
8_388_608 (8 MiB) | Above the 5 MiB S3 minimum with headroom; fixed, because Cloudflare R2 requires every non-final part to be the same size |
MAX_PARTS |
10_000 | S3/R2 protocol maximum; 80 GiB ceiling, far above VIDEO_MAX_UPLOAD_BYTES |
PRESIGN_TTL_SECONDS |
900 (15 min) | Long enough for a stalled mobile part, short enough to bound URL leakage. This is the single value for every pre-signed part URL in the product; no other lifetime for an upload URL exists anywhere |
PRESIGN_BATCH_SIZE |
5 | Parts pre-signed ahead of need, refreshed as consumed |
PART_RETRY_ATTEMPTS |
5 | Per part |
PART_RETRY_BASE_MS |
500 | Exponential: 500, 1000, 2000, 4000, 8000, each ±25% jitter |
PART_RETRY_CAP_MS |
30_000 | Hard cap on any single backoff wait |
UPLOAD_CONCURRENCY |
3 (desktop, mobile_high), 1 (mobile_low or battery-saver) |
Parallel part PUTs |
SESSION_IDLE_TIMEOUT_HOURS |
24 | After which an incomplete session is swept |
SESSION_TOKEN_BYTES |
32 | Random, returned once, stored SHA-256 hashed |
VIDEO_MAX_UPLOAD_BYTES |
150 MiB (Free) / 600 MiB (paid) | Per 9.3.2 |
9.6.3 Step 1 — Initiate #
POST /api/v1/uploads
Content-Type: application/json
Idempotency-Key: 018f9c4a-2b3e-7d10-9f31-6c5a2e0b7a44{
"requestPageId": "018f9c40-1111-7000-8000-000000000001",
"draftToken": "d_9QK4x2mZpR7nF1sB6vTy0WcJ3hLdE8u",
"kind": "video",
"mimeType": "video/mp4;codecs=avc1.42E01E,mp4a.40.2",
"container": "mp4",
"captureMethod": "browser_recorder",
"captureOrientation": "portrait",
"captureWidth": 1080,
"captureHeight": 1920,
"deviceClass": "mobile_high",
"estimatedBytes": 41943040
}Validation, all enforced with the shared schema package and returning 422 with the details
array defined in Section 7.3:
| Field | Rule |
|---|---|
requestPageId |
Required, UUID, must resolve to a request page that is published and not soft-deleted; otherwise 404 REQUEST_PAGE_NOT_FOUND |
draftToken |
Required, must match an unexpired row in submission_drafts for that page (Section 8 owns the draft lifecycle); otherwise 404 DRAFT_NOT_FOUND |
kind |
Required, video | audio | avatar | company_logo |
mimeType |
Required, ≤ 255 chars, must match `^(video |
container |
Required, mp4 | webm | m4a | webm_audio | jpeg | png | webp | heic |
captureMethod |
Required, browser_recorder | file_upload |
captureOrientation |
Optional, portrait | landscape | square |
captureWidth, captureHeight |
Optional integers 1–8192 |
deviceClass |
Optional, mobile_low | mobile_high | desktop |
estimatedBytes |
Optional integer ≥ 0; if present and > VIDEO_MAX_UPLOAD_BYTES, respond 413 UPLOAD_TOO_LARGE before any storage is touched |
Pre-conditions checked before creating anything, in this order:
- Request page exists, is published, and its workspace is not soft-deleted → else
404. - The workspace is accepting submissions (testimonial cap not reached — Section 19 owns the
rule) → else
200with thecollectionClosedpayload Section 8 defines. This is deliberately not an error: the respondent did nothing wrong. - Per-IP submission rate limit (Section 7 owns the numbers) → else
429withRetry-After. - No more than 5 live sessions (state
activeorcompleting) exist for this draft token → else the oldest are aborted server-side and a new one is created. This bounds the storage cost of a respondent hammering retake with a flaky abort path. estimatedBytes, when supplied, is within the workspace's remaining storage headroom plus a 20% tolerance (Section 19 owns the headroom calculation) → else409 STORAGE_LIMIT_EXCEEDED. The tolerance exists so a respondent mid-recording is never refused over a rounding error, and the status is409rather than403because the request is authorized and merely conflicts with the workspace's current state.
Response 201:
{
"data": {
"uploadSessionId": "018f9c4a-2b3e-7d10-9f31-6c5a2e0b7a44",
"uploadToken": "ut_2Yh8Kq0Rj5wZbN3cVx7LmT1sPd6AfEgU",
"partSizeBytes": 8388608,
"maxParts": 10000,
"maxUploadBytes": 629145600,
"expiresAt": "2026-03-14T18:42:11.000Z",
"parts": [
{ "partNumber": 1, "url": "https://s3.example.com/vouch-media/...&partNumber=1&X-Amz-Signature=...", "expiresAt": "2026-03-14T18:57:11.000Z" },
{ "partNumber": 2, "url": "https://s3.example.com/vouch-media/...&partNumber=2&X-Amz-Signature=...", "expiresAt": "2026-03-14T18:57:11.000Z" },
{ "partNumber": 3, "url": "https://s3.example.com/vouch-media/...&partNumber=3&X-Amz-Signature=...", "expiresAt": "2026-03-14T18:57:11.000Z" },
{ "partNumber": 4, "url": "https://s3.example.com/vouch-media/...&partNumber=4&X-Amz-Signature=...", "expiresAt": "2026-03-14T18:57:11.000Z" },
{ "partNumber": 5, "url": "https://s3.example.com/vouch-media/...&partNumber=5&X-Amz-Signature=...", "expiresAt": "2026-03-14T18:57:11.000Z" }
]
},
"meta": { "requestId": "req_01J8ZQ4M5T2K9B7C3D1E0F6G8H" }
}Server actions: generate the object key, call CreateMultipartUpload, insert the
upload_sessions row in state active, mint the 32-byte session token (returned once,
stored only as SHA-256), and pre-sign the first PRESIGN_BATCH_SIZE part URLs.
Object key template — deterministic, workspace-scoped, unguessable:
originals/{workspaceId}/{yyyy}/{mm}/{uploadSessionId}.{ext}The bucket is private; no object in it is ever publicly readable. Derivatives that must be
public live under public/ in a separate bucket fronted by the CDN (9.8.2).
9.6.4 Step 2 — Part URL issuance #
The client requests more pre-signed URLs when fewer than 2 unused URLs remain, or when a URL is within 120 seconds of expiry.
POST /api/v1/uploads/{uploadSessionId}/part-urls
X-Vouch-Upload-Token: ut_2Yh8Kq0Rj5wZbN3cVx7LmT1sPd6AfEgU{ "partNumbers": [6, 7, 8, 9, 10] }{
"data": {
"parts": [
{ "partNumber": 6, "url": "https://s3.example.com/...", "expiresAt": "2026-03-14T19:04:52.000Z" }
]
},
"meta": { "requestId": "req_01J8ZQ55N0P4R2S6T8U1V3W5X7" }
}Rules: partNumbers is 1–10 integers, each 1..MAX_PARTS, each strictly greater than 0 and not
already recorded as completed (a completed part number returns 409 PART_ALREADY_COMPLETED).
Re-issuing a URL for a part that was issued but never completed is allowed and is the normal
recovery path for an expired URL. The session must be in active; any other
state returns 409 UPLOAD_SESSION_NOT_ACTIVE.
Every pre-signed URL — those returned by Initiate and those returned here — is signed with storage-enforced conditions, not merely with an expiry. The signature binds:
| Condition | Value | Why |
|---|---|---|
content-length-range |
Exactly [PART_SIZE_BYTES, PART_SIZE_BYTES] for any part the client has not flagged as final; [1, PART_SIZE_BYTES] for the one part the client declares final in its part-URL request |
Object storage itself rejects an oversized body. Without this, the 413 gate in 9.6.5 is computed from the size the client declares, so an anonymous respondent could PUT an arbitrarily large body and we would discover it only after CompleteMultipartUpload had already materialized the object |
Content-Type |
Fixed to application/octet-stream |
The client sends no other header (9.6.5), so any request carrying a different type is not ours |
| Key | The exact object key of this session | A URL for one session can never write into another |
| Expiry | PRESIGN_TTL_SECONDS |
Bounds the value of a leaked URL |
A respondent may request the final-part URL only once per part number; requesting a second final-part URL for a different part number invalidates the first, so the relaxed lower bound cannot be used to write many undersized parts.
9.6.5 Step 3 — Part upload, buffering, and retry #
Client buffering algorithm:
// apps/web/src/features/recorder/upload-buffer.ts
const PART_SIZE = 8 * 1024 * 1024;
class PartBuffer {
private chunks: Blob[] = [];
private bufferedBytes = 0;
private nextPartNumber = 1;
/** Called from MediaRecorder's `dataavailable` handler. */
push(chunk: Blob): Blob[] {
if (chunk.size === 0) return [];
this.chunks.push(chunk);
this.bufferedBytes += chunk.size;
const ready: Blob[] = [];
while (this.bufferedBytes >= PART_SIZE) {
const merged = new Blob(this.chunks, { type: this.chunks[0].type });
ready.push(merged.slice(0, PART_SIZE));
const remainder = merged.slice(PART_SIZE);
this.chunks = remainder.size > 0 ? [remainder] : [];
this.bufferedBytes = remainder.size;
}
return ready;
}
/** Called once, after MediaRecorder `onstop`. May return an empty array. */
flushFinal(): Blob[] {
if (this.bufferedBytes === 0) return [];
const merged = new Blob(this.chunks, { type: this.chunks[0].type });
this.chunks = [];
this.bufferedBytes = 0;
return [merged];
}
takePartNumber(): number { return this.nextPartNumber++; }
}Each produced part is uploaded with a plain PUT to its pre-signed URL, with no custom headers
(any header not in the signature invalidates it) and body set to the Blob. The response's
ETag header is read — this requires the bucket CORS configuration to expose it:
[
{
"AllowedOrigins": ["https://v.vouch.app", "https://*.vouch.app"],
"AllowedMethods": ["PUT", "GET", "HEAD"],
"AllowedHeaders": ["content-type"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3000
}
]A missing or unreadable ETag is treated as a failed part and retried; if it fails for the same
reason twice, the client falls back to server-proxied part upload
(PUT /api/v1/uploads/{id}/parts/{partNumber} with the raw body, Content-Type: application/octet-stream, max 8 MiB), where the server performs UploadPart itself and reads the
ETag server-side. This costs bandwidth but guarantees the upload completes when a corporate proxy
strips ExposeHeaders.
Immediately after a successful part PUT, the client acknowledges it:
POST /api/v1/uploads/{uploadSessionId}/parts
X-Vouch-Upload-Token: ut_...{ "partNumber": 7, "etag": "\"9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c\"", "sizeBytes": 8388608 }{
"data": { "partNumber": 7, "receivedParts": 7, "receivedBytes": 58720256, "state": "active" },
"meta": { "requestId": "req_01J8ZQ5H2W8X0Y2Z4A6B8C0D2E" }
}Server validation on acknowledge:
| Rule | Failure |
|---|---|
Session exists, token hash matches, session in active |
404 UPLOAD_SESSION_NOT_FOUND / 401 UPLOAD_TOKEN_INVALID / 409 UPLOAD_SESSION_NOT_ACTIVE |
partNumber integer 1..MAX_PARTS |
422 |
etag matches ^"?[A-Fa-f0-9]{32}(-\d+)?"?$ |
422 |
sizeBytes equals PART_SIZE_BYTES, or this is the highest part number seen and sizeBytes ≤ PART_SIZE_BYTES |
422 PART_SIZE_INVALID |
receivedBytes + sizeBytes <= maxUploadBytes |
413 UPLOAD_TOO_LARGE; session transitions to failed, multipart aborted |
Duplicate partNumber with identical etag |
200, idempotent no-op |
Duplicate partNumber with a different etag |
The new ETag replaces the old (a retried part legitimately produces a new ETag); recorded in the session's part list |
The acknowledge write is a single UPDATE ... SET parts = jsonb_set(...) inside a transaction
with SET LOCAL app.current_workspace_id per Section 6's tenancy rule, so concurrent
acknowledgements from parallel uploads do not lose each other. received_bytes and
received_parts are recomputed from the part list, never incremented blindly.
Retry policy per part: up to 5 attempts, delays 500 · 2^(n−1) ms with ±25% jitter, capped at
30_000 ms. A 403 on the PUT is assumed to be an expired signature and triggers an immediate
re-issue via 9.6.4 rather than consuming a retry attempt. After 5 failed attempts the part is
returned to the pending queue and the client enters degraded upload mode: uploads pause, a
non-blocking banner appears ("Connection trouble — we'll keep trying. Don't close this page."),
and a retry is attempted every 15 seconds until navigator.onLine flips true or a probe succeeds.
Recording is never stopped because uploading is struggling.
9.6.6 Progress reporting #
Two numbers exist and they are never conflated:
| Number | Definition | Where shown |
|---|---|---|
| Recording progress | Elapsed recorded seconds / plan maximum | The timer bar during recording |
| Upload progress | (acknowledgedBytes + inFlightBytes × perRequestProgress) / totalKnownBytes |
The submit step |
During recording, upload progress is deliberately not shown as a percentage, because the denominator is unknown (the recording has not finished). Instead a subtle "Saving as you go" indicator with a three-state label is shown: "Saving as you go" (uploads current), "Catching up" (queue depth > 2 parts), "Waiting for connection" (offline or degraded).
At Stop, the denominator becomes known and a real percentage bar appears. In the overwhelmingly common case it starts at 90–98% and completes within a second or two, which is exactly the experience the 5-second timeslice exists to produce.
Per-request progress uses XMLHttpRequest.upload.onprogress (not fetch, which has no upload
progress in the baseline browsers of Section 3's support matrix). This is the only place in the
codebase permitted to use XMLHttpRequest, and the exemption is noted in the module's header
comment.
9.6.7 Resume after disconnect #
The server is the authority on what has been received. The client never trusts its own memory of what it sent.
GET /api/v1/uploads/{uploadSessionId}
X-Vouch-Upload-Token: ut_...{
"data": {
"uploadSessionId": "018f9c4a-2b3e-7d10-9f31-6c5a2e0b7a44",
"state": "active",
"partSizeBytes": 8388608,
"receivedParts": [1, 2, 3, 4, 5, 7],
"receivedBytes": 50331648,
"missingParts": [6],
"expiresAt": "2026-03-14T18:42:11.000Z"
},
"meta": { "requestId": "req_01J8ZQ6B4C6D8E0F2G4H6J8K0L" }
}Resume algorithm:
- On regaining connectivity (
onlineevent, or a successful 2-second-interval probe ofGET /api/v1/uploads/{id}), fetch the session. - Reconcile: any part number in
receivedPartsis dropped from the local pending queue and itsBlobreleased. Any part inmissingPartsthat the client still holds is re-queued. - Any part in
missingPartsthat the client no longer holds — because the tab was reloaded and in-memory blobs are gone — makes the take unrecoverable. In that case the session moves tofailedand the respondent is shown the Recording lost screen (9.6.10), which offers Record again as its primary action, pre-armed with the same settings. - Uploads restart at the configured concurrency. Nothing already acknowledged is re-sent.
Because a page reload destroys the in-memory blobs, the recorder registers a beforeunload
handler while a take is in progress or unsubmitted, showing the browser's native confirm. This is
the one place a confirm-on-leave is justified: the alternative is silent, total loss.
9.6.8 Step 4 — Complete #
Called when the respondent submits (Section 8 owns the submit screen), never earlier. Completing early would create an object for a take the respondent may still discard.
POST /api/v1/uploads/{uploadSessionId}/complete
X-Vouch-Upload-Token: ut_...
Idempotency-Key: 018f9c4a-2b3e-7d10-9f31-6c5a2e0b7a45{ "durationSeconds": 187.4, "totalParts": 23, "totalBytes": 188743680 }Server steps, in order, all inside one transaction except the external calls:
- Load the session; require state
active(orcompleting/completed, in which case return the stored response — this endpoint is idempotent). - Verify the part list is contiguous from 1 to
totalPartswith no gaps → else409 UPLOAD_INCOMPLETEwithdetailslisting the missing part numbers. - Verify
totalBytesequals the summed part sizes within a 0-byte tolerance → else422 UPLOAD_SIZE_MISMATCH. - Verify
durationSeconds> 0.5 → else422 RECORDING_TOO_SHORT(a sub-half-second take is a mis-click, and the client also blocks it locally). - Verify
durationSecondsis within the workspace plan's maximum video length (9.3.2) plus the 5-second grace in 9.4.5 → else422 VIDEO_LENGTH_EXCEEDED. This is422rather than a quota status because the length of the submitted file is a property of the file, not a consumed allowance; the browser recorder cannot reach it, but the file-upload path can. - Transition the session to
completing. From this point the session accepts no further parts, which is what makes step 7 safe to retry. - Call
CompleteMultipartUploadwith the ordered{ PartNumber, ETag }list. HeadObjectthe resulting key and store the authoritativecontent_lengthandetagon the session; a mismatch againsttotalBytestransitions the session tofailedand returns500 UPLOAD_VERIFICATION_FAILED. IfHeadObjectreports more thanVIDEO_MAX_UPLOAD_BYTES, the handler issuesDeleteObjectimmediately, in the same request, rather than leaving the object for the 48-hour orphan sweep in 9.10.3. An object we have already decided not to keep is never allowed to sit in a bucket for two days.- Transition the session to
completed, setcompleted_at. - Insert the
media_assetsrow in stateuploaded(Section 5 owns the schema) and link it to thesubmission_draftsrow viasubmission_draft_id. This is the moment storage bytes are attributed to the workspace's quota (9.11). - Enqueue
media.ingest(9.7.1) on the BullMQ queue named in Section 29.5. - Return
202 Accepted.
{
"data": {
"mediaAssetId": "018f9c4b-77aa-7c31-b0d4-9e2f1a3c5b6d",
"state": "uploaded",
"durationSeconds": 187.4,
"sizeBytes": 188743680
},
"meta": { "requestId": "req_01J8ZQ7C5D7E9F1G3H5J7K9L1M" }
}The respondent's submit does not wait for transcode. The thank-you screen renders immediately. This is non-negotiable: transcode takes tens of seconds and the respondent has already given us everything we need.
9.6.9 Step 5 — Abort #
POST /api/v1/uploads/{uploadSessionId}/abort
X-Vouch-Upload-Token: ut_...Body is empty. The server calls AbortMultipartUpload, transitions the session to aborted, sets
aborted_at, and returns 204. It is idempotent: aborting a session already in aborted,
expired or failed returns 204; aborting a completed session returns
409 UPLOAD_ALREADY_COMPLETED because the object now exists and belongs to the deletion path in
9.10, not the abort path. A session in completing cannot be aborted — the complete call owns it
until it resolves — and returns 409 UPLOAD_SESSION_NOT_ACTIVE.
The client sends it with fetch(url, { method: 'POST', keepalive: true }) so a retake, a
navigation, or a tab close still delivers it. sendBeacon is not used because it cannot carry the
upload token header.
9.6.10 The failure matrix #
Every row here is a real thing that happens to real respondents. Each has a defined detection signal, a defined system behaviour, and defined copy. Nothing in this table results in silent loss.
| Scenario | Detection | System behaviour | What the respondent sees |
|---|---|---|---|
| Initiate call fails (network or 5xx) | POST /api/v1/uploads rejects |
Recording proceeds anyway; chunks buffer in memory; initiate is retried every 3 s up to 10 times. On success, buffered parts flush immediately | Nothing during the first 30 s. Then: "Trouble connecting — we're still recording and will save when we reconnect." |
| Network lost mid-record | navigator.onLine === false, or 2 consecutive part failures |
Recording continues uninterrupted; parts queue in memory; degraded-upload mode (9.6.5) | Non-blocking banner "Waiting for connection — keep going, we'll save it." |
| Network lost after Stop, before submit | Same | Upload retries indefinitely at 15 s intervals while the tab lives | Submit button shows "Saving… waiting for connection" and is disabled with an explanatory aria-describedby |
| Tab closed mid-record | pagehide |
recorder.requestData() then recorder.stop() are called synchronously; the abort call is not sent (the take may be recoverable); session is left in active and swept to expired after 24 h |
Native "Leave site?" confirm from the beforeunload handler |
| Tab reloaded | New page load with no in-memory blobs | Session is unrecoverable per 9.6.7 step 3; server sweeps it | Recording lost screen: "We couldn't recover that recording. Nothing was sent." with Record again as the primary action |
| Device storage full | dataavailable stops firing, or new Blob(...) throws QuotaExceededError, or a part PUT throws with name === 'QuotaExceededError' |
Recording stops; everything already acknowledged is preserved; the take is offered as-is if ≥ 3 s were recorded | "Your device ran out of space. We saved the first {N} seconds — you can use that or free up space and retake." |
| Permission revoked mid-record | track.onended on the video or audio track |
Recording finalizes immediately (stop()), buffered data flushes, the take is offered |
"Camera access was turned off. We saved the first {N} seconds." plus the recovery instructions from 9.2.4 |
| Incoming phone call | visibilitychange → hidden, plus track.onmute |
If pause() is available: pause and keep the tracks. Otherwise: stop(), flush, and hold the take. In both cases every byte is preserved |
On return: either "Paused for your call — press Resume" or "We saved your recording up to the call. Use it or retake?" |
| App backgrounded / screen locked | visibilitychange → hidden for > 1.5 s |
Identical to the incoming-call path. Uploads continue in the background where the browser allows; on iOS they resume on foreground | Same as above |
| Battery-saver throttling | getBattery() reports charging === false && level < 0.2, or rAF observed under 10 Hz |
UPLOAD_CONCURRENCY drops to 1; the audio meter falls back to a 4 Hz interval; the framing guide's animation stops |
No message — this is invisible degradation, which is the correct behaviour |
| Part PUT returns 403 | HTTP 403 | Treated as signature expiry: re-issue the URL (9.6.4) without consuming a retry attempt | Nothing |
| Part PUT returns 5xx from storage | HTTP ≥ 500 | Counts as a retry attempt with backoff | Nothing until degraded mode |
Upload exceeds VIDEO_MAX_UPLOAD_BYTES |
Server-side on acknowledge | 413; session → failed; multipart aborted |
"This recording got too large for us to save. Please record a shorter one." with Record again |
| Recording under 0.5 s | Client-side duration check | Submit blocked locally | Stop button re-arms with "That was too short — hold Record for at least a second." |
| Two takes racing (respondent double-taps Record) | Client state machine guard | The second start is ignored; only one MediaRecorder may exist |
Nothing |
| Clock skew invalidating signatures | Repeated 403 after re-issue | Client switches permanently to server-proxied part upload (9.6.5) | Nothing |
Corporate proxy strips ETag |
Two consecutive unreadable ETags | Same server-proxied fallback | Nothing |
| Storage provider outage | Part failures across all parts, or CreateMultipartUpload failing |
Initiate returns 503 STORAGE_UNAVAILABLE; the request page keeps the write path fully functional |
"Video isn't available right now — you can still write your testimonial." The write path is never coupled to storage health |
9.6.11 upload_sessions state machine #
Section 5 owns the table definition and the upload_session_state enum; its six values are the
only states that exist, and the transitions and their triggers are owned here.
┌────────────┐
POST /uploads │ active │ ◀── each part acknowledged
─────────────▶└────────────┘ (self-transition)
│ │ │
POST /abort ────────┘ │ └──────── 24 h idle ────────┐
or retake │ │
│ POST /complete (validated) │
│ │ │
▼ ▼ ▼
┌───────────┐ ┌────────────┐ ┌───────────┐
│ aborted │ │ completing │ │ expired │
└───────────┘ └────────────┘ └───────────┘
│ │ (also reached by a
object verified ────┘ └──── size or stalled
│ verification `completing`)
▼ failure
┌───────────┐ │
│ completed │ ▼
└───────────┘ ┌───────────┐
│ media_assets row created │ failed │
▼ └───────────┘
(ownership passes to media_assets, 9.7)| State | Meaning | Terminal | Multipart upload exists in storage |
|---|---|---|---|
active |
CreateMultipartUpload succeeded; parts may be issued and acknowledged. Covers both the moment before the first part lands and every moment after it |
No | Yes |
completing |
POST /complete passed validation; CompleteMultipartUpload and object verification are in flight. No further part is accepted |
No | Yes |
completed |
CompleteMultipartUpload succeeded, object verified, media_assets row created |
Yes | No — a whole object exists instead |
aborted |
Deliberately discarded (retake, navigation, explicit abort) | Yes | No |
expired |
Idle for 24 h without completing, swept by media.sweep_abandoned_uploads (9.10.3) |
Yes | No |
failed |
Verification failure or size violation | Yes | No |
The distinction between expired and failed is deliberate and is what the operator dashboard
counts separately: expired means the respondent walked away, which is normal and needs no
investigation, while failed means the system rejected bytes it had already accepted, which does.
Columns the state machine reads and writes (defined in Section 5): id, workspace_id,
request_page_id, submission_draft_id, state, kind, mime_type, container,
capture_method, capture_orientation, capture_width, capture_height, device_class,
storage_bucket, storage_key, multipart_upload_id, session_token_hash, parts (JSONB array
of { partNumber, etag, sizeBytes, receivedAt }), received_parts, received_bytes,
duration_seconds, max_upload_bytes, last_activity_at, completed_at, aborted_at,
failed_reason, created_at, updated_at.
Invariant enforced by a check constraint: a row in completed must have a non-null
storage_key, completed_at, and duration_seconds; a row in aborted, expired or failed
must have a null multipart_upload_id after the sweep has run.
9.7 Post-submit pipeline #
9.7.1 media.ingest — from stored object to provider asset #
The media.ingest job runs in apps/worker. It is idempotent on media_asset_id; a second run
observing a non-null provider_upload_id re-checks provider state rather than creating a second
asset.
Steps:
- Load the
media_assetsrow; require stateuploaded. Any other state → log and exit successfully (idempotent no-op). - Generate a time-limited (
GET, 6 hours) pre-signed URL for the original object. - Call the video provider's create-asset API with that URL as the input, using the provider SDK at the major line stated in Section 3, with:
// apps/worker/src/jobs/media-ingest.ts
const asset = await mux.video.assets.create({
inputs: [{ url: signedOriginalUrl }],
playback_policies: [workspace.signedPlaybackEnabled ? 'signed' : 'public'],
video_quality: 'plus',
max_resolution_tier: plan.maxVideoHeight === 1080 ? '1080p' : '720p',
normalize_audio: true,
passthrough: JSON.stringify({ mediaAssetId, workspaceId, v: 1 }),
meta: { external_id: mediaAssetId },
});- Store
provider_asset_id(mux_asset_id) and transition the media asset toprocessing, settingprocessing_started_at. - Record the job run in
job_runs(Section 24 owns the observability model).
passthrough carries our own ids so every webhook can be routed without a lookup table, and it
is versioned (v: 1) so its shape can evolve. The provider's external_id metadata carries the
same asset id as a second, independent correlation path used by the reconciliation job in 9.7.4.
Retries: BullMQ, 6 attempts, exponential backoff 30 s → 1 m → 5 m → 15 m → 1 h → 4 h. A permanent
provider rejection (4xx that is not 429) fails immediately without consuming the remaining
attempts and transitions the asset to errored with error_code = 'PROVIDER_REJECTED_INPUT'.
9.7.2 media_assets state machine #
uploading ──▶ uploaded ──▶ processing ──▶ ready
│ │
└──▶ errored ◀┘ (only via a later provider error event)| State | Set by | Meaning |
|---|---|---|
uploading |
Reserved for provider-direct uploads and image pipeline pre-processing | Bytes are still arriving |
uploaded |
POST /uploads/{id}/complete (9.6.8) |
The original object exists and is verified |
processing |
media.ingest after asset creation |
The provider is transcoding |
ready |
video.asset.ready webhook, or the reconciliation poll |
A playback id exists; the testimonial can be moderated and published with video |
errored |
video.asset.errored, video.upload.errored, provider rejection, or reconciliation timeout |
Terminal until a moderator triggers media.retry_ingest |
Rules:
- Transitions are monotonic. A state may only move forward in the order above, except that
erroredis reachable fromprocessingand fromready(a provider may delete/error an asset after readiness). A transition attempt that would move backwards is logged atwarnand ignored; this is what makes out-of-order webhooks harmless (9.7.3). - The state column is a Postgres enum
media_stateper Section 5. - Every transition writes an
audit_eventsrow withactor_type = 'system'per Section 11.6. - A testimonial whose media asset is not yet
readyis fully usable in the moderation inbox: the moderator sees the transcript-pending placeholder, the submitted text fields, and a "Video processing" pill. Publishing is allowed only fromready; the publish action is disabled with the reason "Video is still processing" until then, and the moderation inbox live-updates when it becomes ready.
9.7.3 Webhooks — verification, idempotency, ordering #
Endpoint: POST /api/v1/webhooks/mux. It is public, unauthenticated at the session layer, and
authenticated by signature.
Verification:
- Read the raw body before any JSON parsing (the route uses the raw-body reader; a parsed and re-serialized body will not verify).
- Parse the provider's signature header into timestamp
tand signaturev1. - Reject if
|now − t| > 300seconds →400 WEBHOOK_TIMESTAMP_OUT_OF_TOLERANCE. - Compute
HMAC-SHA256("{t}.{rawBody}", MUX_WEBHOOK_SECRET)and compare withcrypto.timingSafeEqual→ mismatch is401 WEBHOOK_SIGNATURE_INVALID. - Only then parse JSON.
Idempotency: every accepted event is inserted into provider_events with a unique constraint on
(provider, event_id). A duplicate insert (23505) short-circuits with 200 and no processing.
The handler therefore tolerates the provider's at-least-once delivery without any bespoke logic.
Handled events:
| Event | Effect |
|---|---|
video.asset.created |
Records provider_asset_id if not already stored; no state change |
video.asset.ready |
Stores playback_id, duration_seconds, max_stored_resolution, aspect_ratio, max_stored_frame_rate; transitions to ready; enqueues media.poster (9.8.2) and transcription.request (Section 10.5) |
video.asset.errored |
Transitions to errored, stores the provider's error messages in error_detail, enqueues notify.media_failed |
video.asset.deleted |
If not initiated by our own purge, transitions to errored with error_code = 'PROVIDER_ASSET_DISAPPEARED' and raises an operator alert |
video.upload.errored |
Transitions to errored with error_code = 'PROVIDER_UPLOAD_FAILED' |
video.asset.static_renditions.ready |
Records that an MP4 rendition exists, used by the respondent's data-download in Section 22 |
| Anything else | Stored in provider_events and ignored, so the catalogue can grow without redeploying |
Out-of-order handling: ready arriving before created, or errored arriving after ready, are
both normal. The monotonic rule in 9.7.2 plus "store facts, then decide state" makes ordering
irrelevant — each handler writes the facts it carries (playback id, duration, error detail) and
then requests a transition that is applied only if it is legal. The handler responds 200 in all
non-signature-failure cases, including for unknown asset ids, so the provider does not retry
against a workspace we have already erased; unknown ids are counted in a metric and logged at
warn.
Processing is done inline in the request when it is a pure database write, and enqueued to BullMQ when it triggers further work (poster capture, transcription). The endpoint's p99 target is 250 ms (Section 23 owns budgets).
9.7.4 The reconciliation job #
Webhooks get lost. A job named media.reconcile runs every 5 minutes as a BullMQ repeatable job
and is the safety net that keeps a stuck video from becoming a permanently invisible testimonial.
-- Assets that should have finished by now.
SELECT id, provider_asset_id, processing_started_at
FROM media_assets
WHERE state = 'processing'
AND processing_started_at < now() - interval '30 minutes'
AND deleted_at IS NULL
ORDER BY processing_started_at ASC
LIMIT 200;For each row:
- Fetch the asset from the provider by
provider_asset_id. - Provider says
ready→ apply the same transition the webhook would have, and increment amedia_webhook_missed_totalcounter (an alert fires if this exceeds 5 in an hour, because it means webhook delivery is broken). - Provider says
errored→ transition toerrored. - Provider returns 404 → transition to
erroredwithPROVIDER_ASSET_DISAPPEAREDand alert. - Provider still says
preparingafter 2 hours → transition toerroredwithPROCESSING_TIMEOUT, notify the workspace, and offer the moderator a Retry processing action that enqueuesmedia.retry_ingest(which re-creates the provider asset from the still- retained original, provided the original has not yet been swept — see 9.10.1).
A second reconciliation pass covers the opposite gap: assets stuck in uploaded for more than
15 minutes with no provider_asset_id, which means media.ingest never ran or died before its
first write. These are simply re-enqueued.
9.7.5 Media error taxonomy #
Every media error carries a stable code (catalogued in Section 29.3), an operator-facing detail, and a respondent- or moderator-facing message. The respondent never sees a provider's raw text.
| Code | Stage | Cause | Moderator-visible message | Recovery |
|---|---|---|---|---|
UPLOAD_TOO_LARGE |
Upload | Exceeded VIDEO_MAX_UPLOAD_BYTES |
"The recording was too large to save." | Respondent records again |
UPLOAD_INCOMPLETE |
Complete | Gaps in the part list | "The upload didn't finish." | Client resumes; if blobs are gone, record again |
UPLOAD_SIZE_MISMATCH |
Complete | Declared vs. stored bytes differ | Same as above | Same |
UPLOAD_VERIFICATION_FAILED |
Complete | HeadObject disagrees with the part sum |
"We couldn't verify the upload." | Automatic: session failed, respondent prompted to retake |
PROVIDER_REJECTED_INPUT |
Ingest | Provider could not decode the file | "This video couldn't be processed." | Retry processing; if it fails again, the original is downloadable for support |
PROVIDER_UPLOAD_FAILED |
Ingest | Provider could not fetch the pre-signed URL | "Processing failed to start." | Automatic retry, then Retry processing |
PROCESSING_TIMEOUT |
Processing | Still preparing after 2 h | "Processing is taking unusually long." | Retry processing |
PROVIDER_ASSET_DISAPPEARED |
Any | Asset deleted outside our purge path | "The processed video is missing." | Operator alert; re-ingest from original if retained |
STORAGE_UNAVAILABLE |
Initiate | Object storage down | Request page hides the video path | Automatic once storage recovers |
TRANSCRIPTION_FAILED |
Post-ready | Section 10.6 owns it | "Captions couldn't be generated." | Retry transcription; publishing is never blocked |
Whenever a media asset enters errored, notify.media_failed emails the workspace's Owner and
Admins (Section 20 owns templates) with the testimonial link, the respondent's name if given, and
the recovery action. The respondent is not emailed about a processing failure, because there
is nothing useful they can do and the message would only damage the workspace's relationship with
them. The exception is when the workspace enables "Ask the respondent to re-record on failure" on
the request page, which sends a single, gentle re-invitation with a fresh link.
9.8 Playback #
9.8.1 Playback policy — signed versus public #
Two modes, chosen per workspace with a per-testimonial override:
| Mode | Provider policy | Where it is used | Trade-off |
|---|---|---|---|
public (default) |
public playback id |
Widgets, showcase page, dashboard | The playback URL is guessable-resistant but not access-controlled; it is fully CDN-cacheable, which is what makes widget playback fast and cheap |
signed |
signed playback id |
Workspaces that enable "Private video" | Every playback URL is a short-lived JWT minted by our server; CDN caching is per-token, so playback is slower and delivery minutes cost more |
public is the default because a published testimonial is, by definition, public marketing
material the respondent consented to publish (Section 22 owns consent). signed exists for
workspaces in regulated industries and for unpublished testimonials: any media asset whose
testimonial is not in published state is served through a signed playback id regardless of the
workspace setting, so a pending testimonial can never leak via a shared URL.
Signed tokens: HS256, 60-minute expiry for the dashboard, 10-minute expiry for the respondent's
own management link (Section 22 owns that link), audience-scoped per playback id, minted by
GET /api/v1/media-assets/{id}/playback-token, which requires testimonial:read on the
workspace or a valid respondent token.
Switching a workspace from public to signed enqueues media.repolicy, which updates the
playback policy on every existing asset, mints new playback ids, and purges the widget CDN tags
so embeds pick up the change within the 60-second cache window defined in Section 13.
9.8.2 Poster frames — captured, cached, and served from our own CDN #
Widget posters must never depend on the video provider's availability, because a poster is the
only thing 95% of widget viewers ever see. media.poster runs on ready:
- Choose the timestamp: the workspace's per-testimonial
poster_time_secondsif set, elsemin(1.5, duration × 0.1)— 1.5 s in, which reliably avoids the eyes-closed first frame while staying inside very short takes. - Fetch the provider's thumbnail at that timestamp at 1280 px wide.
- Post-process with the raster toolchain at the major line stated in Section 3: strip all metadata, correct orientation, and produce five derivatives.
| Derivative | Width | Format | Purpose |
|---|---|---|---|
poster-320 |
320 | AVIF + WebP + JPEG | Widget list layouts, 1× mobile |
poster-640 |
640 | AVIF + WebP + JPEG | Widget grid, 2× mobile |
poster-1280 |
1280 | AVIF + WebP + JPEG | Showcase hero, desktop |
poster-blur |
24 | WebP | Inline blur-up placeholder, base64-embedded in the widget JSON payload (≤ 400 bytes) |
poster-social |
1200×630 | JPEG | Open Graph fallback; Section 15 owns the designed social cards |
- Write all derivatives to the public bucket under
public/posters/{workspaceId}/{mediaAssetId}/{name}.{ext}withCache-Control: public, max-age=31536000, immutable(the path contains the asset id and the derivative name; a regenerated poster gets a newposter_versionpath segment rather than overwriting, so immutability holds). - Store the base URL and available formats on
media_assets.poster_manifest(JSONB). - Purge the CDN tags
widget:{id}for every widget that includes the testimonial, plusworkspace:{id}.
Widgets serve posters via <picture> with AVIF → WebP → JPEG sources, explicit width/height,
aspect-ratio, loading="lazy", and decoding="async", satisfying the CLS budget Section 13
owns. If the poster job fails after 3 attempts, the widget falls back to the poster-blur
placeholder over the accent colour with the testimonial's initials — never a broken image icon.
Changing the poster timestamp is a moderator action
(PATCH /api/v1/testimonials/{id} with posterTimeSeconds), which bumps poster_version,
re-runs media.poster, and writes an audit event.
9.8.3 The adaptive-bitrate ladder #
The provider produces the ladder; we constrain its ceiling and record what came back.
| Plan max resolution | Renditions requested | Ceiling |
|---|---|---|
| 720p (Free) | 270p, 360p, 480p, 720p | max_resolution_tier: '720p' |
| 1080p (paid) | 270p, 360p, 480p, 720p, 1080p | max_resolution_tier: '1080p' |
Delivery is HLS with a per-title-encoded ladder; the player selects the rendition. video_quality
is set to the provider's balanced tier, which is the right trade for talking-head content and
directly reduces the delivery minutes metered in 9.11. Audio is normalized to −16 LUFS at ingest,
which is the single biggest perceived-quality win across a wall of testimonials recorded in
different rooms.
A downgrade from a 1080p plan to Free does not re-encode or degrade existing assets; per Section 19, reaching or crossing a limit never degrades anything already live. Only new captures are constrained by the new plan's ceiling.
9.8.4 The player chunk #
In widgets, no video code is loaded until the viewer presses play — Section 13 owns the widget's
lazy-loading rules and its byte budget, and this section simply conforms to them: the widget's
initial paint contains a <picture> poster and a play button, and the provider's player module is
dynamically imported on the first play interaction across all widgets on the page (one import,
shared).
In the dashboard the same player module is imported on demand when a moderator opens a testimonial, and it is preloaded on hover/focus of a video card so the perceived delay is zero.
Player configuration everywhere:
| Setting | Value | Reason |
|---|---|---|
playsinline |
true | iOS must not go fullscreen |
preload |
metadata |
Enough for duration and the first keyframe |
autoplay |
false, always | Section 21 forbids autoplay; prefers-reduced-motion is irrelevant because we never autoplay at all |
| Captions | on by default | Section 10.10 owns the rule and the toggle |
| Keyboard | Space/K play-pause, ←/→ ±5 s, ↑/↓ volume, M mute, C captions, F fullscreen | Standard, discoverable, documented in the player's shortcut sheet |
| Analytics | video_start, video_progress (the 25/50/75 marker rides in the event payload, not in the event type), video_complete, video_error beacons |
Section 17 owns the cookie-free transport and the event-type vocabulary |
The scrub bar and the no-dragging requirement. The provider's player supplies the transport UI, which makes its scrub bar the one interactive control in the product we do not author. It is therefore an explicit acceptance condition of the pinned provider player version, verified once per version bump and recorded in the build report, that the scrub bar:
- Seeks on a single tap or click anywhere on the track, so no pointer drag is ever required — the path that satisfies the no-dragging requirement Section 21 owns.
- Exposes a drag handle of at least 24 × 24 CSS px, with a 44 × 44 px hit area.
- Is operable by keyboard with
←/→at ±5 s andHome/Endto the ends, and exposesrole="slider"witharia-valuetextspoken as a time, not a percentage.
If a provider version fails any of these, the player module renders our own scrub bar over the provider's playback API rather than shipping a control that cannot be operated without dragging. This is stated as a hard dependency rather than an assumption, because a silent provider regression would otherwise become an undetected conformance failure.
9.9 Audio-only testimonials #
Audio is a first-class variant of the same pipeline, not a degraded mode. It matters because respondents who will not appear on camera will often speak, and because it is the automatic fallback when a camera is missing or denied (9.2.4).
What is identical: the pre-prompt, permission handling, device picker (microphone only), level meter, countdown, timer, warnings, pause/resume, teleprompter, retake, the entire chunked upload in 9.6, the provider ingest in 9.7, transcription in Section 10, and the purge path in 9.10.
What differs:
| Aspect | Video | Audio-only |
|---|---|---|
getUserMedia constraints |
{ video: {...}, audio: {...} } |
{ audio: {...} } only |
| Mime negotiation order | 9.3.1 | audio/mp4;codecs=mp4a.40.2, audio/webm;codecs=opus, audio/mp4, audio/webm |
| Bitrate | Per 9.3.2 | 128 kbps mono; ~16 KB/s, so a 5-minute take is ~4.8 MB and completes as a single part |
| Preview | Live video element | A large animated waveform of the live input, with the same "No sound detected" status line |
media_assets.kind |
video |
audio |
| Provider asset | Standard | Same provider asset with an audio-only input; the provider generates an audio-only rendition |
| Poster | Provider thumbnail (9.8.2) | Generated waveform poster (below) |
| Player | Video player | Same player module in audio mode: waveform poster, transport controls, caption display rendered as a scrolling transcript beside the waveform rather than as overlaid subtitles |
| Widget rendering | Poster + play | Waveform poster + play, same layout slot |
Waveform poster generation (media.waveform, enqueued instead of media.poster for
kind = 'audio'):
- Download the original audio (or the provider's audio rendition if the original has been swept).
- Decode to mono PCM at 8 kHz inside the media sandbox described below — never in the worker process itself.
- Compute 240 RMS buckets across the duration, normalized to the 99th-percentile peak so a single clipped sample does not flatten the image.
- Render an SVG bar waveform using the workspace's accent colour on the workspace's background,
validated against the contrast rules Section 21 owns; rasterize to the same five derivative
sizes as 9.8.2 with the same cache headers and the same
poster_manifestshape. - Store the 240-bucket array on
media_assets.waveform_peaks(JSONB, ≤ 2 KB) so the widget can render a live progress waveform without downloading an image.
The media sandbox — a hard requirement for every path in Sections 9 and 10 that decodes respondent-supplied bytes. Media decoders are among the most heavily exploited pieces of code in any stack, and the bytes reaching this one arrive from an anonymous member of the public who needs nothing but a request-page URL. The worker process is also the process that holds the key-management grant used to unwrap every workspace's stored integration credentials, so a single memory-safety failure in a decoder would reach far beyond the workspace that supplied the file.
Therefore all decoding of respondent-supplied media bytes — audio waveform decode (this section),
image decode and re-encode (9.12), and any container or stream probe — runs in a dedicated
media-sandbox process, with:
| Property | Requirement |
|---|---|
| Credentials | No key-management grant, no database credential, no object-storage credential, no environment secret of any kind |
| Network | No egress. The parent hands it bytes; it never fetches anything itself |
| Confinement | A seccomp/AppArmor profile permitting only the syscalls the decoder needs, plus CPU and memory cgroups sized to the largest legitimate input |
| Timeout | 60 seconds hard, enforced by the parent, after which the process is killed and the job fails with the media error taxonomy code for its stage (9.7.5) |
| Interface | A pipe carrying the input bytes in and the derived artifact out — a PCM bucket array or an encoded image — and nothing else. No file paths, no URLs, no identifiers |
| Lifetime | One process per job. It is never reused across two respondents' files |
Section 22's rule that the application processes never spawn a child process is amended by exactly this one named exception, and by no other. The sandbox is the only place in the product where a third-party decoder is pointed at bytes we did not create, and keeping that boundary narrow and explicit is what makes the rest of Section 22's threat model hold.
If waveform generation fails after 3 attempts, the poster falls back to a static accent-coloured card carrying the respondent's initials and a microphone glyph — visually deliberate, never broken.
9.10 Retention, deletion, and the guarantee that deletion is real #
9.10.1 Original retention #
| Object | Retention | Enforced by |
|---|---|---|
Original upload, after the asset reaches ready |
30 days, then deleted | retention.sweep_originals, daily at 03:15 UTC |
| Original upload, when "Keep originals" is on (Growth and Scale; counts against the workspace's storage quota) | Indefinite, until the testimonial is deleted | The sweep skips these rows |
Original upload, when the asset is errored |
30 days from the error, so support can diagnose and media.retry_ingest can re-ingest |
Same sweep |
Incomplete upload_sessions (active/completing) idle > 24 h |
Session → expired, AbortMultipartUpload called, orphaned parts released |
media.sweep_abandoned_uploads, hourly |
aborted/expired/failed session rows |
Hard-deleted 24 h after their terminal timestamp | retention.sweep_uploads, daily |
| Posters, waveforms, VTT files, derivatives | Life of the testimonial | media.purge |
retention.sweep_originals:
SELECT id, storage_bucket, storage_key
FROM media_assets
WHERE state IN ('ready', 'errored')
AND original_deleted_at IS NULL
AND keep_original IS FALSE
AND COALESCE(ready_at, errored_at) < now() - interval '30 days'
ORDER BY COALESCE(ready_at, errored_at) ASC
LIMIT 500;For each row: DeleteObject, then set original_deleted_at, then decrement the workspace's
attributed storage bytes (9.11). A delete that returns "not found" is treated as success — the
goal is absence, not a particular API response.
"Keep originals" is a workspace setting available on Growth and Scale (Section 19 owns availability). Turning it off does not retroactively delete anything older than 30 days immediately; it schedules those originals into the next sweep, and the settings UI states that plainly: "Originals older than 30 days will be removed within 24 hours."
9.10.2 media.purge — the deletion job, step by step #
Deleting a testimonial (soft delete, per Section 5's deletion policy) enqueues media.purge for
every media asset it owns, with a delay equal to the 30-day restore window Section 11 owns —
the media must still exist for a moderator who restores the testimonial on day 29. Erasure requests
under Section 22 enqueue the same job with zero delay: an erasure is not reversible and must
not wait. The job is the only code path that removes media, so there is exactly one thing to
get right.
The job id is deterministic — media.purge:{mediaAssetId} — so restoring a testimonial cancels
the pending delayed job by id rather than racing it, and so a second delete of the same asset
cannot enqueue a second purge.
Steps, executed in this exact order, each recorded on the asset row so a resumed run skips completed steps:
| # | Step | Success criterion | On failure |
|---|---|---|---|
| 1 | Mark purge_started_at, set purge_attempts = purge_attempts + 1 |
Row updated | Job retries |
| 2 | Delete the provider asset by provider_asset_id |
Provider returns 204 or 404 | Retry; 404 is success |
| 3 | Verify by re-fetching the provider asset | Provider returns 404 | Retry from step 2 |
| 4 | Delete the original object | DeleteObject returns success or the key is absent |
Retry |
| 5 | Delete every derivative key under the asset's prefixes: posters (all sizes, all formats, all poster_versions), waveform images, VTT caption files (all languages, all vtt_versions), social card renders (Section 15), and the respondent's data-export bundle if one was generated — then delete the media_derivatives rows that indexed them |
A ListObjectsV2 over public/posters/{workspaceId}/{mediaAssetId}/, public/captions/{workspaceId}/{mediaAssetId}/, public/cards/{workspaceId}/{mediaAssetId}/ and originals/{workspaceId}/**/{uploadSessionId}.* returns zero keys |
Retry; deletion is batched 1,000 keys per DeleteObjects call |
| 6 | Enqueue cdn.purge for the tags media:{mediaAssetId}, testimonial:{testimonialId}, every widget:{id} that included it, and workspace:{workspaceId} |
CDN API returns success | Retry |
| 7 | Delete the transcript row, every transcript_revisions row including the version-1 provider snapshot (10.4.3), caption rows, AI suggestion rows, and analytics rows that reference the asset |
Rows gone | Retry |
| 8 | Set purged_at = now(), clear provider_asset_id, playback_id, poster_manifest, waveform_peaks, storage_key |
Row updated | Retry |
| 9 | Write an audit_events row (actor_type system or the erasing user) with the full before-snapshot |
Row written | Retry |
Retry policy: BullMQ with unlimited attempts and backoff 1 m, 5 m, 15 m, 1 h, 6 h, then every
6 h forever. After 3 consecutive failures the job raises a media_purge_failing operator
alert at page severity (Section 24 owns alert routing) naming the asset id, the failing step, and
the provider's error. The job never gives up, because giving up would mean claiming data is
deleted when it is not.
A row is never treated as deleted while its assets survive. Concretely:
testimonials.deleted_atmay be set immediately (the moderator's action must feel instant, and the testimonial disappears from every read path via thenotDeleted()helper Section 5 owns).media_assets.purged_atis set only at step 8.- The workspace's data-export (Section 22) and the erasure-completion certificate both report
purged_at, neverdeleted_at, as the moment of destruction. - Section 22's 30-day maximum erasure SLA is measured against
purged_at.
9.10.3 The orphan sweep and the operator's proof query #
Two jobs catch anything the primary path missed.
media.sweep_abandoned_uploads — hourly:
UPDATE upload_sessions
SET state = 'expired', failed_reason = 'idle_timeout', updated_at = now()
WHERE state IN ('active', 'completing')
AND last_activity_at < now() - interval '24 hours'
RETURNING id, storage_bucket, storage_key, multipart_upload_id;Each returned row gets AbortMultipartUpload. The job additionally calls
ListMultipartUploads on the bucket and aborts any multipart upload older than 48 hours that has
no matching upload_sessions row at all — this catches uploads created by a request that died
between the storage call and the database insert.
media.sweep_orphans — daily at 04:00 UTC. It walks the storage prefixes in pages of 1,000 keys
and, for each key, asserts a live owning row exists:
-- For a batch of keys extracted from object storage, which have no owner?
WITH storage_keys(k) AS (SELECT unnest($1::text[]))
SELECT k FROM storage_keys
WHERE NOT EXISTS (
SELECT 1 FROM media_assets ma
WHERE ma.purged_at IS NULL
AND (ma.storage_key = storage_keys.k OR storage_keys.k LIKE 'public/%/' || ma.id::text || '/%')
)
AND NOT EXISTS (
SELECT 1 FROM upload_sessions us
WHERE us.state IN ('active', 'completing', 'completed')
AND us.storage_key = storage_keys.k
);Keys returned by that query and older than 48 hours are deleted, and the count is exported as
media_orphan_keys_deleted_total. A non-zero value is expected occasionally; a sustained non-zero
rate means the primary purge path has a bug and triggers an alert.
The proof query an operator runs to demonstrate no orphans exist — this is the exact query used in the quarterly deletion audit and in any data-protection enquiry:
-- 1. Purged assets that still carry provider or storage references (must be zero).
SELECT id, workspace_id, purged_at
FROM media_assets
WHERE purged_at IS NOT NULL
AND (provider_asset_id IS NOT NULL
OR playback_id IS NOT NULL
OR storage_key IS NOT NULL
OR poster_manifest IS NOT NULL);
-- 2. Assets belonging to deleted testimonials that have not completed purge (must be zero
-- for anything deleted more than 30 days ago — the SLA in Section 22).
SELECT ma.id, ma.workspace_id, t.deleted_at, ma.purge_attempts, ma.purge_last_error
FROM media_assets ma
JOIN testimonials t ON t.id = ma.testimonial_id
WHERE t.deleted_at IS NOT NULL
AND ma.purged_at IS NULL
AND t.deleted_at < now() - interval '30 days';
-- 3. Live assets whose storage object is missing (integrity in the other direction).
-- Run by the daily `media.verify_integrity` job, which HEADs a 1% sample and any asset
-- touched in the last 24 hours; results land in `media_integrity_checks`.
SELECT id, workspace_id, storage_key, last_integrity_error
FROM media_assets
WHERE purged_at IS NULL
AND last_integrity_checked_at > now() - interval '7 days'
AND last_integrity_error IS NOT NULL;All three must return zero rows. The dashboard's admin health page (Section 24) runs them nightly and shows the counts with their timestamps.
9.11 Quota metering for media #
Section 19 owns the plan numbers and the enforcement rules; this section owns how the two media metrics are counted and attributed.
Both metrics are keyed by the dotted names Section 19 owns — storage.bytes and
delivery.minutes — and are stored in usage_counters under those keys. No other name for either
metric exists.
Storage bytes (storage.bytes, per workspace):
| Counted | Not counted |
|---|---|
The original object, from media_assets creation until original_deleted_at |
Any part of an active, aborted or expired upload_sessions — nothing is charged before submit |
| All poster/waveform derivatives | Provider-side transcoded renditions (those are the provider's storage, reflected in delivery pricing, not ours) |
| VTT caption files (all versions) | CDN cache copies |
| Social card renders (Section 15) | Database rows |
| Avatar and company-logo derivatives (9.12) |
Every counted derivative is a row in media_derivatives carrying its byte size, which is what
makes the storage figure auditable rather than inferred: the sum over media_assets (originals)
plus media_derivatives (everything generated from them) is the workspace's storage.bytes.
Attribution is transactional: every write that creates or deletes a counted object adjusts the
workspace's usage_counters row for storage.bytes in the same transaction, and a nightly
quota.reconcile job recomputes the true sum from media_assets and media_derivatives, compares
it against a storage-listing total, and corrects any drift, logging the delta. Drift greater than
1% raises an alert.
Delivery minutes (delivery.minutes, per workspace per calendar month, UTC):
- Sourced from the video provider's delivery/views data, pulled hourly by
usage.pull_deliveryand attributed by thepassthroughworkspace id stored on the asset (9.7.1). - Counted as video seconds delivered ÷ 60, rounded up per playback session, which matches how the provider bills us so the customer's number and our cost move together.
- Audio-only playback is counted at the same rate; it is far cheaper in bandwidth but the metric stays simple and predictable, which matters more to a customer reading an invoice.
- The dashboard shows month-to-date, the plan's included amount, a 30-day sparkline, and the projection for the month.
Over-quota behaviour is soft and is defined by Section 19: the job still completes, the video still processes, the widgets keep serving at full quality, an overage notice is raised, warnings appear in-product at 80% and 95% with emails at 90% and 100%, and only new collection is blocked after the account has been more than 20% over for 7 consecutive days. Nothing in this section ever deletes media, degrades a rendition, or stops a delivery because of a quota.
When that sustained-overage rule has closed collection, the one refusal it produces in this section
is at Initiate: POST /api/v1/uploads responds 409 STORAGE_LIMIT_EXCEEDED (9.6.3 precondition 5).
It is 409 and never 403, because the caller is authorized and the request conflicts with the
workspace's state rather than with the caller's rights — the distinction Section 19 owns and every
quota refusal in the product follows.
9.12 Image assets on the same pipeline #
The respondent's avatar and the company logo use the same upload machinery (9.6) with
kind = 'avatar' or kind = 'company_logo', but a different post-processing job
(media.process_image) and no video provider involvement.
| Rule | Avatar | Company logo |
|---|---|---|
| Accepted input types | image/jpeg, image/png, image/webp, image/avif, image/heic, image/heif |
Same, plus image/svg+xml |
| Max input bytes | 10 MiB | 10 MiB |
| Max input dimensions | 8192 × 8192 | 8192 × 8192 |
| Min input dimensions | 96 × 96 | 48 × 48 |
| Output crop | Centre square, respondent-adjustable via a drag/pinch cropper with a keyboard-operable alternative (arrow keys nudge, +/- zoom) |
No crop; contained within the target box, transparent padding preserved |
| Derivative sizes | 48, 96, 192, 384 px | 64, 128, 256, 512 px wide |
| Output formats | AVIF, WebP, JPEG (in that source order) | AVIF, WebP, PNG (PNG preserves transparency) |
| Fallback when absent | Initials avatar generated server-side from the respondent's name, on a deterministic accent-derived background that is contrast-checked per Section 21 | No logo rendered; layouts must not reserve empty space |
Processing steps in media.process_image, in order. Steps 1, 2 and 5–6 decode or re-encode
respondent-supplied bytes and therefore run inside the media-sandbox process defined in 9.9;
the worker itself only moves bytes and writes rows.
- Sniff the real type from magic bytes. The declared
Content-Typeand the file extension are ignored for security decisions. A mismatch is logged and the sniffed type wins; an unsniffable file is rejected with422 UNSUPPORTED_IMAGE_TYPE. - Reject bombs. Decompressed pixel count above 100 megapixels, or a declared canvas whose
dimensions exceed the maxima above, is rejected with
413 IMAGE_TOO_LARGEbefore any decode. Animated inputs (animated WebP/GIF-in-WebP, multi-frame AVIF) are flattened to their first frame. - SVG handling (logos only): the file is sanitized with the HTML sanitizer named in
Section 3, configured for SVG, stripping
<script>,<foreignObject>, event handlers, external references, and embedded<image href="http...">. A sanitized SVG is stored and rasterized to the PNG/WebP/AVIF derivatives; widgets and emails use the raster derivatives so no host page ever executes our stored SVG. The sanitized SVG is served only on the showcase page, withContent-Security-Policy: sandboxandContent-Type: image/svg+xmlfrom the asset host, never inline. - EXIF and metadata:
- Read the
Orientationtag, apply the corresponding rotation/flip to the pixels, then writeOrientation = 1. A sideways avatar is the single most common image complaint and this step eliminates it. - Strip every other metadata block: EXIF (including GPS latitude/longitude/altitude and timestamps), IPTC, XMP, Photoshop resources, and any embedded thumbnails (which are a classic leak of the un-cropped original).
- Preserve exactly one thing: the ICC colour profile, converted to sRGB and then discarded — the output is tagged sRGB, so no profile is emitted.
- Read the
- Resize with Lanczos3 to each derivative width, never upscaling: a 64 px source produces a 64 px "96" derivative rather than a blurry enlargement, and the manifest records true widths.
- Encode AVIF (quality 55, effort 4), WebP (quality 82), and JPEG (quality 82, progressive,
mozjpegwhere available) / PNG (palette-quantized where lossless allows). - Write to
public/images/{workspaceId}/{mediaAssetId}/{name}-{width}.{ext}withCache-Control: public, max-age=31536000, immutable, and store the manifest onmedia_assets.image_manifest. - Delete the original immediately once derivatives exist — an un-stripped original with GPS coordinates is a liability with no product value. This is the one case where the 30-day original retention in 9.10.1 does not apply, and the setting "Keep originals" does not extend to images.
Client-side, before upload, the browser downscales anything larger than 2048 px on its longest
edge using createImageBitmap + OffscreenCanvas (falling back to a hidden <canvas>), which
turns a 12 MB phone photo into a ~400 KB upload and removes EXIF as a side effect of re-encoding.
The server still performs every step above, because a client-side transform is a convenience and
never a control.
9.13 Recorder control inventory #
Section 21 owns the accessibility acceptance criteria. This table exists so those criteria can
bind to concrete, stable names. Every control below has a visible label or an aria-label
matching the "Accessible name" column exactly, is reachable in the tab order shown, and meets the
minimum target size stated.
| Order | Control | Element | Accessible name | Min target | Notes |
|---|---|---|---|---|---|
| 1 | Allow camera & mic | button |
"Allow camera and microphone" | 44×44 | The only element that calls getUserMedia |
| 2 | Write it instead | button |
"Write your testimonial instead" | 44×44 | Never disabled, present on every recorder screen |
| 3 | Camera picker | select |
"Camera" | 44×44 | Hidden when one device |
| 4 | Flip camera | button |
"Switch to {front|rear} camera" | 44×44 | Mobile primary; disabled while recording with an explanatory description |
| 5 | Microphone picker | select |
"Microphone" | 44×44 | Hidden when one device |
| 6 | Audio level meter | div + status text |
"Microphone level: {status}" | n/a | role="status", aria-live="polite" |
| 7 | Framing guide toggle | button[aria-pressed] |
"Framing guide" | 44×44 | |
| 8 | Teleprompter toggle | button[aria-pressed] |
"Teleprompter" | 44×44 | Off by default |
| 9 | Teleprompter speed | input[type=range] (5 steps) |
"Teleprompter speed" | 44×44 | Announces words per minute on change |
| 10 | Record | button |
"Start recording" | 44×44 | |
| 11 | Cancel countdown | button |
"Cancel countdown" | 44×44 | Only during countdown |
| 12 | Pause / Resume | button |
"Pause recording" / "Resume recording" | 44×44 | Absent where unsupported |
| 13 | Stop | button |
"Stop recording" | 44×44 | |
| 14 | Start over | button |
"Start over" | 44×44 | Confirms above 10 s |
| 15 | Timer | div |
"Recording time" | n/a | role="timer"; announcements only at 60/30/15 s |
| 16 | Progress bar | div |
"Recording progress" | n/a | role="progressbar" with aria-valuetext |
| 17 | Playback (review) | video + custom controls |
"Your recording" | 44×44 per control | Scrub bar keyboard-operable, ±5 s with arrows |
| 18 | Retake | button |
"Retake video" | 44×44 | |
| 19 | Use this take | button |
"Use this video" | 44×44 | Advances to the details screen (Section 8) |
| 20 | Upload a video | input[type=file] styled as a button |
"Upload a video from your device" | 44×44 | Fallback path, also always available in the settings sheet |
| 21 | Record audio only | button |
"Record audio only" | 44×44 | Offered when no camera exists or camera is denied |
| 22 | Live region (assertive) | div |
— | n/a | Countdown and recording state |
| 23 | Live region (polite) | div |
— | n/a | Timer milestones, meter status, device changes |
All recorder state changes announce through region 22 or 23. No state is conveyed by colour
alone: the recording indicator carries the word "Recording", the meter carries a status phrase,
and every disabled control carries a reason via aria-describedby.
9.14 Media pipeline observability #
Section 24 owns the observability platform; these are the media-specific signals it must carry.
| Metric | Type | Alert threshold |
|---|---|---|
recorder_permission_denied_total{browser,reason} |
counter | Denial rate > 35% over 1 h, per workspace, is a workspace-level warning in the dashboard, not a page |
upload_part_retry_total{attempt} |
counter | — |
upload_part_failure_total{reason} |
counter | > 2% of parts over 15 m pages |
upload_session_abandoned_total |
counter | > 15% of opened sessions over 1 h warns |
upload_complete_duration_seconds |
histogram | p95 > 8 s warns (this is the time the respondent waits after pressing submit) |
media_ingest_duration_seconds |
histogram | p95 > 90 s warns |
media_ready_latency_seconds (submit → ready) |
histogram | p95 > 300 s warns |
media_webhook_missed_total |
counter | > 5 in 1 h pages — webhook delivery is broken |
media_state_errored_total{code} |
counter | > 1% of assets over 1 h pages |
media_purge_failing_total |
gauge | ≥ 1 asset with 3+ failures pages |
media_orphan_keys_deleted_total |
counter | Sustained > 0 across 3 days warns |
poster_generation_failure_total |
counter | > 1% over 1 h warns |
Every log line in this pipeline carries requestId, workspaceId, uploadSessionId and/or
mediaAssetId, and never carries a pre-signed URL, an upload token, a playback JWT, or any
respondent-identifying field. Traces span the full path: request page → initiate → parts →
complete → ingest → webhook → ready, joined on mediaAssetId.
10. Transcription, Captions & the Transcript Editor #
10.1 Scope, and the rule that governs everything below #
A video testimonial that cannot be read is half a testimonial. Roughly 80% of social-feed video is watched muted, screen-reader users cannot watch it at all, search engines cannot index it, the AI highlight assistant in Section 12 has nothing to select from, and a moderator cannot find "the one where she talks about onboarding" without scrubbing through forty clips. Transcription is therefore not a nice-to-have bolted onto the media pipeline — it is the mechanism that turns a video into text the rest of the product can operate on.
The governing rule for this entire section, stated once and enforced everywhere:
One transcript, one source of truth. Every spoken-word derivative — the WebVTT caption track, the reading copy shown beside a video, the pull-quotes the AI assistant proposes, the clip in/out points, the full-text search index, the social card text, and the respondent's own data export — is generated from a single stored transcript structure. Nothing maintains a second copy of the words. When the transcript changes, every derivative is regenerated from it; none of them is ever edited independently.
The second governing rule, which protects the respondent's experience:
Video never blocks on transcription. A testimonial with a failed, pending, or unusable transcript is still fully moderatable and fully publishable. Captions are an enhancement that arrives, not a gate that must be passed.
Scope of this section: provider configuration, language handling, the stored structure, the job flow, the moderator-facing transcript editor, caption generation and delivery, search indexing, and the equivalent treatment of text testimonials. Section 5 owns the table definitions and the full-text index; Section 7 owns the HTTP envelope; Section 11 owns the moderation inbox that hosts the editor; Section 12 owns the AI assistant that consumes the transcript; Section 19 owns the per-plan transcription-minute allowances; Section 21 owns the accessibility criteria; Section 22 owns consent and sub-processor disclosure.
10.2 Provider configuration #
10.2.1 The provider interface #
Transcription sits behind a narrow interface in packages/core/src/transcription/, so the vendor
is a configuration decision rather than an architectural one.
// packages/core/src/transcription/provider.ts
export interface TranscriptionRequest {
/** Pre-signed, time-limited URL to the media the provider should fetch. */
mediaUrl: string;
/** 'video' | 'audio' — affects nothing in the request, recorded for telemetry. */
kind: MediaKind;
/** Workspace default; used as the fallback when detection is not confident. */
fallbackLanguage: SupportedLanguage;
/** When set, detection is skipped and this language is forced. */
forceLanguage?: SupportedLanguage;
/** Seconds; used only to bound provider timeouts and to meter quota. */
durationSeconds: number;
/** Opaque correlation id echoed in logs. */
correlationId: string;
}
export interface TranscriptionResult {
language: SupportedLanguage;
languageConfidence: number; // 0..1
overallConfidence: number; // 0..1, mean word confidence weighted by word duration
segments: TranscriptSegment[];
providerName: string; // e.g. 'deepgram'
providerModel: string; // e.g. 'nova-3'
providerRequestId: string;
billedSeconds: number;
}
export interface TranscriptionProvider {
transcribe(req: TranscriptionRequest): Promise<TranscriptionResult>;
}The default implementation is Deepgram, using the SDK at the major line stated in Section 3. An
AssemblyAI implementation is documented and kept compile-ready as a drop-in alternative at the
major line also stated in Section 3; switching providers is the environment variable
TRANSCRIPTION_PROVIDER (deepgram | assemblyai, default deepgram) plus that provider's API
key. Both providers carry a row in the published sub-processor register in Section 22.9 — the
fallback provider with the note that it is engaged only when the primary is unavailable or the
deployment selects it. A provider that can receive respondent audio and is absent from that
register is a defect in a legal document, not a documentation gap, so the register lists both
whether or not the fallback is currently in use.
The interface deliberately returns a normalized result. No provider-shaped object ever
escapes packages/core/src/transcription/; the rest of the codebase has never heard of the
vendor.
10.2.2 Deepgram request options — exact #
// packages/core/src/transcription/deepgram-provider.ts
const { result } = await deepgram.listen.prerecorded.transcribeUrl(
{ url: req.mediaUrl },
{
model: 'nova-3',
smart_format: true, // punctuation + capitalization + number/date formatting
punctuate: true, // explicit, not inherited from smart_format
utterances: true, // gives us natural segment boundaries
utt_split: 0.8, // seconds of silence that ends an utterance
diarize: false, // single speaker assumed — see below
filler_words: false, // "um"/"uh" excluded from the transcript text
profanity_filter: false, // never alter the respondent's words
redact: [], // never alter the respondent's words
numerals: true,
paragraphs: true,
...(req.forceLanguage
? { language: req.forceLanguage }
: { detect_language: SUPPORTED_LANGUAGES }),
},
);Rationale for each non-obvious choice:
| Option | Value | Why |
|---|---|---|
model |
The provider's current general-purpose flagship family (nova-3 at time of writing; the family, not the exact point release, is what is pinned) |
Best word-error rate on conversational speech across our five launch languages |
utterances + utt_split: 0.8 |
on, 0.8 s | Produces segment boundaries that fall at natural pauses, which is exactly where a caption cue should break |
diarize |
false | v1 assumes one speaker. Multi-speaker testimonials (a two-person interview) are explicitly out of scope for v1 and listed in Section 29.8 as a deferred capability. If two voices are present, the transcript is still produced as a single stream and the moderator can split segments manually |
filler_words |
false | Captions read better without "um"; the AI assistant's verbatim-substring check in Section 12.4 runs against the stored transcript, so the two remain consistent by construction |
profanity_filter |
false | Section 12's hard boundary — the platform never alters a respondent's words. Masking profanity would be an alteration |
redact |
[] |
Same reason. Redaction is an alteration. A moderator who wants something removed rejects or edits the testimonial as a deliberate, audited human act |
paragraphs |
true | Provides the paragraph grouping used for the reading copy beside the video |
Timeouts and limits: the provider call is given max(120, durationSeconds × 2) seconds, capped
at 900 s. The pre-signed media URL passed to the provider is valid for 60 minutes, GET-only,
and points at the transcodable audio rendition when one exists (smaller fetch, identical words)
or the original object otherwise.
10.2.3 What is never sent, and what is never retained #
- Only the media URL is sent. No respondent name, email, workspace name, testimonial text, or request-page content is transmitted to the transcription provider.
- The provider account is configured with model-training opt-out and zero data retention, both set as named account settings rather than assumed: for the primary provider these are the account-level "model improvement / data logging" toggle set to off and the request-level no-retention flag sent on every call, and the equivalent named settings for the fallback provider. Section 22.7 records them and Section 22.9 carries the DPA reference. The statement in Section 12.7 — customer testimonial content is never used to train any model — covers the transcription vendor as well as the AI provider.
- Those settings are verified, not trusted. A monthly
ops.verify_subprocessor_retentionjob reads each provider's account configuration through its management API, asserts that training opt-out and zero retention are still set, and pages an operator on any drift. A contractual commitment that nothing checks is a claim; a checked one is a control, and this is the difference between the two. Where a vendor exposes no management API for a setting, the job records that fact and the check falls to the quarterly sub-processor review, which is stated as such rather than left implicit. - Neither provider is asked to delete a per-request copy after the fact, because neither is asked to keep one. When an erasure request runs, Section 22's erasure path still issues a deletion call to any provider whose API exposes one and records the response, so the erasure record shows what was asked of whom rather than resting on the retention setting alone.
TRANSCRIPTION_*environment variables are catalogued once in Section 29.2. No key is ever logged; the provider's request id is logged, the key is not.
10.3 Languages and automatic detection #
Launch languages, and the only values SupportedLanguage may take:
| Code | Language | Notes |
|---|---|---|
en |
English | Default for new workspaces |
es |
Spanish | |
fr |
French | |
de |
German | |
pt |
Portuguese | Covers both European and Brazilian variants; the transcript stores pt, not a region subtag |
Detection rules:
- If the workspace or the request page has forced a language (setting: "Always transcribe as…"), detection is skipped and that language is used. This exists because a workspace that only ever collects in German should not lose a single transcript to a mis-detection.
- Otherwise the provider is asked to detect, restricted to the five supported codes.
- If
languageConfidence >= 0.6, the detected language is used. - If
languageConfidence < 0.6, the workspace default language is used instead, the transcript is stored withlanguageConfidenceLow: true, and the transcript is flagged for review: the moderation inbox shows a "Check language" pill on the testimonial, the transcript editor opens with a banner ("We weren't sure what language this is — we used {default}. Change it if we got it wrong."), and the language selector is focused. - If the provider returns a language outside the supported five (which can happen even with a
restricted detection list), the same fallback applies with the additional flag
detectedUnsupportedLanguage: '{code}'recorded on the transcript so the workspace's dashboard can report demand for a language we do not yet support. - A moderator can change the transcript's language at any time in the editor. Changing it does not re-run transcription automatically — the words are already there and re-running would discard the moderator's edits. A separate Re-transcribe in {language} action exists, is explicitly destructive, confirms ("This replaces the current transcript and any edits you have made"), and consumes transcription minutes.
- Per-segment language override exists (10.7.2) for the genuinely mixed-language testimonial —
a respondent who switches to Spanish for one sentence. The transcript's top-level
languageis the dominant one; a segment carrying its ownlanguageis emitted into a separate VTT cue with the correctlangattribute on the rendered reading copy.
Adding a sixth language post-v1 requires: adding the code to SUPPORTED_LANGUAGES, confirming
provider support, adding the caption reading-speed constant in 10.9.2, and adding the language
name to the UI string table. It is listed in Section 29.8 as a deferred capability with that
checklist.
10.4 The canonical transcript structure #
10.4.1 The stored JSONB #
One row per media asset in transcripts (Section 5 owns the table). The content column is
jsonb and holds exactly this shape:
{
"version": 1,
"language": "en",
"confidence": 0.94,
"durationSeconds": 187.4,
"segments": [
{
"id": "s1",
"start": 0.0,
"end": 4.12,
"text": "We were spending about six hours a week on manual reporting.",
"words": [
{ "w": "We", "s": 0.00, "e": 0.18, "c": 0.99 },
{ "w": "were", "s": 0.18, "e": 0.36, "c": 0.98 },
{ "w": "spending", "s": 0.36, "e": 0.81, "c": 0.97 },
{ "w": "about", "s": 0.81, "e": 1.06, "c": 0.99 },
{ "w": "six", "s": 1.06, "e": 1.31, "c": 0.72 },
{ "w": "hours", "s": 1.31, "e": 1.68, "c": 0.96 },
{ "w": "a", "s": 1.68, "e": 1.74, "c": 0.94 },
{ "w": "week", "s": 1.74, "e": 2.09, "c": 0.98 },
{ "w": "on", "s": 2.09, "e": 2.22, "c": 0.99 },
{ "w": "manual", "s": 2.22, "e": 2.71, "c": 0.95 },
{ "w": "reporting","s": 2.71, "e": 3.44, "c": 0.93 }
]
},
{
"id": "s2",
"start": 4.60,
"end": 9.87,
"language": "es",
"text": "Ahora tarda cinco minutos.",
"words": [
{ "w": "Ahora", "s": 4.60, "e": 5.02, "c": 0.91 },
{ "w": "tarda", "s": 5.02, "e": 5.40, "c": 0.88 },
{ "w": "cinco", "s": 5.40, "e": 5.79, "c": 0.93 },
{ "w": "minutos", "s": 5.79, "e": 6.44, "c": 0.90 }
]
}
]
}10.4.2 Field rules and invariants #
| Field | Type | Rules |
|---|---|---|
version |
integer | Schema version of this structure. 1 at launch. A reader encountering a higher version than it understands refuses to render rather than guessing |
language |
enum | One of the five supported codes. Dominant language of the transcript |
confidence |
number 0–1, 2 dp | Duration-weighted mean of all word confidences. Recomputed on every save |
durationSeconds |
number ≥ 0 | Copied from the media asset; used to validate that no segment extends past the end |
segments |
array | Ordered by start ascending. May be empty (a silent recording produces a valid, empty transcript, not an error) |
segments[].id |
string | s + ordinal, unique within the transcript, stable across edits. A split produces s3a/s3b; a merge keeps the first id. Ids are stable because Section 12's clip suggestions reference them |
segments[].start / .end |
number, seconds, 2 dp | 0 <= start < end <= durationSeconds + 0.5. Segments must not overlap; a gap between segments is normal (silence) |
segments[].text |
string | The rendered text of the segment. Must equal the space-joined words[].w after whitespace collapsing — this invariant is what lets Section 12.4 validate a proposed pull-quote by exact substring match |
segments[].language |
enum, optional | Present only when it differs from the transcript's top-level language |
segments[].words |
array | May be empty only for a segment created by a moderator typing new text where timings could not be inferred; in that case word timings are interpolated evenly across the segment on save, so downstream consumers never see an empty words array |
words[].w |
string | One token. Never empty, never contains a space |
words[].s / .e |
number, seconds, 2 dp | Monotonic within a segment; s < e; the first word's s ≥ segment start and the last word's e ≤ segment end |
words[].c |
number 0–1, 2 dp | Per-word confidence. Moderator-edited words are set to 1.0 — a human has confirmed them |
Enforcement: the structure is defined once as a Zod schema in packages/validation and validated
on every write, whether the writer is the transcription job or the editor. A structure that
fails validation is never persisted; the job fails loudly instead of storing something the caption
generator will choke on later.
Size guard: the JSONB is capped at 2 MiB. A 5-minute transcript at ~150 words/minute is
roughly 90 KB, so the cap is ~20× headroom; exceeding it means something is wrong and the write is
rejected with 422 TRANSCRIPT_TOO_LARGE. Postgres stores the column TOASTed; the segments
array is never queried by path in a hot path — search uses the derived index in 10.12.
10.4.3 Versioning and history #
| Column (Section 5 owns the table) | Purpose |
|---|---|
id, workspace_id, media_asset_id, testimonial_id |
Identity and tenancy |
content (jsonb) |
The structure above |
version (integer) |
Starts at 1 on first write; incremented on every accepted save. transcripts.version is the optimistic-concurrency token (10.7.6). It is projected onto the wire as transcriptVersion so it can never be confused with content.version, the schema version of the JSONB structure, which is a different number with a different meaning |
language, language_confidence, language_confidence_low |
Denormalized for filtering without touching the JSONB |
overall_confidence |
Denormalized content.confidence |
word_count, low_confidence_word_count |
Denormalized for the inbox's "needs review" filter |
source |
provider | human_edited | human_authored |
provider_name, provider_model, provider_request_id, billed_seconds |
Provenance and quota reconciliation |
vtt_storage_key, vtt_generated_at, vtt_version |
The generated caption file (10.9.4) |
edited_by_user_id, edited_at |
Last human editor |
created_at, updated_at |
Per Section 5's timestamp convention |
Every save also writes a full snapshot to transcript_revisions (transcript_id, version,
content, edit_class, created_by, created_at, change_summary). Revisions are
retained for 90 days or the last 25 revisions per transcript, whichever is larger, then
pruned by the daily retention.prune_transcript_revisions job. The revision at version 1 —
the untouched provider output — is never pruned by retention, so it is always possible to prove
what the machine originally heard.
Erasure overrides that guarantee, and it is the only thing that does. When Section 22's erasure
path or the deletion job in 9.10.2 runs for a testimonial, every transcript_revisions row for
it is deleted, version 1 included. A never-prune rule that survived an erasure request would be a
retained copy of a person's words after they asked for them to be destroyed, which is precisely the
failure the deletion guarantee in 9.10.2 exists to prevent. The rule is "never pruned by the
retention sweep", not "never deleted".
10.4.4 What derives from this structure, and nothing else #
| Derivative | Generated by | Regenerated when |
|---|---|---|
| WebVTT caption file | vtt.generate (10.9) |
Every accepted transcript save |
| Reading copy beside the video (paragraph-grouped text) | Rendered from segments at read time; not stored |
n/a — never stored, so it can never drift |
| AI pull-quote candidates and clip in/out points | Section 12, reading segments and words |
On moderator request |
| Full-text search vector | Trigger on the transcript row (10.12) | Every accepted save |
| Social card quote text | Section 15, from a moderator-approved pull-quote that was itself validated against this transcript | On card render |
| Respondent data export | Section 22, exporting content verbatim plus the VTT |
On request |
| Widget "read the transcript" disclosure | Section 13, from the same JSON payload | On CDN purge |
There is no second words table, no captions text column, no cached plaintext copy on the
testimonial, and no separate "transcript for search". Anything that needs the words reads this
structure or an index derived from it by a trigger.
10.5 The job flow, end to end #
10.5.1 Trigger #
transcription.request is enqueued from exactly two places:
- The
video.asset.readywebhook handler in Section 9.7.3, immediately after the media asset transitions toready. - The Retry transcription moderator action (10.6.3) and the Re-transcribe in {language} action (10.3 rule 6).
It is enqueued with a job id of transcription:{mediaAssetId}:{attemptGeneration}, so a duplicate
webhook cannot produce two concurrent provider calls.
10.5.2 Job steps #
| # | Step | Failure handling |
|---|---|---|
| 1 | Load the media asset with SET LOCAL app.current_workspace_id per Section 6's tenancy rule; require state = 'ready' and purged_at IS NULL |
Any other state → exit successfully (no-op) |
| 2 | Check the workspace's transcription.minutes allowance (10.14). Over allowance is soft: the job proceeds and an overage notice is raised per Section 19 |
Never blocks |
| 3 | Resolve the language mode: forced (request page → workspace) or detect | — |
| 4 | Mint a 60-minute pre-signed GET URL for the audio rendition, falling back to the original object |
Missing object → TRANSCRIPTION_SOURCE_MISSING, no retry, moderator notified |
| 5 | Call provider.transcribe(...) |
429 or 5xx → retry per 10.6.1. 4xx other than 429 → terminal failure |
| 6 | Normalize the response (10.5.3) and validate it against the Zod schema | Validation failure → terminal failure with TRANSCRIPT_SHAPE_INVALID, provider payload stored in the job run for diagnosis |
| 7 | Upsert the transcripts row: version 1 on first write, or, on a re-transcribe, version + 1 with a revision snapshot of the previous content |
— |
| 8 | Enqueue vtt.generate |
— |
| 9 | Record billed_seconds against the workspace's monthly transcription usage |
— |
| 10 | Write an audit_events row with actor_type = 'system', action transcript.created or transcript.retranscribed |
— |
| 11 | Enqueue notify.transcript_ready only when the testimonial is already approved or published (a moderator waiting on captions), never for a pending one — that would double-notify against the submission notification Section 20 already sends |
— |
vtt.generate then: renders the VTT (10.9), writes it to object storage, updates
vtt_storage_key/vtt_version/vtt_generated_at, enqueues cdn.purge for the tags
media:{mediaAssetId}, testimonial:{testimonialId} and every widget:{id} containing it, and — if the AI assistant is
available on the plan and the workspace has auto-suggest enabled — enqueues Section 12's
suggestion job.
10.5.3 Normalization #
The provider's utterances become segments; its words become words. Concretely:
function normalize(raw: DeepgramPrerecordedResponse, fallback: SupportedLanguage): TranscriptionResult {
const alt = raw.results.channels[0].alternatives[0];
const detected = raw.results.channels[0].detected_language ?? null;
const detectedConfidence = raw.results.channels[0].language_confidence ?? 0;
const language = detected && SUPPORTED_LANGUAGES.includes(detected) && detectedConfidence >= 0.6
? (detected as SupportedLanguage)
: fallback;
const segments = (raw.results.utterances ?? []).map((u, i) => {
const words = u.words.map((w) => ({
w: w.punctuated_word ?? w.word,
s: round2(w.start),
e: round2(w.end),
c: round2(w.confidence),
}));
return {
id: `s${i + 1}`,
start: round2(u.start),
end: round2(u.end),
// INVARIANT: text must equal the space-joined words (10.4.2).
text: words.map((w) => w.w).join(' ').replace(/\s+/g, ' ').trim(),
words,
};
});
return {
language,
languageConfidence: round2(detectedConfidence),
overallConfidence: durationWeightedMean(segments),
segments,
providerName: 'deepgram',
providerModel: raw.metadata.models?.[0] ?? 'nova-3',
providerRequestId: raw.metadata.request_id,
billedSeconds: Math.ceil(raw.metadata.duration),
};
}Normalization rules that matter:
- Text is derived from words, never taken from the provider's paragraph string. This is what guarantees the 10.4.2 invariant and therefore guarantees Section 12.4's substring validation cannot produce false rejections.
- All numbers are rounded to 2 decimal places on write, so a re-serialization never produces a spurious diff.
- Unicode is normalized to NFC on write. Section 12.4's verbatim check normalizes to NFC as well, so a composed-versus-decomposed accent can never break a valid pull-quote.
- Empty utterances, zero-word utterances, and utterances with
end <= startare dropped. - A response with zero usable utterances is a valid empty transcript, stored with
segments: [],confidence: 0, andsource: 'provider'. The moderator sees "No speech detected" and a Retry transcription action. It is not an error, because a genuinely silent or music-only recording is a real thing. - If the provider returns audio duration that differs from the media asset's duration by more than
2 seconds, the transcript is still stored and a
duration_mismatchflag is set for telemetry; it is never a failure, because a container-level duration disagreement is common and harmless.
10.5.4 API surface #
All endpoints follow the envelope, pagination and error contract in Section 7 and require
testimonial:read or testimonial:update per Section 6's permission model.
| Method & path | Purpose | Notes |
|---|---|---|
GET /api/v1/testimonials/{id}/transcript |
Fetch the transcript | Returns content, transcriptVersion, language, flags, and the VTT URL. 404 TRANSCRIPT_NOT_FOUND when none exists yet |
PUT /api/v1/testimonials/{id}/transcript |
Save an edited transcript | Requires If-Match: {transcriptVersion}; see 10.7.6 |
POST /api/v1/testimonials/{id}/transcript/retry |
Re-run transcription, keeping the existing transcript until the new one succeeds | 202; 409 TRANSCRIPTION_IN_PROGRESS if a job is already running |
POST /api/v1/testimonials/{id}/transcript/retranscribe |
Destructive re-run in a chosen language | 202; body { "language": "de" }; confirms in UI, snapshots the old version first |
GET /api/v1/testimonials/{id}/transcript/revisions |
Revision list, cursor-paginated per Section 7 | Returns metadata only, not full content |
GET /api/v1/testimonials/{id}/transcript/revisions/{version} |
One revision's full content | Used by the diff view |
POST /api/v1/testimonials/{id}/transcript/revisions/{version}/restore |
Restore a revision as a new version | Writes an audit event; never rewrites history |
GET /api/v1/testimonials/{id}/captions.vtt |
Redirects (302) to the CDN-hosted VTT, or streams it for signed-playback workspaces | Public for published testimonials, authenticated otherwise |
10.6 Retry, partial failure, and the no-blocking guarantee #
10.6.1 Retry policy #
| Failure class | Examples | Behaviour |
|---|---|---|
| Transient | 429, 500–504, connection reset, timeout | BullMQ retry: 5 attempts, backoff 30 s → 2 m → 10 m → 45 m → 3 h, each with ±20% jitter. A 429 carrying Retry-After uses that value as a floor |
| Terminal, our fault | Source object missing, media asset purged, workspace deleted | No retry. Job exits with the error recorded |
| Terminal, provider's verdict | 400 unsupported media, 415, provider says the file has no audio track | No retry. TRANSCRIPTION_UNSUPPORTED_MEDIA |
| Shape failure | Response fails Zod validation | No retry (retrying yields the same shape). TRANSCRIPT_SHAPE_INVALID, alerts an operator, stores the raw payload in the job run for 7 days |
| Billing or account failure at the provider | The vendor refuses the call for payment or account-suspension reasons | No retry. TRANSCRIPTION_PROVIDER_UNAVAILABLE, pages an operator immediately — this is a platform outage, not a customer problem, and it never surfaces to the workspace as a quota error |
After the final transient attempt fails, the transcript row is written (or updated) with
state = 'failed' and failure_code, and notify.transcription_failed emails the workspace's
Owner and Admins with the testimonial link and a one-click Retry transcription. Section 20
owns the template.
10.6.2 Partial failure #
Two partial cases are handled explicitly rather than treated as total failure:
- Transcript succeeded, VTT generation failed. The words exist and are usable everywhere —
reading copy, search, AI, export. Only the caption file is missing.
vtt.generateretries independently (5 attempts, 1 m → 5 m → 15 m → 1 h → 4 h) and the player simply renders no caption track in the meantime, with a "Captions are being prepared" note in the dashboard only. The widget shows nothing about it — a viewer must never see our internal state. - Transcript succeeded but is very low confidence (
overall_confidence < 0.55). It is stored and used, and the moderation inbox flags it as "Low-quality audio — check the transcript" with a direct link into the editor. It is never discarded: a bad transcript a human can fix in two minutes is worth far more than no transcript.
10.6.3 The no-blocking guarantee, stated concretely #
| Situation | Can the testimonial be moderated? | Can it be published? | What the widget shows |
|---|---|---|---|
| Transcription pending | Yes | Yes | Video plays; no caption track; a captionsPending boolean is present in the widget JSON so the dashboard preview can explain it, and the public widget ignores it |
| Transcription failed permanently | Yes | Yes | Video plays; no caption track; nothing indicates failure to a viewer |
| Transcript empty (no speech) | Yes | Yes | Video plays; no caption track |
| Transcript present | Yes | Yes | Video plays with captions on by default (10.10) |
The publish action in Section 11 has no transcript availability pre-condition of any kind: a missing, failed, empty or low-confidence transcript never blocks anything. (The single transcript condition that can block publication is the substantive-edit re-consent gate in 10.7.2, which is about a transcript that was changed by a human, not about one that failed to arrive.) The only place the transcript's absence is surfaced is the moderator's own view, where a persistent, dismissible notice offers Retry transcription. That action is available at any time, on any status, including on an already-published testimonial — and when it succeeds on a published testimonial it regenerates the VTT and purges the CDN so captions simply appear.
10.7 The transcript editor #
10.7.1 The editing surface #
The editor lives inside the moderation detail view (Section 11 owns that shell) as a two-pane layout that collapses to a stacked layout below 900 px.
- Left pane — player. The video (or waveform, for audio-only) with the standard transport controls from Section 9.8.4, plus a caption preview overlay that renders the current cue exactly as a viewer will see it.
- Right pane — segment list. A vertically scrolling list of segment rows. Each row shows:
- The segment's start time as a clickable
MM:SS.mmmchip that seeks the player. - An editable text area containing the segment's text, with per-word confidence marking (10.8).
- A duration chip and a reading-speed chip (characters per second), the latter turning into a warning affordance when it exceeds the cap in 10.9.2.
- A row action menu: Split here, Merge with next, Adjust timing, Set language, Delete segment.
- The segment's start time as a clickable
- Header bar. Save state ("All changes saved" / "Saving…" / "Unsaved changes"), transcript language selector, a Low-confidence: {n} filter chip (10.8), a Revisions button, and a Regenerate captions button (which forces a VTT rebuild without changing content, for the rare case where a storage write failed).
Editing is plain text editing of the segment's text. It is not a rich-text editor: a transcript has no formatting, and offering formatting would invite the alteration of a respondent's words into something the product does not represent.
10.7.2 Segment operations #
| Operation | Behaviour | Timing and word handling |
|---|---|---|
| Edit text | Free text edit of segments[].text |
On save, the text is re-tokenized on whitespace. Tokens that match the previous word list positionally keep their s/e/c. New or changed tokens are assigned interpolated timings across the affected span and c = 1.0 (a human confirmed them). This keeps word-level timing usable after a typo fix without pretending the machine produced it |
| Split | Cursor position determines the split point; Split here creates two segments | The split time is the e of the last word before the cursor (or the midpoint of the segment when there are no words). Ids become {id}a and {id}b. Words are partitioned by the cursor |
| Merge with next | Joins segment N and N+1 | start from N, end from N+1, words concatenated, text re-derived from words, id = N's id. Refused (with an inline explanation) when the two segments have different language values, unless the moderator first clears one |
| Adjust timing | Numeric start/end inputs plus nudge buttons and keyboard shortcuts |
Validated against the invariants in 10.4.2: no overlap with neighbours, start < end, within durationSeconds + 0.5. An adjustment that would overlap a neighbour clamps to the neighbour's boundary and shows a one-line note rather than rejecting the edit |
| Set language | Per-segment language override, from the five supported codes plus "Same as transcript" | Sets or clears segments[].language |
| Delete segment | Removes the segment | Requires confirmation. The words are gone; the time range becomes a gap (silence). Deleting the only segment leaves a valid empty transcript |
| Insert segment | Adds a segment in a gap between two existing segments, or at the start/end | Only permitted where a gap ≥ 0.5 s exists. Text is authored by the moderator, words are interpolated, source becomes human_edited |
Every operation is undoable in-session with a standard undo stack (50 steps), and every operation is durably recoverable via revisions (10.7.7).
Every save is classified, and a substantive change cannot be published silently. The consent a
respondent gives at submission (Section 22 owns the copy) states that their words will not be
edited or reworded — only shortened, and only in a way that keeps their meaning. That is a factual
claim about the product, and a free-text editor over the words that become the captions and the
published quote would falsify it unless the edit is constrained. So each accepted save carries an
edit_class, stored on the revision:
edit_class |
What it covers | Chosen how |
|---|---|---|
timing_only |
Segment or word timings changed; not one character of text differs | Derived automatically — the editor computes it and the moderator cannot override it downward |
punctuation_only |
Only punctuation, casing and whitespace differ after Unicode NFC normalization | Derived automatically |
verbatim_correction |
Words changed, and the moderator asserts the new text is what the respondent actually said (a mis-transcription fixed against the audio) | Chosen by the moderator, with the word-level diff displayed above the choice |
substantive |
Anything else: rephrasing, removing a clause, adding words the audio does not contain | Chosen by the moderator; also assigned automatically when the edit distance exceeds what a correction plausibly explains and the moderator does not justify it |
The editor derives the class, shows the diff it derived it from, and only asks the moderator when
the derivation is verbatim_correction or substantive — a timing nudge never interrupts anyone.
The consequence: a testimonial whose transcript carries a substantive revision cannot be
published until the respondent re-consents through the re-consent flow in Section 22, and the
publish action in Section 11 refuses with the reason surfaced in the moderator's UI ("This
transcript was substantively edited — the respondent needs to confirm the change before it can go
live"). timing_only, punctuation_only and verbatim_correction publish freely, because none of
them changes what the person said. The respondent's own "what was published" view in Section 22
shows the diff, so a divergence between what they said and what is live is visible to the one
person entitled to object to it.
This is the mechanism that keeps the consent copy true. If it is ever removed, the sentence promising the respondent's words are not reworded must be removed from the consent copy in the same change — the claim and the enforcement stand or fall together.
10.7.3 Synchronized playback #
- As the player's
timeupdatefires, the segment whose[start, end)contains the current time is marked active: it receivesaria-current="true", a left accent bar and a bold weight change (never colour alone), and — when the "Follow playback" toggle is on (default on) — the list auto-scrolls to keep it in view withscroll-behavior: smooth, degraded to instant underprefers-reduced-motion: reduce. - Auto-scroll suspends for 6 seconds whenever the moderator scrolls manually or focuses a text area, so the list never yanks itself away mid-edit.
- Clicking a segment's time chip seeks the player to
start. Clicking into a segment's text does not seek — moving the playhead every time someone clicks to fix a typo is infuriating. - A word-level karaoke highlight tracks the active word within the active segment when playback is running and the segment is not being edited. It is purely visual and carries no ARIA state, because announcing every word would flood a screen reader.
10.7.4 Keyboard shortcuts #
All shortcuts are documented in an in-editor Keyboard shortcuts dialog opened with ?, are
disabled while a text area has focus except where noted, and never conflict with browser or
screen-reader defaults.
| Keys | Action | Active while editing text? |
|---|---|---|
Ctrl/Cmd + Space |
Play / pause | Yes |
Ctrl/Cmd + ← / → |
Seek −2 s / +2 s | Yes |
Ctrl/Cmd + Shift + ← / → |
Seek −10 s / +10 s | Yes |
Ctrl/Cmd + ↑ / ↓ |
Jump to previous / next segment and focus it | Yes |
Ctrl/Cmd + Enter |
Split the segment at the cursor | Yes |
Ctrl/Cmd + Shift + M |
Merge with next segment | Yes |
Alt + [ / ] |
Nudge segment start −0.1 s / +0.1 s | Yes |
Alt + Shift + [ / ] |
Nudge segment end −0.1 s / +0.1 s | Yes |
Ctrl/Cmd + S |
Force save now | Yes |
Ctrl/Cmd + Z / Shift + Z |
Undo / redo | Yes |
J / K / L |
Rewind / pause / play — the standard transcription pedal mapping | No |
N |
Jump to the next low-confidence word (10.8) | No |
P |
Jump to the previous low-confidence word | No |
? |
Open the shortcuts dialog | No |
Esc |
Close a dialog; blur the current text area | Yes |
The single-character bindings — J, K, L, N, P — are off by default. A bare
letter key that acts on content is a real hazard: a screen-reader user in browse mode, a
speech-input user, and anyone whose assistive tool forwards individual keystrokes will trigger it
without intending to. So all three of the mitigations that make a single-key shortcut acceptable
are applied, not just one:
- Off by default. They are enabled per user in Settings → Keyboard, which also lists them.
- Remappable. Each can be reassigned, or given a modifier, in the same place.
- Focus-scoped. Even when enabled they are live only while the segment-list region holds
focus. The region is an explicit focus target and
Escreleases it, returning the keys to the browser.
The modifier-based shortcuts in the table above are always available and need none of this, because a modified key is not intercepted by assistive technology in the same way. Every shortcut, single-key or modified, has an equivalent visible control, satisfying the requirement that nothing is keyboard-only or pointer-only. Section 21 owns the verification and the conformance claim, which must cite this subsection rather than describing the editor as having no single-character shortcuts.
10.7.5 Autosave #
- The editor autosaves 3 seconds after the last keystroke, and immediately on: blur of a text
area, any structural operation (split, merge, delete, insert, timing, language),
Ctrl/Cmd+S, andvisibilitychangeto hidden. - Saves are coalesced: a save in flight defers the next one until it resolves, and only the latest state is sent. There is no queue of stale saves.
- The header's save-state indicator has three states with text, not colour alone: "Unsaved
changes", "Saving…", "All changes saved {relative time}", with
aria-live="polite"announcing only the transition to saved or to error. - A failed save retries 3 times (1 s, 3 s, 9 s). After that the indicator becomes "Couldn't
save — retry" with a button, the editor is not cleared, and a
beforeunloadguard is installed so the moderator cannot silently lose work. - Autosave bumps
transcripts.versionon every accepted save. To keep the revision table proportionate, revisions are written on: the first save of an editing session, every structural operation, and at most one text-only revision per 2-minute window (later text-only saves within the window update the latest revision in place rather than appending). The version number still increments on every save; only the snapshot is throttled.
10.7.6 Concurrency — two moderators editing at once #
Optimistic concurrency on transcripts.version, with a real merge UI rather than a lost update.
Two version columns exist in this area of the product and they are deliberately independent.
transcripts.version guards the transcript document and is the value carried in If-Match below.
testimonials.version (Section 11 owns it) guards the testimonial row — its status, body,
attribution and tags. Saving a transcript increments transcripts.version and does not touch
testimonials.version, so a moderator editing captions and a moderator editing the testimonial's
attribution never collide with each other, and a stale transcript editor can never silently
overwrite a status change. A client that confuses the two receives a conflict, not a lost update,
which is the failure mode we want.
PUT /api/v1/testimonials/018f9c4c-.../transcript
If-Match: 7
Content-Type: application/json{
"content": { "version": 1, "language": "en", "confidence": 0.94, "durationSeconds": 187.4, "segments": [ ] },
"changeSummary": "Fixed product name in 3 segments; merged s4 and s5"
}- Success →
200with the newtranscriptVersion(8) and the regenerated VTT URL. If-Matchabsent →428 PRECONDITION_REQUIRED.If-Matchstale →409 TRANSCRIPT_VERSION_CONFLICT, and the error'sdetailsarray carries the current version, the identity of the last editor, and a segment-level diff:
{
"error": {
"code": "TRANSCRIPT_VERSION_CONFLICT",
"message": "Someone else edited this transcript while you were working on it.",
"details": [
{ "field": "transcriptVersion", "issue": "You have version 7; the current version is 9." },
{ "field": "segments.s4", "issue": "Changed by Dana Okafor at 14:22 UTC." },
{ "field": "segments.s7", "issue": "Changed by Dana Okafor at 14:23 UTC." }
],
"docsUrl": "https://docs.vouch.app/errors/TRANSCRIPT_VERSION_CONFLICT"
},
"meta": { "requestId": "req_01J8ZR2A4B6C8D0E2F4G6H8J0K" }
}The client renders a merge-conflict dialog, never an auto-merge and never a silent overwrite:
- A summary line: "Dana Okafor made changes while you were editing."
- A per-segment three-column diff — Theirs (saved) / Yours / Result — showing only segments that differ, with unchanged segments collapsed behind "{n} unchanged segments".
- Per-segment radio choice, defaulting to Theirs for segments the moderator did not touch and Yours for segments they did.
- Three actions: Apply my choices (builds a merged document and re-
PUTs with the currentIf-Match), Discard my changes (reloads theirs), Copy my version (copies the moderator's full text to the clipboard as a safety valve before discarding). - If the second
PUTalso conflicts — a third editor landed in between — the dialog re-opens with the fresh diff. The loop is bounded only by the moderators' patience, which is correct; the system never guesses.
A soft presence indicator reduces how often this happens: opening the editor sends a heartbeat
every 20 s to a Valkey key transcript:{id}:editors with a 60 s TTL, and the header shows
"Dana Okafor is also editing" with an avatar. It is advisory only — it is not a lock, and the
concurrency check above is the actual safety mechanism.
10.7.7 Revision history #
The Revisions panel lists every stored snapshot, newest first, cursor-paginated per Section 7:
| Column | Content |
|---|---|
| Version | v9 |
| When | Absolute timestamp with a relative hover ("2 hours ago") |
| Who | User name, or "Transcription service" for source = 'provider' |
| Change | The changeSummary string, or a generated one ("3 segments edited, 1 merged") |
| Class | The edit_class from 10.7.2, rendered as a word — "Timing", "Punctuation", "Correction", "Substantive" — never as a colour-only badge |
| Actions | View diff, Restore |
View diff renders a segment-level word diff between the selected revision and the current
content. Restore creates a new version whose content equals the selected revision, with
changeSummary = "Restored v{n}". History is append-only; a restore never rewrites or deletes a
prior version. The version-1 provider snapshot is never pruned by retention, and is deleted only by
erasure (10.4.3).
10.7.8 The audit event on save #
Every accepted save writes one audit_events row, per the append-only audit model Section 11.6
owns, with:
| Field | Value |
|---|---|
action |
transcript.updated (or transcript.created, transcript.restored, transcript.retranscribed, transcript.language_changed) |
actor_id / actor_type |
The user, or system for provider writes |
resource_type / resource_id |
transcript / the transcript id |
before / after (JSONB) |
A summary snapshot, not the full 90 KB document: { transcriptVersion, language, segmentCount, wordCount, overallConfidence, changedSegmentIds, editClass }. The full documents live in transcript_revisions, which the audit row references by version. editClass is recorded here as well as on the revision, because the audit trail outlives the revision and a later question about whether a published quote was altered must be answerable from the audit row alone |
ip, user_agent, occurred_at |
Per Section 11.6 |
Keeping the full document out of the audit table is deliberate: audit rows are append-only and retained far longer than revisions, and storing a megabyte of duplicated text per keystroke-batch would make the audit trail unusable.
10.8 Low-confidence highlighting #
Words with c < 0.75 are the ones a moderator should actually look at. They are marked so, and
the marking is never colour alone.
The marking, applied in the transcript editor and in the moderator's read-only transcript view (never in any public surface):
- A dotted underline beneath the word.
- A superscript dot glyph immediately after the word, hidden from assistive tech
(
aria-hidden="true") since the accessible name already conveys it. - An accessible name via a wrapping element:
<mark class="low-confidence" aria-label="uncertain word: six">six</mark>, so a screen reader announces "uncertain word: six" rather than relying on any visual cue. - A tooltip/
titleon hover and focus: "We're {n}% sure of this word — check it against the audio." - Colour is applied as a fourth, redundant channel only, and its contrast against the editor background is verified to the ratios Section 21 owns.
Navigation and filtering:
- The header chip reads "Low-confidence: 12" and is a button. Pressing it enters review mode: the list filters to segments containing at least one low-confidence word.
- Review mode renders a pair of visible stepper buttons beside the chip, with the accessible
names "Previous uncertain word ({n} of {m})" and "Next uncertain word ({n} of {m})". They are the
primary control; the optional
N/Pkeys of 10.7.4 are a shortcut for them and are off by default, so the stepper is the only mechanism a moderator needs and the only one an assistive technology has to find. Both step between individual low-confidence words, scrolling them into view, focusing them, and seeking the player to the word'ssminus 0.4 s so the moderator hears the run-up. - Each visit marks the word as reviewed in session state; reviewed words lose the marking for the
remainder of the session so the moderator can see their progress. Reviewing does not change
c— only editing the word does (which sets it to1.0). - When the count reaches zero the chip becomes "All checked" and review mode exits.
- Editing any word sets
c = 1.0and removes its marking permanently.
The denormalized low_confidence_word_count column powers a moderation-inbox filter, "Transcripts
needing review", so a moderator can batch this work rather than discovering it one testimonial at
a time.
10.9 Caption output — WebVTT generation #
10.9.1 Why cues are not segments #
Provider utterances make good editing units and bad caption cues: a 12-second utterance is one segment but must become three cues, because a caption that occupies the screen for 12 seconds or runs to four lines is unreadable. The generator therefore re-cues from words, using the segment boundaries as hard cue boundaries it may split but never cross.
10.9.2 The rules #
| Rule | Value | Reason |
|---|---|---|
| Max lines per cue | 2 | Any more obscures the speaker's face on mobile |
| Max characters per line | 42 (Latin-script launch languages) | The broadcast-caption convention; fits a 360 px viewport at the default caption size |
| Max characters per cue | 84 | Two full lines |
| Min cue duration | 1.0 s | Below this a cue flashes and cannot be read |
| Max cue duration | 7.0 s | Above this the caption feels stuck |
| Max reading speed | 17 characters/second (en, de), 18 (es, pt, fr) |
Extends a cue's end into the following gap when exceeded; never shortens the text |
| Min gap between cues | 0.08 s | Prevents cue-boundary flicker in players that round timings |
| Cue snapping | Cue start = first word's s − 0.08 s (clamped to ≥ previous cue end + 0.08 s); cue end = last word's e + 0.20 s |
The lead-in and hang-over that make captions feel synchronized rather than late |
| Line-break preference | Break at the last clause boundary (, ; : —) before the limit; else at the last space; never mid-word; never leaving an orphan of one word on line 2 when the split can be balanced |
Readability |
| Balanced two-line split | When a cue needs two lines, prefer the split that minimizes the difference in line lengths, subject to the clause-boundary preference | Readability |
| Sentence integrity | A cue never begins mid-sentence when a sentence boundary is available within ±1 word of the split point | Readability |
10.9.3 The algorithm #
// packages/core/src/transcription/vtt.ts
export function generateVtt(content: TranscriptContent): string {
const limits = READING_LIMITS[content.language];
const cues: Cue[] = [];
for (const segment of content.segments) {
const words = segment.words.length > 0 ? segment.words : interpolate(segment);
let buffer: Word[] = [];
const flush = () => {
if (buffer.length === 0) return;
const text = layoutLines(buffer.map((w) => w.w).join(' '), limits);
const rawStart = buffer[0].s - 0.08;
const rawEnd = buffer[buffer.length - 1].e + 0.20;
cues.push(clampCue({ start: rawStart, end: rawEnd, text, lang: segment.language ?? content.language }, limits, cues));
buffer = [];
};
for (const word of words) {
const candidate = [...buffer, word];
const chars = candidate.map((w) => w.w).join(' ').length;
const span = word.e - candidate[0].s;
if (chars > limits.maxCharsPerCue || span > limits.maxCueSeconds) flush();
buffer.push(word);
}
flush(); // segment boundary is always a cue boundary
}
return serialize(enforceMinDurationAndGaps(cues, limits));
}clampCue applies the minimum duration by extending end into available gap (never overlapping
the next cue's clamped start), and applies the reading-speed cap the same way. When no gap is
available — dense, fast speech — the cue is left at its natural duration and a
vtt_reading_speed_exceeded counter is incremented; the caption is still correct, merely fast,
and shortening the words is forbidden.
enforceMinDurationAndGaps runs a final pass that guarantees strictly increasing, non-overlapping
cue times with at least the minimum gap, because a VTT with overlapping cues is rejected outright
by several players.
10.9.4 Output, naming, storage and cache #
WEBVTT
Kind: captions
Language: en
1
00:00:00.000 --> 00:00:03.640
We were spending about six hours
a week on manual reporting.
2
00:00:04.520 --> 00:00:06.640
<lang es>Ahora tarda cinco minutos.</lang>- The header carries
Kind: captionsandLanguage: {code}. - Cues are numbered from 1. Numbering is cosmetic but aids debugging.
- A segment carrying a per-segment language override emits its text wrapped in a
<lang>tag so conformant players and screen readers announce it in the right language. - No positioning, no styling cues, no
STYLEblocks. Caption appearance is the viewer's decision, honoured by the player (10.10), not ours. - Escaping:
&,<,>are escaped to&,<,>.-->cannot appear in cue text by construction after escaping.
Storage path, in the public bucket behind the CDN:
public/captions/{workspaceId}/{mediaAssetId}/v{vttVersion}/{language}.vtt| Header | Value |
|---|---|
Content-Type |
text/vtt; charset=utf-8 |
Cache-Control |
public, max-age=31536000, immutable |
Access-Control-Allow-Origin |
* |
| CDN cache tags | media:{mediaAssetId}, testimonial:{testimonialId}, workspace:{workspaceId} |
The path contains v{vttVersion}, so a regenerated caption file is a new URL and the
immutable cache header is truthful. The CDN tag purge on save exists to invalidate the widget
JSON payload that carries the caption URL, not the VTT file itself. Old versions are deleted by
media.purge (Section 9.10.2 step 5) and by a weekly retention.prune_caption_versions job that
keeps the current version plus the two previous.
For workspaces on signed playback (Section 9.8.1), the VTT lives in the private bucket and is
served through GET /api/v1/testimonials/{id}/captions.vtt, which authorizes the request and
streams the file with Cache-Control: private, max-age=300.
10.9.5 Regeneration #
The VTT is regenerated on: every accepted transcript save, a transcript restore, a language change, a re-transcription, and the explicit Regenerate captions button. Regeneration is a pure function of the transcript content plus the language's reading limits, so it is deterministic and safe to re-run at any time. It is never hand-edited: there is no "edit the VTT" surface, and the file is not part of any import or export path where a user could substitute one.
10.10 Captions are on by default, in every player, everywhere #
The rule: every player surface renders the caption track enabled by default whenever a caption track exists. No exceptions, on any plan, in any widget, in the dashboard, on the showcase page, and in the respondent's own management view.
| Surface | How the default is applied |
|---|---|
| Widget video (Section 13 owns the widget) | The widget JSON payload carries captions: { url, language, default: true }. The player module sets the text track's mode to 'showing' on load, before first frame |
| Showcase page (Section 14) | Server-rendered <track kind="captions" src="..." srclang="en" label="English" default> |
| Dashboard moderation player | Same, set programmatically after the player mounts |
| Respondent management link (Section 22) | Same |
| oEmbed / SSR fallback | The <track> element with default is present in the returned HTML |
Implementation note that matters: setting the default attribute alone is unreliable across
browsers once a player library takes over track management, so every surface also sets
track.mode = 'showing' in a loadedmetadata handler, and asserts it once more on the first
play event. This belt-and-braces approach is covered by a Playwright assertion in the test suite
Section 25 owns: "a published video testimonial renders visible caption text within 2 seconds of
pressing play, in every widget type."
The viewer's control:
- A CC toggle button is present in every player's control bar with accessible name
"Captions on" / "Captions off" and
aria-pressed. - Inside the embedded widget, turning captions off is remembered for the current page session
only, in JavaScript memory. Nothing is written to cookies,
localStorage, orsessionStorage: that origin belongs to the customer, Section 17's cookie-free guarantee governs it, and a caption preference stored there would be a cross-site-persistable signal we decline to create. - On our own first-party surfaces — the dashboard, request pages, showcase pages, and a
workspace's custom showcase domain — the preference is persisted, in
localStorageunder the keyvouch.a11y.captions. Section 17's constraint exists to keep the widget on someone else's site free of client-side storage; applying it to our own origins would mean a viewer who needs captions off (a magnifier workflow) or on (a hearing aid user) re-setting the control on every page view, which is a real accessibility harm bought for no privacy gain. A user-requested accessibility preference is exactly the "strictly necessary / explicitly requested by the user" case that needs no consent banner. It is disclosed in the privacy notice Section 22 owns, and it is the only client-side storage on those origins — which keeps the claim auditable rather than turning it into a general exception. - Where the browser exposes OS-level caption preferences (font family, size, colour, background opacity, edge style), the player honours them and applies no styling of its own. Where it does not, the default styling is white text at 1.0rem minimum on a 75%-opaque black rounded box, which meets the contrast ratios Section 21 owns against any video content.
- Caption text scales with the widget's font-size scale setting, so a workspace that sets a larger scale gets larger captions too.
If no caption track exists, the CC button is not rendered at all — a disabled control that can never be enabled is noise.
10.11 Translation is out of scope for v1 #
Stated plainly, because it is the single most common feature request this section will attract:
The product does not translate testimonials, and translated caption tracks are not offered in v1.
Three reasons, all of which are product decisions rather than engineering constraints:
- Translating a testimonial changes the customer's words. The hard boundary in Section 12.1 — the platform may select what a respondent said, never rewrite it — applies to language as much as to phrasing. A machine translation is, by definition, words the respondent never said, attributed to them, on a marketing page. Publishing that under someone's name and face is not something the product will do by default.
- A translated track is a different consent question. The consent the respondent gives at submission (Section 22) is permission to publish what they said. It is not permission to publish a machine's rendering of what they said in a language they may not speak and cannot check. Offering translation responsibly requires a fourth consent grant, a review step, and a clear on-screen attribution that the translation is machine-generated — a feature, not a checkbox.
- Quality is unverifiable by the customer. A workspace publishing a German translation of a Portuguese testimonial usually cannot tell whether it is right. Shipping something the customer cannot evaluate, attached to a real person's name, is a reputational risk we hand to them silently.
What v1 does support: five source languages transcribed in the language actually spoken
(10.3), per-segment language overrides for code-switching, and correct lang attributes
throughout so assistive technology pronounces each language properly.
Translated caption tracks are listed in Section 29.8 as a deferred capability, with the scope it
would require: a transcript_translations table keyed by (transcript_id, language), a fourth
optional consent grant, a mandatory "Machine-translated" label rendered in the player and the
widget, a moderator review step before any translated track can be published, and a per-language
caption reading-speed constant. No schema in v1 blocks this: the caption storage path is already
language-segmented (10.9.4) and the player already selects a track by language.
10.12 Search — finding the words inside a video #
Section 5 owns the full-text index definition. This section owns what feeds it and what the moderator can do with it.
What is indexed. A generated column plus trigger on the transcript row maintains a
tsvector built from the concatenated segment texts, using the Postgres text-search
configuration matching the transcript's language (english, spanish, french, german,
portuguese), with simple as the fallback for an empty or unknown language:
-- Owned by Section 5; reproduced here to show what the transcript contributes.
ALTER TABLE transcripts
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector(
CASE language
WHEN 'en' THEN 'english'::regconfig
WHEN 'es' THEN 'spanish'::regconfig
WHEN 'fr' THEN 'french'::regconfig
WHEN 'de' THEN 'german'::regconfig
WHEN 'pt' THEN 'portuguese'::regconfig
ELSE 'simple'::regconfig
END,
transcript_plain_text(content)
)
) STORED;
CREATE INDEX transcripts_search_idx
ON transcripts USING gin (workspace_id, search_vector);transcript_plain_text(jsonb) is an immutable SQL function that concatenates
content->'segments'->>'text' with spaces. It is the only place the transcript is flattened, and
it produces no second stored copy of the words beyond the tsvector itself — which is an index,
not a source of truth.
What the moderator gets.
- The moderation inbox's search box searches, in one query, the written testimonial body, the
respondent's name/role/company, custom-field values, tags, and video transcripts. Results
are ranked with
ts_rank_cd, with transcript matches weighted slightly below body matches so a written testimonial containing the phrase outranks a spoken mention of it. - A transcript hit renders with a spoken-word snippet —
ts_headlineover the plain text — plus the matching segment's timestamp as a Play from 1:24 button that opens the testimonial with the player pre-seeked to that segment'sstart. This is the feature that makes a library of a hundred video testimonials usable. - Matching is applied across the whole transcript, so a phrase spanning two segments is found; the jump target is the segment containing the first matching word.
- Search respects tenancy absolutely: every query runs inside the transaction that has issued
SET LOCAL app.current_workspace_id, and row-level security enforces it independently of the query text, per Section 6. - Search is available on every plan. It is not a paid feature; a workspace that cannot find its own testimonials cannot use the product.
Public search. The widget JSON payload and the showcase page include the transcript's plain
text for published video testimonials so host-page search and search engines can index the spoken
words. This is a deliberate SEO benefit for the customer and is exactly the content the respondent
consented to publish. The showcase page renders it inside a <details> element labelled
"Read the transcript", which is crawlable, keyboard-operable, and collapsed by default.
10.13 Text testimonials get the same discipline #
A written testimonial has no transcript, but the same single-source principle applies to its body, and the rules are stated here so both media types are governed identically.
| Rule | Video | Text |
|---|---|---|
| Single source of truth | transcripts.content |
testimonials.body_html plus testimonials.body_text |
| Sanitization | Not applicable (plain text throughout) | Sanitized once at write time with the HTML sanitizer named in Section 3, allowlist p, br, strong, em, a[href], rel="nofollow noopener" forced on links, and re-sanitized at serve time as defence in depth (Section 13 owns the serve-time rule) |
| Plain-text projection | transcript_plain_text(content) |
body_text, generated from body_html at write time by the same function the sanitizer pipeline uses; never authored separately |
| Search index | transcripts.search_vector |
testimonials.search_vector over body_text plus name/role/company |
| Quote extraction | AI proposes segment-anchored excerpts; validated by exact substring match against the transcript (Section 12.4) | AI proposes character-offset excerpts; validated by exact substring match against body_text (Section 12.4) |
| Social cards | Quote text comes from an approved pull-quote, which came from the transcript | Quote text comes from an approved pull-quote, which came from body_text |
| Editing | Moderator may edit the transcript (a machine's rendering of the words) | Moderator may not edit the words of an imported review at all (Section 16), and may edit a collected written testimonial only through an audited edit that records before/after per Section 11.6 |
| Length limits | Bounded by the plan's max video length | body_text 1–20,000 characters — the column constraint Section 5 owns, and the only length any surface enforces; body_html ≤ 60,000 bytes after sanitization, which is the same ceiling plus the markup overhead the allowlist can produce; both enforced by the shared validation schema |
The asymmetry in the "Editing" row is deliberate and worth stating explicitly: a transcript is a machine's transcription of what a person said, and fixing it makes it more accurate. A written testimonial is what the person actually typed, and changing it makes it less accurate. So transcript editing is a normal, encouraged moderator activity, while editing a written body is a rare, audited, before/after-recorded act, and editing an imported review is impossible by design.
10.14 Transcription quota accounting #
Section 19 owns the per-plan monthly transcription-minute allowances and the enforcement
philosophy. This section owns the counting. The metric key is transcription.minutes, stored in
usage_counters under that name; no other name for it exists.
| Question | Answer |
|---|---|
| What is counted? | billed_seconds returned by the provider, which is the media duration it processed, rounded up to the whole second, then accumulated and reported to the customer in minutes rounded up |
| When is it counted? | At step 9 of the transcription job (10.5.2), i.e. only on a successful transcription. A failed attempt costs the customer nothing, even though it may cost us |
| Does a retry count twice? | A Retry transcription after a failure does not double-count, because the failed attempt was never counted. A successful Re-transcribe does count again — it is a second successful transcription and the customer chose it, with the cost stated in the confirmation dialog |
| Does an edit count? | No. Editing a transcript never contacts the provider |
| What about audio-only? | Counted identically to video; the words are the cost, not the pixels |
| Reset period | Calendar month, UTC, aligned to the workspace's billing anchor for display purposes |
| Over-allowance behaviour | Soft, per Section 19: the job still runs, the transcript is still produced, captions still appear, an overage notice is raised, warnings appear in-product at 80% and 95% with emails to Owner and Admins at 90% and 100%, and only new collection is affected — and only after the account has been more than 20% over for 7 consecutive days |
| Is a request ever refused? | Automatic transcription of media the workspace has already collected: never. The one refusal is a manually requested run — Transcribe now, Retry transcription or Re-transcribe — made after Section 19's sustained-overage rule has closed the workspace, which returns 409 TRANSCRIPTION_MINUTES_EXCEEDED. It is 409 rather than 403 because the caller is authorized and the request conflicts with account state, which is the rule Section 19 owns for every quota refusal in the product |
| Reconciliation | A daily quota.reconcile job sums billed_seconds from transcripts for the period and corrects the transcription.minutes counter, logging any drift. Drift above 1% raises an operator alert |
The workspace's usage page shows minutes used, minutes included, the projection for the month, and a per-testimonial breakdown so a customer can see exactly where the minutes went. A workspace that does not want automatic transcription at all can turn it off per request page ("Transcribe video submissions": on by default), in which case no provider call is made, no minutes are consumed, and the moderator gets a Transcribe now button on each testimonial.
10.15 Error codes and observability for transcription #
Codes catalogued once in Section 29.3; listed here with their behaviour so the executor can wire each one.
| Code | HTTP | Cause | Retried? | Moderator-facing message |
|---|---|---|---|---|
TRANSCRIPT_NOT_FOUND |
404 | No transcript row for the testimonial | n/a | "No transcript yet." |
TRANSCRIPTION_IN_PROGRESS |
409 | A job is already running | n/a | "We're already working on this one." |
TRANSCRIPT_VERSION_CONFLICT |
409 | Stale If-Match (10.7.6) |
n/a | "Someone else edited this transcript while you were working on it." |
PRECONDITION_REQUIRED |
428 | Missing If-Match on save |
n/a | Client bug; never shown to a user |
TRANSCRIPT_SHAPE_INVALID |
422 | Content failed schema validation | No | "We couldn't save that transcript." (with the failing field in details) |
TRANSCRIPT_TOO_LARGE |
422 | Content exceeds 2 MiB | No | "This transcript is too large to save." |
TRANSCRIPT_SEGMENT_OVERLAP |
422 | Timing invariant violated | No | "Segment timings overlap." (with the segment ids in details) |
TRANSCRIPTION_SOURCE_MISSING |
— (job) | Media object absent | No | "The video file is missing." |
TRANSCRIPTION_UNSUPPORTED_MEDIA |
— (job) | Provider cannot decode, or no audio track | No | "We couldn't find any audio to transcribe." |
TRANSCRIPTION_FAILED |
— (job) | Transient failures exhausted | Yes, 5× | "Captions couldn't be generated." + Retry transcription |
TRANSCRIPTION_PROVIDER_UNAVAILABLE |
503 | Provider outage or account problem | Yes | "Transcription is temporarily unavailable." + operator page |
TRANSCRIPTION_MINUTES_EXCEEDED |
409 | A manually requested run while the workspace is closed by Section 19's sustained-overage rule (10.14). Automatic transcription is never refused | n/a | "You're over your transcription minutes for this month." + a link to the plan page |
VTT_GENERATION_FAILED |
— (job) | Caption render or storage write failed | Yes, 5× | "Captions are being prepared." + Regenerate captions |
Metrics (Section 24 owns the platform):
| Metric | Type | Alert |
|---|---|---|
transcription_jobs_total{outcome} |
counter | Failure rate > 5% over 1 h pages |
transcription_latency_seconds (ready → transcript stored) |
histogram | p95 > 240 s warns |
transcription_billed_seconds_total{workspace} |
counter | — |
transcript_confidence |
histogram | p50 < 0.80 over 24 h warns (suggests a provider or model regression) |
transcript_language_fallback_total |
counter | > 10% of jobs over 24 h warns |
transcript_edit_rate (transcripts edited ÷ transcripts created) |
gauge | > 40% over 7 days warns — moderators are fixing too much |
vtt_generation_failure_total |
counter | > 1% over 1 h warns |
vtt_reading_speed_exceeded_total |
counter | — (informational; feeds caption-quality tuning) |
transcript_save_conflict_total |
counter | — (informational; a spike suggests the presence indicator is not working) |
Caption quality has a measured floor, not a self-reported one. Every metric above reports the provider's own confidence, which is a vendor's opinion of its own output and cannot detect a regression the vendor has not noticed. So a fixed 50-clip reference corpus — ten clips per launch language, human-transcribed once, held in the repository with its ground truth — is re-run whenever the provider, the model, or any request option in 10.2.2 changes. CI fails the change above 12% aggregate word error rate, or above 20% for any single language, so a language cannot be quietly sacrificed to an average. The corpus deliberately includes accented speech, background noise, and a code-switching clip, because those are the cases a vendor benchmark omits and a real respondent produces. Section 25 owns where this runs; the numbers are owned here.
Every transcription log line carries requestId, workspaceId, mediaAssetId, transcriptId,
providerRequestId and transcriptVersion, and never carries transcript text, the pre-signed
media URL, or the provider API key. Traces span media-ready → transcription → VTT generation →
CDN purge, joined on mediaAssetId, so a moderator's report of "the captions never showed up" is
answerable in one query.
11. Moderation Inbox, Tagging, Lifecycle & Audit Trail #
11.1 Scope and responsibilities #
This section owns the canonical testimonial lifecycle state machine. Every other section that mentions a testimonial status — the widget payload in Section 13, the showcase page in Section 14, the public REST API in Section 7, the webhook catalogue in Section 18.2 — defers to the state machine defined here in Section 11.2.
Section 11 covers:
- The lifecycle state machine, its transitions, permissions, preconditions, side effects, and reversibility.
- The moderation inbox: queue, detail pane, filters, saved views, sorting, keyboard shortcuts, bulk actions, live-update behaviour, and every empty/loading/error state.
- The editing rules — what a moderator may change, what is immutable, and how the product guarantees it never puts words in a person's mouth.
- The append-only audit trail and the audit viewer that answers the question customers actually ask: "who put that live, and when?"
- Tagging: the workspace vocabulary, merge/rename with propagation, and how tags drive widget filtering.
- Featuring, pinning and manual ordering, and how manual order interacts with filter-driven order.
- Reply and follow-up messaging with a respondent, including inbound reply capture.
- Flagging and safety review.
- Moderation SLAs, digests, and the stale-queue warning.
Section 5 owns every table definition, column type, index and constraint referenced here. Section 6 owns the role-to-permission mapping for every permission string used here; Section 29.6 is the permission matrix reference. Section 7 owns the HTTP envelope, pagination, idempotency and rate limits for every endpoint listed here. Section 20 owns notification templates and delivery. Section 29.3 is the canonical error code catalogue; codes are named here but defined there.
11.2 The testimonial lifecycle state machine #
11.2.1 Diagram #
stateDiagram-v2
direction LR
[*] --> pending : submission accepted / import row created
pending --> approved : approve
pending --> rejected : reject
pending --> published : approve_and_publish (atomic, writes two audit rows)
approved --> published : publish
approved --> rejected : reject
approved --> pending : reopen
approved --> archived : archive
published --> unpublished : unpublish
published --> unpublished : withdrawal_by_respondent (system actor)
published --> unpublished : auto_unpublish_on_flag (system actor)
unpublished --> published : republish
unpublished --> archived : archive
rejected --> pending : reopen
rejected --> archived : archive
archived --> pending : unarchive (restores pre_archive_status)
archived --> approved : unarchive (restores pre_archive_status)
archived --> unpublished : unarchive (restores pre_archive_status)
archived --> rejected : unarchive (restores pre_archive_status)
pending --> deleted : soft delete
approved --> deleted : soft delete
published --> deleted : soft delete (unpublishes first, same transaction)
unpublished --> deleted : soft delete
rejected --> deleted : soft delete
archived --> deleted : soft delete
deleted --> pending : restore (≤ 30 days, restores pre_delete_status)
deleted --> [*] : hard erase (retention sweep at 30 days, or GDPR erasure at any time)
note right of published
Only `published` is publicly visible.
Widgets, showcase pages, the widget JSON API,
oEmbed, permalinks and social cards read
`published` and nothing else.
end note11.2.2 The states #
testimonial_status is a Postgres native enum with exactly these six values, plus soft deletion
expressed by deleted_at rather than by a seventh enum value.
| State | Publicly visible | Counts toward the collected-testimonial cap | Meaning | Timestamps set |
|---|---|---|---|---|
pending |
No | Yes | Submitted or imported, not yet reviewed. The inbox default queue. | created_at, collected_at |
approved |
No | Yes | Passed moderation, deliberately not yet live. Used by teams that batch launches. | approved_at |
published |
Yes | Yes | Live in widgets, on the showcase page, and in the public API. | published_at (first publish only), last_published_at (every publish) |
unpublished |
No | Yes | Was live, deliberately pulled. Retains published_at so publication history survives. |
unpublished_at |
rejected |
No | Yes | Reviewed and declined. Retained for audit and for dispute resolution. | rejected_at |
archived |
No | Yes | Removed from working views, retained indefinitely. Reversible. | archived_at, pre_archive_status |
soft deleted (deleted_at IS NOT NULL) |
No | Yes | Removed from every view except the 30-day restore bin. Deleting never frees a lifetime slot — Section 19 owns that rule and the reasoning behind it. | deleted_at, pre_delete_status |
| hard erased | No | No | Row and every derived asset destroyed. Only an audit stub survives (Section 11.6.6). | — |
Two rules are absolute and are repeated wherever testimonial visibility appears in this document:
- Only
publishedcontent is publicly visible. There is no preview-by-URL of unpublished content, no "unlisted" state, and no widget setting that can surface a non-publishedrow. A moderator previewing a widget sees unpublished items only inside an authenticated dashboard preview that is rendered server-side and never reachable without a session. - Imported reviews enter at
pendingexactly like collected ones. A Google, G2, Capterra, X or CSV row is created inpendingand travels the same transitions, through the same inbox, under the same permissions, writing the same audit rows. There is no second pipeline. Section 16 owns how those rows are fetched and normalized; the moment a row exists, this section governs it.
11.2.3 Transition table #
Every transition below is executed by packages/core/moderation/transition.ts through a single
function, applyTransition(). No route, job, or script may set status by any other means; the
testimonials table grants vouch_app UPDATE only through this path by convention and CI lint
(no-direct-status-write rule described in Section 25).
| # | From → To | Action name | Permission | Preconditions | Side effects | Reversible |
|---|---|---|---|---|---|---|
| T1 | pending → approved |
approve |
testimonial:approve |
No open flag; required publication consent grant present (Section 22); for kind = video, media_assets.state = ready; workspace not read-only |
approved_at set; audit testimonial.approved; queue counters updated; webhook testimonial.approved; Slack fan-out if configured |
Yes (T5 reopen) |
| T2 | pending → rejected |
reject |
testimonial:reject |
Rejection reason supplied (enum + optional note ≤ 500 chars) | rejected_at, rejection_reason set; audit testimonial.rejected; webhook testimonial.rejected; no message is sent to the respondent unless the moderator explicitly opts in |
Yes (T6 reopen) |
| T3 | approved → published |
publish |
testimonial:publish |
All T1 preconditions re-checked; no open flag; text passes the serve-time sanitizer (Section 13); workspace is not canceled (canceled workspaces are read-only) |
published_at set if null, last_published_at set; CDN purge for tags widget:{id} of every widget whose filter matches, plus workspace:{id} and showcase:{workspaceId}; widget payload rebuild enqueued; webhook testimonial.published; Slack/HubSpot fan-out (Section 18); social-card auto-render enqueued if the workspace enabled it (Section 15.9); search index upsert |
Yes (T7) |
| T4 | pending → published |
approve_and_publish |
testimonial:approve and testimonial:publish |
Union of T1 and T3 preconditions | Executes T1 then T3 inside one database transaction and writes two audit rows in order, so the audit trail never shows a testimonial that appeared live without an approval | Yes (T7) |
| T5 | approved → pending |
reopen |
testimonial:approve |
— | approved_at cleared; audit testimonial.reopened |
Yes |
| T6 | rejected → pending |
reopen |
testimonial:approve |
— | rejected_at, rejection_reason cleared (previous values preserved in the audit row); audit testimonial.reopened |
Yes |
| T7 | published → unpublished |
unpublish |
testimonial:unpublish |
— | unpublished_at set; immediate CDN purge on the same tags as T3; widget payload rebuild; webhook testimonial.unpublished; Slack fan-out; social cards remain on the CDN but the showcase permalink returns 410 (Section 14) |
Yes (T8) |
| T8 | unpublished → published |
republish |
testimonial:publish |
Same as T3 | last_published_at updated, published_at untouched; audit testimonial.published with is_republish: true; same purge and fan-out as T3 |
Yes |
| T9 | approved / unpublished / rejected → archived |
archive |
testimonial:archive |
Not currently published — publishing must be reversed first, so nothing is archived out from under a live widget |
archived_at, pre_archive_status set; audit testimonial.archived |
Yes (T10) |
| T10 | archived → pre_archive_status |
unarchive |
testimonial:archive |
pre_archive_status is a valid non-published state; if it was published (impossible via T9 but possible via data import), it resolves to unpublished |
archived_at, pre_archive_status cleared; audit testimonial.unarchived |
Yes |
| T11 | any → soft deleted |
delete |
testimonial:delete |
— | If currently published, T7 runs first in the same transaction (so a delete can never leave live content); deleted_at, pre_delete_status set; audit testimonial.deleted; webhook testimonial.deleted; media.purge enqueued with a 30-day delay equal to the restore window (deterministic job id media.purge:{testimonialId} so T12 can cancel it); the row leaves every inbox, widget and export count except the restore bin, but continues to count toward the lifetime collected-testimonial cap (Section 19) |
Yes for 30 days (T12) |
| T12 | soft deleted → pre_delete_status |
restore |
testimonial:restore |
Within 30 days of deleted_at; the delayed media.purge job is cancelled by id before the row is restored, and the restore fails with MEDIA_ALREADY_PURGED if the cancel loses the race |
deleted_at, pre_delete_status cleared; audit testimonial.restored; no quota change — the slot was never released, so a restore is never refused for being at the cap |
— |
| T13 | soft deleted → hard erased |
erase |
system actor only, triggered by the 30-day retention sweep or by a respondent/GDPR erasure request |
Row is soft-deleted, or an erasure request exists (erasure bypasses the 30-day wait and runs immediately) | Row and children hard-deleted; media.purge runs with zero delay; Mux asset deleted, every object-storage key deleted, CDN purged by tag; an anonymized audit stub is written (Section 11.6.6); webhook testimonial.erased |
No |
| T14 | published → unpublished |
withdrawal_by_respondent |
system (initiated from the respondent's own consent link, Section 22) |
Valid respondent token | Same as T7, completed within 60 seconds including CDN purge; audit actor type system with initiated_by: "respondent"; Owner and every Admin notified; the testimonial is additionally flagged consent_withdrawn, which blocks republish until an Owner clears it |
Only by a fresh consent grant |
| T15 | published → unpublished |
auto_unpublish_on_flag |
system |
A flag is raised with reason abusive_language, third_party_pii, or legal_request on live content |
Same as T7, within 60 seconds; audit actor type system; Owner and Admins notified with the flag reason |
Yes, after the flag is cleared |
Transitions not listed are invalid. applyTransition() rejects them with 409 and error code
INVALID_STATUS_TRANSITION — the request is well-formed and authorized, it simply conflicts with
the row's current state. This is the one 409 that carries a details array, naming the current
state and the set of legal next states; Section 7.3.3 records the exception explicitly so the
general "details only on 422" rule stays intact everywhere else.
{
"error": {
"code": "INVALID_STATUS_TRANSITION",
"message": "A rejected testimonial cannot be published. Reopen it for review first.",
"details": [
{ "field": "status", "issue": "current=rejected, requested=published, allowed=[pending, archived, deleted]" }
],
"docsUrl": "https://docs.vouch.app/errors/INVALID_STATUS_TRANSITION"
},
"meta": { "requestId": "req_01J8ZC3QK7M2N4P6R8T0V2X4Y6" }
}11.2.4 Transition mechanics #
// packages/core/moderation/transition.ts
export type TransitionAction =
| 'approve' | 'reject' | 'publish' | 'approve_and_publish' | 'reopen'
| 'unpublish' | 'republish' | 'archive' | 'unarchive'
| 'delete' | 'restore' | 'erase'
| 'withdrawal_by_respondent' | 'auto_unpublish_on_flag';
export interface TransitionInput {
workspaceId: string;
testimonialId: string;
action: TransitionAction;
actor: Actor; // { type: 'user' | 'api_key' | 'system', id, ip, userAgent }
expectedVersion?: number; // optimistic concurrency; omitted only by system actors
reason?: RejectionReason; // required for `reject`
note?: string; // ≤ 500 chars, stored on the audit row
}
export interface TransitionResult {
testimonial: Testimonial;
auditEventIds: string[]; // one per state change; two for `approve_and_publish`
purgeTags: string[]; // CDN tags scheduled for purge
jobsEnqueued: { name: string; jobId: string }[];
}
export async function applyTransition(input: TransitionInput): Promise<TransitionResult>;Invariants enforced by the implementation:
- One transaction. The status write, timestamp writes, counter updates, audit row insert, and transactional-outbox rows (webhook delivery, CDN purge, job enqueue) all commit together or not at all. Side effects are never fired before commit; the outbox worker dispatches them after.
- Optimistic concurrency. Every user-initiated transition sends
expectedVersion. A mismatch returns409 TESTIMONIAL_VERSION_CONFLICTwith the current version and the actor who changed it, so the UI can show "Dana approved this 4 seconds ago" rather than silently overwriting. - Idempotent by action. Re-issuing a transition that has already produced the target state
returns
200with the current resource and does not write a second audit row or re-fire webhooks. The public API additionally honoursIdempotency-Keyper Section 7. - Precondition re-check at publish time. Consent, flags, media readiness, and workspace billing state are re-evaluated at T3/T8 even if they were checked at T1, because hours or weeks may separate approval from publication.
- Reaching a plan cap never unpublishes anything. Quota affects new collection only (Section 19). No transition in this table is ever executed by a quota or billing process.
11.3 The moderation inbox #
11.3.1 Layout #
Route: /w/{workspaceSlug}/inbox. A three-region layout at ≥ 1024 px, collapsing to a single
scrolling column at < 768 px.
| Region | Width (desktop) | Contents |
|---|---|---|
| Rail | 240 px, collapsible to 56 px | Saved views, status counts, source counts, tag list with counts, flag count, "Needs caption review" count |
| Queue | 380 px, resizable 320–520 px, persisted per user | Virtualized list of testimonial cards, filter bar, sort control, select-all, bulk action bar |
| Detail | remaining space | Full testimonial, media player, transcript, consent chips, tags, AI suggestions, activity/audit timeline, action bar |
At < 768 px the queue is the primary view and opening an item pushes the detail pane as a full screen with a back affordance. The action bar is pinned to the bottom safe area on touch devices, with 44 × 44 CSS px targets per Section 21.
11.3.2 Queue card #
Each card shows, in this order: selection checkbox, kind icon (text / video) with a text label for
screen readers, avatar or monogram, display name, company, a two-line body excerpt (or the first
two transcript lines for video), star rating if present, source badge, up to three tag chips with
a "+N" overflow, relative submission time with an absolute title, and a status pill. Status pills
carry an icon and a text label — never colour alone (Section 21).
An "at risk" badge appears when the item has been pending longer than the workspace's moderation
SLA target (Section 11.10). A red-bordered flag badge appears when an open flag exists.
11.3.3 Filters #
All filters combine with AND across facets and OR within a facet, except tags, which offer an explicit AND/OR toggle. Filter state is encoded in the URL query string so a filtered inbox is shareable and bookmarkable, and is the exact same state a saved view stores.
| Facet | Control | Values | Notes |
|---|---|---|---|
status |
multi-select | pending, approved, published, unpublished, rejected, archived |
Default: pending. Soft-deleted items are never included; they live in a separate "Recently deleted" view |
kind |
multi-select | text, video |
— |
source |
multi-select | collected, google, g2, capterra, x, csv |
Section 16 owns source semantics |
rating |
multi-select | 1–5, none |
none matches rows with a null rating |
tag |
multi-select + AND/OR toggle | workspace tag vocabulary | untagged is a reserved pseudo-value |
language |
multi-select | en, es, fr, de, pt, und |
und = undetermined |
requestPageId |
multi-select | workspace request pages | Only meaningful for source = collected |
dateRange |
preset + custom | today, 7d, 30d, 90d, custom |
Applies to collected_at; a toggle switches it to updated_at |
hasVideo |
tri-state | any, yes, no |
Equivalent to kind = video but retained separately because a text testimonial may carry a video attachment |
needsCaptionReview |
boolean | — | True when the transcript has any word confidence below 0.75 or a detection confidence below 0.6 (Section 10) |
flagged |
tri-state | any, open, cleared |
— |
consentAdvertising |
tri-state | any, granted, not_granted |
Drives Section 15 card generation for paid use |
hasAiSuggestions |
tri-state | any, yes, no |
Growth and Scale only (Section 12.8) |
featured |
tri-state | any, yes, no |
— |
q |
free text | ≤ 200 chars | Full-text over body, transcript text, display name, company; Postgres tsvector with the language-specific configuration, falling back to simple for und |
Applying a filter never mutates the open detail item. If the open item no longer matches the filter, it stays open with a subtle "no longer matches the current filter" note and a Close button.
11.3.4 Saved views #
Saved views are persisted in the inbox_saved_views table, whose DDL, indexes and deletion policy
Section 5.6 owns. A saved view stores a name (≤ 48 chars), the owning user_id, the full filter
state and sort as JSONB, a position for manual ordering in the rail, and a visibility of
private (creator only) or shared (whole workspace, requires testimonial:approve). Up to 30
saved views per workspace. Four system views ship pre-created and cannot be deleted or renamed;
they are materialized in the UI rather than stored as inbox_saved_views rows, so they can never
be edited away:
| System view | Filter |
|---|---|
| Needs review | status=pending, sort oldest_first |
| Flagged | flagged=open, any status |
| Needs caption review | needsCaptionReview=true, kind=video |
| Recently deleted | soft-deleted rows within 30 days, sort by deleted_at DESC |
11.3.5 Sorting #
| Sort key | Order | Notes |
|---|---|---|
newest (default) |
collected_at DESC, id DESC |
For imported rows collected_at is the source's original publication time (Section 16.4) |
oldest_first |
collected_at ASC, id ASC |
The triage default inside the "Needs review" system view |
rating_desc |
rating DESC NULLS LAST, collected_at DESC |
— |
rating_asc |
rating ASC NULLS LAST, collected_at DESC |
Surfaces the reviews that need attention |
recently_updated |
updated_at DESC, id DESC |
— |
sla_risk |
pending age DESC |
Only selectable when status includes pending |
Pagination is cursor-based exactly as defined in Section 7, with a page size of 25 and infinite scroll that prefetches the next page when the user reaches 70 % of the current one. There is no page number anywhere in the inbox.
11.3.6 Keyboard shortcuts #
Shortcuts are active whenever focus is not inside a text input. ? opens a shortcut cheat sheet.
Every shortcut has an equivalent visible control; nothing is keyboard-only or pointer-only.
| Key | Action | Notes |
|---|---|---|
j / ↓ |
Next item | Wraps to loading the next page |
k / ↑ |
Previous item | — |
a |
Approve | Moves selection to the next item automatically |
Shift + A |
Approve and publish | Confirmation suppressed after the first use per session, restorable in settings |
r |
Reject | Opens the reason picker; Esc cancels |
p |
Publish | Only enabled from approved or unpublished |
u |
Unpublish | Only enabled from published |
e |
Edit body | Opens the editor with the caret at the start |
t |
Tag palette | Typeahead over the existing vocabulary |
f |
Toggle featured | — |
g then f |
Flag | Opens the flag reason picker |
x |
Toggle selection of the current item | — |
Shift + x |
Range-select from the last selection | — |
/ |
Focus search | — |
z |
Undo the last action | Within the 10-second undo window (Section 11.3.8) |
Esc |
Close the detail pane / dismiss the current dialog | — |
⌘/Ctrl + Enter |
Save the open editor | — |
Shortcut execution announces its result via a polite live region ("Approved. Showing 1 of 42.") so keyboard and screen-reader triage stay in sync.
11.3.7 Bulk actions #
Selection supports individual, range, "select all on this page" (≤ 25), and "select all matching this filter" (capped at 500; above 500 the UI states the cap plainly and offers to narrow the filter). Bulk operations available: approve, reject, publish, unpublish, archive, unarchive, add tags, remove tags, feature, unfeature, delete, export CSV.
| Rule | Behaviour |
|---|---|
| Confirmation | Required for reject, unpublish, delete, and for any operation over 25 items. The dialog names the operation, the exact count, and the irreversible parts |
| Execution | ≤ 50 items: synchronous, 200 with a per-item result array. > 50 items: 202 Accepted and a moderation.bulk job with a progress bar and per-item results |
| Partial failure | Never aborts the batch. Each item reports ok or an error code; the summary shows "38 approved, 4 skipped (flagged), 0 failed" and offers a CSV of the skipped rows |
| Undo | A toast offers Undo for 10 seconds. Undo enqueues a compensating batch (the inverse transition per item), never a database rollback, and writes its own audit rows so the trail shows both the action and its reversal |
| Not undoable | Delete is excluded from the 10-second undo because it is already reversible for 30 days from "Recently deleted"; the toast says so |
| Rate | Bulk endpoints are limited to 10 requests per minute per workspace on top of the Section 7 limits |
11.3.8 Empty, loading and error states #
| Condition | Presentation |
|---|---|
| First load | Skeleton of 6 queue cards and a skeleton detail pane; no spinner-only screens |
| No testimonials at all in the workspace | Illustration-free empty state: "No testimonials yet." with two primary actions — "Create a request page" and "Import existing reviews" — and a link to the collection guide |
| Filter matches nothing | "No testimonials match these filters." plus a "Clear filters" button that restores the active saved view, plus the count that would match with the filters cleared |
| Queue loaded, nothing selected | Detail pane shows "Select a testimonial to review" and the keyboard shortcut hints |
| Fetch failure | Inline error region (not a toast) with the error message from the envelope in Section 7.3, the requestId, and a Retry button. The previously loaded page stays on screen; the list is never blanked |
| Transition failure | The optimistic UI change reverts, an inline error appears on the item, and the item is left selected so the moderator can retry |
| Permission denied | The action controls render disabled with an accessible description ("Viewers cannot publish testimonials"), rather than being hidden, so the user understands why |
Workspace read-only (canceled) |
A persistent banner explains that moderation is read-only, export remains available, and published widgets keep serving; every mutating control is disabled with that explanation |
| Media still processing | The detail pane shows the media state (uploading/processing) with a progress indication and disables approve for video items with MEDIA_NOT_READY explained inline |
11.3.9 Live updates while moderating #
The inbox subscribes to a Server-Sent Events stream at GET /api/v1/workspaces/{id}/events
(dashboard session auth only, never an API key, text/event-stream, 25-second heartbeat comment,
automatic reconnect with exponential backoff to 30 s, Last-Event-ID replay of the last 5 minutes).
| Event | Inbox behaviour |
|---|---|
testimonial.created |
The list is not mutated underneath the moderator. A pill appears above the queue: "3 new submissions — Show" (aria-live="polite", announced once per batch, not per event). Clicking it prepends them |
testimonial.updated by another user |
If the item is in the visible list but not open, its card updates in place. If it is open, the pane does not change; an inline banner says "Dana updated this testimonial 5 seconds ago" with Reload and Keep editing |
testimonial.status_changed by another user |
The card's status pill updates; if it is open and the moderator then acts, the version check returns 409 and the banner escalates to a diff view |
testimonial.deleted |
The card is removed with a 300 ms fade; if it was open, the pane shows "This testimonial was deleted by Dana" with a Restore action for users holding testimonial:restore |
| Stream disconnected > 60 s | A subtle "Live updates paused — reconnecting" indicator; the inbox continues to work, and a full refetch runs on reconnect |
Concurrent moderation is additionally guarded by a soft lock: opening an item publishes a
viewing presence event, and other moderators see "Dana is viewing" on that card. The lock is
advisory only — it never blocks an action, because a hard lock would strand items when a tab closes.
11.4 Editing rules #
These rules are the reputational core of the product. A testimonial is a person's own words, and the platform's promise is that what gets published is what they actually said.
11.4.1 Collected testimonials — what a moderator may change #
| Field | Editable | Constraint |
|---|---|---|
body (text testimonials) |
Yes, under 11.4.2 | 1–20,000 characters after sanitization — the ceiling is the body_text column constraint owned by Section 5.6.1, and no surface imposes a lower one |
| Transcript segments (video) | Yes — Section 10 owns the transcript editor | Editing the transcript never edits the audio; the video is never re-cut |
display_name |
Yes | 1–120 chars. Correcting spelling or applying a requested pseudonym only |
role_title |
Yes | 0–120 chars |
company |
Yes | 0–120 chars |
avatar |
Crop and rotate only | The uploaded image is never replaced by a different person |
rating |
No | The rating is the respondent's own scoring and is immutable in every source, including collected submissions |
headline |
Yes | 0–120 chars. Platform-authored by definition and always rendered in the workspace's voice, never in quotation marks, never attributed to the respondent |
tags, is_featured, sort_weight |
Yes | Metadata, not content |
| Pull-quote selection | Yes | Must be a verbatim excerpt — the same rule Section 12.1 applies to the AI assistant applies to a human selecting a quote by hand |
| Consent grants | No | Only the respondent can grant or withdraw consent (Section 22) |
11.4.2 Permitted edit classes #
Every save of body requires an edit_reason chosen from this closed vocabulary. There is no
"other" escape hatch.
edit_reason |
Permitted for | Example |
|---|---|---|
typo |
Spelling, punctuation, capitalization, obvious autocorrect damage | "definately" → "definitely" |
transcription_fix |
Correcting what the speech-to-text heard wrong, verified against the audio | "our new pipe line" → "our new pipeline" |
trim |
Removing leading, trailing, or interior content without changing the meaning of what remains | Dropping a 40-word tangent about the weather |
redaction |
Removing third-party personal data, a competitor's confidential figure, or content the respondent asked to remove after the fact | Removing a colleague's phone number |
formatting |
Paragraph breaks, list structure, whitespace | Splitting a 300-word wall into three paragraphs |
11.4.3 What editing may never do #
An edit may never turn what the person said into something they did not say. Three mechanisms enforce that, in ascending order of strictness:
- Diff classification. The editor computes a word-level diff on save. Inserted runs are
counted. If inserted word tokens exceed 10 % of the original token count, or any single
inserted run exceeds 3 consecutive tokens, the save is intercepted by a modal that states:
"This edit adds wording the respondent did not write. Testimonials must remain the
respondent's own words. Continue only if you are correcting a transcription error against the
recording." Continuing sets
material_edit = trueon the revision and on the audit row. - Material-edit surfacing. Any testimonial carrying a
material_edit = truerevision shows an "Edited" marker in the dashboard detail pane, in the audit viewer, and to the workspace Owner in the weekly digest. Material edits are counted per workspace and per user, and a workspace whose material-edit rate exceeds 15 % of published testimonials over a rolling 90 days triggers an internal integrity review (Section 24 owns the operator alert path). - Original retention, forever. Every save writes a row to the revision table defined in
Section 5, holding the complete previous text, the new text, the computed diff, the
edit_reason, the actor, and the timestamp. The original submitted text is retained for the life of the testimonial and is never overwritten, never truncated, and never garbage-collected — only a hard erasure (T13) removes it. The dashboard exposes "View original" and a revision timeline on every edited testimonial.
11.4.4 The trimming disclosure rule #
The product never silently shortens a person's words.
| Condition | Result |
|---|---|
| Any interior deletion (content removed from the middle) | is_elided = true. The published text renders the elision as a spaced ellipsis … at the exact removal point, in every surface: widgets, showcase, permalink, API payload, social cards |
| Final length < 80 % of the original | is_trimmed = true. The published rendering appends a "Shortened" affordance |
| Either flag set | The public permalink page (Section 14) renders a disclosure control, "Show the full original", which expands the complete original text inline. This is not buried in a tooltip and is keyboard reachable |
Widget display truncation (a maxLength widget setting) |
Presentation only. It never mutates stored text, always ends at a word boundary, always renders …, and always links to the permalink with the full text |
The public API and the widget JSON payload both expose isElided and isTrimmed booleans so any
consumer can honour the same disclosure.
11.4.5 Imported reviews — the text is immutable #
For any testimonial whose source is not collected, body is immutable. The API rejects a
body edit with 422 IMPORTED_REVIEW_IMMUTABLE. The dashboard renders the body in a read-only
region with an inline explanation.
Why, stated plainly for the reader and repeated in the UI:
- It is another platform's record. A Google, G2, Capterra or X review is a published statement on that platform. Editing the copy the platform hosts, and then displaying it under that platform's badge, misrepresents both the reviewer and the source.
- The syndication terms require verbatim display. Every source in Section 16 conditions reuse on unmodified presentation with attribution. Editing would breach those terms and expose the customer to takedown.
- Verifiability is the point. An imported review carries a permalink. A reader can click through and compare. If the two differ, the testimonial is worthless as social proof and damaging as a signal.
What a moderator may do with an imported review: tag it, feature it, set sort_weight, hide it
(unpublish/archive), reject it, delete it, generate a social card from it (Section 15, subject to
15.7), and configure presentation-only display truncation. What a moderator may never do: change a
character of the text, change the rating, change the author's name, or remove the source badge and
permalink (Section 16.6).
11.4.6 The shared principle #
The same rule governs humans and machines. Section 12.1 states the AI boundary — the assistant may only select, never rewrite — and it is not a special rule for AI. It is this section's rule, applied to a different actor. A human moderator selecting a pull-quote by hand is validated by the same verbatim validator described in Section 12.4, with the same normalization, the same multi-part ellipsis rule, and the same negation-clipping guard. There is no path in the product — manual, assisted, bulk, or API — that publishes an excerpt which is not a verbatim substring of what the person actually said or wrote.
11.4.7 Editing endpoints #
| Method | Path | Purpose | Permission |
|---|---|---|---|
PATCH |
/api/v1/testimonials/{id} |
Edit body, name, role, company, headline. Requires editReason when body changes and version for concurrency |
testimonial:update |
PUT |
/api/v1/testimonials/{id}/pull-quote |
Set or replace the published excerpt. Body: { parts: [...] } or { segmentIds: [...] }, validated by the function in Section 12.4 before it is stored |
testimonial:update |
DELETE |
/api/v1/testimonials/{id}/pull-quote |
Clear the excerpt and fall back to the full body | testimonial:update |
GET |
/api/v1/testimonials/{id}/revisions |
Cursor-paginated revision history including the original | testimonial:read |
GET |
/api/v1/testimonials/{id}/revisions/{revisionId} |
One revision with its full before/after text and diff | testimonial:read |
POST |
/api/v1/testimonials/{id}/revisions/{revisionId}/revert |
Restores a previous text as a new revision (history is never rewound) | testimonial:update |
// PATCH /api/v1/testimonials/tst_01J8Z.../ request
{
"version": 7,
"body": "Vouch cut our testimonial collection time from three weeks to two days.",
"editReason": "typo"
}// 200 response
{
"data": {
"id": "tst_01J8ZC3QK7M2N4P6R8T0V2X4Y6",
"version": 8,
"body": "Vouch cut our testimonial collection time from three weeks to two days.",
"isElided": false,
"isTrimmed": false,
"materialEdit": false,
"editCount": 2,
"editedAt": "2026-03-04T10:12:44.219Z"
},
"meta": { "requestId": "req_01J8ZC3QK7M2N4P6R8T0V2X4Y6" }
}11.5 Tagging #
11.5.1 The vocabulary #
Tags are workspace-scoped, never global, and never shared between workspaces. Section 5 owns the table; the behavioural rules are:
| Property | Rule |
|---|---|
label |
1–40 characters, must contain at least one non-whitespace character, unique per workspace case-insensitively after trimming |
slug |
Derived from the label, [a-z0-9-]{1,48}, unique per workspace, used in filter URLs and widget config |
color |
One of 12 preset tokens (slate, red, orange, amber, lime, emerald, teal, sky, indigo, violet, fuchsia, rose). Free-form hex is deliberately not offered, because a customer-chosen hex on a chip cannot be guaranteed to meet the 3:1 non-text contrast requirement in Section 21. Each token ships a light and a dark pairing that both pass |
description |
0–200 characters, shown in the tag manager and as the chip's accessible description |
| Limits | 200 tags per workspace — creating the 201st returns 409 TAG_LIMIT_REACHED. 25 tags per one testimonial — applying the 26th returns 422 TESTIMONIAL_TAG_LIMIT_REACHED. They are deliberately two codes: a client cannot act on a single one, because the first is fixed by deleting a tag from the workspace vocabulary and the second by removing a tag from this row |
| Creation | Requires tag:create. Moderators without it can apply existing tags but not invent new ones |
11.5.2 Suggestions #
The tag typeahead offers only tags that already exist in the workspace. The AI assistant's tag
suggestions are drawn from the same closed vocabulary and can never invent a tag (Section 12.3).
When a moderator types a string that matches no existing tag, the UI offers "Create tag «…»" only
if the user holds tag:create; otherwise it explains that new tags are created by Admins.
11.5.3 Rename and merge #
| Operation | Behaviour |
|---|---|
| Rename | Changes label and regenerates slug. Widget configuration stores tag ids, never slugs or labels, so renaming can never break a live widget. The previous slug is recorded as an alias for 12 months; public tag-filtered showcase URLs using the old slug 301 to the new one |
| Merge | Moderator picks a source tag and a target tag. All testimonial-tag rows repoint to the target, duplicates are dropped, widget configs referencing the source are rewritten to the target, the source tag is soft-deleted with a merged_into_tag_id pointer, one audit row is written per affected testimonial plus one for the merge itself, and every widget touched is CDN-purged. Merging is irreversible in one click but fully reconstructible from the audit trail |
| Delete | Soft-deletes the tag and removes it from every testimonial and every widget filter. If removing it would leave a widget with an empty filter that matches nothing, the UI warns with the affected widget names before confirming |
| Archive | A tag can be archived instead of deleted: it stays on existing testimonials and keeps working in existing widget filters, but disappears from the typeahead |
Merge and delete are executed as a moderation.tag_operation job when they touch more than 200
testimonials, with progress reporting, and are transactional in batches of 500 rows.
11.5.4 Counts and filtering #
Per-tag counts are maintained as a denormalized counter updated in the same transaction as the
tagging change, with a nightly reconciliation job that recomputes from the join table and alerts on
any drift. The rail shows counts scoped to the currently active status filter, so "Product (12)"
under a pending filter means twelve pending items.
Tags drive widget filtering: a widget's configuration (owned by Section 13) holds an include list
and an exclude list of tag ids plus an all/any match mode. Tag changes on a published
testimonial therefore change widget membership, which is why every tag mutation on a published
row triggers a CDN purge of the affected widget tags and a payload rebuild.
11.6 The audit trail #
11.6.1 Guarantees #
The audit trail is append-only. No UPDATE and no DELETE is possible on it, enforced at the
database level — a Postgres rule plus revocation of UPDATE and DELETE from the application role
on that table. Section 5 owns that DDL. The application has no code path that attempts either
operation, and the CI schema test asserts the privileges are absent.
Audit rows are written inside the same transaction as the change they describe. If the change commits, the audit row exists; if the audit insert fails, the change is rolled back. There is no "best effort" audit logging anywhere in this product.
11.6.2 What every row contains #
| Field | Description |
|---|---|
id |
UUIDv7, time-ordered |
workspace_id |
Tenancy scope (Section 6) |
subject_type |
testimonial, tag, widget, request_page, consent, import_run, social_card, member, api_key, domain, workspace |
subject_id |
The affected row |
action |
From the vocabulary in 11.6.3 |
actor_type |
user, api_key, system |
actor_id |
User id, API key id, or the job name for system |
actor_label |
Denormalized display name and email at the time of the action, so the trail stays readable after a member leaves |
before |
JSONB snapshot of the changed fields before |
after |
JSONB snapshot of the changed fields after |
metadata |
JSONB — editReason, rejectionReason, note, materialEdit, isRepublish, bulkOperationId, initiatedBy |
ip |
Source IP, stored in full for security-log purposes under legitimate interest and retained per 11.6.5 |
user_agent |
Raw UA string, truncated to 512 chars |
request_id |
The req_… identifier from Section 7, joining the audit row to the application logs |
occurred_at |
timestamptz, UTC |
before/after contain only the fields that changed, plus status and version always, so the
snapshots stay small. Body text is stored in full in both snapshots for content edits — the audit
trail is a legal record and truncating it would defeat the purpose.
11.6.3 Action vocabulary #
| Action | Subject | Written by |
|---|---|---|
testimonial.created |
testimonial | Submission handler, import worker, API |
testimonial.approved |
testimonial | T1, T4 |
testimonial.rejected |
testimonial | T2 |
testimonial.reopened |
testimonial | T5, T6 |
testimonial.published |
testimonial | T3, T4, T8 |
testimonial.unpublished |
testimonial | T7, T14, T15 |
testimonial.archived / testimonial.unarchived |
testimonial | T9, T10 |
testimonial.deleted / testimonial.restored |
testimonial | T11, T12 |
testimonial.erased |
testimonial | T13 |
testimonial.body_edited |
testimonial | 11.4 editor |
testimonial.attribution_edited |
testimonial | Name/role/company/avatar change |
testimonial.headline_set |
testimonial | Headline create or change |
testimonial.reverted |
testimonial | Revision revert |
testimonial.tag_added / testimonial.tag_removed |
testimonial | Tagging, single or bulk |
testimonial.featured / testimonial.unfeatured |
testimonial | Featuring |
testimonial.sort_weight_changed |
testimonial | Manual ordering |
testimonial.flagged / testimonial.flag_cleared |
testimonial | Section 11.9 |
testimonial.pull_quote_set / testimonial.pull_quote_removed |
testimonial | Manual or accepted-AI selection |
testimonial.transcript_edited |
testimonial | Section 10 transcript editor |
testimonial.transcript_regenerated |
testimonial | Retry transcription |
testimonial.message_sent |
testimonial | Section 11.8 reply/follow-up |
testimonial.message_received |
testimonial | Inbound respondent reply |
consent.granted / consent.withdrawn |
consent | Section 22 |
ai.suggestion_generated |
testimonial | Section 12 |
ai.suggestion_accepted / ai.suggestion_rejected |
testimonial | Section 12.5 |
ai.suggestion_discarded_by_validator |
testimonial | Section 12.4, actor system |
tag.created / tag.renamed / tag.merged / tag.archived / tag.deleted |
tag | Section 11.5 |
import_run.started / import_run.completed / import_run.failed / import_run.canceled |
import_run | Section 16.7. import_run.completed is written for both terminal success statuses, succeeded and partial, with the status itself in after |
import.source_connected / import.source_disconnected |
workspace | Section 16 |
social_card.created / social_card.updated / social_card.deleted |
social_card | Section 15 |
social_card.alt_text_edited |
social_card | Section 15.6 |
bulk.operation_started / bulk.operation_completed / bulk.operation_undone |
workspace | Section 11.3.7 |
Sections 6, 14, 18, 19 and 22 append their own actions for members, domains, integrations, billing and privacy events. The complete cross-subject list — every event, with a column marking whether it is delivered as a webhook or is internal-only — is consolidated in Section 29.4, which is a regeneration of the webhook catalogue Section 18.2 owns.
11.6.4 The audit viewer — answering "who put that live, and when?" #
This is the question customers ask when a testimonial appears somewhere unexpected, and the product answers it in two clicks from any published testimonial:
- Click 1 — open the testimonial in the inbox (or click "History" from the widget preview, the showcase admin view, or a search result).
- Click 2 — the detail pane's Activity tab.
The Activity tab shows a reverse-chronological timeline with the publication event pinned to the top in a highlighted card:
┌────────────────────────────────────────────────────────────┐
│ ● Published by Dana Okafor (dana@acme.com) — Admin │
│ 4 March 2026, 10:14:07 UTC · 2 days ago │
│ From: approved → To: published │
│ Request 01J8ZC3QK7M2N4P6R8T0V2X4Y6 · 203.0.113.42 │
│ Chrome 141 on macOS │
│ [ View full audit record ] [ Copy permalink ] │
└────────────────────────────────────────────────────────────┘Below it, every other event in the row's life: created, approved, edited (with an inline diff), tagged, flagged, unpublished, republished. Each entry names the actor by display name and email, its role at the time, the exact UTC timestamp with a relative hover, and expands to the full before/after JSON. System actors are labelled with the job that acted ("System · retention sweep") and, where relevant, what initiated it ("System · initiated by the respondent").
If the acting user has since been removed from the workspace, the denormalized actor_label keeps
the trail readable: "Dana Okafor (dana@acme.com) — removed from this workspace on 12 April 2026".
If the action was performed with an API key, the entry reads "API key «CI publisher» (vch_live_a1b2c3d4e5f6…)"
showing only the stored 12-character display prefix, never the key itself.
A workspace-wide audit viewer at /w/{slug}/settings/audit requires audit:read (Owner and Admin).
It filters by actor, actor type, action, subject type, subject id, date range, IP, and free text
over actor_label. It is cursor-paginated per Section 7 and defaults to the last 30 days. The
per-testimonial Activity tab is visible to every role that can read testimonials, including Viewer,
because the customer's own team should never have to ask an Admin who published something.
11.6.5 Retention and export #
| Aspect | Rule |
|---|---|
| Retention | 7 years from occurred_at, on every plan including Free. Audit retention is deliberately independent of the analytics retention in Section 19 — analytics is a product metric, the audit trail is a record |
| Sweep | A monthly job deletes rows older than 7 years. This is the only process permitted to remove audit rows, and it runs as the migrator role, not the application role, so the append-only guarantee holds for the application at all times |
| Export | GET /api/v1/audit-events/export?format=csv|json with the same filters as the viewer. Runs as a job above 10,000 rows and delivers a signed download link valid for 7 days. Requires audit:read |
| CSV shape | One row per event; before/after/metadata serialized as JSON strings in their own columns; UTF-8 with BOM so it opens cleanly in spreadsheet software; timestamps as ISO 8601 UTC |
| Workspace export | The full data export in Section 22 includes the complete audit trail in JSON |
11.6.6 Erasure and the audit stub #
A GDPR erasure destroys the testimonial and its media, but the fact that a publication and an
erasure happened must survive, or the record becomes untrustworthy. On erasure (T13) the historical
audit rows for that subject have their before/after payloads replaced by the migrator role with
{"erased": true} — the only write the trail ever receives, performed outside the application role
and itself audited — and a final testimonial.erased row is appended recording the erasure request
id, the actor, the timestamp, and the count of assets destroyed. Personal data is gone; the shape
of the history remains. Section 22 owns the erasure runbook and the 30-day completion SLA.
11.7 Featuring, pinning and ordering #
| Mechanism | Field | Effect |
|---|---|---|
| Featured | is_featured boolean |
A workspace-wide "this is one of our best" marker. Widgets with sort = featured_first promote featured items above the rest of the filtered set; a widget may also filter to featured-only |
| Sort weight | sort_weight integer, default 0, range −1000…1000 |
A workspace-wide manual nudge. Higher sorts first. Applied as a tiebreaker in every filter-driven sort, before the created_at DESC, id DESC default from Section 7 |
| Pinning | widget_items.position |
Per-widget curation. A curated widget holds an explicit ordered list of testimonial ids. Section 13 owns the widget configuration model |
Interaction rules, stated precisely because curation and filtering fight otherwise:
- A widget is either curated (
selectionMode = manual) or filter-driven (selectionMode = filter). It is never both. - In a curated widget, items render in
positionorder. Filters do not apply. If a curated item leavespublishedfor any reason, it is simply skipped at render time and the remaining items close the gap — the widget never renders a hole and never falls below its configured minimum count without saying so in the dashboard ("2 of 8 curated items are no longer published"). - In a filter-driven widget, the effective order is:
is_featured DESC(only whensort = featured_first), then the widget's chosen sort key, thensort_weight DESC, thencreated_at DESC, id DESC. random_stablesort seeds the shuffle from the widget id plus the current UTC date, so the order is stable for a day and identical for every visitor within that day. This keeps CDN caching effective while still rotating the wall.- Changing
is_featured,sort_weight, or a curated position on apublishedtestimonial purges the affected widget tags and rebuilds the payload, exactly like a publish.
11.8 Reply and follow-up #
11.8.1 Available actions #
From the detail pane, a moderator holding testimonial:reply can send:
| Action | When available | Consent requirement |
|---|---|---|
| Thank-you | Any status, once the respondent's email or phone is on file | Publication consent (already required to collect) |
| Request a revision | pending or rejected |
Publication consent |
| Ask for a video version | The testimonial is kind = text |
Publication consent |
| Ask about a case study | Any status | Requires the optional follow-up consent grant (Section 22). Without it the control is disabled with the explanation "This respondent did not agree to follow-up contact" |
| Notify of publication | On T3 or T8 | Publication consent; off by default, a workspace setting |
Channel selection: email via the transactional provider named in Section 3, or SMS via the SMS provider in Section 3 when a phone number was captured and the respondent consented to SMS at submission. Message content, templating, localization and delivery guarantees are owned by Section 20; this section owns only when the action is offered and how the thread is stored.
11.8.2 Threading and inbound capture #
Every message in either direction is one row in testimonial_replies, whose DDL, indexes and
deletion policy Section 5.6 owns: workspace, testimonial, thread_id, direction
(outbound/inbound), body, actor, attachments JSONB and created_at. A thread is the set of
rows sharing a thread_id; there is no separate thread table.
Outbound messages set a per-thread reply address, reply+{threadToken}@mail.vouch.app, where
threadToken is a 32-byte random value stored hashed. The transactional email provider's inbound
webhook posts replies to POST /api/v1/webhooks/inbound-email, which:
- Verifies the provider's signature and rejects anything unsigned with
401. - Resolves
threadTokento a testimonial; an unknown token is discarded silently (no bounce, no information disclosure) and counted in a metric. - Strips quoted history using a standard reply-delimiter heuristic, retaining the full raw body in object storage for 90 days for support purposes.
- Rejects the message if
Auto-Submittedis present and notno, or ifPrecedenceisbulk,auto_replyorjunk— this is the auto-responder loop breaker. - Enforces a hard cap of 20 messages per thread and 10 inbound messages per hour per thread; beyond that, messages are stored but no notification is sent, and the thread shows a notice.
- Drops all attachments except images (
image/png,image/jpeg,image/webp) up to 5 MB each and 3 per message, stored to object storage and scanned for content type by magic bytes, not by the declared header. - Writes a
testimonial.message_receivedaudit row and notifies the moderators who are watching the thread.
The thread renders in the detail pane in chronological order with clear inbound/outbound
attribution, delivery status per outbound message (queued, sent, delivered, bounced, complained),
and a bounce handling rule: two hard bounces mark the respondent's email undeliverable and disable
further sends to it with an inline explanation.
11.8.3 Revision requests #
"Request a revision" generates a fresh single-use link to the originating request page, bound to
the existing testimonial by a revision_of reference. When the respondent submits, a new
testimonial row is created in pending, the original is marked superseded_by_id and moved to
archived, and the inbox shows the pair linked with "Revision of a testimonial from 12 Feb 2026".
The original is never overwritten and never deleted — the respondent's first words remain in the
record. Revision links expire after 14 days and are single-use.
11.9 Flagging and safety #
11.9.1 Raising a flag #
Any member holding testimonial:flag (Owner, Admin, Editor) may flag a testimonial. Flags are
internal — never visible to the respondent, never exposed in the public API, never in a widget.
A flag is a row in testimonial_flags, whose DDL, indexes and deletion policy Section 5.6 owns,
with the flag_reason enum below defined in Section 5.2. Each row carries the workspace, the
testimonial, the reason, the raising actor and timestamp, and — once resolved — the clearing actor,
timestamp, resolution and note. A flag is open while its cleared_at is null. The
testimonials table deliberately carries no flag columns: "has an open flag" is always a predicate
over testimonial_flags, so a row can hold several flags at once and the history of every one of
them survives.
flag_reason |
Meaning |
|---|---|
suspected_fake |
The submission looks fabricated, incentivized-but-undisclosed, or submitted by a workspace member |
abusive_language |
Slurs, harassment, threats |
third_party_pii |
Contains someone else's personal data — a name, email, phone number, account number |
competitor_disparagement |
Attacks a named third party in a way that creates legal exposure |
off_topic |
Not a testimonial about the customer's product |
legal_request |
A lawyer, a source platform, or the respondent's employer has objected |
consent_withdrawn |
Raised automatically by the withdrawal flow in T14 |
other |
Requires a note of at least 20 characters |
11.9.2 The rule #
Flagged content cannot be published. T3 and T8 both check for an open flag and fail with
409 TESTIMONIAL_FLAGGED, naming the reason and who raised it. This applies to manual publishing,
bulk publishing, API publishing, and the auto-publish setting on a request page.
If a flag is raised on content that is already live, the reason determines the response:
| Reason | Live content behaviour |
|---|---|
abusive_language, third_party_pii, legal_request, consent_withdrawn |
Auto-unpublished within 60 seconds by T15, including an immediate CDN purge. Owner and Admins notified with the reason |
suspected_fake, competitor_disparagement, off_topic, other |
Stays live pending human review. The dashboard shows a prominent "Flagged and live" banner and the item is pinned to the top of the Flagged view |
11.9.3 Automatic pre-screening #
At submission and at import, every testimonial runs a deterministic pre-screen — no model, no network call, no latency risk:
| Check | Result |
|---|---|
| Profanity/slur list (maintained per launch language, word-boundary matched, allowlisted for legitimate compounds) | Auto-flag abusive_language |
| PII detection: email addresses, E.164 and national phone patterns, credit-card numbers passing a Luhn check, IBAN patterns, national identifier patterns for the launch locales | Auto-flag third_party_pii with the matched span highlighted in the editor for redaction |
| Text identical (after the normalization in Section 16.5) to another testimonial in the same workspace | Auto-flag suspected_fake with the duplicate linked |
| Submission IP matches a workspace member's recent dashboard session IP | Auto-flag suspected_fake with the note "submitted from an address recently used by a team member" |
| Submission completed in under 8 seconds from page load with more than 200 characters of body text | Auto-flag suspected_fake |
Auto-flags are advisory. They never reject, never delete, and never notify the respondent. They
are raised by the system actor and clear exactly like manual flags. A workspace can disable
individual auto-flag checks in settings, with the exception of the PII check, which cannot be
disabled because it protects a third party who never agreed to anything.
11.9.4 Clearing a flag #
Only Owner and Admin hold testimonial:flag_clear. Clearing requires a resolution:
flag_resolution |
Meaning |
|---|---|
cleared_no_issue |
Reviewed, nothing wrong |
content_redacted |
The problem was edited out under edit_reason = redaction |
testimonial_rejected |
The item was rejected as part of clearing |
escalated_legal |
Handed to the customer's legal contact; the flag stays open and the item stays unpublishable until a second resolution is recorded |
A resolution note of at least 10 characters is required for every value except cleared_no_issue.
Clearing writes testimonial.flag_cleared with the reason, the resolution, the note, and the actor.
A testimonial may accumulate multiple flags over its life; publication is blocked while any flag
is open.
11.10 Moderation SLAs, digests and the stale queue #
11.10.1 Workspace settings #
| Setting | Default | Range |
|---|---|---|
moderationSlaHours |
72 (3 business days) | 4–720 |
digestFrequency |
daily |
off, daily, weekly |
digestHour / digestTimezone |
09:00, the workspace's timezone | Any hour, any IANA zone |
digestRecipients |
Every member holding testimonial:approve |
Selectable subset |
staleQueueWarning |
On | On/off, threshold fixed at 7 days |
autoPublishOnApprove |
Off | On/off — when on, T1 chains into T3 automatically, still writing both audit rows |
11.10.2 The digest #
The digest is sent only when there is something to say — a digest with zero pending items is not
sent, because a recurring empty email trains people to ignore the channel. It contains: the count
of new submissions since the last digest, the count of items pending longer than the SLA target,
the three oldest pending items with a one-line excerpt and a deep link, any open flags, any failed
transcriptions awaiting retry, and any import runs that ended partial (Section 16.7.1). Section 20 owns the
template and delivery.
11.10.3 Stale queue #
When any item has been pending for more than 7 days:
- An in-app banner appears at the top of the inbox: "4 testimonials have been waiting more than 7 days." with a one-click filter to see them.
- One email is sent to the Owner and every Admin. It is rate-limited to once per 7 days per workspace, not once per day, and not once per item.
- The oldest pending age is exposed as a workspace metric on the dashboard home and in the operator dashboard (Section 24), because a stalled moderation queue is a leading indicator of churn.
11.10.4 Moderation metrics surfaced to the customer #
| Metric | Definition |
|---|---|
| Time to first moderation | Median hours from created_at to the first approved/rejected audit row, last 30 days |
| Approval rate | approved + published ÷ total reviewed, last 90 days |
| Publish latency | Median hours from approved_at to published_at, last 30 days |
| Pending backlog | Current count of pending, and the age of the oldest |
| Edit rate | Share of published testimonials with at least one body revision, last 90 days |
| Material edit rate | Share of published testimonials with a material_edit revision, last 90 days |
11.11 Endpoints owned by this section #
All follow the envelope, pagination, idempotency, versioning and rate-limit rules in Section 7.
Every permission string below is defined in Section 6.6.2, which is the only place they exist.
One consequence is worth stating here because it shapes the inbox: an Editor may not delete a
testimonial. The role may shape collection and curation — approve, publish, unpublish, archive,
tag, feature, edit — but destroying content is Owner and Admin only, so testimonial:delete is not
an Editor permission and the delete control renders disabled for that role with the explanation in
11.3.8. Bulk delete is refused by the same check, per item.
| Method | Path | Purpose | Permission |
|---|---|---|---|
GET |
/api/v1/testimonials |
List with every filter in 11.3.3 as query parameters | testimonial:read |
GET |
/api/v1/testimonials/{id} |
Single testimonial with tags, media, consent summary, flags | testimonial:read |
PATCH |
/api/v1/testimonials/{id} |
Content and attribution edits (11.4.7) | testimonial:update |
PUT · DELETE |
/api/v1/testimonials/{id}/pull-quote |
Set or clear the published excerpt, validated by Section 12.4 | testimonial:update |
POST |
/api/v1/testimonials/{id}/approve |
T1 | testimonial:approve |
POST |
/api/v1/testimonials/{id}/reject |
T2, body { reason, note? } |
testimonial:reject |
POST |
/api/v1/testimonials/{id}/publish |
T3, T4, T8 (?andApprove=true for T4) |
testimonial:publish |
POST |
/api/v1/testimonials/{id}/unpublish |
T7 | testimonial:unpublish |
POST |
/api/v1/testimonials/{id}/archive · /unarchive |
T9, T10 | testimonial:archive |
DELETE |
/api/v1/testimonials/{id} |
T11 soft delete | testimonial:delete |
POST |
/api/v1/testimonials/{id}/restore |
T12 | testimonial:restore |
POST |
/api/v1/testimonials/{id}/flags |
Raise a flag | testimonial:flag |
POST |
/api/v1/testimonials/{id}/flags/{flagId}/clear |
Clear a flag | testimonial:flag_clear |
PUT |
/api/v1/testimonials/{id}/tags |
Replace the tag set | testimonial:tag |
POST |
/api/v1/testimonials/{id}/messages |
Send a thank-you, revision request, or follow-up | testimonial:reply |
GET |
/api/v1/testimonials/{id}/messages |
Thread history | testimonial:read |
GET |
/api/v1/testimonials/{id}/revisions |
Revision history | testimonial:read |
GET |
/api/v1/testimonials/{id}/audit-events |
Per-testimonial trail | testimonial:read |
POST |
/api/v1/testimonials/bulk |
Bulk operations (11.3.7) | Per-operation permission, all-or-nothing at the permission check |
GET |
/api/v1/tags · POST · PATCH /{id} · DELETE /{id} |
Tag vocabulary | tag:read / tag:create / tag:update / tag:delete |
POST |
/api/v1/tags/{id}/merge |
Merge into a target tag | tag:update |
GET |
/api/v1/audit-events |
Workspace-wide audit viewer | audit:read |
GET |
/api/v1/audit-events/export |
CSV/JSON export | audit:read |
GET |
/api/v1/saved-views · POST · PATCH /{id} · DELETE /{id} |
Saved views, stored in inbox_saved_views (11.3.4) |
testimonial:read (shared views need testimonial:approve) |
GET |
/api/v1/testimonials/{id}/flags |
Open and cleared flags from testimonial_flags (11.9) |
testimonial:read |
GET |
/api/v1/workspaces/{id}/events |
Dashboard SSE stream (11.3.9) | Session auth only |
11.11.1 Error codes raised by this section #
Catalogued canonically in Section 29.3.
| Code | Status | Raised when |
|---|---|---|
INVALID_STATUS_TRANSITION |
409 | The requested transition is not in the table in 11.2.3. The one 409 that carries details (11.2.3) |
TESTIMONIAL_VERSION_CONFLICT |
409 | expectedVersion does not match |
TESTIMONIAL_FLAGGED |
409 | Publish attempted with an open testimonial_flags row |
CONSENT_MISSING |
409 | Publish attempted without the required publication consent |
MEDIA_NOT_READY |
409 | Approve or publish attempted on a video whose asset is not ready |
IMPORTED_REVIEW_IMMUTABLE |
422 | Body, rating or author edit attempted on an imported review |
EDIT_REASON_REQUIRED |
422 | body changed without an editReason |
HIGHLIGHT_NOT_VERBATIM |
422 | A hand-selected pull-quote failed the validator in Section 12.4; details carries the VerbatimFailure code |
TAG_LIMIT_REACHED |
409 | The workspace already holds 200 tags |
TESTIMONIAL_TAG_LIMIT_REACHED |
422 | More than 25 tags applied to one testimonial |
TAG_LABEL_CONFLICT |
409 | Case-insensitive duplicate tag label in the workspace |
BULK_SELECTION_TOO_LARGE |
422 | More than 500 items in one bulk operation |
RESTORE_WINDOW_EXPIRED |
410 | Restore attempted more than 30 days after soft delete |
MEDIA_ALREADY_PURGED |
409 | Restore lost the race with the delayed media purge |
WORKSPACE_READ_ONLY |
403 | A mutating moderation action in a canceled workspace |
FOLLOW_UP_CONSENT_MISSING |
409 | Case-study outreach attempted without the optional follow-up grant |
THREAD_MESSAGE_LIMIT |
409 | More than 20 messages in one respondent thread |
403 appears exactly once in this table, and only for the read-only-workspace refusal: per
Section 7.6 it is reserved for role, scope and read-only-workspace failures, while a request that
is authorized but conflicts with stored state is a 409.
11.12 Acceptance criteria for Section 11 #
- Every transition in 11.2.3 is covered by an integration test asserting the status change, the timestamps, the audit row, the purge tags, and the enqueued jobs.
- A test asserts that no code path other than
applyTransition()writestestimonials.status. - A test asserts that
UPDATEandDELETEon the audit table fail for the application role. - A test asserts that a body edit on an imported review returns
422 IMPORTED_REVIEW_IMMUTABLE. - A test asserts that publishing a flagged testimonial fails from the UI path, the bulk path, the API path, and the auto-publish path.
- A test asserts that a soft delete of a
publishedtestimonial unpublishes it in the same transaction and that no widget payload can serve it afterwards. - A test asserts that an elided edit sets
isElidedand that the widget payload, the showcase page, the permalink and the API all expose it. - An end-to-end test triages 20 items using only the keyboard and asserts the resulting states.
- An accessibility test asserts zero serious or critical axe violations on the inbox, including the detail pane, the bulk confirmation dialog, and the flag dialog.
- A test asserts the two-click path from a published testimonial to the record naming the publishing actor and the exact timestamp.
- A test asserts that soft-deleting a testimonial leaves the workspace's lifetime collected-testimonial count unchanged, and that restoring it within the 30-day window neither consumes nor frees a slot — a workspace at its cap can still restore.
- A test asserts that an Editor is refused
DELETE /api/v1/testimonials/{id}and the bulk delete operation, while an Owner and an Admin succeed. - A test asserts that publication is blocked while any
testimonial_flagsrow for the testimonial has a nullcleared_at, and unblocked the moment the last one is cleared.
12. AI Highlight Assistant #
12.1 The boundary #
The assistant may only SELECT, never REWRITE. It proposes which of the respondent's own words to feature and which seconds of their video to clip. It never edits, paraphrases, corrects, smooths, translates, or otherwise alters the wording of a testimonial. Any published pull-quote is a contiguous or clearly-elided verbatim excerpt of what the person actually said or wrote, and the platform enforces this in code, not by policy alone.
Two clarifications belong with the boundary, because they are the only places generated prose is permitted anywhere near a testimonial, and both are outside the respondent's words:
- Alt text for a social image card is prose about the card — "A dark card with a five-star rating and a quote from Maya Chen, VP Engineering at Northwind." It describes an image for people who cannot see it. It is not, and is never rendered as, the testimonial's text. Section 15.6 owns how alt text is stored, edited and required.
- A suggested case-study headline is platform-authored copy in the workspace's own voice. It is never wrapped in quotation marks, never attributed to the respondent, and always carries an author label in the dashboard. A workspace may edit or delete it freely. It is a heading, not a quote.
Everything else the assistant emits is a selection — offsets, segment ids, seconds, or an item from a closed tag vocabulary. It never emits testimonial prose.
This boundary is not an AI policy. It is the product's editorial rule (Section 11.4.6) applied to a non-human actor, and the same validator enforces both. Section 2 states it as a product promise, Section 10 applies it to transcripts, Section 11 applies it to human editing, and Section 22 states it as a privacy and data-handling commitment.
12.2 What the assistant does #
The assistant runs per testimonial, on demand, and produces one ai_suggestion_run containing up to
five kinds of proposal. Every proposal is stored, versioned by prompt, and requires an explicit
human accept before it affects anything.
| Capability | Output | Cap | Applies to |
|---|---|---|---|
| Pull-quote candidates | Ordered list of excerpts, each expressed as { parts: [{ startOffset, endOffset }] } character offsets into the stored body, or { segmentIds: [...] } for video |
5 candidates, each with 1–3 parts | Text bodies and video transcripts |
| Clip suggestions | { startSeconds, endSeconds, rationale } |
3 clips, each 6–60 seconds, rationale ≤ 140 chars | Video only |
| Tag suggestions | Array of existing tagIds with a confidence score |
5 tags | All |
| Social-card alt-text drafts | Prose string, 10–420 characters (the stored-column range in Section 15.6) | 1 per card size requested | Cards (Section 15) |
| Case-study headline | Prose string ≤ 90 chars, labelled platform-authored | 3 alternatives | All |
Details that make each capability unambiguous:
- Pull-quote candidates. Offsets are zero-based, half-open (
endOffsetexclusive), and index into the stored body string as it exists at the moment of the run. The run recordssource_version(the testimonial'sversionfrom Section 11.2.4); if the body is edited afterwards, every unaccepted suggestion from that run is invalidated and markedstale, because offsets into a changed string are meaningless. The UI says so plainly and offers to re-run. - Video pull-quotes are expressed as an ordered list of transcript segment ids, not offsets,
because segments are the stable unit of the transcript structure owned by Section 10. The excerpt
text is the concatenation of those segments'
textvalues in order, and the validator checks it against the same concatenation. - Clip suggestions must align to transcript segment boundaries:
startSecondssnaps to thestartof the first included segment andendSecondsto theendof the last, so a clip never begins or ends mid-word. Clips shorter than 6 seconds or longer than 60 are discarded before a human sees them. - Tag suggestions are selected from the workspace's existing tag ids, supplied to the model as a closed list. Any returned id not in that list is dropped. Confidence below 0.5 is dropped.
- Ranking. Candidates are returned in the model's proposed order and displayed in that order, with no re-ranking, so the reviewer sees exactly what was proposed.
Every suggestion row carries an ai_suggestion_state. Section 5.2 owns the enum; these are its six
values and this section is where their meanings are fixed:
ai_suggestion_state |
Meaning |
|---|---|
proposed |
Written by the worker, passed the validator in 12.4, awaiting human review |
accepted |
A human accepted it (12.5.2); it is now the testimonial's pull-quote, clip window or tag |
dismissed |
A human rejected it, with an optional reason from the list in 12.5.2 |
expired |
Time-based: left unreviewed for 30 days |
stale |
Content-based: the body or transcript changed after the run, so the offsets or segment ids no longer address the text they were computed against |
discarded |
Invalidated at accept time — the final re-validation in 12.5.2 failed, or the run it belonged to was abandoned by the structured-output handling in 12.6.2 |
expired and stale are deliberately separate values rather than one "no longer usable" state.
One measures a reviewer who never came back; the other measures a testimonial that moved underneath
a suggestion. The metrics in 12.4.8 count them separately, and merging them would hide a prompt
regression behind a slow queue.
12.3 What the assistant must never do #
This list is enforced, not aspirational. The enforcement mechanism for each item is named.
| Prohibited | Enforcement |
|---|---|
| Rewrite or paraphrase any part of a testimonial | Verbatim validator (12.4) — a non-substring proposal is discarded before display |
| Correct grammar, spelling or punctuation inside a quote | Verbatim validator; the excerpt must match the source byte-for-byte after the normalization in 12.4.2, and normalization is applied to both sides, so it can never launder a correction into the output |
| Translate a testimonial, or a quote from it | Verbatim validator; a translation is not a substring. The assistant is additionally instructed to operate only in the source language, and the run records the detected language |
| Merge non-consecutive sentences without a visible ellipsis | Multi-part rule (12.4.4): parts must be ordered, non-overlapping, and are always rendered joined by a spaced … |
| Clip a quote immediately before a negation, inverting the meaning | Negation-clipping guard (12.4.5) |
| Invent a tag | Closed vocabulary — returned ids are intersected with the workspace's tag ids |
| Change, infer or suggest a rating | The rating field is not sent to the provider and is not part of the structured output schema |
| Generate a testimonial, a quote, or an attribution from nothing | The schema has no free-text field for testimonial content; the only free-text outputs are the clip rationale, the alt-text draft and the headline, none of which can be published as the respondent's words |
| Auto-publish anything | No AI code path calls applyTransition(). The assistant writes suggestion rows only (12.5) |
| Suggest content for a testimonial whose workspace disabled the assistant, or whose plan excludes it | Plan and workspace gate checked before the provider call (12.8) |
| Send respondent contact details to a provider | The request payload is constructed by an explicit allowlist (12.7.2), not by serializing the row |
12.4 Enforcement in code, not policy #
12.4.1 Where the validator runs #
The validator runs server-side, in the worker, before any suggestion row is persisted. A proposal that fails is never written to the database, never returned by an API, and never rendered. The failure is counted, the raw provider response is retained for 7 days for debugging in a restricted store, and the reviewer simply sees fewer candidates.
The same function validates human-selected pull-quotes on the PUT /api/v1/testimonials/{id}/pull-quote
path catalogued in Section 11.4.7, under the shared principle stated in Section 11.4.6, so the
guarantee holds regardless of who made the selection. A human selection that fails is refused with
422 HIGHLIGHT_NOT_VERBATIM carrying the VerbatimFailure code in details — the same code an AI
proposal fails on, because there is only one verbatim rule and therefore only one error for
breaking it.
12.4.2 Normalization #
Comparison is performed on a normalized projection of both the source and the excerpt, but the stored excerpt is always taken from the original string by offset — normalization never leaks into published text.
// packages/core/ai/verbatim.ts
export interface NormalizedText {
/** Normalized characters used for comparison only. */
text: string;
/** map[i] = index in the ORIGINAL string of normalized character i. */
map: Int32Array;
}
/**
* Applied identically to source and candidate.
* 1. Unicode NFC normalization.
* 2. Remove format characters that carry no meaning for matching:
* U+00AD SOFT HYPHEN, U+200B ZWSP, U+200C ZWNJ, U+FEFF BOM.
* U+200D ZWJ is PRESERVED (it binds emoji sequences).
* 3. Remove bidi controls for comparison only: U+200E, U+200F, U+061C, U+2066–U+2069.
* They are preserved in the extracted excerpt and re-balanced at render time (12.4.6).
* 4. Map every Unicode space separator (U+0020, U+00A0, U+1680, U+2000–U+200A, U+202F,
* U+205F, U+3000) and every tab/newline/CR to a single U+0020.
* 5. Collapse runs of U+0020 to one.
* 6. Trim leading and trailing U+0020.
* Quotation marks, apostrophes, dashes and ellipses are NOT folded — a curly apostrophe
* and a straight one are different characters and a proposal that swaps them has altered
* the text, which is exactly what this validator exists to catch.
*/
export function normalizeForMatch(input: string): NormalizedText;
export type VerbatimFailure =
| 'NOT_A_SUBSTRING'
| 'PARTS_OUT_OF_ORDER'
| 'PARTS_OVERLAP'
| 'PART_TOO_SHORT'
| 'TOO_MANY_PARTS'
| 'EMPTY'
| 'TOO_LONG'
| 'NEGATION_CLIPPED'
| 'BOUNDARY_NOT_WORD_ALIGNED';
export type VerbatimResult =
| {
ok: true;
/** Exact substrings of the ORIGINAL source, in order. */
parts: string[];
/** Offsets into the ORIGINAL source, half-open. */
ranges: { start: number; end: number }[];
/** parts joined by ' … ' when parts.length > 1, else parts[0]. */
rendered: string;
}
| { ok: false; failure: VerbatimFailure; detail: string };
export function validateVerbatimExcerpt(
source: string,
candidate: { parts: string[] } | { ranges: { start: number; end: number }[] },
options?: {
language?: 'en' | 'es' | 'fr' | 'de' | 'pt' | 'und';
maxParts?: number; // default 3
minPartChars?: number; // default 15
maxTotalChars?: number; // default 320
},
): VerbatimResult;12.4.3 The matching algorithm #
- Normalize the source once per run and cache
NormalizedText. - For each candidate part, normalize it and search for it in the normalized source with
String.prototype.indexOf. Multiple occurrences: the first occurrence at or after the previous part's end is used, which preserves ordering semantics. - No match →
NOT_A_SUBSTRING, the whole candidate is discarded. A partial match is not a match; there is no fuzzy fallback, no edit-distance tolerance, and no "close enough" threshold. This is deliberate and is the single most important line of code in the feature. - Map the normalized match range back to original offsets via
map, extending the end to include the original characters that the normalized form collapsed (trailing whitespace inside the range is included; whitespace outside the range is not). - Snap both boundaries outward to grapheme-cluster boundaries using
Intl.Segmenterwithgranularity: 'grapheme', so an excerpt can never split an emoji ZWJ sequence, a combining accent from its base character, or a regional-indicator flag pair. Snapping outward can only make the excerpt a longer substring, so it remains verbatim. - Snap boundaries to word boundaries using
Intl.Segmenterwithgranularity: 'word': the start moves left to the start of the word it lands in, the end moves right to the end of the word it lands in. A boundary that cannot be word-aligned (for scripts without word boundaries in the segmenter's data) is accepted as-is. A candidate whose original boundaries fell mid-word by more than the snap can repair returnsBOUNDARY_NOT_WORD_ALIGNED. - Extract each part from the original string using the final ranges. These strings are what is stored and what is eventually published.
12.4.4 Multi-part excerpts and the ellipsis #
| Rule | Value | Failure code |
|---|---|---|
| Maximum parts | 3 | TOO_MANY_PARTS |
| Minimum characters per part (after normalization) | 15 | PART_TOO_SHORT |
| Parts must be strictly ordered by start offset | — | PARTS_OUT_OF_ORDER |
| Parts must not overlap or touch (a gap of at least one character is required, otherwise it is one part) | — | PARTS_OVERLAP |
| Maximum total characters across all parts | 320 | TOO_LONG |
| Minimum total characters | 20 | EMPTY |
| Render form | parts.join(' … ') using U+2026 with a space either side |
— |
The rendered ellipsis is not optional and not stylable away. Every surface that displays a
multi-part excerpt — widgets, showcase, permalinks, social cards, the public API, oEmbed — renders
the same joined string produced by the validator, and the API additionally exposes parts as an
array so a consumer can render its own elision marker without inventing the join.
12.4.5 The negation-clipping guard #
Clipping a quote just before a negation is the classic way to invert meaning while remaining technically verbatim: "I would recommend this to anyone" extracted from "I would recommend this to anyone who never needs support." — or, more directly, ending a part immediately before "not".
The guard: for each part, examine the next word token in the source after the part's end (skipping
whitespace and closing punctuation). If that token is in the negation list for the excerpt's
language, the candidate is rejected with NEGATION_CLIPPED.
| Language | Negation tokens (word-boundary matched, case-insensitive) |
|---|---|
en |
not, n't, never, no, nor, neither, nothing, nobody, none, without, hardly, barely, scarcely, except, unless, until, but |
es |
no, nunca, jamás, ni, ninguno, ninguna, nada, nadie, sin, salvo, excepto, pero, aunque, hasta |
fr |
ne, pas, jamais, aucun, aucune, rien, personne, ni, sans, sauf, mais, jusqu'à |
de |
nicht, nie, niemals, kein, keine, keinen, nichts, niemand, ohne, außer, aber, sondern, bis |
pt |
não, nunca, jamais, nem, nenhum, nenhuma, nada, ninguém, sem, exceto, salvo, mas, até |
und |
The English list, as a conservative default |
Exception: the guard does not fire when the part already ends with a sentence-terminating mark
(., !, ?, 。, ?, !) followed by whitespace, because a completed sentence cannot be
inverted by what follows it. This exception is itself unit-tested.
The guard is a heuristic and is documented as one. It cannot catch every meaning inversion — no code can — which is why 12.5 makes human approval mandatory and never optional. It exists because the single most common automated failure mode is cheap to block.
12.4.6 Bidirectional text #
Excerpts extracted from right-to-left or mixed-direction text can contain unbalanced isolate
controls. Before rendering, the excerpt is wrapped: the renderer emits U+2068 FSI … U+2069 PDI
around each part and appends any missing PDI for isolates opened inside the part. The stored text
is untouched; balancing happens at render time in the widget, the showcase page, the card renderer,
and the API's rendered field. Base direction is determined by the first strong directional
character (UAX #9 rules P2/P3), and the containing element carries dir="auto" as a second line of
defence.
12.4.7 Unit-test table #
These cases ship as the validator's test suite and are a hard gate in CI (Section 25).
| # | Source (excerpt) | Candidate | Expected |
|---|---|---|---|
| 1 | We shipped in two days. |
shipped in two days |
ok, exact substring |
| 2 | We shipped in two days. |
shipped in 2 days |
NOT_A_SUBSTRING |
| 3 | It’s the best tool we’ve used. (U+2019) |
It's the best tool (U+0027) |
NOT_A_SUBSTRING — apostrophes are not folded |
| 4 | It’s the best tool we’ve used. |
It’s the best tool (U+2019) |
ok |
| 5 | Support replied in minutes. (NBSP) |
Support replied in minutes (normal space) |
ok — space separators normalize to U+0020 |
| 6 | Résumé review was fast. (é as U+00E9) |
Résumé review (e + U+0301) |
ok — NFC makes both identical |
| 7 | Great team 👨👩👧👦 all round. |
team 👨👩 (splits the ZWJ sequence) |
ok after grapheme snapping — boundary extends to include the whole family emoji |
| 8 | Great team 👨👩👧👦 all round. |
team 👨👩👧👦 all round |
ok |
| 9 | منتج رائع وسريع جدا |
منتج رائع |
ok — RTL substring, isolates balanced at render |
| 10 | The onboarding was smooth and never confusing. |
The onboarding was smooth and |
NEGATION_CLIPPED — the next token in the source is never |
| 11 | We would recommend it. It is not for large teams. |
We would recommend it. |
ok — the exception applies, the part ends with a sentence terminator |
| 12 | I would recommend it not for reporting but for collection. |
I would recommend it |
NEGATION_CLIPPED — next token is not |
| 13 | Fast setup. Great support. Fair price. |
["Fast setup", "Fair price"] |
ok — two ordered non-overlapping parts, rendered Fast setup … Fair price |
| 14 | Fast setup. Great support. Fair price. |
["Fair price", "Fast setup"] |
PARTS_OUT_OF_ORDER |
| 15 | Fast setup. Great support. Fair price. |
["Fast setup. Great", "Great support"] |
PARTS_OVERLAP |
| 16 | any | ["ok", "fine"] |
PART_TOO_SHORT — under 15 characters |
| 17 | any | 4 parts | TOO_MANY_PARTS |
| 18 | any | 400-character excerpt | TOO_LONG |
| 19 | Line one.\nLine two. |
Line one. Line two. |
ok — newline normalizes to a single space |
| 20 | softhyphen inside |
softhyphen inside |
ok — soft hyphen is removed on both sides |
| 21 | A zerowidth space |
zerowidth space |
ok — ZWSP removed |
| 22 | We grew 40% in Q1. |
We grew 40 % in Q1 |
NOT_A_SUBSTRING — inserted space is a real difference |
| 23 | Leading and trailing. |
Leading and trailing. |
ok — trimmed identically on both sides |
| 24 | Best. Tool. Ever. |
`` (empty string) | EMPTY |
| 25 | The API is great |
the api is great |
NOT_A_SUBSTRING — case is never folded |
| 26 | Setup took 5 min—fast. (em dash) |
Setup took 5 min-fast (hyphen) |
NOT_A_SUBSTRING — dashes are not folded |
| 27 | נהדר ומהיר מאוד |
ומהיר |
ok, grapheme and word snapping applied |
| 28 | Video transcript, segments s1..s9 |
segmentIds: ["s2","s4"] |
ok — concatenated segment text validated as a two-part excerpt |
| 29 | Video transcript | segmentIds: ["s4","s2"] |
PARTS_OUT_OF_ORDER |
| 30 | Body edited after the run (version changed) |
any | Suggestion marked stale, never validated, never shown |
12.4.8 The rejection metric #
| Metric | Definition | Threshold |
|---|---|---|
ai.proposal_rejection_ratio |
Proposals discarded by the validator ÷ proposals returned by the provider, rolling 1 hour, evaluated only when the denominator is ≥ 50 | Warn above 2 %, page above 10 % |
ai.rejection_by_failure_code |
The same, broken out by VerbatimFailure |
Used to diagnose whether a prompt version regressed |
ai.stale_suggestion_ratio |
Suggestions invalidated by a source edit before review | Informational |
A sustained breach of the 2 % threshold is treated as a prompt regression, not as noise: the on-call runbook in Section 24 instructs rolling back to the previous prompt version, which is possible because every suggestion row stores the version that produced it.
12.5 Human in the loop, always #
12.5.1 The rule #
Nothing the assistant proposes is published without an explicit human approval click. This holds on every plan, for every capability, for every workspace, with no setting that turns it off and no API parameter that bypasses it. There is no auto-accept, no confidence threshold above which a proposal applies itself, and no bulk "accept all AI suggestions" action.
Manual curation is the default and is always available. A workspace that never opens the assistant can select pull-quotes by hand, pick clip points on the timeline, tag manually, write its own alt text and headlines, and loses no capability whatsoever. The assistant is a shortcut, never a gate.
12.5.2 The review UI #
The review surface is a panel inside the moderation detail pane (Section 11.3.1), opened by a "Suggestions" tab that shows a count badge when unreviewed suggestions exist.
Layout: two columns at ≥ 900 px, stacked below.
| Column | Contents |
|---|---|
| Source | The complete body, or the full transcript with segment boundaries and timecodes. The proposed excerpt is highlighted in place with a 3:1-contrast background and a left rule and an accessible name ("proposed pull-quote, candidate 1 of 5") — never colour alone (Section 21) |
| Proposal | The rendered excerpt exactly as it would publish, including the ellipsis join; the character count; the clip's in/out timecodes with a scrubbable preview for video; the rationale line for clips; and the action bar |
Actions per candidate:
| Action | Behaviour |
|---|---|
| Accept | Writes the pull-quote to the testimonial as the selected excerpt, writes ai.suggestion_accepted to the audit trail with the prompt version, and re-runs the validator one final time against the current body before persisting. The row moves to accepted. If the body changed since the run, the accept is refused with 409 AI_SUGGESTION_STALE and the row moves to stale; if the final validation fails for any other reason, the accept is refused with 422 HIGHLIGHT_NOT_VERBATIM and the row moves to discarded |
| Adjust and accept | Opens drag handles on the highlight in the source column, and keyboard controls (Shift+←/→ to move the active boundary by one word, Alt+←/→ by one character). The validator runs live on every adjustment and the Accept button is disabled with an inline reason while the selection is invalid |
| Reject | Optional reason from not_representative, awkward_boundary, wrong_emphasis, duplicate, other; writes ai.suggestion_rejected and moves the row to dismissed |
| Skip | Leaves the candidate unreviewed; after 30 days it moves to expired |
Additional guarantees in this UI:
- The source column is read-only. The reviewer cannot edit the testimonial from the suggestion
panel; editing goes through the Section 11.4 editor with its
editReasonrequirement. - Accepting a clip does not cut the video. It sets the poster window and the highlight range used by the video reel and the social card; the full recording remains available and playable in every surface, with the clip offered as "Play highlight" alongside "Play full video".
- Accepting a tag suggestion applies existing tags only, and each is applied individually — there is no "accept all tags" button, because a wrong tag changes widget membership.
- The headline suggestion renders in a field explicitly labelled "Headline (written by Vouch, not by the customer)" and is empty by default.
- Everything in the panel is keyboard operable, and the adjustment handles have a documented keyboard equivalent, satisfying 2.5.7 as described in Section 21.
12.6 Architecture #
12.6.1 The provider interface #
// packages/core/ai/provider.ts
export interface HighlightRequest {
/** Plain text body, or transcript segments — never both. */
readonly content:
| { kind: 'text'; body: string }
| { kind: 'transcript'; segments: { id: string; start: number; end: number; text: string }[] };
readonly language: 'en' | 'es' | 'fr' | 'de' | 'pt' | 'und';
readonly maxQuotes: number; // ≤ 5
readonly maxClips: number; // ≤ 3, 0 for text
}
export interface HighlightResponse {
readonly quotes: { parts: { startOffset: number; endOffset: number }[] }[]
| { segmentIds: string[] }[];
readonly clips: { startSeconds: number; endSeconds: number; rationale: string }[];
}
export interface TagRequest {
readonly content: HighlightRequest['content'];
readonly language: HighlightRequest['language'];
/** Closed vocabulary. The provider may only return ids from this list. */
readonly vocabulary: { id: string; label: string; description: string | null }[];
}
export interface TagResponse {
readonly tags: { id: string; confidence: number }[];
}
export interface AltTextRequest {
readonly templateId: string;
readonly size: string;
readonly theme: 'light' | 'dark';
readonly quotePreview: string; // ≤ 200 chars, already-verbatim excerpt
readonly attribution: { displayName: string | null; roleTitle: string | null; company: string | null };
readonly rating: number | null;
readonly hasAvatar: boolean;
readonly hasLogo: boolean;
readonly hasVideoStill: boolean;
}
export interface AltTextResponse {
readonly altText: string; // 10–420 chars, per Section 15.6
}
export interface AiProvider {
readonly id: string; // e.g. 'anthropic'
suggestHighlights(req: HighlightRequest, ctx: AiCallContext): Promise<HighlightResponse>;
suggestTags(req: TagRequest, ctx: AiCallContext): Promise<TagResponse>;
draftAltText(req: AltTextRequest, ctx: AiCallContext): Promise<AltTextResponse>;
}
export interface AiCallContext {
readonly workspaceId: string;
readonly requestId: string;
readonly promptVersion: string; // e.g. 'highlights@2026-02-11.3'
readonly signal: AbortSignal;
}The default implementation targets the AI provider named in Section 3, accessed through its official
SDK. An alternative implementation against the second SDK listed in Section 3 ships in the same
package and is selected by the AI_PROVIDER environment variable catalogued in Section 29.2. No
call site anywhere in the codebase imports a vendor SDK directly; everything goes through
AiProvider, which is what makes the provider swap a one-line configuration change.
12.6.2 Structured output #
The provider is required to return structured output validated against a schema defined with the validation library named in Section 3. The schema is the contract; free-form text is not accepted.
// packages/core/ai/schema.ts
export const HighlightOutput = z.object({
quotes: z.array(z.object({
parts: z.array(z.object({
startOffset: z.number().int().min(0),
endOffset: z.number().int().min(1),
})).min(1).max(3),
})).max(5),
clips: z.array(z.object({
startSeconds: z.number().min(0),
endSeconds: z.number().min(0),
rationale: z.string().min(1).max(140),
})).max(3),
}).strict();Handling of malformed output: one automatic repair attempt that returns the validation error to the
provider and asks for a corrected object. A second failure discards the run, counts
ai.structured_output_failure, and the UI reports "Suggestions are unavailable right now" — never a
stack trace, never a partial result.
12.6.3 Prompts and versioning #
Prompt templates live in source at packages/core/ai/prompts/{task}/{version}.ts and are pure data
— no runtime string assembly outside the template file. The version string is
{task}@{YYYY-MM-DD}.{n} and is:
- Recorded on every
ai_suggestion_runrow and every individual suggestion row. - Displayed in the audit entry for accepted and rejected suggestions.
- Used to scope the quality metrics in 12.9.
- The rollback unit when the rejection metric in 12.4.8 breaches its threshold.
A prompt change is a code change: it goes through review, ships with the evaluation gate in 12.9,
and rolls out behind the feature flag flag_ai_prompt_{task}_{version} at 10 %, then 50 %, then
100 % of eligible workspaces, with the previous version remaining selectable for 30 days.
12.6.4 Execution, timeouts, retries and degradation #
| Concern | Decision |
|---|---|
| Where | A BullMQ job, ai.suggest, in the worker application. Never in a request handler — the dashboard fires the job and streams the result over the SSE channel in Section 11.3.9 |
| Concurrency | 4 concurrent provider calls per worker instance; a per-workspace concurrency of 1, so one workspace cannot starve another |
| Timeout | 20 seconds per provider call, enforced with AbortSignal.timeout |
| Retries | 2 retries on 429, 500, 502, 503, 504, connection reset, and timeout. Exponential backoff 2 s, 8 s, with full jitter. No retry on 400, 401, 403, or a schema failure that already consumed its repair attempt |
| Circuit breaker | 5 consecutive failures opens the breaker for 60 seconds per provider; while open, jobs fail fast with AI_UNAVAILABLE and are not retried |
| Degradation | When the provider is unavailable, the Suggestions tab shows "Suggestions are unavailable right now — you can still select highlights by hand" with a Retry button. Nothing else in the product changes. Moderation, publishing, transcription, widgets, cards and imports are entirely unaffected; the assistant has no synchronous dependents anywhere |
| Queue behaviour | ai.suggest jobs older than 10 minutes are dropped rather than executed, because a suggestion nobody is waiting for is not worth a provider call |
| Kill switch | The flag flag_ai_assistant disables the feature platform-wide in one setting change, without a deploy |
12.6.5 Token accounting and cost caps #
Every provider call writes an accounting row: workspace, run id, task, provider id, model id, prompt version, input tokens, output tokens, computed cost in integer minor units with its currency (per the money convention in Section 5), latency, and outcome.
| Control | Growth | Scale |
|---|---|---|
| Suggestion runs per calendar month | 500 | 3,000 |
| Hard provider-cost ceiling per calendar month | 1,000 cents | 4,000 cents |
| Behaviour at the run cap | The Suggestions tab explains the monthly limit and the reset date; manual curation is unaffected. Error AI_RUN_LIMIT_REACHED |
|
| Behaviour at the cost ceiling | The assistant disables itself for the remainder of the cycle, the Owner is emailed once, and an operator alert fires because hitting the ceiling before the run cap means a prompt or a pricing assumption has drifted. Error AI_COST_CAP_REACHED |
|
| Warning thresholds | In-product notice at 80 % and 95 % of the run cap, matching the metered-limit warning behaviour in Section 19 | |
| Auto-suggest on arrival | A workspace setting, default off, that fires ai.suggest automatically for each new pending testimonial. Off by default because it converts an on-demand cost into a per-submission cost |
12.7 Privacy and data handling #
12.7.1 Commitments #
- Customer testimonial content is never used to train any model. The provider account is configured with training disabled, and the requirement is written into the processing agreement with the provider.
- Zero or minimal retention. The provider's zero-retention or shortest-available-retention setting is mandatory; where a provider offers only a minimum retention window for abuse monitoring, that window is documented in the sub-processor entry and must not exceed 30 days.
- The AI provider is a listed sub-processor with its purpose, and Section 22.9 owns the sub-processor register and the agreement reference for each entry.
- No respondent data crosses the region boundary described in Section 26. An EU-region deployment uses the provider's EU endpoint; if a required capability is unavailable in-region, the assistant is disabled in that region rather than routing data out of it.
- A workspace on Growth or Scale can disable the assistant entirely at the workspace level, in
one setting. Disabling is immediate, cancels queued
ai.suggestjobs, hides the Suggestions tab, and is recorded in the audit trail. Nothing else in the workspace changes. - Respondents are told, in the privacy copy on the request page (Section 22), that a workspace may use automated assistance to select excerpts, and that their words are never rewritten.
12.7.2 The payload allowlist #
The request payload is built field by field from an explicit allowlist. Serializing a database row into a provider request is prohibited and is caught by a lint rule.
| Sent | Never sent |
|---|---|
| Testimonial body text, or transcript segment text with ids and timings | Respondent email address |
| Detected language code | Respondent phone number |
| The workspace's tag vocabulary (id, label, description) | Respondent IP address or truncated IP |
| For alt text only: template id, size, theme, a ≤ 200-character already-verbatim quote preview, and the attribution fields that appear on the card | Consent records |
| — | Rating |
| — | User account data, member emails, API keys |
| — | Any other testimonial from the workspace |
Attribution fields (display name, role, company) are sent only for the alt-text task, because alt text must describe what is visible on the card. The highlight and tag tasks receive the text and nothing else.
12.7.3 Retention of AI artefacts #
| Artefact | Retention |
|---|---|
| Accepted suggestions | Life of the testimonial; erased with it |
| Rejected and skipped suggestions | 90 days, then hard-deleted |
| Raw provider responses (for debugging validator failures) | 7 days, in a restricted store, access audited |
| Token accounting rows | 24 months, for billing reconciliation |
| Prompt versions | Indefinitely, in source control |
12.8 Plan gating #
The assistant is available on Growth and Scale only. Section 19 owns the plan table and is the single source of truth for what each plan includes.
What a Free or Starter workspace sees:
- The full manual curation toolset, unchanged and unrestricted: hand-selected pull-quotes with the same validator, manual clip points on the transcript timeline, manual tagging, hand-written alt text, hand-written headlines.
- A single, quiet upgrade affordance in the Suggestions tab: a one-line description of what the assistant does, a link to the plan comparison, and nothing else. No modal, no interstitial, no repeated toast, no badge on the navigation, no email campaign triggered by opening the tab. The affordance is dismissible per user and stays dismissed.
- No degraded behaviour anywhere else. A Free workspace's testimonials, widgets, cards and exports are functionally identical to a Scale workspace's, minus the suggestions.
API behaviour on an ineligible plan: 409 PLAN_FEATURE_UNAVAILABLE, with a message naming the plans
that include it. 409 rather than 403 because the request is properly authenticated and the
caller holds ai:suggest — what it conflicts with is the workspace's subscription state, which is
the rule Section 19 owns for every plan and quota refusal in the product. The endpoint exists and is
documented on every plan so integrations fail predictably rather than 404-ing.
12.9 Quality evaluation #
12.9.1 The evaluation set #
A fixed, version-controlled set of 120 testimonials lives in packages/core/ai/eval/fixtures:
| Slice | Count | Composition |
|---|---|---|
| Text bodies | 60 | 12 per launch language; lengths spread across 40, 150, 400 and 900 words |
| Video transcripts | 60 | 12 per launch language; durations 45 s, 2 min and 5 min; includes 6 with deliberately poor transcription quality |
| Adversarial subset (within the above) | 20 | Contains negation traps, sarcasm, mixed-direction text, heavy emoji, smart-quote-dense prose, and one testimonial that is genuinely unquotable |
Each fixture carries a human-authored gold set of acceptable excerpts and a list of excerpts that must never be produced.
12.9.2 Metrics #
| Metric | Definition | Gate to ship a prompt version |
|---|---|---|
| Verbatim validation pass rate | Proposals passing 12.4 ÷ proposals returned | ≥ 99.5 % |
| Forbidden-excerpt rate | Proposals matching a fixture's must-never list | 0 |
| Negation-clip violations | NEGATION_CLIPPED rejections |
0 (a rejection means the model tried; the gate is that none reaches a human, which the validator guarantees, so this is tracked as a prompt-quality signal with a ceiling of 1 % of proposals) |
| Gold-set overlap | Proposals whose range overlaps a gold excerpt by ≥ 60 % of characters | ≥ 70 % |
| Human acceptance rate (production) | Accepted ÷ reviewed, rolling 30 days | ≥ 60 %, and never more than 10 points below the previous version |
| Edit-after-accept rate (production) | Accepted candidates whose boundaries were adjusted before accepting ÷ accepted | ≤ 35 % |
| Tag precision | Suggested tags accepted ÷ suggested | ≥ 65 % |
| Latency p95 | End to end, job start to suggestions available | ≤ 8 s |
| Cost per run p95 | Provider cost in minor units | ≤ 6 cents |
12.9.3 The regression gate #
A new prompt version cannot ship unless:
- The offline evaluation run meets every "gate to ship" threshold above.
- A shadow run against 200 live proposals (generated in parallel with the current version, never shown to a human, discarded after scoring) meets the same thresholds.
- A human reviewer signs off on a side-by-side sample of 30 proposals from both versions.
- The rollout follows the 10 % → 50 % → 100 % flag schedule in 12.6.3, with an automatic halt if
ai.proposal_rejection_ratiofor the new version exceeds 2 % or the acceptance rate drops more than 10 points against the incumbent over 500 reviewed proposals.
The evaluation harness runs in CI on every change to packages/core/ai, and its results are written
to docs/ai-eval/{version}.md in the repository so the history of prompt quality is reviewable
alongside the code.
12.9.4 Error codes raised by this section #
Catalogued canonically in Section 29.3.
| Code | Status | Raised when |
|---|---|---|
PLAN_FEATURE_UNAVAILABLE |
409 | Assistant requested on Free or Starter (12.8) |
AI_DISABLED_FOR_WORKSPACE |
409 | The workspace turned the assistant off |
AI_UNAVAILABLE |
503 | Provider failure or open circuit breaker |
AI_RUN_LIMIT_REACHED |
409 | Monthly run cap reached — a quota refusal, and every quota refusal is a 409 (Section 19.5.6) |
AI_COST_CAP_REACHED |
409 | Monthly provider-cost ceiling reached |
AI_SUGGESTION_STALE |
409 | Accept attempted after the source body changed |
HIGHLIGHT_NOT_VERBATIM |
422 | A human- or AI-selected excerpt failed 12.4; details carries the VerbatimFailure code. This is the single code for a verbatim failure anywhere in the product |
12.9.5 Acceptance criteria for Section 12 #
- Every row of the table in 12.4.7 is an executing unit test.
- A test asserts that a proposal failing validation is never persisted and never returned by any endpoint.
- A test asserts that no AI code path can call the transition function from Section 11.2.4.
- A test asserts that the provider request payload contains no field outside the allowlist in 12.7.2, driven by a snapshot of the serialized request.
- A test asserts that with the provider stubbed to fail, moderation, publishing, transcription, widget serving, card rendering and import all still pass their own suites.
- A test asserts the plan gate returns
409 PLAN_FEATURE_UNAVAILABLEon Free and Starter and that the manual curation endpoints remain fully functional on those plans. - An accessibility test asserts the suggestion review panel, including boundary adjustment, is fully keyboard operable with no serious or critical axe violations.
- A test asserts that editing the source body moves every unaccepted suggestion from that run to
stale, that a suggestion left unreviewed for 30 days moves toexpired, and that the two are counted by separate metrics.
13. Widgets & the Embed SDK #
This is the section to get right. Everything else in the product runs on infrastructure we control. The widget runs on somebody else's website, inside somebody else's CSS, under somebody else's Content Security Policy, on somebody else's performance budget. It cannot be patched by asking the customer to change anything, because the customer pasted two lines of HTML months ago and has forgotten they exist. It must be small, silent, isolated, and permanently backward-compatible.
Treat every number in Section 13.3 as a hard requirement enforced by CI, not an aspiration. A pull request that exceeds a budget fails the build; it does not emit a warning.
Canonical status of this section. This section is the single owner of the embed loader, the
widget catalogue and its configuration vocabulary, the widget data payload, and everything the
widget does on a host page. Where any other section appears to contradict it on those subjects,
this section wins and the other section is the defect to be corrected — the same precedence rule
Section 7.1 states for the API contract. The one exception is the widget_type enum itself,
whose values are defined in Section 5 and reproduced here verbatim; the two must always match
character for character.
13.1 The snippet #
This is the entire integration. It is the only thing a customer ever copies, and it is frozen for the life of the product.
<script async src="https://cdn.vouch.app/e.js" data-vouch="wgt_01JQY7K2M3N4P5Q6R7S8T9V0W1"></script>
<div data-vouch-widget="wgt_01JQY7K2M3N4P5Q6R7S8T9V0W1"></div>Widget ids use the wgt_ prefix followed by the widget's UUIDv7 rendered in Crockford
base32, matching the identifier conventions in Section 5. The id is opaque to the customer
and safe to expose publicly: it grants read access to already-published content and nothing
else (Section 13.8 states the invariant).
13.1.1 Why each part is exactly this #
| Part | Reason it is this way |
|---|---|
<script async> |
async guarantees the tag is never render-blocking. defer would also be non-blocking but delays execution until after HTML parsing, costing up to several hundred milliseconds on slow pages for no benefit; the loader is written to work whether it executes before or after the placeholder exists (Section 13.2.2). |
src="https://cdn.vouch.app/e.js" |
No version in the path, so a snippet pasted on day one keeps receiving fixes forever. No workspace host, so a workspace can add, change, or drop a custom domain without touching the page. cdn.vouch.app is a permanent, platform-owned host and is treated as an unbreakable public commitment. The consequence for Subresource Integrity is handled honestly in Section 13.10.4. |
data-vouch="wgt_…" on the script |
Lets the loader begin the data request in the same task in which it executes, before it scans the DOM. On a page with a single widget this removes one dependency on DOM readiness and shaves the fetch start earlier. It is also the only integration mode available to hosts that can inject a script but cannot place a div (some email-to-web and page-builder tools); in that mode the loader creates its own render target immediately after the script tag. |
<div data-vouch-widget="wgt_…"> |
The placeholder is owned by the customer: their page controls where it sits, how wide it is, and what surrounds it. The widget renders into a shadow root attached to this element and never moves it, never re-parents it, and never styles it beyond the reservation described in Section 13.2.4. |
| Both carry the same id | The script attribute primes the batch; the div identifies the render target. If a page has many widgets it needs one script tag and one div per widget. |
13.1.2 Optional placeholder attributes #
The copy action in the builder emits exactly the two lines above. A customer may additionally set the presentation-only attributes below by hand. All of them are applied client-side to the payload that was already delivered, so none of them changes the CDN cache key and none of them can widen what data is returned.
| Attribute | Values | Effect | Clamp |
|---|---|---|---|
data-vouch-theme |
light | dark | auto |
Overrides the configured theme for this instance. | Invalid values ignored, configured theme used. |
data-vouch-limit |
integer ≥ 1 | Renders at most N of the delivered items. | Silently clamped to the widget's configured maxItems; never increases it. |
data-vouch-tag |
tag slug | Filters the delivered items to that tag. | Ignored if the tag is not present in the payload. Never triggers a new request. |
data-vouch-align |
start | center | end |
Horizontal alignment inside the placeholder. | Defaults to start. |
data-vouch-h |
integer px | Overrides the layout reservation height (Section 13.2.4). | Clamped to 0–2000. |
data-vouch-debug |
present | Enables one-time console.info diagnostics for this instance. |
Never enabled by default; never logs testimonial content. |
Anything that would change which testimonials are returned — workspace, plan, selection rules, sort — lives in the widget configuration on the server, never in the page.
13.1.3 What an already-pasted snippet does when things change #
This table is the compatibility contract. Every row is covered by an automated test (Section 13.13.5).
| Change | What the pasted snippet does |
|---|---|
| Widget reconfigured (layout, theme, filters, sort, max items) | Picks up the new configuration on the next payload fetch. Worst case is 60 seconds of stale payload plus CDN propagation; a publish-triggered purge (Section 13.8.6) usually makes it immediate. No re-paste. |
| Testimonial published or unpublished | Appears or disappears on the next fetch after the purge. No re-paste. |
| Custom domain added, changed, or removed | Link targets and canonical URLs inside the widget change on the next payload rebuild. The loader origin, the script URL, the widget id, and the data API host do not change. No re-paste, ever. Section 14.6 restates this. |
| Workspace slug renamed | Old showcase links 301 to the new slug; the payload is rebuilt with the new links. No re-paste. |
| Workspace downgraded to a lower plan | Everything already live keeps serving at full quality and full volume (Section 19 owns the rule). If the plan no longer permits badge removal, the payload's badge object re-asserts show: true server-side on the next rebuild (Section 13.8.2). |
| Workspace past due or cancelled | Widgets and the showcase page keep serving published testimonials indefinitely (Section 19). The dashboard becomes read-only; the embed does not degrade. |
| Widget deleted (soft) | The data endpoint returns 410 Gone with WIDGET_DELETED. The loader removes its reservation and leaves the placeholder empty and zero-height. Nothing is drawn, no error surfaces on the host page. |
| Widget id typo / unknown id | 404 with WIDGET_NOT_FOUND. Same silent-empty behaviour. A Vouch.on('error') handler receives the detail if the host registered one. |
| All matching testimonials removed | Payload with zero items. Default behaviour collapses the placeholder to zero height (emptyState: 'hide'). |
| CDN edge unreachable | One retry, then silent empty (Section 13.2.6). The host page is never blocked, never shifted after the reservation is released, and never sees a thrown error. |
| Customer's page moves to a new domain | Nothing to do. The widget is origin-agnostic unless the workspace explicitly enabled an origin allowlist (Section 13.10.6). |
13.2 Loader architecture #
e.js is written in TypeScript (see Section 3 for the language line), compiled by esbuild to
a single ES2022 IIFE with no imports, no polyfills, and no framework. It has exactly one job:
discover render targets, fetch their data in one request, and hand off to a runtime chunk
that it pins by content hash.
Baseline browser support: the last two versions of Chrome, Edge, Firefox, and Safari, plus iOS Safari 16 and later. Anything older takes the fallback path in Section 13.11.
13.2.1 Execution outline #
// apps/embed/src/loader.ts — abridged to the control flow. Compiled to an ES2022 IIFE.
const CDN_ORIGIN = 'https://cdn.vouch.app';
const RUNTIME_URL = `${CDN_ORIGIN}/r/${__RUNTIME_HASH__}.js`; // hash injected at build time
const BATCH_MAX = 10;
const FETCH_TIMEOUT_MS = 4000;
const RESERVE_PX = 240;
(function bootstrap() {
if ((window as any).Vouch?.__loaded) return; // idempotent: extra script tags are no-ops
const api = createPublicApi(); // Section 13.5
Object.defineProperty(window, 'Vouch', {
value: api, enumerable: false, configurable: false, writable: false,
});
const self = document.currentScript as HTMLScriptElement | null;
const primed = self?.dataset.vouch; // id from the script tag, if present
discover(primed); // Section 13.2.2
})();The loader never uses eval, new Function, document.write, synchronous XMLHttpRequest,
or innerHTML. A CI test (Section 25) asserts the absence of all six by scanning the built
bundle's AST, not its text, so a minified rename cannot smuggle one through.
13.2.2 Discovery #
Because the script is async, it may execute before or after its placeholder is parsed.
Discovery handles both without a race:
- If
data-vouchis present on the script tag, that id enters the batch immediately. document.querySelectorAll('[data-vouch-widget]')runs at once for whatever is already parsed.- If
document.readyState === 'loading', a singleDOMContentLoadedlistener runs a second scan and merges any newly-parsed ids into the batch if the request has not been sent yet; otherwise it issues a second batched request. - One shared
MutationObserveris attached todocument.documentElement(childList: true, subtree: true) with a 100 ms trailing debounce. It exists so single-page-application hosts that mount a placeholder after navigation get a widget without calling anything. Its callback does a cheapnode.nodeType === 1 && node.matches?.('[data-vouch-widget]')check plus onequerySelectorAllon added subtrees, and does nothing else. Its cost is covered by the zero-long-task budget in Section 13.3. - Each discovered element is marked with
data-vouch-state="pending"and an internalWeakSetguard so it is never claimed twice.
Placeholders are deduplicated by element identity, not by id: two placeholders carrying the same widget id both render, and both are served from the same single payload.
13.2.3 Batching #
All ids discovered within the same task are coalesced into one request:
GET https://cdn.vouch.app/api/v1/widgets/data?ids=wgt_a,wgt_b,wgt_cBatches are capped at BATCH_MAX = 10 ids; a page with more widgets issues additional
parallel requests in chunks of ten. The request is issued with
fetch(url, { credentials: 'omit', mode: 'cors', cache: 'default', priority: 'high' }) and an
AbortController armed at 4000 ms. credentials: 'omit' is not optional: it guarantees the
widget never sends a cookie to any origin and keeps the response CDN-cacheable for every
visitor.
Deduplication of ids inside a batch is exact, so a page with twelve copies of the same badge issues one request for one id.
13.2.4 Layout reservation and CLS #
Layout shift is the failure mode that turns a fast widget into a slow-feeling page. The rule:
- On discovery, in the same task, the loader inspects the placeholder's computed height.
If it is zero, the loader sets
min-block-size: 240px(or thedata-vouch-hvalue),display: block, andcontain: layout styleas inline styles without!important, and paints a neutral skeleton inside the shadow root. Setting them inline and without!importantmeans the customer's own CSS can always win. - The payload carries the real reservation. Every widget document includes
layout.reservedHeight, a map of container-width bucket to expected pixel height computed at payload-build time from the item count, the layout, and the average body length. The runtime applies the bucket that matches the measured container width before it renders any content. - Growth happens in one frame. The runtime measures, sets the final container height, and
paints content inside a single
requestAnimationFramecallback. There is never a two-stage "render then resize". - Below-the-fold widgets contribute nothing, because layout shift is only scored for content in the viewport. The dominant risk is an above-the-fold widget, which is exactly what the CI fixture in Section 13.3 measures.
- Zero-item payloads collapse to zero height and release the reservation in the same frame, before first paint of the widget region.
- For pixel-exact zero shift, the install guide (Section 13.12.4) documents adding an
explicit
min-heightto the placeholder in the host's own stylesheet, and the installation checker (Section 13.12.5) reports the measured value it observed on the customer's page.
13.2.5 Runtime handoff #
The loader dynamically imports the runtime chunk, whose URL is content-addressed and baked in at build time:
const [payload, runtime] = await Promise.all([
fetchPayload(ids),
import(/* @vite-ignore */ RUNTIME_URL) as Promise<RuntimeModule>,
]);
runtime.mount(targets, payload, api);The import and the data fetch run in parallel, so the critical path is
max(payload RTT, runtime RTT) rather than their sum. Both are served from the same edge
origin over one already-warm connection. The runtime chunk is immutable
(Cache-Control: public, max-age=31536000, immutable), so every repeat visitor and every
other site using the same release gets it from cache.
If the dynamic import fails (network, CSP, or a browser without module support that slipped past the baseline), the loader releases the reservation and stops. It does not attempt a classic-script fallback: a second failure path is more risk than it removes.
13.2.6 Paths and timings #
| Path | Sequence | Expected wall-clock after loader executes, throttled 4G, mid-tier mobile |
|---|---|---|
| Cold (first ever visit, empty cache) | DNS + TLS to cdn.vouch.app (often already warm from the script fetch), payload fetch from edge cache, runtime chunk fetch, mount, paint |
≤ 400 ms to widget first paint |
| Warm (runtime cached, payload fresh at edge) | Payload fetch only (runtime from disk cache), mount, paint | ≤ 180 ms |
| Repeat view within 60 s | Payload served from HTTP cache, no network at all | ≤ 40 ms |
| Multi-widget (n widgets, n ≤ 10) | One payload request for all n, one runtime import, n mounts | Same as single-widget plus ≈ 4 ms per additional widget mount |
| SPA soft navigation | No network if the payload is still fresh; Vouch.render or the observer mounts the new target |
≤ 25 ms |
13.2.7 Failure handling #
| Failure | Behaviour |
|---|---|
| Payload fetch network error | One retry after 300–700 ms of jitter. Then give up. |
| Payload fetch 5xx | One retry. Then give up. |
| Payload fetch 404 / 410 | No retry. Placeholder collapses, data-vouch-state set to missing or gone. |
| Payload fetch 429 | No retry (retrying is exactly what a rate limit is telling us not to do). Collapse. |
| Timeout (4 s) | Abort, collapse, no retry. |
| Malformed JSON or payload version newer than the runtime understands | Render whatever validates; unknown fields ignored (Section 13.13.4). If nothing validates, collapse. |
| Runtime import failure | Collapse. |
| Exception thrown inside mount | Caught per instance. That instance collapses; other instances on the page are unaffected. |
"Give up" means: remove the reservation, set data-vouch-state="unavailable", emit an
error event to any handler the host registered via Vouch.on, and stop. Nothing is written
to the console unless data-vouch-debug is present. The widget never throws into the host
page's error handler, never rejects an unhandled promise, and never registers a global
error or unhandledrejection listener.
13.3 The performance budget #
These numbers are the widget's contract with every host page. Each row names the CI check that enforces it. Every check is a hard fail: the pipeline stops, the release does not ship. Section 23 owns the wider non-functional budget for the platform; the widget-specific numbers here are the canonical source and Section 23 references them.
| Metric | Budget | CI check | Failure mode |
|---|---|---|---|
Loader e.js transfer size |
≤ 5 KB gzip, ≤ 6 KB brotli | size-limit entry embed/loader in .size-limit.json |
Hard fail |
| Lazy runtime chunk | ≤ 22 KB gzip | size-limit entry embed/runtime |
Hard fail |
| Total JS added to the host page before interaction | ≤ 27 KB gzip | size-limit sum check embed/total |
Hard fail |
| Render-blocking resources added | 0 | ESLint rule vouch/embed-async-only on the snippet fixtures + Lighthouse CI render-blocking-resources audit = 0 items |
Hard fail |
| Framework runtime shipped to host | 0 bytes | esbuild metafile assertion: no module path matching react, react-dom, vue, svelte, preact, solid-js, lit in the bundle graph |
Hard fail |
| CLS contribution | ≤ 0.01 | Lighthouse CI on fixtures/host-above-fold.html, comparing to a widget-free control; 5 iterations, median compared |
Hard fail |
| INP contribution | ≤ 50 ms | The maximum, over the fixed scripted sequence of 20 interactions defined in fixtures/widget-inp-script.ts (8 carousel next, 4 dot jumps, 4 card expands, 4 caption toggles) on fixtures/host-above-fold.html at 4× CPU throttle and the Section 23 4G profile, of PerformanceEventTiming.duration for entries whose target resolves inside the widget's shadow root — p95 across 21 runs, with no trimming — see Section 23.1. The widget-free control run must report zero such entries |
Hard fail |
| LCP delta vs. widget-free control page | ≤ 25 ms | Lighthouse CI comparison run over 5 iterations, median compared | Hard fail |
| Main-thread long tasks (> 50 ms) caused by the widget | 0 | Playwright PerformanceObserver({type:'longtask'}) assertion across mount, scroll, and interaction |
Hard fail |
| Network requests before first widget paint | exactly 1 (the JSON payload) | Playwright request-log assertion on a cold profile with the runtime pre-warmed, and ≤ 2 on a fully cold profile | Hard fail |
| Web fonts downloaded | 0 | Playwright request-log assertion: no request with destination === 'font' |
Hard fail |
| Time to widget first paint (throttled 4G, mid-tier mobile CPU 4× slowdown) | ≤ 400 ms after the loader executes | Lighthouse CI throttled run, custom user timing mark vouch:first-paint; 5 iterations, median compared |
Hard fail |
| Player chunk (loaded only after an explicit play) | ≤ 120 KB gzip | size-limit entry embed/player |
Hard fail |
| Payload document size, 24-item wall | ≤ 60 KB uncompressed | Vitest assertion against a seeded fixture workspace | Hard fail |
Notes that keep the numbers honest:
- The 27 KB total covers everything the widget loads before a user interaction. The video player chunk is explicitly outside it because it is only ever fetched after a deliberate play click; it carries its own 120 KB budget so the exclusion cannot become a loophole.
- Budgets are measured on the compressed transfer size produced by the production build with
the production CDN's compression settings, not on a local
gzip -9of the file. - The Lighthouse CI fixtures include a deliberately hostile host page
(
fixtures/host-hostile.html) that ships 400 KB of blocking CSS, a 2 MB hero image, and aggressive global styles, so the budgets are proven under realistic conditions rather than on an empty page. - Budgets are checked on every pull request and again on the release commit. There is no "temporarily over budget" state and no override flag.
- Sample sizes are stated, because a percentile without one is not a measurement. A row reported as a median runs 5 iterations and compares the median; a row reported as a percentile runs 21 iterations and takes the percentile over all 21, with no trimming. Section 23.1 owns this rule and states it once. Trimming is deliberately absent: discarding the highest runs before taking a p95 measures the 95th percentile of a truncated sample rather than of the real distribution, which systematically hides the tail behaviour the percentile exists to catch. The run count is the outlier control, and stacking a trim on top of it is two controls doing one job. Three runs cannot produce a p95, and two engineers using different iteration counts on identical code would otherwise get different verdicts. Section 23 states the same rule for the platform-wide budgets, and the browser, Lighthouse, and Playwright versions used for these runs are pinned in one place there so a tooling upgrade is a deliberate, reviewed change rather than a silent shift in every number in this table.
13.4 Style isolation #
13.4.1 Open shadow root #
Every instance renders into a shadow root attached to the customer's placeholder:
const root = placeholder.attachShadow({ mode: 'open', delegatesFocus: false });Open, not closed, deliberately:
- Screen readers and browser accessibility trees traverse open and closed roots alike, but automated tooling does not. axe-core, Playwright's accessibility snapshot, Lighthouse's accessibility audits, and the customer's own end-to-end tests can all enter an open root and cannot enter a closed one. Section 21 requires the widget to be auditable by the customer, not just by us.
- Closed mode provides no meaningful security benefit here: any script on the host page can
already read the payload, monkey-patch
attachShadow, or read the DOM before we attach. Closed mode buys obscurity and costs testability. - Support engineering is far cheaper when a customer can inspect the rendered markup.
delegatesFocus is false because the widget manages its own focus order explicitly and
delegatesFocus produces surprising focus jumps when the host clicks the container.
13.4.2 One adopted stylesheet per document #
Styles are compiled to a single string at build time and installed as one constructable stylesheet, created once per document and shared by every instance:
let sheet: CSSStyleSheet | null = null;
function styleSheetFor(doc: Document): CSSStyleSheet | null {
if (sheet) return sheet;
if (typeof CSSStyleSheet === 'undefined' || !('replaceSync' in CSSStyleSheet.prototype)) return null;
sheet = new CSSStyleSheet();
sheet.replaceSync(WIDGET_CSS); // one string, ~9 KB before compression, shared by all widgets
return sheet;
}
function installStyles(root: ShadowRoot) {
const s = styleSheetFor(root.ownerDocument);
if (s && 'adoptedStyleSheets' in root) {
root.adoptedStyleSheets = [s]; // zero parse cost after the first instance
} else {
const el = root.ownerDocument.createElement('style'); // iOS Safari 16.0–16.3 path
el.textContent = WIDGET_CSS;
root.appendChild(el); // inside the shadow root — never document.head
}
}One shared CSSStyleSheet object means the CSS is parsed once no matter how many widgets are
on the page, and it is the reason twelve badges cost roughly the same as one.
The fallback <style> element exists only for iOS Safari 16.0–16.3, which supports shadow
DOM but not adoptedStyleSheets. It is still inside the shadow root, so it is still scoped;
its only cost is a per-instance parse and a style-src requirement (Section 13.9).
13.4.3 Hard rules #
- The widget never appends to, modifies, or reads-and-rewrites
document.head. - The widget never registers a global stylesheet, a
@font-face, a CSS custom property on:root, or a class ondocument.documentElementordocument.body. - The widget never sets a style on any node it does not own, with exactly one exception:
the placeholder itself receives
display,min-block-size, andcontainas inline styles (Section 13.2.4). It sets nothing else on it and it never uses!important. - The widget never mutates
document.body— it does not append a portal, an overlay container, or a toast root. The popup type lives inside its own placeholder's shadow root (Section 13.6.8 covers how, and what it does when that placement cannot work). - The widget downloads zero web fonts and uses only a system stack:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji".
13.4.4 The inheritance surface, and defending against it #
Shadow DOM blocks selectors, not inheritance. A host page's body { font-family: Papyrus; line-height: 3; letter-spacing: 4px; } reaches inside our root, and so does every CSS custom
property. That is the entire attack surface for visual breakage, and it is closed explicitly.
The :host rule re-declares every inheritable property to a known value:
:host {
/* structural */
display: block;
contain: layout style;
box-sizing: border-box;
all: initial; /* resets inherited props; every structural prop below is re-set */
display: block; /* re-declared after `all: initial` — order matters */
/* explicit re-declaration of the inheritable surface */
color: var(--vch-color-text);
font-family: var(--vch-font-stack);
font-size: 16px; /* px, never rem — see below */
font-weight: 400;
font-style: normal;
font-variant: normal;
font-stretch: normal;
line-height: 1.5;
letter-spacing: normal;
word-spacing: normal;
text-align: start;
text-indent: 0;
text-transform: none;
text-shadow: none;
white-space: normal;
word-break: normal;
overflow-wrap: anywhere;
hyphens: manual;
list-style: none;
quotes: none;
cursor: auto;
visibility: visible;
direction: ltr; /* mount-time default only — see the writing-direction rule below */
writing-mode: horizontal-tb;
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
-webkit-font-smoothing: auto;
caret-color: auto;
pointer-events: auto;
isolation: isolate;
}Additional defences:
all: initialfirst, then re-declare.all: initialon:hostwipes every inherited value in one declaration, including properties that do not exist yet in some future CSS spec. Everything the widget actually needs is then re-declared immediately after. This is why the widget survives host CSS we have never seen.- Pixels, not
rem. A host that setshtml { font-size: 10px }for a legacyrem-based design system would shrink everyrem-sized widget by 37.5%. All internal sizing is inpx, derived from a base of16px * fontScalewherefontScalecomes from the widget configuration (Section 13.6.1). The one place relative units are used is inside the widget's own type scale, relative to the widget's own:hostfont-size, which we control. - Namespaced custom properties, re-declared on
:host. The widget reads only--vch-*variables, and it writes every one of them onto:hostfrom the payload configuration at mount time. Because a declaration on:hostbeats an inherited value, a host page that happens to define--vch-accentcannot influence us. Host-page overrides of--vch-*are explicitly unsupported. - Reset for inner elements. A short
.vch *reset zeroes margin, padding, and border on the widget's own subtree; because it lives inside the shadow root it cannot match anything outside. - Writing direction is resolved, not hardcoded. The
direction: ltrin the reset above is a neutral starting value that stops a host page'sdirectionfrom leaking in; it is not the final answer. At mount the runtime sets the real value —host.style.setProperty('direction', isRtl(resolvedLocale) ? 'rtl' : 'ltr')— from the widget's resolved locale, and each item's root additionally carries its ownlangfrom the payload (Section 13.8.2) together withdir="auto", so a right-to-left testimonial inside a left-to-right page lays out correctly and is announced in the correct voice, and the reverse case works too. Every internal spacing, alignment, and border declaration uses logical properties (margin-inline-start,padding-block,border-inline-end,inset-inline), never physical ones, so nothing needs mirroring by hand. This is a conformance requirement, not a nicety: hardcoding one direction fails WCAG 1.3.2 and 3.1.2 on every customer site at once. Section 13.13.5's fixture matrix therefore includes an Arabic and a Hebrew testimonial in a left-to-right host page, and an English testimonial in a right-to-left host page. - Stacking and containment.
contain: layout styleon:hoststops the widget's internal layout from participating in the host's, andisolation: isolatecreates a stacking context so noz-indexinside can climb over host chrome. ::partand::slottedare not used. The widget exposes no parts, so a host cannot style internals and then have a future release break their overrides. Theming is done through the widget configuration, which is versioned and testable.- Forced colors. Under
@media (forced-colors: active)the widget switches to system colour keywords (CanvasText,Canvas,LinkText,ButtonFace,ButtonText), removes decorative shadows, and keeps every focus indicator visible, per Section 21. - Print.
@media printrenders a flat, unstyled list of testimonials with visible author attribution and no controls.
13.4.5 Defending the other direction #
Isolation is bidirectional and the outbound direction matters just as much: nothing the
widget renders may alter the host's layout. contain: layout style prevents internal layout
from escaping, the widget uses no position: fixed outside the popup type, it never sets
overflow: hidden on an ancestor, it never focuses an element on mount, and it never
scrolls the page. Section 13.6.8 covers the popup's containing-block detection, which is the
one place where escaping the placeholder is even attempted, and where the widget declines to
render rather than fight the host.
13.5 Global footprint and the public JavaScript API #
13.5.1 Footprint #
| Surface | What the widget does |
|---|---|
| Global properties | Exactly one: window.Vouch, defined with Object.defineProperty as non-enumerable, non-configurable, non-writable. for (const k in window) never sees it, so host code that iterates globals is unaffected. |
| Prototype patching | None. The widget never touches Element.prototype, Array.prototype, fetch, History.pushState, XMLHttpRequest, or any other built-in. |
| Global event listeners | One lazily-registered passive resize listener on window, shared by every instance, registered only when at least one instance needs viewport-level breakpoints (currently only the popup type), debounced at 150 ms. Element-level width changes use a single shared ResizeObserver, which is cheaper and does not touch window. |
document-level listeners |
One DOMContentLoaded listener, removed after it fires. One visibilitychange listener for analytics flushing (Section 17.4), passive. No click, keydown, scroll, or focus listeners on document — all input handling is attached inside the shadow root. |
| Global error handling | None. The widget never registers window.onerror or an unhandledrejection handler and never overwrites an existing one. |
| Storage | Zero. No cookies, no localStorage, no sessionStorage, no IndexedDB, no Cache Storage. A CI test stubs all four with throwing proxies and asserts a full render, interaction, and analytics cycle completes (Section 25). |
| Network | cdn.vouch.app only, until a user presses play, after which Mux media hosts are contacted for playback (Section 13.9 lists them). No analytics vendor, no tag manager, no third-party script of any kind. |
| Banned APIs | eval, new Function, document.write, document.writeln, synchronous XMLHttpRequest, innerHTML, outerHTML, insertAdjacentHTML, DOMParser, Range.createContextualFragment, Element.setHTMLUnsafe, Document.parseHTMLUnsafe, and assignment to srcdoc. Enforced by an AST scan of the built bundle. This list and the one in Section 13.10.1 are the same list and must be kept identical — two lists of the same sinks at different lengths is how a sink gets missed, and the shorter one is always the one someone checks against. |
13.5.2 The public API #
type VouchEventName =
| 'ready' // a widget instance finished its first paint
| 'render' // any subsequent re-render
| 'error' // an instance failed; detail carries { widgetId, code, message }
| 'impression' // analytics impression fired (Section 17.3)
| 'interaction'
| 'click'
| 'video:start'
| 'video:progress'
| 'video:complete'
| 'popup:show'
| 'popup:dismiss';
interface VouchRenderOptions {
widgetId: string;
theme?: 'light' | 'dark' | 'auto';
limit?: number; // clamped to the configured maxItems
tag?: string; // client-side filter over delivered items
align?: 'start' | 'center' | 'end';
}
interface VouchInstance {
readonly widgetId: string;
readonly element: Element;
readonly state: 'pending' | 'ready' | 'empty' | 'unavailable';
refresh(): Promise<void>;
destroy(): void;
}
interface VouchGlobal {
readonly version: string; // e.g. "1.14"
readonly instances: readonly VouchInstance[];
render(el: Element, opts: VouchRenderOptions): Promise<VouchInstance>;
scan(root?: ParentNode): Promise<VouchInstance[]>; // manual re-scan for [data-vouch-widget]
refresh(widgetId?: string): Promise<void>; // omit the id to refresh everything
destroy(widgetIdOrElement?: string | Element): void; // omit to tear down everything
on(event: VouchEventName, handler: (detail: unknown) => void): () => void; // returns unsubscribe
off(event: VouchEventName, handler: (detail: unknown) => void): void;
}Semantics:
render(el, opts)attaches a shadow root toeland mounts. Ifelalready hosts an instance, the existing instance is updated in place rather than duplicated.refresh()bypasses the HTTP cache withcache: 'reload'and re-renders. It is rate limited internally to one call per widget per 10 seconds; excess calls resolve immediately without a network request.destroy()removes listeners, disconnects observers for that instance, empties the shadow root, releases the reservation, and drops the instance frominstances. Calling it twice is safe.on()returns an unsubscribe function so hosts do not have to keep a reference to the handler. Handlers are invoked in atry/catch; a throwing host handler never breaks the widget or other handlers.- The API is available synchronously after
e.jsexecutes. Calls made before the runtime chunk has loaded are queued and flushed in order.
13.5.3 SPA integration #
React (any bundler; Section 3.1 states the React version line):
import { useEffect, useRef } from 'react';
export function VouchWidget({ widgetId }: { widgetId: string }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
let cancelled = false;
const mount = () => {
if (cancelled) return;
window.Vouch?.render(el, { widgetId });
};
if (window.Vouch) mount();
else {
const s = document.createElement('script');
s.async = true;
s.src = 'https://cdn.vouch.app/e.js';
s.onload = mount;
document.body.appendChild(s);
}
return () => { cancelled = true; window.Vouch?.destroy(el); };
}, [widgetId]);
return <div ref={ref} data-vouch-widget={widgetId} />;
}Note the cleanup: without destroy, React's development-mode double-invocation of effects
would mount twice. The widget tolerates that (mounting an element that already hosts an
instance updates in place), but explicit cleanup is correct and is what the guide shows.
Next.js App Router (Section 3.1 states the Next.js version line):
// app/testimonials/page.tsx — server component
import Script from 'next/script';
export default function Page() {
return (
<>
<div data-vouch-widget="wgt_01JQY7K2M3N4P5Q6R7S8T9V0W1" />
<Script src="https://cdn.vouch.app/e.js" strategy="afterInteractive" />
</>
);
}strategy="afterInteractive" is the correct choice: beforeInteractive would move the
script into the document head and make it compete with hydration for no benefit, and lazy OnLoad delays the widget until after every other load event has settled. For a widget below
the fold, strategy="lazyOnload" is documented as a valid alternative.
Vue 3:
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref } from 'vue';
const el = ref<HTMLElement | null>(null);
const props = defineProps<{ widgetId: string }>();
onMounted(() => { window.Vouch?.render(el.value!, { widgetId: props.widgetId }); });
onBeforeUnmount(() => { window.Vouch?.destroy(el.value!); });
</script>
<template><div ref="el" :data-vouch-widget="widgetId" /></template>For frameworks not listed, the general rule is documented: place the placeholder, call
Vouch.render on mount, call Vouch.destroy on unmount, and never render the script tag
more than once. Hosts that do nothing at all still work, because the MutationObserver in
Section 13.2.2 catches the placeholder — the imperative API exists to make teardown precise,
not to make mounting possible.
13.6 The widget catalogue #
Six types. They share one runtime, one stylesheet, one data contract, and one accessibility implementation; they differ in layout and interaction. A seventh type is never added without proving it cannot be expressed as a preset of an existing one.
13.6.1 Shared configuration #
// These six strings are the `widget_type` Postgres enum values from Section 5, reproduced
// verbatim. Section 7.3.5 requires enum values on the wire to be exactly the database values,
// so no short form ('wall', 'quote', 'reel') exists anywhere — not in the type, not in a
// payload, not in a query parameter.
export type WidgetType =
| 'wall_of_love' | 'carousel' | 'single_quote' | 'video_reel' | 'badge' | 'popup';
export interface WidgetSharedConfig {
type: WidgetType;
name: string; // internal label, 1–80 chars, never rendered publicly
// Appearance
theme: 'light' | 'dark' | 'auto' | 'custom';
accentColor: string; // #rrggbb, validated and contrast-checked (Section 21)
surfaceColor?: string; // custom theme only
textColor?: string; // custom theme only
fontScale: 0.875 | 1 | 1.125 | 1.25;
radius: 'none' | 'sm' | 'md' | 'lg' | 'full'; // 0 / 4 / 8 / 16 / 999 px
density: 'compact' | 'comfortable';
// Content selection
selection:
| { mode: 'curated'; testimonialIds: string[] } // explicit, ordered, max 200
| { mode: 'filtered'; tagIds?: string[]; tagMatch?: 'any' | 'all';
minRating?: 1 | 2 | 3 | 4 | 5;
mediaTypes?: Array<'text' | 'video'>;
sources?: Array<'collected' | 'google' | 'g2' | 'capterra' | 'x' | 'csv'>;
featuredOnly?: boolean };
maxItems: number; // 1–200; defaults per type (see 13.6.9)
sort: 'newest' | 'oldest' | 'rating_desc' | 'featured' | 'random' | 'manual';
// Fields shown on each card
showRating: boolean;
showAvatar: boolean;
showName: boolean; // always true; false is rejected at validation (consent
// is granted for attributed publication, Section 22)
showRole: boolean;
showCompany: boolean;
showDate: boolean;
showSource: boolean; // source platform badge for imported reviews; forced
// true for imported items (Section 16 attribution rule)
// Behaviour
linkBehavior: 'none' | 'permalink' | 'showcase' | 'custom';
customLinkUrl?: string; // https only, required when linkBehavior === 'custom'
openLinksInNewTab: boolean; // default true
emptyState: 'hide' | 'message';
emptyMessage?: string; // ≤ 140 chars, plain text
// Localization
locale: string; // BCP-47; controls date formatting and built-in labels
labels?: Partial<Record<WidgetLabelKey, string>>; // per-label overrides, ≤ 60 chars each
// Platform
showBadge: boolean; // forced true on Free (Section 19); server-authoritative
allowedOrigins?: string[]; // empty/absent = any origin (Section 13.10.6)
}Validation rules for the shared configuration (Zod schemas live in packages/validation;
Section 4 owns the conventions):
| Field | Rule | Error code on violation |
|---|---|---|
name |
1–80 chars after trim | VALIDATION_FAILED (422, details[].field = "name") |
accentColor |
/^#[0-9a-f]{6}$/i; contrast ratio against the resolved surface must be ≥ 3:1 for UI use and ≥ 4.5:1 when used behind text (Section 21.5 owns the thresholds; if the standard moves, it moves there and this rule follows) |
WIDGET_ACCENT_CONTRAST_INSUFFICIENT (422) |
surfaceColor, textColor |
Required together when theme === 'custom'; body text contrast ≥ 4.5:1 |
WIDGET_THEME_INCOMPLETE (422) |
maxItems |
Integer 1–200; the effective value is additionally clamped so the payload stays ≤ 60 KB | VALIDATION_FAILED (422) |
selection.testimonialIds |
1–200 ids, all belonging to the workspace, all currently or previously published | TESTIMONIAL_NOT_FOUND (404) |
selection.minRating |
Integer 1–5 | VALIDATION_FAILED (422) |
sort: 'manual' |
Only valid with selection.mode === 'curated' |
WIDGET_SORT_REQUIRES_CURATION (422) |
customLinkUrl |
Absolute https: URL, ≤ 2048 chars |
VALIDATION_FAILED (422) |
showName: false |
Rejected outright | WIDGET_ATTRIBUTION_REQUIRED (422) |
showBadge: false |
Rejected on Free | PLAN_FEATURE_UNAVAILABLE (409 — the request is authorized, it conflicts with plan state; Section 19 owns the rule that every plan and quota refusal is a 409) |
allowedOrigins[] |
Each a valid origin (https://host[:port]), max 20 |
VALIDATION_FAILED (422) |
locale |
Valid BCP-47 tag; unknown tags fall back to en at render time rather than failing |
VALIDATION_FAILED (422) for malformed tags |
Error codes are catalogued once in Section 29.3; the envelope is defined in Section 7.3.
13.6.2 Breakpoints #
Layout responds to the container, not the viewport, because a widget in a 320 px sidebar
on a 1920 px monitor must look like a narrow widget. The runtime uses CSS container queries
inside the shadow root, with a measured-width class fallback driven by the shared
ResizeObserver for the small number of engines in the support baseline that lack container
query support.
| Bucket | Container width | Typical placement |
|---|---|---|
xs |
< 400 px | Phone, narrow sidebar |
sm |
400–639 px | Large phone, half-width column |
md |
640–899 px | Tablet, content column |
lg |
900–1199 px | Desktop content area |
xl |
≥ 1200 px | Full-width section |
13.6.3 Wall of Love #
Three layouts under one type: masonry, grid, list.
interface WallConfig extends WidgetSharedConfig {
type: 'wall_of_love';
layout: 'masonry' | 'grid' | 'list';
columns?: Partial<Record<'xs'|'sm'|'md'|'lg'|'xl', 1|2|3|4|5>>; // overrides the defaults below
gap: 'sm' | 'md' | 'lg'; // 8 / 16 / 24 px
initialItems: number; // items rendered before "Load more"; 1–48, default 12
showFilters: boolean; // tag + rating filter bar rendered inside the widget
truncateAt: number; // body characters before "Read more"; 0 = never, default 280
cardStyle: 'card' | 'plain' | 'bordered';
}Default columns per bucket: xs 1, sm 1, md 2, lg 3, xl 3 for masonry and grid;
list is always 1.
- Masonry uses CSS multi-column (
column-count+break-inside: avoid), not a JavaScript layout engine. Zero measurement passes, zero reflow loops, and DOM order equals reading order, which keeps keyboard and screen-reader traversal correct. The known trade-off — items flow top-to-bottom within a column rather than left-to-right across columns — is documented in the builder next to the layout picker, andgridis offered as the alternative for customers who need strict left-to-right order. - Grid uses
grid-template-columns: repeat(var(--vch-cols), minmax(0, 1fr))with equal-height cards and a clamped body (-webkit-line-clampvia theline-clampproperty) so rows stay even. - List is a single column of full-width rows, optimised for long testimonials; media sits
left at
mdand above, and stacks above the text atsmand below.
Interaction model: cards are not clickable by default. When linkBehavior !== 'none', the
author name becomes a link and the whole card gets a "Read full testimonial" action rendered
as a real <a>; the card itself is never a click target, because a card-wide click target
swallows text selection and confuses screen readers. "Load more" is a <button> that reveals
the next initialItems from the already-delivered payload, moves focus to the first newly
revealed card's heading, and announces "12 more testimonials shown" through the widget's
polite live region. When the delivered payload is exhausted and next.hasMore is true, the
button fetches the next payload chunk (Section 13.8.4).
The card heading that focus lands on is the author attribution, and it is marked up as
role="heading" aria-level="3" on a <div> — never a native <h3>. The distinction is
deliberate: a native heading inside the shadow root still joins the host page's document
outline, so a wall of 24 testimonials would inject 24 headings into somebody else's page
structure and wreck their screen-reader navigation. An explicit role and aria-level give the
widget an internally coherent heading structure that a user can navigate inside the region
without the widget claiming a place in the host's outline. Section 21 states the general rule
that the widget emits no headings into the host document by default; this is how it is honoured
while still giving "Load more" a meaningful focus target.
Filters, when showFilters is true, render as a row of toggle buttons for tags (with
aria-pressed) plus a rating select. Filtering is client-side over delivered items, updates
the live region with the resulting count, and never issues a network request.
13.6.4 Carousel #
interface CarouselConfig extends WidgetSharedConfig {
type: 'carousel';
slidesPerView?: Partial<Record<'xs'|'sm'|'md'|'lg'|'xl', 1|2|3>>; // default xs/sm 1, md 2, lg/xl 3
peek: boolean; // show a sliver of the next slide as an affordance; default true
autoplay: boolean; // default false
intervalMs: number; // 4000–15000, default 6000; ignored when autoplay is false
loop: boolean; // default true
showArrows: boolean; // default true
showDots: boolean; // default true
equalHeight: boolean; // default true
}- Scrolling is native: a
scroll-snap-type: x mandatorycontainer withscroll-snap-align: startchildren. There is no JavaScript animation loop, which is how the zero-long-task budget is met while dragging. - Swipe is native scrolling, and arrow buttons always exist, satisfying WCAG 2.5.7 Dragging Movements (Section 21).
- Keyboard: the scroll container is focusable (
tabindex="0",role="group",aria-roledescription="carousel",aria-labelfrom the widget name). Left/Right arrows move one slide, Home/End jump to first/last, and each slide is arole="group"witharia-roledescription="slide"andaria-label="3 of 12". - Autoplay is disabled entirely — regardless of configuration — when
matchMedia('(prefers-reduced-motion: reduce)')matches. When it does run, it pauses on hover, on focus within, when the document is hidden, and permanently after the first manual navigation. A visible pause/play control is always rendered when autoplay is on, satisfying WCAG 2.2.2. - A polite live region announces "Slide 4 of 12" on manual navigation only, never on autoplay ticks, so a screen-reader user is not spammed.
- The dots, when
showDotsis true, are real<button>elements — not decorative spans with a click handler — each with an accessible name of "Go to slide 4 of 12" andaria-current="true"on the active one. They sit in arole="group"labelled "Choose a slide". A visible "4 / 12" counter is rendered alongside them and is never replaced by the dots alone, because position communicated only by which dot is filled is position communicated only by colour and shape. Each dot's hit target is at least 24 × 24 CSS px (44 × 44 on touch) regardless of the size of the drawn dot.
13.6.5 Single Quote #
interface QuoteConfig extends WidgetSharedConfig {
type: 'single_quote';
rotate: 'none' | 'perLoad' | 'interval';
intervalMs: number; // 5000–60000, default 8000; used only when rotate === 'interval'
align: 'start' | 'center';
quoteMarks: boolean; // decorative, aria-hidden; default true
size: 'sm' | 'md' | 'lg'; // affects type scale only
}rotate: 'perLoad' picks one item deterministically from the analytics view identifier
(Section 17.2) so that all widgets on one page view are stable while different visitors see
different quotes. rotate: 'interval' cross-fades with a 200 ms transition, is disabled under
reduced motion (it becomes an instant swap), and pauses when the document is hidden. Rotation
never changes the container height: the widget reserves the height of the tallest delivered
quote at the current width.
13.6.6 Video Reel #
A preset of the Wall's media handling, not a second codebase.
interface ReelConfig extends WidgetSharedConfig {
type: 'video_reel';
thumbSize: 'sm' | 'md' | 'lg'; // 120 / 180 / 240 px tall
aspect: '16:9' | '9:16' | '1:1'; // applied uniformly; posters are letterboxed to fit
openIn: 'inline' | 'lightbox'; // default lightbox
showDuration: boolean; // default true
showTranscriptExcerpt: boolean; // one-line pull quote under each thumb; default true
}- Selection is forced to video items:
mediaTypesis implicitly['video']and text-only testimonials are excluded at payload build. - The strip is a horizontally scrolling, snap-aligned list with arrow buttons and the same keyboard model as the carousel.
openIn: 'lightbox'uses a native<dialog>element inside the shadow root, opened withshowModal(). The browser provides the focus trap, the inert backdrop, and Escape-to-close, so the widget ships no focus-trap code. On close, focus returns to the thumbnail that opened it. A<dialog>inside a shadow root renders in the top layer and therefore escapes hostoverflow: hiddenwithout the widget touchingdocument.body.openIn: 'inline'replaces the poster in place with the player, keeping the strip's height.
13.6.7 Badge / Rating Summary #
interface BadgeConfig extends WidgetSharedConfig {
type: 'badge';
layout: 'inline' | 'stacked' | 'compact';
showCount: boolean; // default true
showStars: boolean; // default true
showAvatars: boolean; // stacked avatars of up to 5 recent authors; default false
linkTo: 'showcase' | 'none'; // default showcase
}Honesty rules, enforced server-side at payload build:
- The average is computed over every published testimonial in the widget's selection that
carries a rating, never over a hand-picked subset. If the selection is
curated, the badge renders the count only and suppresses the average, because an average over cherry-picked reviews is misleading. The builder explains this next to the setting. - If fewer than three rated testimonials qualify, the average is suppressed and the badge shows the count alone with the label "3 reviews".
- The average is rendered to one decimal place, rounded half-up, and the star graphic is quantised to the nearest half star with the numeric value always present as text (never a graphic alone, per Section 21).
- The badge never claims a rating scale it does not have: imported reviews are normalised to a 1–5 integer scale at import (Section 16), and the original value is retained but not shown here.
- The imported-review source badge is never a logo alone. Wherever an item carries
source.label— Google, G2, Capterra, X, CSV — the platform name is rendered as visible text next to the mark, the mark itself isaria-hidden, and the container's accessible name is "Imported from Google". A bare glyph fails both WCAG 1.1.1 and 1.4.1, and the provenance it carries is an integrity signal (Section 22 treats it as one), so losing it to a screen-reader user or a greyscale display is a correctness problem and not only a styling one. The same rule governs the badge wherever it appears — in this widget, on a Wall of Love card, on the showcase page, and in the social card. Section 21 owns the normative colour-and-icon table this row belongs to.
The badge is the smallest widget: at compact it renders in a 220 × 44 px box and is designed
for headers and footers.
13.6.8 Popup / Slide-in #
The type most likely to annoy someone else's visitors, and therefore the type with the most restraint built in.
interface PopupConfig extends WidgetSharedConfig {
type: 'popup';
position: 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right';
delayMs: number; // 5000–120000, default 8000; values below 5000 are rejected
displayMs: number; // 6000–60000, default 12000; 0 = until dismissed
gapMs: number; // between successive popups in one page view; 15000–600000, default 45000
maxPerPageView: number; // 1–5, default 2
rotate: boolean; // show a different testimonial each time; default true
pathAllow?: string[]; // glob patterns matched against location.pathname
pathDeny?: string[]; // evaluated after pathAllow; deny wins
hideOnMobile: boolean; // default false; when true, suppressed below 640 px viewport
slideFrom: 'edge' | 'fade'; // 'fade' under reduced motion regardless
}Restraint rules — all of them non-negotiable and covered by tests:
Never in the first 5 seconds.
delayMshas a hard floor of 5000 and the timer starts after the widget's first paint, not at page load.Never more than
maxPerPageView, with at leastgapMsbetween them.Dismissal is respected. A dismissal via the close button, Escape, or a click on the testimonial hides the popup for the remainder of the page view. Because the widget stores nothing on the device (Section 17.1), "the session" means the life of the page in memory; in a single-page-application host the loader instance survives soft navigations, so the dismissal survives with it. A full page reload re-arms it, subject to
delayMsagain.Never blocking. The popup is not modal, never traps focus, never sets
inerton anything, and never covers more than a 380 × 160 px region. It is rendered withposition: fixedinside its own shadow root andpointer-eventsare confined to the card itself, so clicks anywhere else on the page pass straight through.Never covering the host's own controls, and never covering the host's focused control. Before showing, the widget hit-tests the target corner with
document.elementFromPointat four sample points inside the intended footprint. If the topmost element at any sample is not ours and has a computedpositionoffixedorsticky— a cookie bar, a chat launcher, a back-to-top button — the widget tries the opposite corner, and if that is also occupied it does not show at all for that page view and emitspopup:blockedto any registered handler. It never covers something it did not put there.A fixed-position hit test is not sufficient on its own, because the most damaging case is an element in normal flow: a user tabbing through a form in the bottom-right of the host page has the field they are typing into covered by a panel that appeared on a timer. WCAG 2.4.11 Focus Not Obscured applies to the composed page, and the widget is author content on that page, so this is our failure and we export it to every customer site at once. Therefore: on every show, and again on every
focusinwhile the popup is displayed, the widget comparesdocument.activeElement's bounding rectangle against its own intended footprint. On any intersection it defers the show — or, if already shown, moves to the opposite corner, and hides entirely if that corner also intersects — and re-checks on the nextfocusout. A Playwright fixture asserts this against a host page carrying a focusable control in each of the four corners.Never where it cannot be positioned. If any ancestor of the placeholder establishes a containing block for fixed positioning (
transform,filter,perspective,backdrop-filter,contain: paint,will-change: transform),position: fixedwould be trapped inside that ancestor. The widget detects this by walking ancestors once at mount, and falls back to rendering as an inline Single Quote in place. The installation checker (Section 13.12.5) reports this so the customer can move the placeholder to a direct child of<body>, which is what the install guide tells them to do in the first place.Host opt-out. A page carrying
<meta name="vouch-popup" content="off">, or a placeholder carryingdata-vouch-popup="off", suppresses the popup entirely. Path allow/deny lists give the same control from the dashboard.Accessible. The popup is a
role="status"region witharia-live="polite", so it is announced without stealing focus. The close button is a real<button>with an accessible name, is at least 24 × 24 CSS px (44 × 44 on touch), is the first focusable element inside, and Escape closes the popup whenever focus is inside it. The auto-hide timer pauses on hover and on focus within, satisfying WCAG 2.2.1.Reduced motion.
slideFrom: 'edge'becomes a fade, and the fade becomes an instant appearance, underprefers-reduced-motion: reduce.
13.6.9 Behaviour at zero, one, and two hundred items #
| Type | 0 items | 1 item | 200 items | Default maxItems |
|---|---|---|---|---|
wall_of_love |
Collapses to zero height (emptyState: 'hide') or renders the configured message |
Single centred card, columns collapse to 1 regardless of bucket | Renders initialItems (default 12) and reveals the rest through "Load more"; items beyond the first payload chunk are fetched from next.url; filter bar shows counts per tag |
24 |
carousel |
Collapses; arrows and dots are not rendered | One slide, arrows and dots hidden, autoplay disabled, container behaves as a static card | Dots collapse to a "4 / 200" counter above 12 slides; slides are windowed so only the visible ±2 are in the DOM | 12 |
single_quote |
Collapses | Renders it; rotation disabled | Rotation pool is capped at 50 items to bound payload size; the pool is a stable sample, not a random one per request, so the CDN cache stays effective | 20 |
video_reel |
Collapses | Single thumbnail, no arrows | Windowed strip, 8 posters in the DOM at a time, IntersectionObserver-driven; posters beyond the first 24 come from the next chunk |
24 |
badge |
Renders "No reviews yet" only when emptyState: 'message'; otherwise collapses |
Shows the count, suppresses the average (below the 3-item threshold) | Shows the true count and the average over all rated items | 200 (aggregate only) |
popup |
Never shows | Shows once, no rotation | Rotates through the first 50; never repeats within a page view | 30 |
Windowing rule: any type that can render more than 24 items keeps at most 24 item nodes in the DOM at once and recycles nodes as the user scrolls, so a 200-item wall never costs more main-thread time than a 24-item one. Recycled nodes preserve focus by restoring it to the logically equivalent element when a focused node is about to be recycled — in practice focused nodes are pinned and excluded from recycling until focus leaves.
13.7 Video in widgets #
13.7.1 Nothing loads until someone asks #
Until a user presses play, a video testimonial costs exactly one image: the poster. There is
no <video> element in the DOM, no HLS manifest request, no player script, and no preconnect
to the video host.
<button class="vch-play" aria-label="Play video testimonial from Dana Reyes, 47 seconds">
<img
src="https://cdn.vouch.app/m/pb_7Yx.../poster-640.webp"
srcset="https://cdn.vouch.app/m/pb_7Yx.../poster-320.webp 320w,
https://cdn.vouch.app/m/pb_7Yx.../poster-480.webp 480w,
https://cdn.vouch.app/m/pb_7Yx.../poster-640.webp 640w,
https://cdn.vouch.app/m/pb_7Yx.../poster-960.webp 960w,
https://cdn.vouch.app/m/pb_7Yx.../poster-1280.webp 1280w"
sizes="(max-width: 400px) 100vw, 480px"
width="640" height="360"
loading="lazy" decoding="async" alt="">
<span class="vch-play-icon" aria-hidden="true"></span>
<span class="vch-duration">0:47</span>
</button>widthandheightare always present and always match the real intrinsic ratio, and the wrapper carriesaspect-ratio: 16 / 9(or the item's own ratio), so the poster reserves its exact box before it loads. This is the single largest contributor to meeting the CLS budget.alt=""on the poster with the accessible name carried by the button avoids double announcement; the button's label names the person and the duration.- Posters are served from
cdn.vouch.app, not from the video provider, so a provider outage does not blank the widget (Section 9 owns poster generation and caching). - Formats: AVIF and WebP with a JPEG fallback, negotiated by the CDN through
Accept;Vary: Acceptis set on poster responses. - The first poster in a widget that is above the fold is rendered with
loading="eager"andfetchpriority="high"; all others are lazy. This is decided at render time from theIntersectionObserverentry, not guessed.
13.7.2 The player chunk #
On the first play click anywhere on the page:
const player = await import(`${CDN_ORIGIN}/p/${__PLAYER_HASH__}.js`);
await player.mount(container, { playbackId, captionsUrl, poster, startMuted: false, plan });- The player is fetched once per page and reused by every subsequent play.
- It is self-hosted on
cdn.vouch.app— the video provider's player is vendored into our own bundle at build time rather than loaded from a third-party origin. This keeps the host's requiredscript-srcto a single hostname (Section 13.9) and means a third-party script CDN outage cannot break playback. - Budget: ≤ 120 KB gzip, enforced by
size-limit(Section 13.3). It is outside the 27 KB pre-interaction budget by definition, and no code path can load it without a user gesture — a Playwright test asserts that a page load, a scroll to the bottom, and a 30-second idle never request it. - The chunk is content-addressed and immutable, exactly like the runtime chunk.
13.7.3 Playback behaviour #
| Situation | Behaviour |
|---|---|
| Normal play | Adaptive HLS at up to the plan's maximum resolution (Section 19). Captions on by default (Section 10 owns generation and the VTT artefact), with a visible toggle. Controls: play/pause, seek, volume, captions, fullscreen, playback speed. |
| iOS | playsinline is always set so video plays in place rather than taking over the screen. Fullscreen remains available through the control. |
| Autoplay policies | Irrelevant by construction: playback is always user-initiated, unmuted, and therefore always permitted. The widget never autoplays, never preloads, and never plays muted-on-scroll. |
| Data saver | If navigator.connection.saveData === true or effectiveType is 2g/slow-2g, the poster uses the smallest srcset candidate, the player mounts with preload="none", the initial rendition is capped at 480p, and the play button's accessible name gains "uses mobile data". |
| Reduced motion | No effect on playback (the user asked for it), but decorative poster hover animations are disabled. |
| Provider unreachable | The player mount has an 8-second budget. On timeout or manifest error it retries once against the static MP4 fallback rendition stored in object storage and served from cdn.vouch.app (720p, capped to the plan's resolution). If that also fails, the poster is restored with an inline message "Video unavailable — try again", a retry button, and the item's transcript-derived pull quote remains visible so the testimonial still communicates. A video_error analytics event is recorded (Section 17.3). |
| Captions unavailable | Playback proceeds without a caption track and the captions control is rendered disabled with the accessible name "Captions unavailable for this video". Section 10 states that video is never blocked on transcription. |
| Multiple videos | Starting one pauses any other playing inside any Vouch widget on the page. The widget never pauses a host page's own media. |
| Page hidden | Playback pauses on visibilitychange to hidden and does not auto-resume. |
13.8 The widget data API #
13.8.1 The endpoint #
GET https://cdn.vouch.app/api/v1/widgets/{widgetId}/data
GET https://cdn.vouch.app/api/v1/widgets/data?ids={id1},{id2},… (batch, max 10)| Property | Value |
|---|---|
| Authentication | None. Public. |
| CORS | Access-Control-Allow-Origin: *, Access-Control-Allow-Methods: GET, HEAD, OPTIONS, Timing-Allow-Origin: * |
| Credentials | Never accepted. Cookies on the request are ignored and are not part of the cache key. |
| Caching | Cache-Control: public, max-age=60, stale-while-revalidate=300 |
| Validators | ETag: W/"<first 16 hex of the payload sha-256>", Last-Modified |
| Compression | brotli preferred, gzip fallback; Vary: Accept-Encoding |
| Cache tags | widget:{id}, workspace:{id} (Section 13.8.6) |
| Rate limiting | Exempt from per-workspace API limits; subject to the per-IP edge limit of 600 requests per minute defined in Section 7.10 |
| Content type | application/json; charset=utf-8 |
13.8.2 Payload shape #
{
"data": {
"widget": {
"id": "wgt_01JQY7K2M3N4P5Q6R7S8T9V0W1",
"type": "wall_of_love",
"payloadVersion": 1,
"config": {
"layout": "masonry", "theme": "auto", "accentColor": "#2563eb",
"fontScale": 1, "radius": "md", "density": "comfortable",
"columns": { "xs": 1, "sm": 1, "md": 2, "lg": 3, "xl": 3 },
"gap": "md", "initialItems": 12, "showFilters": true, "truncateAt": 280,
"cardStyle": "card", "showRating": true, "showAvatar": true, "showRole": true,
"showCompany": true, "showDate": true, "showSource": true,
"linkBehavior": "permalink", "openLinksInNewTab": true,
"emptyState": "hide", "locale": "en-US"
},
"labels": {
"loadMore": "Load more", "readMore": "Read more", "filterByTag": "Filter by tag",
"allTags": "All", "playVideo": "Play video testimonial", "badge": "Powered by Vouch"
}
},
"layout": { "reservedHeight": { "xs": 640, "sm": 640, "md": 520, "lg": 460, "xl": 460 } },
"aggregate": { "ratedCount": 87, "average": 4.7, "distribution": { "5": 71, "4": 12, "3": 3, "2": 1, "1": 0 } },
"tags": [ { "id": "tag_01JQ…", "slug": "onboarding", "label": "Onboarding", "count": 14 } ],
"items": [
{
"id": "tst_01JQY8A1B2C3D4E5F6G7H8J9K0",
"kind": "video",
"rating": 5,
"lang": "en",
"bodyNodes": [
{ "t": "p", "children": [
{ "t": "text", "text": "The migration took " },
{ "t": "strong", "text": "one afternoon" },
{ "t": "text", "text": "." }
] }
],
"bodyText": "The migration took one afternoon.",
"bodyTruncated": false,
"pullQuote": "The migration took one afternoon.",
"author": {
"name": "Dana Reyes",
"role": "Head of Support",
"company": "Northwind",
"avatarUrl": "https://cdn.vouch.app/a/av_01JQ…/96.webp",
"avatarInitials": "DR"
},
"media": {
"playbackId": "pb_7YxQ2mVw1KcT",
"posterBase": "https://cdn.vouch.app/m/pb_7YxQ2mVw1KcT/poster",
"aspectRatio": "16:9",
"durationSec": 47,
"captionsUrl": "https://cdn.vouch.app/m/pb_7YxQ2mVw1KcT/captions.en.vtt",
"captionsLang": "en",
"fallbackMp4Url": "https://cdn.vouch.app/m/pb_7YxQ2mVw1KcT/720.mp4"
},
"source": { "type": "collected", "label": "Vouch", "permalink": null },
"tags": ["onboarding", "support"],
"publishedAt": "2026-07-14T09:12:44.000Z",
"permalinkUrl": "https://reviews.northwind.example/t/tst_01JQY8A1B2C3D4E5F6G7H8J9K0"
}
],
"badge": {
"show": true,
"label": "Powered by Vouch",
"href": "https://vouch.app/?ref=widget&w=wgt_01JQY7K2M3N4P5Q6R7S8T9V0W1",
"accessibleName": "Powered by Vouch — opens vouch.app in a new tab"
},
"next": { "hasMore": true, "url": "https://cdn.vouch.app/api/v1/widgets/wgt_01JQY7K2M3N4P5Q6R7S8T9V0W1/data?after=eyJrIjpbIjIwMjYtMDctMTRUMDk6MTI6NDRaIiwidHN0XzAxSlFZOEExIl19" }
},
"meta": {
"requestId": "req_01JQZB4T7N8M2K5H9P3R6S0V1X",
"generatedAt": "2026-08-19T06:00:00.000Z",
"payloadVersion": 1
}
}Field rules:
| Field | Rule |
|---|---|
bodyNodes |
The payload never carries an HTML string. Rich text travels as a small, closed token array — the exact structure the runtime consumes — so there is no string anywhere in the pipeline that could be handed to an HTML parser on a customer's origin (Section 13.10.1 states the rule and the build-time enforcement). A node is { t: 'p' | 'br' | 'strong' | 'em' | 'a', text?, href?, children? }; text is always plain text inserted with textContent, and href is always an absolute https: URL that survived payload-build validation. Any other t value, any other key, or a nesting depth above 2 is a payload-build failure, not a client-side concern. Present only for text testimonials and for video testimonials that also carry a written body. |
bodyText |
Plain-text projection, used for the truncated card view, the accessible name of "Read more", and search. |
lang |
BCP-47 language tag of this item's text, detected at submission and stored on the testimonial. Always present. The runtime sets it as the lang attribute on the item's root and pairs it with dir="auto", so an Arabic or Hebrew testimonial inside an English page is announced in the right voice and laid out in the right direction (Section 21 owns the requirement; Section 13.4.4 owns the mechanism). |
bodyTruncated |
true when the stored body exceeds 900 characters; the payload carries the first 900 characters at a word boundary plus an ellipsis, and the full text lives on the permalink page. This is the primary payload-size control. |
pullQuote |
Present when a moderator or the highlight assistant selected one (Section 12). It is always a verbatim excerpt; Section 12.1 owns that rule. |
author.avatarUrl |
Absent when no avatar exists; avatarInitials is always present so the widget can render a text avatar without a network request. |
media.* |
Present only for kind: "video". posterBase is a prefix; the widget appends -{width}.webp. |
source.permalink |
For imported reviews, the original URL on the source platform. Always rendered as a link when showSource is true (Section 16's attribution rule). |
permalinkUrl |
Built from the workspace's primary domain: the active custom domain when one exists, otherwise {workspace-slug}.vouch.app. Only a domain in the active state is eligible to be primary — a domain in the suspended state (Section 14.3) is not — so a suspension enqueues a payload rebuild and a workspace:{id} purge, and this field falls back to {workspace-slug}.vouch.app before the suspended hostname stops answering. This is the only place a custom domain appears in the payload, and it is why a domain change requires a payload rebuild but never a snippet change (Section 14.6). |
next |
Absent when the selection fits in one document. |
The badge object. This section owns its shape. It is called the badge object everywhere,
in this document and in code; there is no poweredBy object and no required field. The object
is always present in the payload, and it carries exactly four fields:
| Field | Rule |
|---|---|
badge.show |
Boolean, and server-authoritative. On Free it is true and cannot be turned off; the widget re-asserts the badge on every data refresh, so removing it from the DOM has no lasting effect (Section 19 owns which plans may set it to false). The field is named show — never required, never enabled, never visible. |
badge.label |
The visible text, from the widget's badge label key, default "Powered by Vouch". ≤ 60 characters, plain text, sanitized like every other string in the payload. |
badge.href |
Always https://vouch.app/?ref=widget&w={widgetId}. The query parameter is w, never wid. It is the only URL in the payload that points at vouch.app (Section 13.8.5 invariant 5), and it carries rel="noopener noreferrer" plus target="_blank" when the widget's openLinksInNewTab is true. It is our own link rather than respondent-authored content, so it does not take the nofollow ugc tokens that Section 13.10.1 forces onto testimonial links. |
badge.accessibleName |
Required whenever badge.show is true. The accessible name for the badge link, which differs from label because the visible text alone ("Powered by Vouch") does not tell a screen-reader user where the link goes or that it opens in a new tab. Default: "{label} — opens vouch.app in a new tab". It is rendered as the link's aria-label, is never empty, and never merely repeats label when the link opens in a new tab (Section 21 owns the requirement). |
A badge click is reported as a click event with x.linkType = 'badge' (Section 17.3).
13.8.3 Batch form #
{
"data": [ { "widget": { "id": "wgt_a", "…": "…" }, "…": "…" },
{ "widget": { "id": "wgt_b", "…": "…" }, "…": "…" } ],
"pagination": { "nextCursor": null, "hasMore": false, "limit": 2 },
"meta": { "requestId": "req_01J…", "missing": ["wgt_c"] }
}The batch response uses the collection envelope defined in Section 7.3.2. It is a fixed-size
document set rather than a query result, so hasMore is always false and nextCursor is
always null; item-level continuation happens through each widget's own next link.
Ids that are unknown, deleted, or belong to a widget whose workspace is deleted are omitted
from data and listed in meta.missing, so one bad id on a page never breaks the other
widgets. Errors are returned only when the whole request is unusable:
| Condition | Status | Code |
|---|---|---|
| More than 10 ids | 400 | WIDGET_BATCH_TOO_LARGE |
No valid id syntax in ids |
400 | WIDGET_BATCH_INVALID |
| Single-widget form, unknown id | 404 | WIDGET_NOT_FOUND |
| Single-widget form, soft-deleted widget | 410 | WIDGET_DELETED |
Origin allowlist configured and Origin not on it |
403 | WIDGET_ORIGIN_NOT_ALLOWED |
Malformed cursor in after |
400 | INVALID_CURSOR |
All of these use the error envelope in Section 7.3. Negative responses are cacheable —
404 and 410 carry Cache-Control: public, max-age=300 — so a typo in a snippet on a
high-traffic page cannot become an origin load problem.
13.8.4 Continuation #
When a widget's selection exceeds one document, the payload carries next.url containing an
opaque cursor identical in form to the one defined in Section 7.4.2: base64url of
{"k":[sortKey,id]}. The cursor is derived deterministically from the pre-rendered document,
so the continuation URL for a given widget state is stable and therefore cacheable at the edge
with the same 60-second freshness. There is no page number and no offset anywhere in this API.
13.8.5 How the payload is built #
The edge never runs a live query on the request path. A background job
(widget.payload_build, catalogued in Section 29.5) renders each widget's document set and
stores it in widget_payload_cache keyed by (widget_id, cursor), with the ETag and byte
size. The edge read is a single key lookup with a 20 ms budget.
The job runs when: the widget configuration changes; a testimonial in the widget's selection is published, unpublished, edited, retagged, or deleted; the workspace's primary domain changes; the workspace's plan changes (badge state); or nightly as a consistency sweep. On a cold miss the edge computes the document inline with a 300 ms budget and a single-flight lock per key so a cache stampede cannot reach the database more than once.
Invariants, enforced in code and asserted by tests:
- Every query on this path runs inside a transaction that has issued
SET LOCAL app.current_workspace_idfrom the widget row's workspace id, so row-level security applies exactly as it does everywhere else (Section 6 owns the tenancy model). There is no unscoped query on this path, ever. - Only testimonials in status
publishedare ever included.pending,approved,rejected,unpublished,archived, and soft-deleted rows are excluded by the query and re-checked by an assertion before serialization; a violation raises an alert and serves the previous document rather than the new one. - No respondent email address, phone number, IP address, raw consent record, internal note, moderator identity, or workspace member name is ever present in the payload. A serialization allowlist — not a denylist — governs which fields can appear.
- Only consented fields are published: name, role, company, avatar, and media appear because the required publication consent covers them (Section 22 owns consent). A testimonial whose consent has been withdrawn is removed from the document within 60 seconds of withdrawal by the purge in Section 13.8.6.
- Every URL in the payload points at
cdn.vouch.app, the workspace's verified primary domain, the source platform's own permalink for imported reviews, orvouch.appfor the badge. A URL that fails this check is dropped rather than emitted.
13.8.6 Purging #
Purge is tag-based and immediate:
| Trigger | Tags purged | Target latency |
|---|---|---|
| Testimonial published / unpublished | widget:{id} for every widget whose selection matches, plus workspace:{id} |
< 5 s |
| Testimonial body, author, tags, or rating edited | Same as above | < 5 s |
| Testimonial deleted, or consent withdrawn | workspace:{id} (the blunt instrument, deliberately) |
< 5 s, and within the 60-second withdrawal guarantee in Section 22 |
| Widget configuration saved | widget:{id} |
< 5 s |
| Custom domain activated, changed, suspended, or removed | workspace:{id} |
< 30 s |
| Plan change affecting badge state | workspace:{id} |
< 30 s |
Purges are issued from the worker after the payload rebuild commits, never before, so a purge
can never repopulate the edge with a stale document. If a purge API call fails it is retried
with backoff up to 6 times and then raises an operator alert (Section 24); the 60-second
max-age bounds the damage in the meantime.
13.9 Content Security Policy #
Whose policy governs, stated once so it is not assumed wrongly. The widget runs inside the
customer's document, so the only Content Security Policy that constrains it is the customer's
own — the one this section teaches them to write. A CSP served as a response header on
cdn.vouch.app alongside e.js constrains documents navigated to on that origin; it does not
constrain the widget when a customer's page loads the bundle as a subresource, because CSP governs
the document doing the loading, not the response being loaded. Anyone reading a restrictive policy
on our CDN responses and concluding the widget is thereby sandboxed on customer sites has
misread it, and treating that header as a control would be a false assurance on the
highest-consequence asset in the system. What actually constrains the widget on a customer's page
is three things and no others: the customer's CSP below, the integrity controls in Section 13.10.4,
and the fact that the runtime has no HTML-string sink to reach in the first place
(Section 13.10.1).
The widget is designed to run under a strict host CSP. The baseline it must satisfy without any special-casing is:
script-src 'self' https://cdn.vouch.app;
img-src 'self' https://cdn.vouch.app data:;
style-src 'self' 'unsafe-inline';
connect-src 'self' https://cdn.vouch.app;13.9.1 Directives by capability #
| Capability | Required directives | Notes |
|---|---|---|
| Loader + runtime (all types) | script-src https://cdn.vouch.app |
One host. The loader and every chunk are served from it. |
| Payload fetch, analytics beacon | connect-src https://cdn.vouch.app |
Both endpoints live on the same origin, so one entry covers them. |
| Avatars, posters, badge mark | img-src https://cdn.vouch.app data: |
data: is needed only for the 1×1 transparent placeholder used while a poster decodes; a host that omits data: sees an empty box for a few milliseconds and nothing else breaks. |
| Styles, modern browsers | nothing | Styles are installed as a constructable CSSStyleSheet applied through adoptedStyleSheets. That is a programmatic API, not an inline <style> element or a style attribute, and it is not governed by style-src in current Chromium, Firefox, and Safari. |
| Styles, iOS Safari 16.0–16.3 | style-src 'unsafe-inline' |
The <style>-element fallback (Section 13.4.2). A host that refuses 'unsafe-inline' will see unstyled content on those specific iOS versions; the builder's CSP guide states this plainly and notes that the affected population shrinks every month. |
Video playback (wall_of_love with video, video_reel, single_quote with video) |
media-src https://*.mux.com https://cdn.vouch.app blob:, connect-src https://*.mux.com, img-src https://image.mux.com |
Added to the baseline. blob: is required by adaptive streaming's use of Media Source Extensions. |
| Video captions | connect-src https://cdn.vouch.app |
The VTT file is served from our CDN, not the video host. |
| oEmbed / iframe variant (Section 13.11) | frame-src https://v.vouch.app |
Only for hosts using the iframe embed rather than the script embed. |
| Nothing else | — | The widget requires no font-src (zero web fonts), no worker-src, no object-src, no frame-src for the script embed, and no 'unsafe-eval'. |
13.9.2 Copy-paste policies #
Text-only widgets:
Content-Security-Policy:
default-src 'self';
script-src 'self' https://cdn.vouch.app;
connect-src 'self' https://cdn.vouch.app;
img-src 'self' https://cdn.vouch.app data:;
style-src 'self';Widgets containing video:
Content-Security-Policy:
default-src 'self';
script-src 'self' https://cdn.vouch.app;
connect-src 'self' https://cdn.vouch.app https://*.mux.com;
img-src 'self' https://cdn.vouch.app https://image.mux.com data:;
media-src https://cdn.vouch.app https://*.mux.com blob:;
style-src 'self';The builder generates the exact policy for the configured widget type and offers a
Content-Security-Policy-Report-Only variant so a customer can validate before enforcing. The
installation checker (Section 13.12.5) reads the customer's live CSP headers and meta tags and
names the precise missing directive rather than saying "CSP problem".
13.9.3 What the widget does under a policy it cannot satisfy #
Nothing loud. If script-src blocks e.js, no code of ours runs at all and the page is
exactly as it was. If the runtime import is blocked, the loader's catch releases the
reservation and collapses the placeholder. If the <style> fallback is blocked on old iOS,
the widget renders semantic but unstyled content rather than hiding it — visible plain text is
better than an empty region. The widget never registers a securitypolicyviolation listener,
because that is a global document listener and the footprint rules in Section 13.5.1 forbid
it; CSP diagnosis is done by the installation checker, server-side.
13.10 Security #
13.10.1 Text insertion #
- Every string that originated from a respondent is inserted with
textContent. A CI AST scan fails the build if the bundle contains any HTML-string sink, and the list is exhaustive rather than illustrative:innerHTML,outerHTML,insertAdjacentHTML,document.write,document.writeln,DOMParser,Range.createContextualFragment,setHTMLUnsafe,parseHTMLUnsafe,eval,new Function, and assignment tosrcdoc. A one-sink list is how this control fails in practice — a rule that bansinnerHTMLalone is satisfied by code that usesDOMParser— so the scan asserts the whole set, and adding a sink to the language means adding it to this list in the same change. - Rich-text bodies are sanitized server-side at write time with DOMPurify (Section 3 states the line) and sanitized again at payload-build time, so a body written before a sanitizer-rule change is re-cleaned before it is ever served.
- The allowlist is exactly
p,br,strong,em,a[href]. Every other element is unwrapped (its text is kept, its tag is dropped). Every attribute excepthrefonais stripped. - Links are forced to
rel="nofollow ugc noopener noreferrer"— the same four tokens on every respondent-authored link on every surface, always, whether or not the link opens in a new tab.ugcis not cosmetic: it tells search engines the link is user-generated content rather than the customer's own editorial endorsement, which is what it actually is.target="_blank"is added whenopenLinksInNewTabis true. hrefvalues must parse as absolutehttps:URLs.http:is upgraded tohttps:;javascript:,data:,vbscript:,file:, and relative URLs are dropped and the link is unwrapped to plain text.- The wire format is the token array, not HTML. The payload's
bodyNodesfield (Section 13.8.2) is already the parsed form: the runtime walks it and builds nodes withcreateElement+textContent, never with a parser. This is the load-bearing decision of the whole section. If the payload carried an HTML string, the runtime would have to parse it, every available way of doing so is on the forbidden-sink list above, and a stored cross-site-scripting bug would execute simultaneously on every embedding customer's origin — the highest-blast-radius outcome in the product. Shipping the array instead removes the sink rather than guarding it, so there is no path from payload to HTML parser in the widget at all, and a test asserts the runtime bundle exports no function that accepts a markup string.
13.10.2 Media and image URLs #
Every avatarUrl, posterBase, captionsUrl, and fallbackMp4Url must be on
cdn.vouch.app; anything else is dropped at payload build. The widget re-validates the origin
before setting src, so even a compromised payload cannot make a host page fetch from an
arbitrary origin.
13.10.3 What the widget will not do #
It does not read cookies, does not read or write any storage, does not read form fields, does
not observe keystrokes, does not read document.referrer, does not send the full page URL
(Section 17.3 defines exactly what is sent), does not load third-party scripts, does not
inject iframes, and does not communicate with any origin other than cdn.vouch.app and — after
an explicit play — the video host. A Playwright test with a request interceptor asserts the
complete set of origins contacted during a full lifecycle.
13.10.4 The Subresource Integrity trade-off, stated honestly #
e.js is served from a versionless URL so that a snippet pasted years ago keeps getting fixes.
A versionless URL cannot carry a stable SRI hash: the moment we ship a fix, every pasted
integrity attribute would be wrong and every widget on the internet would stop loading. We
chose permanence over SRI on the loader, and we compensate:
| Control | What it does |
|---|---|
| Tiny, behaviourally frozen loader | e.js is ≤ 5 KB and does four things (discover, batch, fetch, hand off). Its surface for hiding malicious behaviour is minimal and its diff is reviewed line by line on every release. |
| Content-addressed runtime and player | r/<hash>.js and p/<hash>.js are named by the SHA-256 of their own contents and served immutable with a one-year lifetime. The loader has the hash compiled in. Substituting the runtime requires writing to a specific content-addressed key on our CDN, and any substitution changes the bytes and is detected by the verifier below. |
| Published release manifest | Every release publishes https://cdn.vouch.app/manifest.json listing the SHA-384 of e.js and every chunk, signed with an offline Ed25519 release key whose public key is published in the developer documentation. Any customer can verify what we shipped. |
| Independent subresource monitoring | A scheduled job outside the deploy pipeline fetches e.js and every chunk from at least three geographic regions every 5 minutes, compares the SHA-384 to the signature-verified manifest, and pages on any mismatch (Section 24 owns the runbook). A CDN-level compromise is detected in minutes, not by a customer. The control only works if the expected value cannot be set by whatever published the artefact, so: the Ed25519 verification key is held outside the deploy pipeline's credential scope — the signing key is offline and the public key is compiled into the monitor and published in the developer documentation, neither of them reachable from CI. The monitor fails closed: an unverifiable signature, a missing manifest, or an unreachable key is treated exactly like a hash mismatch and pages. A compromised deploy credential can therefore change the bytes or the manifest, but it cannot make the monitor agree with itself. |
| Strict deploy pipeline | Uploads to the CDN bucket are made only by a CI identity with write access to that prefix and nothing else; object versioning and access logging are on; human write access is broken-glass only and alerts on use. |
| Optional pinned snippet | Customers whose security posture requires SRI can use https://cdn.vouch.app/v/{major}.{minor}/e.js with the published integrity hash for that release. It is immutable and therefore SRI-safe, and it does not auto-update — the customer accepts responsibility for upgrading, and the dashboard shows them when a newer release exists. The builder offers this as "Pinned (advanced)". |
13.10.5 Framing and clickjacking #
| Surface | Header | Reason |
|---|---|---|
Dashboard (app.vouch.app) |
Content-Security-Policy: frame-ancestors 'none' and X-Frame-Options: DENY |
Nothing may frame the dashboard. |
Request pages (v.vouch.app/r/…) |
frame-ancestors 'self' plus the workspace's verified custom domains |
A collection form that anyone can frame is a phishing kit. Framing is permitted only from domains the workspace has proven it controls (Section 14.2). |
| Showcase pages | frame-ancestors * |
Customers legitimately iframe their own showcase page into their site. There is no state-changing action on a showcase page, so framing is harmless. |
oEmbed iframe (v.vouch.app/embed/{widgetId}) |
frame-ancestors * |
This surface exists to be framed. |
| Widget script embed | Not applicable | The widget is not an iframe; it renders in the host's own document. |
The widget itself has no clickjacking surface: it exposes no state-changing action, no form, and no authenticated control. The worst case for an attacker framing a page containing a widget is that they trick a user into clicking through to a testimonial permalink.
13.10.6 Pages the widget does not recognise #
By default the widget does not check where it is running, and this is deliberate: a host
allowlist breaks staging environments, preview deployments, reverse proxies, and translation
services, while providing little protection because Origin is attacker-controllable outside
a browser. The data it serves is published content that is already public.
Workspaces that want the restriction anyway can set allowedOrigins. Enforcement is
server-side at the edge, not in the widget: when the list is non-empty, the edge compares
the request's Origin header and returns 403 WIDGET_ORIGIN_NOT_ALLOWED on a mismatch, and
the cache key for that widget includes Origin (with Vary: Origin) so the restriction cannot
be bypassed through a cached response. The builder warns that this reduces cache efficiency
and that it is a courtesy control rather than a security boundary.
13.11 The no-JavaScript and crawlable path #
13.11.1 <noscript> #
The builder offers an optional third line:
<noscript>
<a href="https://reviews.northwind.example/">Read our customer testimonials</a>
</noscript>The link points at the workspace's showcase page on its primary domain (Section 14.1), so users without JavaScript reach real, server-rendered content.
13.11.2 oEmbed #
GET https://v.vouch.app/api/v1/widgets/{widgetId}/oembed?url={pageUrl}&format=json&maxwidth=&maxheight={
"data": {
"type": "rich",
"version": "1.0",
"provider_name": "Vouch",
"provider_url": "https://vouch.app",
"title": "Customer testimonials — Northwind",
"width": 640,
"height": 480,
"html": "<iframe src=\"https://v.vouch.app/embed/wgt_01JQY7K2M3N4P5Q6R7S8T9V0W1\" width=\"640\" height=\"480\" style=\"border:0\" loading=\"lazy\" title=\"Customer testimonials\" allow=\"fullscreen\" referrerpolicy=\"no-referrer\"></iframe>",
"cache_age": 3600
},
"meta": { "requestId": "req_01J…" }
}The iframe target is server-rendered HTML with the same styling as the script embed. It posts
{"type":"vouch:height","widgetId":"wgt_…","height":812} to its parent on resize; the host may
listen and adjust, and if it does not, the iframe keeps the height oEmbed reported. The
postMessage target origin is * (the iframe does not know its parent), the message carries
no data beyond a height, and the parent-side helper — offered in the install guide — validates
event.origin === 'https://v.vouch.app' before acting. format=xml is supported for consumers
that require it; unknown formats return 400.
13.11.3 Server-rendered fragment #
GET https://v.vouch.app/api/v1/widgets/{widgetId}/html?maxItems=12&theme=lightReturns an HTML fragment (not a full document) with all styles inlined under a hashed class
prefix (vch-a1b2c3-), no JavaScript, no shadow DOM, and no external requests beyond images.
It is Cache-Control: public, max-age=300, stale-while-revalidate=3600 and carries the same
cache tags as the JSON payload. Hosts fetch it at build time or on their own revalidation
schedule; the widget.published webhook, which Section 18.2 defines as firing whenever a
widget's configuration changes in a way that alters the served payload, tells them when to
re-fetch. The
fragment carries no interactivity: no filters, no load-more, and video items link to the
permalink rather than playing inline.
13.11.4 The SEO answer, without spin #
Client-rendered testimonials inside a shadow root are not reliable SEO content. Google renders JavaScript and does flatten shadow DOM for indexing, so the text is often seen; other crawlers, social scrapers, and AI retrievers largely do not. Anyone who tells a customer that a JavaScript widget will win them rankings is overselling.
The recommendation, given in the builder next to the install snippet:
- If the goal is conversion — showing proof next to a call to action — use the script embed. It is the fastest, most flexible option and SEO is irrelevant to the job.
- If the goal is indexable content on your own domain, use the server-rendered fragment (Section 13.11.3) or render your own markup from the public REST API (Section 7) and cache it. That content is yours, on your domain, in your HTML.
- If the goal is a page that ranks for "«brand» reviews", use the showcase page on a custom domain (Section 14.1). It is server-rendered, has its own sitemap and structured data, and is the surface actually designed to be indexed.
- Structured data must be emitted by the host page, not by the widget: markup injected by JavaScript into a shadow root is not reliably picked up, and — more importantly — the host is the party making the claim. The builder generates a JSON-LD block the customer can paste, subject to the honesty constraints in Section 14.1.4.
13.12 The widget builder #
13.12.1 Layout #
A two-pane editor in the dashboard: configuration on the left, live preview on the right. The
preview is an <iframe> pointing at an internal fixture page that loads the real production
loader against a draft payload, so what the customer sees is the real widget, not a React
mock.
Preview controls:
| Control | Options |
|---|---|
| Viewport | 360 × 640, 768 × 1024, 1280 × 800, and a free-drag handle |
| Host theme | Light page, dark page, brand-coloured page |
| Host stress test | "Hostile CSS" toggle that applies body { font-family: Papyrus; line-height: 3; letter-spacing: 2px; }, a * { box-sizing: content-box } reset, a 2000-property utility stylesheet, and --primary/--accent custom properties, proving the isolation in Section 13.4 in front of the customer |
| Content | Real published testimonials, or a seeded sample set when the workspace has none yet |
| Item count | 0, 1, 5, 24, 200 — the same cases as Section 13.6.9 |
13.12.2 Configuration panels #
- Content — selection mode (curated or filtered), tag and rating filters, media types, sources, featured-only, max items, sort. Curated mode offers drag-to-reorder with a keyboard-operable alternative (move up / move down buttons, per Section 21).
- Layout — type-specific: layout variant, columns per breakpoint, gap, density, initial items, truncation.
- Style — theme, accent colour, custom surface/text colours, font scale, radius, card style, which fields to show.
- Behaviour — link behaviour, new-tab, empty state, plus the type-specific behaviour block (autoplay for carousel, popup restraint settings, lightbox for reel).
- Install — the snippet, platform guides, CSP guide, installation checker.
Every change is applied to the preview within 150 ms without a network round trip; saving
issues PATCH /api/v1/widgets/{id} (Section 7 owns the endpoint catalogue), triggers a payload
rebuild, and purges the widget's cache tag.
13.12.3 Contrast checking #
The accent colour input runs the contrast rules owned by Section 21 on every change:
- Contrast is computed against the resolved surface for both the light and dark themes when
theme: 'auto', because a colour that passes on white can fail on the dark surface. - Text-on-accent must reach 4.5:1; accent-as-UI-boundary must reach 3:1.
- A failure shows an inline, non-colour-only warning ("Fails contrast: 2.8:1, needs 4.5:1"), names the failing pairing, and offers a one-click "Use nearest accessible colour" that walks the colour's lightness in OKLCH until the ratio passes while preserving hue and chroma as far as possible.
- A text or focus-indicator contrast failure is a hard block. Save is disabled while any
text pair or focus indicator fails, the API rejects the write with
WIDGET_ACCENT_CONTRAST_INSUFFICIENT(422), and there is no override, no confirmation dialog, and no "save anyway" — an acknowledgement checkbox would make the rule advisory, and an advisory rule ships unreadable text onto the public web at the exact moment the product is generating an accessibility statement on the customer's behalf. Section 21 owns this enforcement posture; this section only names where it applies. - A decorative element that fails the lower 3:1 non-text threshold — a divider, a quote mark, an inactive carousel dot — is a genuinely different case and may be overridden, once, through an explicit action that records an audit event naming the widget, the pairing, and the actor. Text and focus indicators are never in that category. The override is recorded on the widget row so the accessibility report in Section 21 lists it.
13.12.4 Install guides #
Each guide gives numbered steps, the exact place to paste, a screenshot-free textual description (so it does not rot when the third-party UI changes), and the platform's known pitfall.
| Platform | Where the snippet goes | Known pitfall the guide calls out |
|---|---|---|
| Plain HTML | Anywhere in <body> |
None. |
| WordPress | A "Custom HTML" block for the placeholder; the script via the theme's footer hook or a code-snippets plugin | Many themes and security plugins strip <script> from post content; the guide tells the customer to put the script in the footer and only the div in the post. |
| Webflow | Embed element for the div; Project Settings → Custom Code → Footer for the script |
The Embed element has a 10,000-character limit and the Designer canvas does not execute scripts — the widget only appears on the published site or in Preview. |
| Shopify | theme.liquid before </body> for the script; a Custom Liquid section for the placeholder |
Some themes defer all scripts; the guide notes the widget is compatible with deferred loading. |
| Squarespace | Code Block for the div; Settings → Advanced → Code Injection → Footer for the script |
Code Blocks are not rendered in the editor preview on some templates. |
| Framer | Embed component (HTML mode) | Framer's canvas sandbox blocks the fetch; test on the published URL. |
| React | The component in Section 13.5.3 | Effect double-invocation in development; the guide's cleanup handles it. |
| Next.js | The next/script example in Section 13.5.3 |
beforeInteractive is the wrong strategy and is called out explicitly. |
| Vue / Nuxt | The component in Section 13.5.3 | Nuxt SSR must not render the script during server rendering; use client-only. |
Each guide ends with the same verification step: open the page, confirm the widget appears, and run the installation checker.
13.12.5 The installation checker #
"Test your installation" takes a URL and reports what it actually found.
Process:
- Validate the URL:
https:(orhttp:with a warning), public hostname, resolves to a public IP. Private, loopback, link-local, and cloud-metadata ranges are refused; DNS is resolved once and the connection is pinned to that address so a rebind cannot redirect us. At most 3 redirects, all re-validated. 8-second timeout, 2 MB body cap. - Fetch with
User-Agent: VouchInstallChecker/1.0 (+https://vouch.app/bot)and honourrobots.txtfor our own user agent; if disallowed, say so rather than fetching anyway. - Parse the static HTML for the script tag and placeholders.
- If neither is found in static HTML, re-run once in a headless browser (Playwright, per Section 3) with a 15-second budget, so single-page-application hosts are diagnosed correctly rather than told their snippet is missing.
- Read the response's CSP headers and any CSP
<meta>tag and evaluate them against the directives in Section 13.9 for this widget's type.
Report:
| Check | Result detail |
|---|---|
| Loader script present | Found / not found, with the exact src observed (catches typos and copies of an old URL). |
| Loader loads successfully | HTTP status and transfer size observed from the customer's page. |
| Placeholder present | Count of [data-vouch-widget] elements and the ids found. |
| Ids match | Whether each id belongs to this workspace; a foreign id is reported plainly. |
| Duplicate script tags | Count; harmless but reported, because it usually means two plugins are both installing it. |
| Rendering mode | Server-rendered HTML or JavaScript-rendered, with the implication for crawlers (Section 13.11.4). |
| CSP | Pass, or the exact missing directive, e.g. connect-src is missing https://cdn.vouch.app. |
| Popup placement | For popup widgets: whether the placeholder has a fixed-positioning-blocking ancestor (Section 13.6.8, rule 6). |
| Measured timing | Time to loader execution and to widget first paint, as observed in the headless run. |
| Measured layout shift | CLS observed for the widget region in the headless run. |
Results are stored on the widget for 30 days so support can see the last check without asking the customer to re-run it.
13.13 Versioning and rollout #
13.13.1 Artefacts #
| Artefact | URL | Cache policy | Mutable? |
|---|---|---|---|
| Loader | https://cdn.vouch.app/e.js |
public, max-age=300, stale-while-revalidate=86400 |
Yes — the URL is stable, the bytes change per release |
| Pinned loader | https://cdn.vouch.app/v/{major}.{minor}/e.js |
public, max-age=31536000, immutable |
No |
| Runtime chunk | https://cdn.vouch.app/r/{sha256-16}.js |
public, max-age=31536000, immutable |
No |
| Player chunk | https://cdn.vouch.app/p/{sha256-16}.js |
public, max-age=31536000, immutable |
No |
| Release manifest | https://cdn.vouch.app/manifest.json |
public, max-age=60 |
Yes |
Immutable chunks are never deleted. A page loaded three releases ago and still open keeps working. Storage cost is trivial; breaking a live page is not.
The loader's 300-second max-age is the deliberate control knob: it is short enough that a
rollback reaches the world in minutes and long enough that the loader is served from cache for
the overwhelming majority of requests.
13.13.2 Release process #
- Build, hash, and sign.
pnpm --filter embed buildproduces the loader, the runtime, the player, and the manifest; the manifest is signed with the offline release key. - The full budget suite (Section 13.3) runs. Any red row stops the release.
- The fixture matrix runs: 6 widget types × 5 item counts × 4 host pages (clean, hostile CSS, strict CSP, slow 4G) × 4 browsers, in Playwright.
- The compatibility replay runs (Section 13.13.5).
- Immutable chunks are uploaded first and verified readable from three regions.
e.jsis rolled out by canary: the edge serves the new loader to a percentage of requests chosen by a per-request random draw — no cookie, no identifier, and therefore no consistency requirement between requests, which is acceptable because both loaders are functionally identical from the host page's perspective and each page load picks one and stays with it.- Canary steps: 1% for 30 minutes, 10% for 30 minutes, 50% for 60 minutes, 100%. Each step is
gated on: loader JavaScript error rate, payload fetch failure rate,
first-paintp75, and the widget-visible error events reported by our own instrumentation. Any regression beyond the gate holds the rollout and pages the on-call engineer.
13.13.3 Rollback #
Rollback is repointing e.js at the previous release's bytes. It takes one command, needs no
build, and is exercised in a quarterly game day.
- Propagation: 300 seconds worst case for a fresh fetch, plus an explicit purge of the
e.jscache tag which drops it to seconds. - Already-loaded pages are unaffected because their runtime chunk is immutable and still served.
- Because chunks are never deleted, a rollback cannot 404 a chunk that some page is about to request.
- The runbook, including the exact command and the verification steps, lives in Section 24.
13.13.4 The compatibility promise #
We never break a pasted snippet. There is no exception, no deprecation window that ends in breakage, and no "customers on the old snippet should migrate".
Concretely:
| Surface | Promise |
|---|---|
| Snippet syntax | The data-vouch and data-vouch-widget attributes and the e.js URL are permanent. |
| Optional attributes | Once shipped, never removed. An attribute we stop honouring becomes a documented no-op. |
window.Vouch API |
Additive only. A method is never removed and never changes signature; new parameters are optional. |
| Payload | payloadVersion increments only for additive changes. Fields are added, never removed. A deprecated field keeps being emitted for at least 12 months after deprecation is announced, and in practice keeps being emitted indefinitely if any runtime in the wild still reads it. |
| Runtime tolerance | The runtime ignores unknown payload fields and renders any payloadVersion greater than or equal to the one it was built against. A payload from the future never breaks an older runtime. |
| Loader/runtime pairing | A loader always requests a runtime hash it was built with, so a loader and runtime are never mismatched. |
| Browser support | The baseline may move forward, but a browser dropped from the baseline gets the fallback path (Section 13.11), never a broken widget. |
13.13.5 The compatibility replay test #
CI keeps an archive of every snippet variant and every payload version ever shipped. On each release it replays them:
- Every historical snippet form is loaded against the new loader in a real browser, and the widget must render.
- Every historical payload version is served to the new runtime, and the widget must render.
- The current payload is served to every runtime released in the last 12 months, and each must render.
The same replay run carries the writing-direction fixtures, because a direction bug is
invisible to every test that uses English content: an Arabic testimonial and a Hebrew
testimonial rendered in a left-to-right host page, and an English testimonial rendered in a
host page whose <html> carries dir="rtl". Each asserts that the item root's lang matches
the payload, that dir="auto" is present, and that no physical margin or padding property
appears in the computed style of any widget element (Section 13.4.4 states the rule).
Any failure is a hard build failure. This test is the mechanical reason the promise in Section 13.13.4 stays true after the people who made it have moved on.
14. Public Showcase Pages & Custom Domains #
Every workspace gets a public page that lists its published testimonials. It is server-rendered, fast, crawlable, and can live on the customer's own domain. This is the surface that ranks in search, that gets linked from an email signature, and that a salesperson pastes into a deal thread.
Custom domains are the feature most competitors lose people in — not because the technology is hard, but because the status UI is a spinner and the failure messages are useless. Section 14.3 exists to fix that.
14.1 The showcase page #
14.1.1 URLs #
| Path | Purpose |
|---|---|
/ |
The showcase: hero, filters, and the testimonial grid |
/t/{testimonialId} |
Single-testimonial permalink page |
/tag/{tagSlug} |
Pre-filtered showcase for one tag |
/video |
Pre-filtered showcase for video testimonials only |
/rss.xml |
RSS 2.0 feed of the 50 most recently published testimonials |
/sitemap.xml |
Sitemap covering /, every tag page, and every permalink |
/robots.txt |
Robots policy (Section 14.1.5) |
/oembed |
oEmbed discovery endpoint for the showcase page itself |
These paths appear at exactly two kinds of origin:
- Shared subdomain:
https://{workspace-slug}.vouch.app/…— always available, on every plan including Free. - Custom domain:
https://reviews.northwind.example/…— the same paths at the root of the customer's domain, once the domain isactive(Section 14.2).
https://v.vouch.app/s/{workspace-slug} is a permanent stable alias that issues a 301 to
whichever of the two is currently primary. It exists so that a link stored in an external
system keeps working through a slug rename or a domain change.
Workspace slugs are validated as lowercase [a-z0-9-]{3,48} (Section 5 owns the slug rules),
are unique platform-wide, and are checked against a reserved list (app, api, cdn, v,
edge, www, mail, admin, status, docs, blog, help, support, plus every path
segment above). Renaming a slug keeps the old slug reserved to that workspace forever and
301s it to the new one, so links never rot.
14.1.2 Layout #
| Region | Content |
|---|---|
| Header | Workspace logo (or wordmark fallback), workspace name, an optional link back to the customer's own site, and a "Leave a testimonial" call to action that links to the workspace's default request page when one is published |
| Hero | Headline (default: "What our customers say", overridable), optional subhead, and the aggregate rating summary when it qualifies under Section 13.6.7's honesty rules |
| Filter bar | Tag toggles with counts, rating filter, media-type filter (All / Video / Written), and a sort control (Newest, Highest rated, Featured). Every control is a real form control inside a <form method="get"> so the page works without JavaScript |
| Grid | Testimonial cards, 1 column below 640 px, 2 to 900 px, 3 above; masonry via CSS multi-column with a grid alternative in the settings |
| Load more | A real <a href="?after=…"> styled as a button, progressively enhanced into an in-place fetch |
| Footer | "Powered by Vouch" (mandatory on Free, optional above it — Section 19 owns the rule), a link to the privacy notice, and the workspace's own footer links when configured |
Configuration lives on the workspace and is edited in the dashboard: headline, subhead, logo, accent colour (contrast-checked exactly as in Section 13.12.3), theme (light / dark / auto), default sort, whether filters are shown, whether the request-page call to action appears, and visibility.
Visibility has three values:
| Value | Behaviour |
|---|---|
public |
Indexable, in the sitemap, in RSS. The default. |
unlisted |
Served normally but carries X-Robots-Tag: noindex, nofollow and a <meta name="robots" content="noindex">, is excluded from the sitemap, and is excluded from robots.txt's allowances. Link-shareable. |
private |
Requires a shared secret in the URL (?k={32-byte token}, stored hashed) or an authenticated dashboard session. Without it, 404 — never 403, so the page's existence is not confirmed. |
14.1.3 Pagination: "Load more", not infinite scroll #
The showcase uses an explicit Load more control. Infinite scroll is rejected, and the reasons are worth stating because someone will ask for it:
- Keyboard and screen-reader operability. Infinite scroll requires a scroll event to reveal content, which is not a keyboard action; the accessible workarounds are worse than a button.
- The footer becomes unreachable. Every infinite-scroll page fights its own footer, and the footer here carries the privacy link and the customer's own navigation.
- The back button breaks. Returning from a permalink to position 87 of an infinitely scrolled list requires either restoring a huge DOM or losing the user's place.
- Shareable, crawlable URLs.
?after={cursor}is a real URL. A crawler follows it; a user can bookmark it. An infinite scroll has one URL for everything. - Predictable layout shift. Content appended on scroll near the viewport bottom is a classic CLS source; content appended after a deliberate click is not scored against us in the same way and, more importantly, is expected by the user.
Mechanics: the cursor is the opaque {"k":[sortKey,id]} form defined in Section 7.4.2 — the same
model as everywhere else in the product, with no page numbers and no offsets. Default page size
is 24, maximum 48. On click, the new items are fetched, appended, announced through a polite
live region ("24 more testimonials shown, 72 of 187"), focus moves to the first new card's
heading, and the URL is updated with history.replaceState so a reload restores the same view.
With JavaScript disabled, the anchor navigates normally and the server renders the next page.
14.1.4 SEO and structured data #
Rendering: Next.js App Router server components (Section 3 states the framework line), streamed,
with the HTML cached at the edge for 60 seconds with a 300-second stale-while-revalidate and
purged by the workspace:{id} tag on any publish, unpublish, edit, or domain change — the same
purge mechanism as Section 13.8.6.
The edge cache key is {scheme}:{host}:{path}:{normalized-query} — never the workspace id,
never the path alone. This is stated because one workspace's showcase is reachable at
{slug}.vouch.app and at one or more custom domains simultaneously, all served from one shared
edge: a key that omits Host lets an entry populated on one hostname be served on another, which
across tenants is a cross-tenant content leak rather than a caching curiosity. Tag-based purging
does not help — purging is about invalidating the right entries, not about keeping them apart.
Vary is not used as a substitute, because Vary asks the cache to keep variants under one key
and the requirement here is separate keys. A CI test requests the same path on two hostnames and
asserts two distinct cache entries with distinct bodies. Section 26 owns the edge configuration
that implements this.
| Element | Rule |
|---|---|
<title> |
Showcase: "{Workspace} reviews & testimonials". Permalink: "{Author name} on {Workspace}". Tag page: "{Workspace} testimonials — {Tag}". Truncated at 60 characters at a word boundary. |
| Meta description | Showcase: the configured subhead, or a generated sentence naming the count and average when it qualifies. Permalink: the first 155 characters of the testimonial body or pull quote, verbatim. |
| Canonical | Always the primary origin: the active custom domain when one exists, otherwise the shared subdomain. The non-primary origin serves the same content with a canonical pointing at the primary, so duplicate content never accumulates. |
| Open Graph / Twitter | og:title, og:description, og:url, og:type=article on permalinks and website on the showcase, og:image pointing at the generated social card for that testimonial or workspace (Section 15 owns generation), twitter:card=summary_large_image. |
| Headings | One <h1> per page. On the showcase it is the hero headline; on a permalink it is the author's name and company. |
| Sitemap | /sitemap.xml lists /, every tag page, /video, and every published permalink with lastmod from updated_at. Regenerated on publish, capped at 50,000 URLs per file with an index file beyond that. unlisted and private workspaces emit an empty sitemap. |
| Robots | Section 14.1.5. |
| Pagination hints | <link rel="next"> is emitted on paged views. prev/next are advisory only; every page is independently indexable. |
| Language | <html lang> from the workspace's configured locale. |
Structured data, with the honesty constraints spelled out. Review markup is heavily abused, and the rules below exist so a customer cannot use this product to mislead a search engine — which would ultimately be a penalty on their own domain.
| Markup | When it is emitted | When it is suppressed |
|---|---|---|
Organization |
Always on the showcase page, describing the workspace | Never suppressed |
Review |
On a permalink page, when the testimonial has a named author and a rating, and the required publication consent is on file (Section 22) | When the testimonial has no rating, when the author is anonymous, or when consent has been withdrawn |
AggregateRating |
On the showcase root only, when at least 3 published testimonials carry a rating and the aggregate is computed over every published rated testimonial in the workspace | Suppressed on tag pages, on any filtered view, and whenever the showcase is configured to display a curated subset — an average over a hand-picked set is not an aggregate rating |
itemReviewed |
The customer's own Organization, or a Product, Service, or SoftwareApplication whose name and URL the customer supplies in settings |
Never guessed. If the customer supplies nothing, itemReviewed is the Organization |
| Imported reviews | Review markup on their permalink pages carries publisher naming the source platform and links the original permalink |
Imported reviews are excluded from AggregateRating entirely, because the source platform is the publisher of record for those ratings and re-aggregating them as first-party is misleading |
The dashboard states plainly, next to the setting: search engines generally do not show rich review results for reviews a business collects and publishes about itself, and enabling this markup is about clarity for consumers and other consumers of structured data, not about guaranteed stars in search results. No claim to the contrary appears anywhere in the product.
Example emitted on a permalink page:
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Review",
"itemReviewed": { "@type": "Organization", "name": "Northwind", "url": "https://northwind.example" },
"author": { "@type": "Person", "name": "Dana Reyes" },
"reviewRating": { "@type": "Rating", "ratingValue": 5, "bestRating": 5, "worstRating": 1 },
"reviewBody": "The migration took one afternoon.",
"datePublished": "2026-07-14",
"publisher": { "@type": "Organization", "name": "Northwind" }
}
</script>14.1.5 robots.txt #
User-agent: *
Allow: /
Disallow: /preview/
Disallow: /*?k=
Sitemap: https://reviews.northwind.example/sitemap.xmlunlisted and private workspaces instead serve User-agent: * / Disallow: / and also send
X-Robots-Tag: noindex, nofollow on every response, because robots.txt alone does not remove
an already-indexed URL.
14.1.6 RSS #
/rss.xml is RSS 2.0, the 50 most recently published testimonials, Cache-Control: public, max-age=300. Each <item> carries <title> (author and company), <link> and <guid isPermaLink="true"> (the permalink on the primary domain), <pubDate> from published_at,
<description> (the plain-text body, escaped), and for video testimonials an <enclosure>
pointing at the fallback MP4 rendition with its length and type. The feed is auto-discoverable
through <link rel="alternate" type="application/rss+xml"> in the page head.
14.1.7 Performance and accessibility targets #
| Metric | Target | Verified by |
|---|---|---|
| LCP, throttled 4G, mid-tier mobile | ≤ 1.8 s | Lighthouse CI, hard fail |
| CLS | ≤ 0.02 | Lighthouse CI, hard fail |
| INP | ≤ 150 ms | Lighthouse CI, hard fail |
| Total JS, gzip | ≤ 90 KB | size-limit, hard fail |
| Web fonts | 0 by default; 1 variable font maximum if the customer opts in, font-display: swap, preloaded, self-hosted |
request-log assertion |
| Images | AVIF/WebP with responsive srcset, explicit dimensions, lazy below the fold, fetchpriority="high" on the first card's media |
Lighthouse CI audits |
| Accessibility | Zero axe-core violations at "serious" or "critical" (Section 21 owns the standard) | axe-core in CI on the showcase, a tag page, and a permalink |
Accessibility specifics beyond the general rules in Section 21: a skip link to the grid,
<main>/<nav>/<footer> landmarks, filter changes announced through a polite live region
with the resulting count, the sort control as a labelled <select> inside a form with a visible
submit for the no-JavaScript path, and video players that follow the same caption-on-by-default
rules as the widget (Section 13.7.3).
The showcase page sets no cookies and uses the same cookie-free measurement as the widget (Section 17), so it needs no consent banner either.
14.2 Custom domains: the flow #
The whole flow is four steps, and the product's job is to make step three boring.
Add domain → Show exact DNS records → Live verification → TLS issuance → Active14.2.1 Adding a domain #
POST /api/v1/custom-domains with { "hostname": "reviews.northwind.example" }. Validation,
in order:
| Rule | Failure |
|---|---|
Trim, lowercase, strip a trailing dot, strip a leading http(s):// and any path if pasted as a URL |
— |
| Convert Unicode to punycode (IDNA 2008, UTS-46 transitional processing off) and store the A-label; display the U-label | DOMAIN_INVALID (422) |
Total length ≤ 253 characters, each label 1–63 characters, labels match [a-z0-9]([a-z0-9-]*[a-z0-9])? |
DOMAIN_INVALID (422) |
| At least two labels; not a bare public suffix (checked against the Public Suffix List, refreshed weekly) | DOMAIN_IS_PUBLIC_SUFFIX (422) |
Not an IP address, not localhost, not a .local/.internal/.test/.invalid name |
DOMAIN_INVALID (422) |
Not vouch.app or any subdomain of it |
DOMAIN_RESERVED (422) |
Not already claimed by another workspace in any state — pending_dns, dns_detected, verifying, issuing_tls, active, suspended, or failed. Every one of the seven holds the claim: pending_dns is the routine state for hours while a customer edits DNS, failed rows persist indefinitely so the customer can retry, and a suspended domain is still held by its workspace with its records in place. A hostname becomes claimable again only when the holding row is soft-deleted — on removal (Section 14.5.4) or on the 7-day release after a lost ownership proof (Section 14.3.1) |
DOMAIN_ALREADY_CLAIMED (409) |
| Workspace plan permits another domain (Section 14.7) | PLAN_FEATURE_UNAVAILABLE (409) when the plan has no custom-domain entitlement at all, or CUSTOM_DOMAIN_LIMIT_REACHED (409) when it does but the allowance is used up. Both are 409, not 403: the caller is authorized and the request conflicts with plan state (Section 19 owns the rule). |
On success the row is created in custom_domains (Section 5 owns the schema) with a freshly
generated 32-byte challenge value rendered as 64 lowercase hex characters, status = pending_dns, and kind derived as apex when the hostname equals its registrable domain and
subdomain otherwise. The response is 201 with the record set to create.
The claim is enforced by the database, not by the application check above. The validation
table reads a state and then writes a row, and two concurrent POST /api/v1/custom-domains
calls for the same hostname both pass the read before either writes — a plain
time-of-check-to-time-of-use race that ends with two workspaces holding claims on one hostname
and, after verification, one workspace's content served on a name the other is mid-claim on.
Therefore custom_domains carries
CREATE UNIQUE INDEX custom_domains_hostname_uk ON custom_domains (hostname) WHERE deleted_at IS NULL;
(Section 5 owns the DDL). The application check exists to produce a good error message; the index
is what makes the guarantee true. A unique-violation from the index is translated to the same
DOMAIN_ALREADY_CLAIMED (409), so the race and the ordinary case are indistinguishable to the
caller.
Adding an apex also offers, in one click, to add www.{apex} as a redirect domain, because a
customer who sets up northwind.example and not www.northwind.example will get a support
ticket within a week.
14.2.2 The record set #
Subdomain (reviews.northwind.example):
| Type | Name | Value | TTL | Purpose |
|---|---|---|---|---|
CNAME |
reviews |
edge.vouch.app |
300 | Routes traffic to the edge |
TXT |
_vouch-challenge.reviews |
vouch-verify=3f9c… (64 hex) |
300 | Proves control of the name |
Apex (northwind.example), preferred form where the DNS provider supports it:
| Type | Name | Value | TTL | Purpose |
|---|---|---|---|---|
ALIAS / ANAME / CNAME flattening |
@ |
edge.vouch.app |
300 | Routes traffic, follows our address changes automatically |
TXT |
_vouch-challenge |
vouch-verify=3f9c… |
300 | Ownership proof |
Apex, fallback for providers with no ALIAS support:
| Type | Name | Value | TTL |
|---|---|---|---|
A |
@ |
first documented anycast IPv4 address | 300 |
A |
@ |
second documented anycast IPv4 address | 300 |
AAAA |
@ |
first documented anycast IPv6 address | 300 |
AAAA |
@ |
second documented anycast IPv6 address | 300 |
TXT |
_vouch-challenge |
vouch-verify=3f9c… |
300 |
The anycast addresses are supplied by the edge provider chosen at deployment (Section 26 owns
that decision) and are read at runtime from the DOMAIN_APEX_A_RECORDS / DOMAIN_APEX_AAAA_RECORDS and DOMAIN_APEX_AAAA_RECORDS
environment variables catalogued in Section 29.2. The dashboard renders the live values rather
than hard-coded ones, so a provider change is a configuration change and never a documentation
error. The dashboard also warns, next to the A-record option, that A records must be updated by
hand if the addresses ever change, and that ALIAS is preferred for exactly that reason.
The UI presents the records as a table with a copy button per field, a "copy all as zone file" action, and provider-specific notes for the twelve most common registrars and DNS hosts (Cloudflare, Route 53, Google Domains successors, GoDaddy, Namecheap, Squarespace Domains, Porkbun, Hover, DNSimple, Gandi, OVH, and Azure DNS), each naming the exact field labels that provider uses — because "Name" in one UI is "Host" in another and "@" in a third.
14.2.3 How verification actually checks #
Verification does not ask a caching resolver. It resolves the zone's authoritative nameservers and queries them directly, so the answer reflects what the customer's DNS provider is actually publishing rather than what some resolver cached ten minutes ago.
- Walk the name upward to find the closest zone cut and fetch its
NSrecords. - Query each authoritative nameserver directly (UDP with TCP fallback, 3-second timeout, 2 retries, all servers queried and their answers compared).
- Validate DNSSEC where the zone is signed; a bogus signature is reported as a distinct failure rather than as "record not found".
- Record the full observed answer set — record type, name, value, TTL, which nameserver
answered, and the query timestamp — in
domain_verification_checks. This is what the UI shows the customer, verbatim. - Compare against the required set and produce a structured diff.
Verification succeeds when the TXT challenge matches exactly and the routing record
(CNAME, ALIAS, or the A/AAAA set) points at us. The challenge is checked on every subsequent
health check too, not just at setup, so a domain that is re-pointed elsewhere is noticed.
14.3 The seven states #
Exactly seven — the seven values of the domain_state enum in Section 5, and no others. Every
state has copy, an icon, an accessible text label that carries the meaning on its own (never
colour alone — Section 21), and one obvious next action.
| State | Label (visible text) | Icon | What it means | Next action | Typical duration |
|---|---|---|---|---|---|
pending_dns |
"Waiting for DNS records" | Clock | We have looked, and the records are not published yet | "I've added the records — check now" (forces an immediate check) | 0–30 minutes, depending on the customer |
dns_detected |
"DNS records found — verifying" | Magnifier | At least one required record is visible at the authoritative nameservers | None; automatic | Under 1 minute |
verifying |
"Verifying ownership" | Shield | The challenge record is being matched | None; automatic | Under 1 minute |
issuing_tls |
"Issuing the security certificate" | Lock outline | An ACME order is in flight | None; automatic | 15 seconds–2 minutes |
active |
"Active" | Filled check | The domain is serving | "Visit site", "Make primary", "Remove" | — |
suspended |
"Paused — your plan no longer covers this domain" | Pause | The domain is no longer serving as itself: requests 301 to the equivalent path on the shared subdomain, and the domain is not eligible to be the workspace's primary domain |
"Upgrade your plan, or add a domain add-on" | Until the plan changes |
failed |
"Needs attention" | Alert triangle | A specific, named problem is blocking progress | The specific fix, named in the copy (Section 14.3.2) | Until fixed |
suspended is the one state no customer action can enter or leave directly. It is entered
only by the billing downgrade path in Section 19 — at the end of the grace period described in
Section 14.7.1, when a workspace holds more domains than its new plan allows — and it is left
only when the plan regains the entitlement, at which point Section 19 restores the domain to
active without re-verification if the challenge record still matches. There is no API call, no
dashboard button, and no DNS change that moves a domain into or out of suspended; the domain
detail view therefore renders the state read-only, with the upgrade action as the only control.
A suspended domain keeps its DNS records, its row, and its certificate renewals for the window
stated in Section 14.7.1, which is what makes re-activation a single click.
Rules for the state UI:
- The state name is always rendered as text. The icon is decorative (
aria-hidden) and the colour is redundant with the text. - The card always shows: the state, the last check time as both relative and absolute ("34 seconds ago · 2026-08-19 06:12:07 UTC"), the records observed at the authoritative nameservers, and a plain-English diff against the records required.
- A live region announces state transitions politely, once per transition.
- The "check now" button is available in every state except
activeandsuspended, is rate-limited to one manual check per 10 seconds per domain, and never hides the automatic polling. Insuspendedthe button is absent rather than disabled, because there is nothing a DNS check could change — the blocker is the plan, and the upgrade action takes its place.
14.3.1 Polling cadence #
Client-side, while the domain detail view is open:
| Elapsed since the domain was added or "check now" was pressed | Interval |
|---|---|
| 0–2 minutes | every 5 seconds |
| 2–10 minutes | every 15 seconds |
| 10 minutes–24 hours | every 60 seconds |
| Beyond 24 hours | client polling stops; the view shows "We're still checking in the background" and a manual check button |
Polling pauses when the tab is hidden and resumes with an immediate check when it becomes visible, so an unattended tab does not generate traffic for hours.
Server-side, independent of any open browser: a BullMQ repeatable job (domain.verify,
catalogued in Section 29.5) checks every 5 minutes for 72 hours from the moment the domain was
added. After 72 hours in a non-active state the domain moves to failed with the reason
"Setup timed out", the Owner and Admins are emailed, and the row remains so the customer can
retry without re-adding it. Retrying resets the 72-hour window and issues a fresh challenge
value.
Once a domain is active, a lighter health check (domain.health) runs every 6 hours, and it
checks two independent things whose failures mean different things and get different
treatment:
| What failed after three consecutive checks | What it means | What we do |
|---|---|---|
The routing record no longer points at us, but the _vouch-challenge TXT record is still present and correct |
The customer still demonstrably controls the name; they have changed or broken their routing record, possibly by accident | Move to failed, email the Owner and Admins, and keep serving. Cached DNS may still be sending real visitors to us, and cutting them off would be worse than the alternative. The hostname stays claimed by this workspace. |
The _vouch-challenge TXT record is missing or no longer matches |
The proof of control is gone — the customer removed it, transferred the domain, or let the registration lapse and somebody else now runs the zone | Move to failed, stop serving the hostname, email the Owner and Admins daily, and release the hostname for reclaim after 7 days, so it can be added by whoever genuinely controls it next. |
That second row is the reason the challenge is re-checked forever and not only at setup. A
domain whose registration lapses can be bought by anyone, and a CNAME left pointing at our
edge would otherwise let the new registrant serve the previous customer's testimonials — or,
worse, keep a verified claim alive under a brand they now control. Continuing to serve a
hostname whose ownership proof has disappeared is the one failure mode in this section that
ends in phishing under somebody else's name, so it is the one case where we stop serving rather
than degrade gracefully. The 7-day release delay exists so an accidental TXT deletion can be
undone by the real owner before anyone else can claim it.
14.3.2 The eight failure modes, with the exact copy #
Every failure carries a machine-readable failureCode on the domain row, a human sentence, and
a fix. The copy is written to be read by someone who does not know what a CNAME is.
| # | failureCode |
Detection | Copy shown |
|---|---|---|---|
| 1 | DNS_RECORD_MISMATCH |
The routing record exists but its value differs from the required value (after normalising case and trailing dots) | "The record is there, but the value doesn't match." We found edge.vouch.app.cdn.northwind.example where we expected edge.vouch.app. Some DNS providers add your domain to the end of a value automatically — try entering the value with a trailing dot: edge.vouch.app. |
| 2 | CNAME_AT_APEX |
A CNAME was found at the zone apex, or the apex has a CNAME alongside SOA/NS |
"A CNAME can't be used on a root domain." DNS doesn't allow a CNAME at northwind.example itself. Use your provider's ALIAS, ANAME, or CNAME-flattening record instead, or use the A and AAAA records below. We've shown both options. |
| 3 | PROXIED_BY_ANOTHER_CDN |
The routing record resolves, but the answering addresses belong to a known proxy provider and an HTTP probe to the hostname classifies the responder as a third-party proxy rather than our edge | "Another service is sitting in front of this domain." reviews.northwind.example is currently being proxied by another CDN, so requests never reach us. In your DNS provider, turn proxying off for this record (Cloudflare calls this the grey cloud / "DNS only"), then check again. |
| 4 | CONFLICTING_RECORD |
An A, AAAA, or second CNAME exists at the same name as the required record, or a CNAME coexists with any other record type at that name |
"There's already a record with this name." We found an existing A record on reviews pointing to 203.0.113.7. A name can't have both. Delete or rename the existing record, then check again. |
| 5 | CAA_FORBIDS_ISSUANCE |
A CAA record exists at or above the name and does not authorise our certificate authorities |
"A CAA record is blocking the certificate." Your domain has a CAA record that only allows certificates from digicert.com. Add 0 issue "letsencrypt.org" (and 0 issue "sectigo.com" if you'd like our backup authority to work too), then check again. |
| 6 | PROPAGATION_IN_FLIGHT |
Authoritative nameservers disagree with one another, or the record is present with a TTL longer than 3600 seconds and was observed changing within the last hour | "Your DNS change is still spreading." Two of your four nameservers have the new record and two don't yet. This usually settles within a few minutes. We'll keep checking — no action needed. |
| 7 | NAMESERVERS_NOT_PUBLISHING |
The zone's nameservers return NXDOMAIN or REFUSED, or the registrar's delegation points at nameservers that do not answer for the zone |
"Your domain's DNS isn't answering yet." The nameservers listed at your registrar (ns1.example-dns.net) aren't responding for northwind.example. If you changed nameservers recently, this can take a few hours. If you haven't, check that the domain is active at your registrar. |
| 8 | DOMAIN_ALREADY_CLAIMED |
Another workspace holds this hostname in a non-released state | "This domain is already connected to another Vouch workspace." For security we only let one workspace use a domain at a time. If this is your domain, remove it from the other workspace, or contact support and we'll help you verify ownership and move it. |
Three additional failure codes exist for completeness and use the same presentation:
DNSSEC_VALIDATION_FAILED ("Your domain's DNSSEC signatures aren't validating — your DNS
provider can help"), TLS_ISSUANCE_FAILED (Section 14.4), and SETUP_TIMED_OUT (72 hours
elapsed).
The diff panel below the copy always shows two columns, "Required" and "Found at your
nameservers", row-aligned by record name and type, with differences marked by text (missing,
different value, unexpected extra record) as well as by styling.
The HTTP probe is a server-side request to a hostname the customer chose, and is treated as
one. A customer controls their own DNS, so nothing stops them pointing a hostname they have
added at 169.254.169.254, at an RFC 1918 address, or at any internal service, and then pressing
"check now" to make our backend fetch it. Two rules close that:
- The probe goes through the shared
safe-fetchclient inpackages/core/http, under the full outbound rule set in Section 22 — DNS resolved once and the connection pinned to the resolved address so a rebind cannot redirect it, private/loopback/link-local/multicast and cloud-metadata ranges refused after resolution, redirects not followed, a hard timeout, a response byte cap, and egress from the isolated outbound path. The same applies to the TLS pre-flight probe in Section 14.4.4. Section 22 lists this probe explicitly among the customer-supplied-URL call sites; a probe that is not in that list is a defect in both sections. - No upstream response detail is ever reflected back to the customer. The probe returns a
value from the closed enum
{ ours, known_cdn, unknown, unreachable }and nothing else. NoServerheader, noViaheader, no body, no status code, no timing. Reflecting an upstream header would turn a blind request into a readable oracle against internal services — the difference between a bug and an exploit — so the classification is computed server-side and only the enum value crosses back into the dashboard and into the copy above.
14.4 TLS #
14.4.1 Issuance #
- ACME, with Let's Encrypt as the primary certificate authority and ZeroSSL as the fallback. An external account binding is configured for ZeroSSL; both accounts are provisioned per environment.
- HTTP-01 is the default challenge for both subdomains and apexes, because by the time we
order, traffic already routes to the edge and we can answer
/.well-known/acme-challenge/{token}directly. - DNS-01 is the fallback, used automatically when HTTP-01 fails twice for the same domain
(typically because the customer has a redirect or another service still answering on port 80),
and always for wildcard certificates. DNS-01 requires the customer to add a
_acme-challengeCNAMEpointing at a per-domain delegated name we control ({domainId}.acme.vouch.app), so we can complete and rotate the challenge without ever needing credentials to their DNS provider. The UI shows this record with the same copy-button treatment as the others. - Certificates cover the hostname and, for an apex with the
wwwcompanion, both names on one certificate. - Key type: ECDSA P-256, with an RSA-2048 certificate additionally issued and served to clients that do not advertise ECDSA support.
14.4.2 Storage #
Certificates and private keys live in certificates (Section 5 owns the schema) with the
private key encrypted using envelope encryption: a data key per certificate, wrapped by a
key-management-service master key, with the wrapped key stored alongside the ciphertext. Plain
private keys are never written to disk, never written to logs, never included in a database
export, and never returned by any API — including to the workspace that owns the domain. The
edge fetches decrypted material into memory with a 5-minute cache and re-fetches on
invalidation. Key material is excluded from Sentry payloads by an explicit scrubber (Section 24).
14.4.3 Renewal and alerting #
| Trigger | Action |
|---|---|
| 30 days remaining | Renewal job scheduled with a random offset inside a 24-hour window, so renewals do not stampede |
| 21 days remaining and not yet renewed | Renewal retried, warning logged |
| 14 days remaining | Alert to the operations channel; the domain card in the dashboard shows "Certificate renewal needs attention" with a text label |
| 7 days remaining | Pages the on-call engineer (Section 24 owns the escalation policy); the Owner and Admins are emailed with plain-language guidance |
| Certificate expired without renewal | The hostname is served a branded 503 error page over the last valid certificate where the client will still accept it, naming the workspace's shared-subdomain URL so the content stays reachable; the domain moves to failed with TLS_ISSUANCE_FAILED; the Owner and Admins are emailed daily, not once, because a silent expiry under the customer's own brand is the worst outcome in this section. The showcase remains fully available at {slug}.vouch.app throughout |
| Renewal succeeds | New certificate deployed to the edge within 60 seconds; the previous certificate is retained until it expires so an in-flight connection is never broken |
14.4.4 Not burning the certificate authority's quota #
Let's Encrypt's production limits that matter here are 50 certificates per registered domain per week, 5 duplicate certificates per week, 300 new orders per account per 3 hours, and 5 failed validations per account per hostname per hour. A naive retry loop breaks all of them and locks out every customer sharing our account. The guards:
- Per-domain order budget: at most 5 ACME orders per domain per 24 hours, counted in Valkey
with a 24-hour sliding window. Exceeding it sets
failedwithTLS_RATE_LIMITEDand copy explaining that we will retry automatically tomorrow. - Circuit breaker: 3 consecutive order failures for a domain stop ordering for 6 hours.
- Backoff: retries at 5 minutes, 15 minutes, 1 hour, 6 hours, 24 hours, with full jitter.
- Global order budget: a platform-wide counter caps new orders at 200 per rolling 3 hours, leaving headroom under the 300 limit. When the cap is reached, orders queue rather than fail, and the queue depth is an alerting metric.
- Pre-flight checks: we never place an order until DNS verification has succeeded and an
HTTP probe to the hostname reaches our edge. This alone removes the majority of would-be
failed validations. The pre-flight probe is issued through the same
safe-fetchclient and under the same no-reflection rule as the diagnostic probe in Section 14.3.2 — it returns only "reached our edge" or "did not", never anything the upstream said. - Duplicate-certificate guard: an order for a name set that already has a valid certificate with more than 30 days remaining is refused unless an operator explicitly forces it.
- Staging by default everywhere else: development, preview, and staging environments use
the ACME staging directory (configured through
ACME_DIRECTORY_URL, Section 29.2), so no test can ever consume production quota. A guard refuses to start a non-production environment configured with a production directory URL. - Fallback authority: after 2 consecutive Let's Encrypt order failures that are not the customer's fault (rate limiting or authority outage), the next order goes to ZeroSSL. The authority used is recorded on the certificate row.
14.5 Routing #
14.5.1 Host resolution #
Every request arriving at the edge is resolved by Host header:
- Normalise: lowercase, strip the port, strip a trailing dot.
- If the host is
{slug}.vouch.app, resolve the workspace by slug from a Valkey cache (slug:{slug}, 60-second TTL). - Otherwise look up
domain:{host}in Valkey →{ workspaceId, domainId, status, kind, isPrimary, redirectTo }. TTL 60 seconds for hits, 30 seconds for misses (a negative cache entry, so an unknown host cannot hammer the database). - On a cache miss, query
custom_domainswith a 50 ms budget. On database unavailability, serve from stale cache for up to 10 minutes rather than failing — a database blip must not take customers' domains offline. - Invalidate on any domain state change through a Valkey pub/sub message consumed by every edge process, so activation and removal take effect within a second rather than waiting out the TTL.
| Resolution outcome | Response |
|---|---|
active, primary |
Serve the showcase for that workspace |
active, not primary |
301 to the same path on the primary domain, preserving path and query |
redirectTo set (the www companion of an apex, or vice versa) |
301 to the target, preserving path and query |
suspended |
301 to the same path on the workspace's shared subdomain, preserving path and query, for as long as the certificate is renewed (Section 14.7.1). The visitor reaches the content; the customer's plan simply no longer buys the vanity hostname. Never a 503, because the content is live and reachable |
Domain exists but is in any other non-active state |
503 with Retry-After: 60 and a neutral branded page: "This site isn't ready yet." No workspace name, no configuration detail |
| Host unknown | 404 with a generic page. No workspace is named, no list is exposed, and the response is identical for "never existed" and "was removed", so the endpoint cannot be used to enumerate customers |
14.5.2 Redirect and canonicalisation rules #
- One primary. A workspace picks its primary origin: the shared subdomain, or one of its
active custom domains. Everything else
301s to it. wwwversus apex. The customer chooses which is primary when they add the pair; the other301s to it. We do not impose a preference, because both are defensible and the customer's existing links may go either way.- HTTP to HTTPS. Plain HTTP always
301s to HTTPS on the same host and path, except for/.well-known/acme-challenge/*, which is answered over HTTP so HTTP-01 validation works. - Trailing slashes.
/t/{id}/301s to/t/{id}. One canonical form. - Canonical tag. Always the primary origin, on every page, including on the non-primary origins that are still serving during a transition.
14.5.3 HSTS #
Strict-Transport-Security is powerful and unforgiving: a customer whose other services live on
subdomains can take them offline with a careless includeSubDomains.
| Condition | Header |
|---|---|
Domain has been active for less than 7 days |
max-age=86400 (one day) — long enough to be useful, short enough to recover from a mistake |
Domain active 7 days or more |
max-age=31536000 |
includeSubDomains |
Off by default. Available as an explicit opt-in with a confirmation dialog that spells out the consequence for other subdomains |
preload |
Never set automatically, ever. Offered only as documented guidance for customers who understand that preload list removal takes months |
Shared subdomain {slug}.vouch.app |
max-age=31536000; includeSubDomains — we control the whole zone |
14.5.4 Removal, expiry, and failure #
| Event | Behaviour |
|---|---|
| Customer removes a domain | Removal is scheduled rather than immediate: the row keeps its current state and is marked for removal. For 30 days the host is still answered, with a 301 to the same path on the shared subdomain, so links in the wild keep working while the customer updates them. After 30 days we stop answering and the row is soft-deleted (Section 5 owns custom_domains and its soft-delete column). The certificate is deleted from storage 30 days after that. Removal never uses a domain_state value of its own — the seven states in Section 14.3 are the whole enum. |
| Customer's DNS stops pointing at us | The 6-hour health check fails; after 3 consecutive failures the domain moves to failed and the Owner and Admins are emailed. Serving continues — visitors may still be reaching us through cached DNS, and cutting them off would be worse than the alternative. |
| Domain expires at the registrar | Detected as NAMESERVERS_NOT_PUBLISHING or NXDOMAIN by the health check. Same treatment as above, with copy naming registrar expiry as the likely cause. |
| Workspace cancelled | The domain keeps serving indefinitely, because Section 19 guarantees that published content never stops being served. Certificates keep renewing. |
| Workspace deleted (hard erasure) | The domain stops answering immediately, the certificate is deleted, and the DNS challenge delegation is removed (Section 22 owns the erasure runbook). |
14.6 The embed guarantee, restated #
Section 13.1 states it from the widget's side. It is restated here because this is the section a customer reads while changing their domain, and it is the moment they are most likely to fear they will have to touch their website again.
Adding, changing, or removing a custom domain never requires re-pasting a snippet that is already installed. Not once, not ever.
| What changes when a custom domain becomes active | What does not change |
|---|---|
permalinkUrl on every item in the widget payload |
The e.js URL in the snippet |
| The canonical URL and Open Graph URLs on showcase and permalink pages | The cdn.vouch.app loader origin |
| Links in the RSS feed and sitemap | The widget id |
| Link targets on request pages and in invitation emails | The widget data API host |
| The URL in the generated social cards (Section 15) | The analytics beacon host |
| The "Powered by Vouch" badge target (unchanged host, unchanged behaviour) | The shape of the payload, the API contract, or anything the host page can observe |
Mechanically: activating, changing, or removing a domain enqueues a payload rebuild for every
widget in the workspace and purges the workspace:{id} cache tag, so live widgets pick up the
new links within about 30 seconds and no later than the 60-second max-age plus purge
propagation. The customer does nothing.
The one thing that does change for the customer is their own outbound links: if they linked to
{slug}.vouch.app from their navigation, those links now 301 to the custom domain. They keep
working; the dashboard tells them they can update them at their leisure.
14.7 Plan gating and add-ons #
Section 19 owns the price table and the plan matrix; this section owns the behaviour.
| Plan | Custom domains |
|---|---|
| Free | Not available. The domain page shows what it would look like and a single upgrade action. |
| Starter | Available as a paid add-on, billed per domain per month. No domain is included. |
| Growth | One included. Additional domains billed per domain per month. |
| Scale | One included. Additional domains billed per domain per month at the lower Scale rate. |
Add-on mechanics: adding a domain beyond the included allowance creates a metered subscription item in Stripe immediately, prorated by Stripe's default proration behaviour (Section 19 owns the billing engine). Removing a domain removes the subscription item at the end of the current period; we do not refund partial months, and the domain keeps serving until the period ends, which is stated in the confirmation dialog before the customer confirms.
14.7.1 Downgrade #
A downgrade that leaves the workspace with more active domains than its new plan allows never breaks anything immediately. The rule mirrors Section 19's principle that reaching a limit never degrades what is already live.
| Day | What happens |
|---|---|
| 0 (downgrade takes effect) | Every domain over the new allowance enters a 30-day grace period and keeps serving normally, with a full certificate lifecycle. The Owner and Admins are emailed, and a persistent banner appears in the dashboard naming the affected domains and the date they will revert. |
| 14 | Second email and an in-product notice. |
| 27 | Third and final email, and a notification, three days before the change. |
| 30 | The over-allowance domains move to suspended (Section 14.3) and stop being primary. Requests to them 301 to the equivalent path on the shared subdomain {slug}.vouch.app, permanently. Every widget in the workspace is rebuilt and the workspace:{id} cache tag is purged, so permalinkUrl falls back to the shared subdomain before the hostname stops answering (Section 13.8.2). Certificates continue to be renewed for a further 30 days so the redirect stays on HTTPS, then the certificate is deleted and the host stops being answered. |
The domain row moves to suspended, not deleted — this is the only path into that state, and
the only path out of it is regaining the entitlement. If the customer upgrades again within 90
days and the DNS records are still in place, re-activating is one click and skips verification
entirely — we re-check the challenge record and, if it still matches, go straight to
issuing_tls. This matters: the most common reason a customer downgrades is a slow quarter, and
punishing them with a full re-setup when they come back is a bad trade.
The customer always chooses which domains keep the allowance; if they do not choose within the grace period, the primary domain keeps it and the rest revert, oldest-added first.
15. Social Image Card Generator #
15.1 Purpose #
A published testimonial is social proof that lives on the customer's own site. A social image card is the same proof, shaped so it can travel: a single image a marketer can post to X or LinkedIn, attach to a newsletter, drop into a deck, or serve as the Open Graph preview when someone shares a testimonial permalink.
The generator turns a testimonial into that image, deterministically, in the worker, with the same editorial guarantees the rest of the product carries:
- The quote on a card is a verbatim excerpt, produced by and validated with the same function described in Section 12.4. A card can never contain words the person did not say.
- The card is never generated silently from unconsented content. Section 15.7 gates it.
- The card always carries alt text. Section 15.6 makes it a hard requirement.
- Text is never silently cut. Section 15.4.4 makes truncation visible and reported.
Cards are generated from testimonials in approved, published or unpublished state. pending,
rejected, archived and soft-deleted testimonials cannot produce a card — the generator refuses
with SOCIAL_CARD_SOURCE_NOT_ELIGIBLE, because a card is a publication act and moderation comes
first.
15.2 Template catalogue #
Seven templates ship in v1. Each is a pure function of a typed props object; there is no per-customer template code, no user-supplied HTML, and no arbitrary layout editor — the brand controls in Section 15.5 are the extension point.
Every template shares a safe area: a 64 px inset on every edge at 1200 px-class sizes and a 72 px inset at 1080 px-class sizes, inside which no text or logo is ever placed. Backgrounds bleed to the full canvas; content never does. Social platforms crop unpredictably in feed previews, and the safe area is what makes the card survive it.
| # | Template id | Composition | Required data | Degradation when data is missing |
|---|---|---|---|---|
| 1 | quote-classic |
Large quote set on a solid or gradient background; attribution block bottom-left; small accent rule above the quote; brand mark bottom-right | Quote, display name | Missing role/company: the attribution collapses to one line. Missing everything but the quote: renders as an anonymous quote with "Verified customer" as the attribution, never a blank line |
| 2 | quote-avatar |
Circular avatar 160 px top-left, quote to its right, attribution beneath | Quote, display name, avatar | No avatar: falls back to a monogram disc built from the initials, coloured from a deterministic hash of the display name, contrast-checked against the background. No name: template falls back to quote-classic at render time and records template_fallback: 'quote-classic' on the card row |
| 3 | quote-logo |
Customer's company logo top-right at a fixed 48 px optical height, quote centre-left, attribution bottom-left | Quote, display name, company logo asset | No logo: the logo slot collapses and the quote block re-centres; the layout is designed so this is a deliberate variant, not a hole. Logos are letterboxed into a 200 × 64 box preserving aspect ratio, never stretched, and are auto-padded when their bounding box touches the frame |
| 4 | rating-forward |
Five stars rendered as vector shapes at 96 px across the top with the numeric rating beside them, quote below, attribution and source badge at the bottom | Rating (1–5), quote | No rating: the template is not offered for that testimonial and is hidden in the picker with the reason "This testimonial has no rating". Half-stars are never drawn — the canonical rating is an integer (Section 16.4) |
| 5 | video-still |
Full-bleed video poster frame, a scrim gradient from 0 % to 72 % opacity across the lower 60 %, quote overlaid in the lower third, a play glyph centred, duration badge top-right | Video poster frame, quote | No poster yet (media still processing): generation is refused with MEDIA_NOT_READY rather than rendering a grey box. Poster too dark or too light for the overlay: the scrim opacity is raised in 8 % steps until the quote text passes 4.5:1 against the sampled region, up to 92 %; if it still fails, a solid scrim is used and the operator is told the poster was too busy |
| 6 | metric-case-study |
A single large metric (value + unit) at 140 px, a supporting line beneath, the quote at reduced size, attribution and logo at the bottom | Metric value, metric label, quote | The metric is entered by the moderator, never inferred and never extracted by the assistant, because a number attributed to a customer must be one a human confirmed. No metric entered: the template is disabled in the picker with an inline "Add a metric to use this template" field |
| 7 | quote-minimal |
Type-only card: quote at large size on a flat background, a single hairline rule, attribution in small caps. No avatar, no logo, no stars | Quote | Never degrades — this is the universal fallback and every testimonial can render it |
Cross-template rules:
- The quote is always wrapped in typographic quotation marks appropriate to the card's language
(
" "for English,« »with thin spaces for French,„ "for German). The marks are decoration drawn by the template, not characters added to the stored text. - A multi-part excerpt renders with the same spaced
…join produced by the validator in Section 12.4.4. A card never silently stitches two fragments together. - Every card carries the source attribution required by Section 16.6 when the testimonial was imported — the platform name and its badge — and this element cannot be disabled by brand controls.
- On the Free plan every card carries the "Powered by Vouch" mark, rendered server-side into the image itself. It is part of the raster, not an overlay, so it cannot be removed client-side. This mirrors the widget badge object owned by Section 13.8, under the plan gating owned by Section 19.
- Templates never render the respondent's email, phone number, or IP.
15.3 Output sizes #
| Size id | Pixels | Primary use | Aspect | Adaptations |
|---|---|---|---|---|
og |
1200 × 630 | Open Graph, LinkedIn, Slack unfurls, email | 1.91:1 | The reference layout. Quote clamped to 5 lines |
x |
1200 × 675 | X (summary large image) | 16:9 | Identical to og with 22 px more vertical breathing room; quote clamped to 5 lines |
square |
1080 × 1080 | Instagram feed, LinkedIn square | 1:1 | Attribution moves below the quote instead of beside it; avatar grows to 200 px; quote clamped to 8 lines; the metric in template 6 grows to 180 px |
portrait |
1080 × 1350 | Instagram portrait | 4:5 | As square with a taller quote area, clamped to 11 lines; brand mark moves to the bottom centre |
story |
1080 × 1920 | Instagram/Facebook stories, TikTok | 9:16 | Content is vertically centred within a 1080 × 1420 band, leaving 250 px clear top and bottom for platform chrome; quote clamped to 9 lines; the play glyph in template 5 grows to 160 px; safe area increases to 96 px |
Every template renders in every size except that metric-case-study in story drops the quote to a
maximum of 4 lines to keep the metric dominant. Requesting a size for a template always succeeds;
there is no unsupported combination.
Default set generated when a moderator clicks "Create card" with no size chosen: og and x. The
other three are generated on demand or as part of a bulk run.
15.4 Rendering pipeline #
15.4.1 Stages #
Rendering runs as the social_card.render job in the worker application, never in a web request,
because it is CPU-bound and would otherwise contend with request serving.
props ─▶ [1] resolve ─▶ [2] fit text ─▶ [3] satori ─▶ [4] resvg ─▶ [5] sharp ─▶ [6] store ─▶ [7] purge
assets/fonts binary search JSX→SVG SVG→PNG optimise S3 + CDN tag purge| Stage | Detail |
|---|---|
| 1. Resolve | Fetch avatar/logo/poster from object storage into memory (never from a third-party URL at render time — see 15.4.6), decode with the image library named in Section 3, downscale to the exact slot size, convert to a data URI. Load the font subset for the detected script. Any asset that fails to load triggers that template's documented degradation, never a broken image |
| 2. Fit text | The algorithm in 15.4.4 |
| 3. satori | The template's JSX tree plus resolved props is converted to SVG by the SVG layout engine named in Section 3. Only the flexbox subset that engine supports is used; templates are lint-checked against an allowlist of supported CSS properties so an unsupported property can never reach production |
| 4. resvg | SVG is rasterized to PNG at the exact output pixel dimensions (no scaling afterwards, so text stays crisp) |
| 5. sharp | Metadata stripped, sRGB profile attached, PNG re-encoded at compression level 9 with adaptive filtering. If the PNG exceeds 1 MB, a JPEG at quality 82 is produced alongside it and becomes the default download for that size, with the PNG still available. Hard ceiling 2 MB; a card exceeding it after JPEG encoding fails with SOCIAL_CARD_TOO_LARGE, which in practice can only happen with a pathological background image and is surfaced with "Try a simpler background" |
| 6. Store | Written to object storage at workspaces/{workspaceId}/social-cards/{cardId}/{size}-{renderHash8}.{png|jpg} |
| 7. Purge | For a card already in use as an Open Graph image, the stable alias URL's CDN tag is purged (15.8.3) |
Job configuration: concurrency 2 per worker instance, attempts 3 with exponential backoff (5 s, 30 s), per-job timeout 30 seconds, and a memory guard that fails the job rather than the process if a render exceeds 512 MB.
15.4.2 Fonts #
| Concern | Decision |
|---|---|
| Bundled families | A variable sans-serif family in weights 400/600/700 for Latin, plus Noto Sans for extended Latin, Cyrillic and Greek, Noto Sans Arabic, and Noto Sans Hebrew. All are bundled in the worker image — there is no font download at render time and therefore no external dependency in the rendering path |
| Subsetting | Fonts are pre-subsetted per script at build time into WOFF-free raw TTF buffers (the SVG layout engine requires font buffers, not web fonts). The Latin subset covers Unicode blocks Basic Latin, Latin-1 Supplement, Latin Extended-A, and General Punctuation |
| Script detection | The card's text is scanned for its dominant script; the matching font is loaded first and the others are supplied as fallbacks in a fixed order |
| Coverage guard | Before layout, every code point in the card's text is checked against the loaded fonts' coverage. If any code point has no glyph in any loaded font, the job fails with SOCIAL_CARD_FONT_COVERAGE, naming the unsupported characters. The product never renders tofu boxes. The UI offers the choices: edit the excerpt, or download the quote as text |
| CJK | Not covered by the bundled fonts in v1. A card whose text contains CJK code points fails the coverage guard with a message naming CJK support as a planned capability (Section 29.8 lists it as deferred) |
15.4.3 Emoji #
Emoji are rendered as images, not as font glyphs, so they look identical on every platform and never depend on a system emoji font being present in the worker container. A bundled set of open-licensed emoji SVGs is mapped through the layout engine's grapheme-image hook. The mapping is by full grapheme cluster, so ZWJ sequences and skin-tone modifiers resolve to their composed image rather than to a sequence of component images. An emoji with no bundled image renders as its Unicode fallback if a loaded font covers it, and otherwise triggers the coverage guard.
15.4.4 Text fitting — binary search, then a visible ellipsis #
Every template and size declares minFontPx, maxFontPx, maxLines, and a fixed text box in CSS
pixels. The algorithm:
// packages/core/social-cards/fit-text.ts
export interface FitInput {
text: string;
box: { width: number; height: number };
minFontPx: number; // e.g. 28
maxFontPx: number; // e.g. 72
maxLines: number; // e.g. 5
lineHeight: number; // multiplier, e.g. 1.25
fontStack: FontBuffer[];
locale: string;
}
export interface FitResult {
fontPx: number;
lines: string[];
wasTruncated: boolean;
droppedCharacters: number;
}
export function fitText(input: FitInput): FitResult;- Binary search on font size between
minFontPxandmaxFontPx. At each probe, lay the text out with the real font metrics and the real line-break algorithm (Intl.Segmenterwithgranularity: 'word', honouring the locale's line-breaking rules) and test whether the result fits withinmaxLinesand the box height. Terminate at 0.5 px precision, which is at most 8 iterations for a 28–72 px range. Take the largest fitting size. - If nothing fits at
minFontPx, truncate. Truncation walks backwards from the last permitted line's overflow point to the nearest word boundary, then to the nearest grapheme cluster boundary, and appends a real…(U+2026) — never three periods, never a CSS text-overflow that the raster cannot show reliably. - Truncation is never silent. The result sets
wasTruncated: trueanddroppedCharacters. The card row stores both. The dashboard shows a warning on the card preview: "This quote was shortened to fit. 47 characters were removed. Choose a taller size, pick a shorter excerpt, or edit the selection." The bulk generation report (15.9) lists every truncated card. An API response includeswasTruncatedin the card payload. - The quote text on the card is still verbatim. Truncation removes a suffix; it never substitutes or reorders words. If the moderator wants a shorter quote, they choose a shorter excerpt through the validated selection path in Sections 11.4.6 and 12.5 — the card generator never picks different words.
- Attribution lines use the same fitter with a single-line constraint and truncate with
…at a grapheme boundary; a company name that does not fit is truncated, never wrapped onto the quote.
15.4.5 Right-to-left support #
| Concern | Decision |
|---|---|
| Base direction | Determined by the first strong directional character (UAX #9 P2/P3) of the quote. The card's dir is set accordingly, per card, not per workspace |
| Mirroring | The whole layout mirrors: avatar and logo swap sides, the accent rule moves to the right edge, text aligns right, the attribution block right-aligns, the brand mark moves to the bottom-left |
| Quotation marks | The locale's marks are used and their opening/closing roles swap correctly |
| Numbers and Latin runs | Left-to-right runs inside RTL text are isolated with FSI…PDI exactly as described in Section 12.4.6, so "40%" and product names read correctly |
| Punctuation | Neutral characters resolve by the bidi algorithm implemented in the layout engine; templates never hard-code punctuation placement |
| Testing | A visual-regression fixture in Arabic and one in Hebrew render for every template and every size |
15.4.6 Third-party images are never fetched at render time #
Avatars and logos are always read from the workspace's own object storage. Imported reviews' avatars are not re-hosted (Section 16.4), so a card generated from an imported review uses the monogram fallback. This removes an entire class of failure — a slow or dead third-party host stalling a render job — and avoids re-publishing another platform's hosted image into a derivative work.
15.4.7 The cache key #
An unchanged card is never re-rendered. The render_hash is the SHA-256, hex-encoded, of the
canonical JSON serialization (keys sorted, no whitespace) of:
{
"rendererVersion": 3,
"fontBundleVersion": 2,
"emojiBundleVersion": 1,
"templateId": "quote-avatar",
"size": "og",
"theme": "dark",
"locale": "en",
"direction": "ltr",
"quoteParts": ["Vouch cut our collection time from three weeks to two days."],
"attribution": { "displayName": "Maya Chen", "roleTitle": "VP Engineering", "company": "Northwind" },
"rating": 5,
"metric": null,
"brand": {
"accentColor": "#1F6FEB",
"backgroundKind": "gradient",
"backgroundValue": "linear-gradient(160deg,#0B1220,#131C2E)",
"fontScale": 1,
"logoAssetVersion": "asst_01J8Z…:7",
"avatarAssetVersion": "asst_01J8Y…:2",
"posterAssetVersion": null,
"showPoweredBy": false
},
"sourceBadge": null
}Rules:
- A request whose
render_hashalready exists for the workspace returns the stored CDN URL immediately with"cached": trueand does not enqueue a job. rendererVersionis bumped by hand whenever a template's visual output changes for any reason — layout, spacing, colour derivation, or a library upgrade that alters rasterization. Bumping it invalidates every cached card globally, which is the intended behaviour, and re-renders happen lazily on next request rather than as a mass job.- Asset versions are
{assetId}:{version}, so replacing a logo with a new file at the same id correctly invalidates every card that used it. - The hash never includes alt text — editing alt text updates the card row without re-rendering the image, because alt text is not pixels.
15.5 Brand controls #
| Control | Values | Notes |
|---|---|---|
| Accent colour | Any hex. Used for the accent rule, the star fill, the metric value, and the play glyph | Contrast-validated (15.5.1) |
| Background | solid (hex), gradient (two stops + angle), or image (an uploaded asset, max 5 MB, min 1200 × 1200, auto-cropped per size with a focal point the customer sets) |
An image background always renders under a scrim sufficient to keep text at 4.5:1 |
| Logo | One uploaded asset per workspace, PNG or SVG, max 2 MB. SVG is rasterized at 4× the slot size and sanitized (scripts, external references and embedded fonts stripped) before use | Optical height normalization: the logo's ink bounding box, not its canvas, is fitted to the slot |
| Font scale | 0.85, 1.0, 1.15 | Multiplies minFontPx and maxFontPx; the fitter still governs the final size |
| Theme | light, dark, or both |
"Both" generates two cards and is the default for new workspaces, because posts land in different contexts |
| Powered-by mark | On/off | Locked on for Free (Section 19). Off is only honoured on paid plans |
| Corner radius | 0, 16, 32 px on inner elements | The canvas itself is always square-cornered — platforms crop, and a rounded canvas produces visible corner artefacts |
15.5.1 Contrast validation before save #
Section 21 owns the contrast requirements; this section applies them at the point a brand preset is saved and again at the point a card is rendered.
| Pair | Minimum | On failure |
|---|---|---|
| Quote text vs. its effective background (sampled per region for image backgrounds, at 9 points across the text box) | 4.5:1 | Save blocked. The editor names the failing pair, shows the measured ratio, and offers the nearest accessible colour with the ratio it would achieve — one click to apply |
| Attribution text vs. background | 4.5:1 | Same |
| Accent elements (rule, stars, glyph) vs. background | 3:1 | Same |
| Powered-by mark vs. background | 3:1 | The mark's own colour is auto-selected from a black/white pair to satisfy this, and cannot be overridden |
The validation runs on the rendered pixels for image backgrounds — the scrim is applied first and the
sampled values come from the composited result, not from the declared colour, so a busy photo cannot
pass by accident. A card that fails validation at render time (possible if a background image was
replaced after the preset was saved) fails the job with SOCIAL_CARD_CONTRAST_FAILED and is
reported to the operator rather than published.
15.6 Alt text is mandatory #
A card cannot be downloaded, copied, shared, or published without non-empty alt text. Stated
plainly because it is a hard product rule, not a nudge: every endpoint that returns a downloadable
artefact or attaches a card to a public surface checks the field first and returns
422 ALT_TEXT_REQUIRED if it is empty or shorter than the ten-character floor below.
| Field | Rule |
|---|---|
alt_text |
Required, 10–420 characters after trimming, no leading/trailing whitespace, must contain at least one letter. The floor is 10 rather than 1 because a one- or two-character alt text satisfies a validator and defeats the purpose; Section 5.6.8 carries the same range as a CHECK constraint |
alt_text_source |
ai_draft, human_authored, human_edited, system_template |
alt_text_locale |
Defaults to the card's locale |
How the field is populated, in order of preference:
ai_draft— the assistant drafts it viadraftAltText(Section 12.6.1). This is prose about the card, explicitly permitted by the clarification in Section 12.1, and it never contains invented testimonial wording; the quote preview it receives is an already-verbatim excerpt. Available on Growth and Scale.system_template— when the assistant is unavailable, disabled, or excluded by plan, a deterministic template fills the field so it is never empty:"Testimonial card: {displayName}{, roleTitle at company} — quote begins {first 90 characters}… {rating} out of 5 stars. {theme} background."Sections that do not apply are omitted rather than rendered empty.human_authored/human_edited— any edit by a person sets the source accordingly. The editor shows a live character count, the 420-character limit, and a short guideline: describe what is visible, do not repeat the quote verbatim in full, do not start with "Image of".
Editing alt text does not re-render the image (15.4.7) and writes a social_card.alt_text_edited
audit row. The dashboard shows a quiet "Alt text is auto-generated — review it" hint on any card
still at system_template or ai_draft, and the bulk report flags how many cards in a run have
unreviewed alt text.
Where the alt text is used: the alt attribute on the showcase page's card image, the og:image:alt
meta tag, the twitter:image:alt meta tag, the ZIP manifest in bulk downloads, the clipboard copy's
accompanying text where the platform supports it, and the API payload.
15.7 Attribution and consent gating #
A card is a publication. The consent that governs publication therefore governs the card, and the UI shows the moderator what the source testimonial actually carries before anything is generated.
15.7.1 The consent chips #
The card creation panel opens with a consent summary rendered as chips, each with an icon and a text label (never colour alone, per Section 21):
| Chip | Meaning | Effect |
|---|---|---|
| ✔ Publication | The required publication consent is granted (Section 22) | Card generation is allowed |
| ✔ / ✖ Advertising | The optional paid-advertising consent | Controls intendedUse (below) |
| ✔ / ✖ Follow-up | The optional case-study contact consent | Displayed for context; does not gate cards |
| Source: Google / G2 / Capterra / X | The testimonial was imported | Adds the mandatory source badge and blocks paid-ads use |
15.7.2 The rules #
- No publication consent → no card.
409 CONSENT_MISSING. There is no override, no "generate anyway", and no admin bypass. These refusals are409rather than403for the reason Section 7.6 gives: the caller is authorized and holds the permission, but the request conflicts with what the respondent actually agreed to, which is stored state. intendedUseis a required parameter on card creation, with valuesorganicandpaid_ads. Selectingpaid_adswithout the advertising consent grant returns409 ADVERTISING_CONSENT_MISSING, and the control is disabled in the UI with the explanation "This person did not agree to paid advertising use."- Imported reviews can never be used for
paid_ads—409 IMPORTED_REVIEW_ADS_BLOCKED— because the source platforms' syndication terms permit display with attribution, not repurposing into paid creative. The UI states this reason inline. intendedUseis stored on the card row and shown on the card's detail view, so a marketer picking an asset months later can see what it was cleared for.- Consent withdrawal invalidates cards. When a respondent withdraws (Section 22), every card
derived from that testimonial is soft-deleted, its CDN objects are purged within 60 seconds, the
permanent URLs return
410 Gone, and the workspace is notified with the list of affected cards so they can be pulled from any external channel. A hard erasure additionally destroys the objects. - Source badges are not optional. An imported review's card renders the platform's name and badge and, where the platform's display terms require it, the reviewer's display name in the form that platform mandates (Section 16.6). Brand controls cannot remove these elements.
15.8 Delivery #
15.8.1 Download and clipboard #
| Method | Behaviour |
|---|---|
| Download PNG | Direct download of the stored object, filename {workspace-slug}-{testimonial-short-id}-{size}.png |
| Download JPEG | Offered when the PNG exceeded 1 MB (15.4.1) |
| Download all sizes | ZIP of every rendered size for that card plus alt-text.txt |
| Copy to clipboard | Uses the asynchronous clipboard API with an image item. Where the browser refuses image writes, the button falls back to a download and says so — it never silently does nothing |
15.8.2 Share links #
Pre-filled share intents open in a new tab with rel="noopener":
| Target | Behaviour |
|---|---|
| X | Intent URL with pre-filled text: the quote (truncated to fit the platform's character budget with a visible …), the attribution, and the permalink. The image is not uploaded by the intent; the permalink's Open Graph tags supply the card |
Share URL pointing at the testimonial permalink; the card is served as that page's og:image |
|
| Sharer URL pointing at the permalink | |
| Copy link | The testimonial permalink |
| Copy image URL | The permanent CDN URL (15.8.3) |
The pre-filled text is assembled from the verbatim excerpt and never rewritten for the platform. If the excerpt does not fit, it is truncated with the visible-ellipsis rule from 15.4.4 and the UI shows what will be posted before the intent opens.
15.8.3 Permanent URLs and Open Graph #
| URL | Caching | Purpose |
|---|---|---|
https://cdn.vouch.app/c/{cardId}/{renderHash8}/{size}.png |
public, max-age=31536000, immutable |
Content-addressed, safe to cache forever |
https://cdn.vouch.app/c/{cardId}/{size}.png |
public, max-age=300, s-maxage=86400, purge tags card:{cardId}, workspace:{id} |
Stable alias used in Open Graph tags, so re-rendering a card updates the preview without changing the URL that has been shared |
Automatic Open Graph use: the showcase page (Section 14) uses the workspace's designated showcase
card, and every single-testimonial permalink uses that testimonial's og-size card when one exists.
When none exists, the permalink falls back to a generated-on-first-request card using
quote-minimal, which is then stored like any other — so a shared link never previews without an
image. That first-request generation is capped at one per testimonial per minute and returns a
placeholder if the render is still in flight, never a broken preview.
Meta tags emitted on a permalink page:
<meta property="og:image" content="https://cdn.vouch.app/c/crd_01J8Z.../og.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="Testimonial card: Maya Chen, VP Engineering at Northwind — quote begins “Vouch cut our collection time…">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="https://cdn.vouch.app/c/crd_01J8Z.../x.png">
<meta name="twitter:image:alt" content="Testimonial card: Maya Chen, VP Engineering at Northwind — quote begins “Vouch cut our collection time…">15.9 Bulk generation #
| Aspect | Decision |
|---|---|
| Entry point | "Generate cards" from the inbox with any filter applied, or from a saved view |
| Selection cap | 500 testimonials per run; above that the UI states the cap and offers to narrow the filter |
| Inputs | One template (or "best fit", which picks per testimonial: video-still for video with a ready poster, rating-forward when a rating exists, quote-avatar when an avatar exists, else quote-minimal), one or more sizes, one theme or both, and intendedUse |
| Pre-flight | Before the job starts, the UI reports how many items are ineligible and why — missing publication consent, ineligible status, media not ready, missing rating for the chosen template, advertising consent missing for paid_ads — with a per-reason count and a link to view them. The run proceeds with the eligible subset only after the moderator confirms |
| Job | social_card.bulk fans out one social_card.render job per card, with a per-run concurrency of 4 |
| Progress | A progress panel with completed/total, a live per-item list, and an SSE-driven update. Closing the panel does not cancel the run; a persistent indicator remains in the navigation |
| Cancel | Cancels queued items; already-rendered cards are kept |
| Partial failure | Never aborts the run. Each failure records its error code and message |
| Output | A ZIP assembled by a streaming writer directly to object storage, containing {template}/{size}/{slug}.png, plus manifest.csv with columns testimonialId, cardId, template, size, theme, intendedUse, wasTruncated, droppedCharacters, altText, altTextSource, cdnUrl, status, errorCode |
| Delivery | A signed download link valid for 7 days, emailed to the initiator and shown in-app. The ZIP is deleted from storage after 7 days |
| Auto-generation | An optional workspace setting generates an og-size card on every publish, using the workspace's default template and theme. Default off on Free and Starter, default on for Growth and Scale, and always overridable |
15.10 Endpoints owned by this section #
| Method | Path | Purpose | Permission |
|---|---|---|---|
GET |
/api/v1/social-cards |
List cards, filterable by testimonial, template, size, intendedUse, truncation state |
social_card:read |
POST |
/api/v1/social-cards |
Create one or more cards for a testimonial. Body: testimonialId, templateId, sizes[], theme, intendedUse, optional quote (validated excerpt), optional metric. Returns 202 with job ids, or 200 with cached results when every requested card already exists |
social_card:generate |
GET |
/api/v1/social-cards/{id} |
One card with its URLs, alt text, truncation info, and consent snapshot | social_card:read |
PATCH |
/api/v1/social-cards/{id} |
Edit alt text only. Any other field is a new card | social_card:generate |
DELETE |
/api/v1/social-cards/{id} |
Soft-delete the card and purge its CDN objects | social_card:generate |
POST |
/api/v1/social-cards/bulk |
Bulk generation (15.9) | social_card:generate |
GET |
/api/v1/social-cards/bulk/{runId} |
Bulk run status and per-item results | social_card:read |
GET |
/api/v1/social-card-templates |
Template catalogue with per-template requirements, so a client can disable unavailable options | social_card:read |
GET |
/api/v1/workspaces/{id}/card-brand · PUT |
Brand preset with contrast validation (15.5.1) | settings:read / brand:update |
Section 6.6.2 holds exactly two card permissions — social_card:read and social_card:generate —
and every write above uses the second one. There is no separate update or delete permission,
because a card is a derived artefact: editing its alt text and retiring it are the same
"may produce and manage cards" capability that generating one is.
15.10.1 Error codes raised by this section #
Catalogued canonically in Section 29.3.
| Code | Status | Raised when |
|---|---|---|
SOCIAL_CARD_SOURCE_NOT_ELIGIBLE |
409 | Source testimonial is pending, rejected, archived or deleted |
CONSENT_MISSING |
409 | Publication consent absent |
ADVERTISING_CONSENT_MISSING |
409 | intendedUse = paid_ads without the advertising grant |
IMPORTED_REVIEW_ADS_BLOCKED |
409 | intendedUse = paid_ads on an imported review |
ALT_TEXT_REQUIRED |
422 | Alt text empty, or under 10 characters, on download, share, or publish |
MEDIA_NOT_READY |
409 | video-still requested before the poster exists |
TEMPLATE_REQUIREMENTS_UNMET |
422 | e.g. rating-forward without a rating, metric-case-study without a metric |
SOCIAL_CARD_FONT_COVERAGE |
422 | A code point has no glyph in any bundled font |
SOCIAL_CARD_CONTRAST_FAILED |
422 | Rendered contrast below the thresholds in 15.5.1 |
SOCIAL_CARD_TOO_LARGE |
422 | Output exceeds 2 MB after JPEG encoding |
HIGHLIGHT_NOT_VERBATIM |
422 | A supplied quote failed the validator in Section 12.4 |
BULK_SELECTION_TOO_LARGE |
422 | More than 500 testimonials in one bulk run |
PLAN_FEATURE_UNAVAILABLE |
409 | An AI alt-text draft was requested on a plan without the assistant (Section 12.8); the system_template path in 15.6 is used instead and never fails |
15.11 Acceptance criteria for Section 15 #
- A visual-regression test renders all 7 templates × 5 sizes × 2 themes and compares against committed baselines with a 0.1 % pixel tolerance.
- A test asserts every documented degradation path (no avatar, no logo, no rating, no metric, dark poster) renders a complete card with no empty slots and no broken images.
- A test asserts that a card whose text cannot fit at
minFontPxsetswasTruncated, appends a real U+2026, and surfaces the warning in the API payload and the bulk manifest. - A test asserts that identical inputs produce an identical
render_hashand that the second request returnscached: truewithout enqueuing a job. - A test asserts that bumping
rendererVersionchanges the hash for every fixture. - A test asserts a card cannot be downloaded, copied, shared, or attached to Open Graph when its
alt text is empty or shorter than ten characters, and that the
system_templatefallback always produces a value inside the 10–420 range. - A test asserts
paid_adsis refused without the advertising grant and refused outright for imported reviews. - A test asserts consent withdrawal purges every derived card within 60 seconds and that the
permanent URLs then return
410. - RTL fixtures in Arabic and Hebrew render with mirrored layout and correctly isolated Latin runs.
- A test asserts an unsupported code point fails with
SOCIAL_CARD_FONT_COVERAGErather than rendering tofu.
16. Review Import — Google, G2, Capterra, X & CSV #
16.1 The principle: no scraping, ever #
Every review that enters this product arrives by one of exactly three routes:
- An official API the customer authorizes with their own credentials.
- An official syndication feed the customer is contractually entitled to.
- A customer-supplied export — a file the customer downloaded from the source platform, or assembled themselves, and uploaded here.
There is no fourth route. The product does not scrape review pages, does not parse rendered HTML, does not drive a headless browser against a review site, does not use an unofficial or reverse- engineered endpoint, and does not buy scraped datasets. This is stated in the product documentation, in the connect-time terms in Section 16.9, and in the sales material.
Why, in plain terms:
- Terms of service. Every major review platform prohibits automated collection of its review content. Scraping on a customer's behalf breaches those terms in the customer's name and puts the customer's listing at risk of suspension — the exact asset they came here to leverage.
- Legal exposure. Automated access in breach of terms creates contract and computer-misuse exposure in several jurisdictions, and database-right exposure in the EU, for both the platform operator and the customer.
- Attribution integrity. A scraped review has no verifiable provenance. An API-sourced review carries a stable source id and a permalink that a reader can check. Verifiability is the entire value of imported social proof.
- Durability. Scrapers break on every markup change. A product feature that silently stops working is worse than one that was never offered.
When a customer asks for scraping, the answer is the CSV path, offered without friction: the support article and the in-product empty state both say "We don't scrape review sites. Export your reviews from the platform and upload the file — here's the template and a two-minute walkthrough." CSV import is available on every plan including Free (Section 16.8), so the answer is never "upgrade to get around this."
16.2 Per-source specification #
Common structure for all connected sources: an OAuth or key-based connection stored per workspace in an encrypted credential record (Section 22 owns secret storage), a selectable listing/product scope, a manual "Sync now" action, an optional daily schedule, and a documented fallback that requires no API entitlement at all.
16.2.1 Google — Google Business Profile #
| Aspect | Specification |
|---|---|
| Connection | OAuth 2.0 authorization-code flow with PKCE, per workspace, initiated by a member holding import:connect (Owner and Admin only — storing a third-party credential for the whole workspace is not Editor work, while running a sync against an existing connection is). Refresh token stored encrypted; access tokens are never persisted beyond their lifetime |
| Scope requested | https://www.googleapis.com/auth/business.manage — the minimum scope that exposes reviews. No other Google scope is requested, and the consent screen explains why |
| Prerequisite | The connecting Google account must have manage rights on the Business Profile listing. This is an ownership check, not a workaround: reviews of a listing the customer does not own cannot be imported |
| Selection | After connect, the UI lists accounts, then locations, and the customer picks the locations to import. Multiple locations per workspace are supported; each becomes an import source row |
| Endpoints used | Account listing and location listing through the account-management and business-information APIs; review retrieval through the reviews endpoint for the selected location, plus the single-review endpoint for re-verification |
| Fields captured | reviewId, reviewer.displayName, reviewer.isAnonymous, reviewer.profilePhotoUrl (stored as a URL only, never re-hosted — see 16.4.3), starRating (an enum of ONE…FIVE), comment (may be absent for rating-only reviews), createTime, updateTime, and reviewReply (the customer's own public reply, stored but not published by default) |
| Rating-only reviews | A review with a star rating and no text is imported and is eligible for the rating-forward card and for the rating-summary widget, but is excluded from text widgets. It is not an error |
| Anonymous reviewers | isAnonymous = true imports with the display name "A Google user", which is the form Google itself uses. The product never invents a name |
| Pagination | Page-token based, page size 50 (the documented maximum), following nextPageToken until exhausted or the run cap is reached |
| Rate limits and backoff | The project's per-minute quota is treated as a hard budget of 300 requests per minute, with a client-side token bucket set to 240 to leave headroom. On 429 or a RESOURCE_EXHAUSTED error: exponential backoff 2 s, 8 s, 30 s, 2 min, 10 min with full jitter, maximum 5 attempts per page, then the run is marked partial and resumes from the last successful page token on the next run |
| Sync schedule | Manual any time (rate-limited to once per 15 minutes per source), plus optional daily at a customer-chosen hour. Incremental: only reviews with updateTime after the last successful run's high-water mark are processed |
| Deletions at source | A weekly integrity sweep re-fetches known review ids. A review that returns not-found is marked source_removed; if it is published, it is unpublished within 24 hours of detection and the moderator is notified |
| Token expiry | A revoked or expired refresh token sets the source to disconnected, raises an in-app banner and one email to the Owner, and keeps every already-imported review exactly as it is |
| Fallback with no listing access | CSV import using the Google column mapping preset (16.3). The customer can export their reviews from the Business Profile interface or transcribe them; the preset maps the common export headings automatically |
16.2.2 G2 #
| Aspect | Specification |
|---|---|
| Connection | API token issued to the customer by the source platform under a review-syndication entitlement, pasted into the workspace's integration settings and stored encrypted. There is no OAuth flow because the entitlement is contractual, not per-user |
| Prerequisite | An active syndication entitlement for the customer's product listing. This is contract-gated and most customers will not have it. The connect screen says so before the customer hunts for a token, and puts the CSV path at equal visual weight — not as an error state, but as the expected path |
| Selection | The customer selects one or more product listings exposed by their entitlement |
| Endpoints used | The syndication API's review collection endpoint for the entitled product, plus the single-review endpoint for re-verification |
| Fields captured | Review id, title/headline, star rating (0.5-step scale from 0.5 to 5.0), the structured sections the platform provides — what the reviewer liked, what they disliked, and problems being solved — reviewer display name, reviewer job title, reviewer company size segment, review permalink, publication date, and last-update date |
| Section handling | The structured sections are stored individually and concatenated for the testimonial body in the order liked → disliked → problems solved, with the platform's own section headings preserved as bold labels. The moderator can choose, per import source, to import only the "liked" section — a display choice made before import, never an edit afterwards |
| Pagination | Page-number based with a page size of 100, following the response's link metadata until exhausted |
| Rate limits and backoff | Budgeted at 60 requests per minute with a client-side bucket at 45. Backoff identical to 16.2.1. 403 on a specific product means the entitlement lapsed: the source is marked entitlement_expired, the Owner is emailed, and the CSV path is offered inline |
| Sync schedule | Manual (once per 15 minutes) plus optional daily. Incremental by last-update timestamp |
| Deletions at source | Weekly integrity sweep as in 16.2.1 |
| Fallback with no entitlement | Customer-supplied export. The product accepts both the platform's CSV export and a JSON export, with a dedicated mapping preset for each, including the structured sections. This is a first-class flow with its own onboarding, not an error branch |
16.2.3 Capterra — Gartner Digital Markets #
| Aspect | Specification |
|---|---|
| Connection | A partner key plus a feed identifier issued to the customer under a review-syndication agreement covering the Gartner Digital Markets network (which includes Capterra, GetApp and Software Advice), stored encrypted per workspace |
| Prerequisite | An active syndication agreement. As with 16.2.2, this is contract-gated and the CSV path is presented with equal prominence at connect time |
| Selection | One or more product listings within the entitled feed, and a choice of which network properties to include |
| Endpoints used | The syndication feed endpoint, polled; the feed is a document rather than a paginated collection, so the whole feed is fetched and diffed against what has already been imported |
| Fields captured | Review id, overall rating (1–5, may carry a decimal), title, pros, cons, overall comment, reviewer first name and last initial, reviewer job title, company size, industry, review date, review permalink, and the network property the review originated on |
| Body assembly | Overall comment, then pros, then cons, with the platform's headings preserved, mirroring 16.2.2 |
| Pagination | None — a full feed. Feeds larger than 10,000 reviews are streamed and processed in 500-row batches rather than buffered |
| Rate limits and backoff | Polling is limited to once per hour per feed regardless of what the customer clicks; a manual sync inside the window returns the last run's result with a note. Backoff on 429/5xx: 1 min, 5 min, 30 min, maximum 3 attempts |
| Sync schedule | Daily at a customer-chosen hour, manual once per hour |
| Deletions at source | A review absent from the feed for two consecutive successful runs is marked source_removed and, if published, unpublished within 24 hours |
| Fallback with no agreement | CSV import with the Capterra preset, which maps the pros/cons/overall columns and the "first name last initial" author convention |
16.2.4 X #
| Aspect | Specification |
|---|---|
| Connection | OAuth 2.0 authorization-code flow with PKCE, user context, per workspace |
| Scopes requested | tweet.read, users.read, offline.access. Nothing that permits posting. The consent screen states that the product only reads posts the customer explicitly nominates |
| Prerequisite | An X developer project with API access on the customer's own account, at any access tier. The product detects the tier from the rate-limit headers it receives and adapts its budget rather than assuming one |
| Import modes | (a) By URL — the customer pastes one or more post URLs; the product extracts the post id and fetches it. (b) By approved mentions — the product fetches recent mentions of the connected account and presents them as a review list where the customer ticks the ones to import. Nothing from a mention list is ever imported automatically |
| Endpoints used | The posts lookup endpoint with an id list (up to 100 ids per request) and the user-mentions timeline endpoint, both with expansions=author_id and the post fields for created_at, text, lang, public_metrics, and entity annotations |
| Fields captured | Post id, full text, author id, author handle, author display name, author profile image URL (stored as a URL only), post permalink, created_at, language, and public metrics at import time (retained as a snapshot, never refreshed, and never displayed as if live) |
| Rating | None. X posts import with a null rating and are excluded from rating-based widgets and from the rating-forward card |
| Media | Attached images and videos are not imported. The testimonial is the text, and the permalink carries the rest. This avoids re-hosting third-party media entirely |
| Pagination | Token-based for the mentions timeline; id-batched for lookups |
| Rate limits and backoff | Budget derived at runtime from the x-rate-limit-limit, x-rate-limit-remaining and x-rate-limit-reset response headers. The client never issues a request when remaining is below 3, and sleeps until reset. On 429 it always honours the reset timestamp rather than guessing a backoff |
| Sync schedule | Manual for URL imports. The mention list refreshes on demand and optionally daily; it produces candidates, never testimonials |
| Deletions at source | Weekly integrity sweep. A post that is deleted, or whose author has become protected or suspended, is marked source_removed and unpublished within 24 hours. This is a compliance requirement, not a nicety |
| Fallback with no developer access | CSV import with the X preset (post URL, author handle, text, date), which the customer can assemble by hand for the handful of posts they actually want to feature. A single-post "paste a URL and fill in the text yourself" form is also offered, clearly labelled as a manual entry with the permalink preserved |
16.2.5 Source capability summary #
| G2 | Capterra | X | CSV | ||
|---|---|---|---|---|---|
| Auth | OAuth 2.0 (PKCE) | API token | Partner key | OAuth 2.0 (PKCE) | None |
| Entitlement gate | Listing ownership | Contract | Contract | Developer access | None |
| Native rating | 1–5 enum | 0.5–5.0 | 1–5 decimal | None | Optional column |
| Text always present | No | Yes | Yes | Yes | Yes |
| Author avatar | URL only | No | No | URL only | Optional URL |
| Permalink | Yes | Yes | Yes | Yes | Optional column |
| Incremental sync | Yes | Yes | Feed diff | Manual/candidates | N/A |
| Scheduled sync | Daily | Daily | Daily | Daily (candidates) | N/A |
| Fallback | CSV | CSV or JSON export | CSV | CSV or manual entry | — |
16.3 CSV import #
CSV is the universal path. It works on every plan, needs no credentials, and is the documented answer to every "can you pull my reviews from X platform" question the product does not have a connector for.
16.3.1 Column schema #
A downloadable template (vouch-import-template.csv) is offered at the top of the import screen,
pre-filled with two example rows and a header row.
| Column | Required | Type | Validation | Default when absent |
|---|---|---|---|---|
body |
Yes | Text | 1–20,000 characters after trimming; must contain at least one letter. The ceiling is the body_text column constraint owned by Section 5.6.1 — import imposes no lower limit of its own |
— |
author_name |
Yes | Text | 1–120 characters | — |
author_role |
No | Text | 0–120 characters | Empty |
author_company |
No | Text | 0–120 characters | Empty |
rating |
No | Integer | 1–5 inclusive; a decimal is accepted and rounded half-up with the raw value retained | Null |
review_date |
No | Date | ISO 8601 preferred; other formats resolved per 16.3.5 | The import run's timestamp |
permalink |
No | URL | https only, ≤ 2,048 characters, must parse |
Empty |
source_platform |
No | Text | Free text ≤ 40 characters, used as the displayed badge label when the row is not from a connected source | CSV import |
source_review_id |
No | Text | ≤ 128 characters; used for deduplication and re-import | A stable hash of body + author_name |
language |
No | Text | ISO 639-1 from the launch set, or blank for auto-detection | Detected |
tags |
No | Text | Semicolon-separated tag labels; only labels that already exist in the workspace are applied, unknown labels are reported in the pre-flight, never auto-created | None |
author_avatar_url |
No | URL | https only |
Monogram |
title |
No | Text | 0–120 characters, stored as the review's own title where the source had one | Empty |
consent_confirmed |
Yes | Boolean | Must be true, yes, or 1. See 16.9 |
— |
Unknown columns are ignored with a notice listing them, so a raw platform export can usually be uploaded unmodified.
16.3.2 Upload and limits #
| Limit | Value | On breach |
|---|---|---|
| File size | 10 MB | 413 with IMPORT_FILE_TOO_LARGE and a suggestion to split the file |
| Rows per file | 5,000 (excluding the header) | 422 IMPORT_ROW_LIMIT naming the row count and the limit |
| Files per run | 1 | — |
| Concurrent runs per workspace | 1 | 409 IMPORT_RUN_IN_PROGRESS |
| Processing | Chunked at 250 rows per transaction | — |
| Encoding | UTF-8 (with or without BOM) and UTF-16 detected by BOM; otherwise a heuristic check for valid UTF-8 sequences, falling back to Windows-1252. The detected encoding is shown in the pre-flight so a customer can see when it guessed | — |
| Delimiter | Sniffed among ,, ;, tab, and ` |
` by counting consistent field counts across the first 20 rows; the customer can override before import |
| Quoting | RFC 4180: double quotes, doubled to escape, newlines permitted inside quoted fields | Malformed quoting fails the whole file at pre-flight with the offending line number |
16.3.3 Mapping UI #
- Header auto-detection. Each uploaded header is normalized (lowercased, non-alphanumerics
collapsed to underscores) and matched in three passes: exact match against the canonical names, a
synonym dictionary (
review,review_text,comment,content,feedback→body;reviewer,name,customer_name,display_name→author_name;stars,score,overall_rating→rating; and so on for every column), and finally a fuzzy match with a Levenshtein distance of 2 or less. The confidence of each mapping is shown. - Presets. Google, G2, Capterra, X and "generic" presets pre-fill the mapping for those platforms' export headings in one click.
- Manual override. Every mapping is a dropdown the customer can change, including "do not import" for a column.
- Live preview. The first five mapped rows render exactly as they will appear as testimonials, including the badge and permalink, so mapping errors are visible before commitment.
- Persistence. The mapping is saved per workspace and per detected header signature, so a recurring monthly export maps itself on the second upload.
16.3.4 Pre-flight report #
Nothing is written until the customer confirms the pre-flight, which reports:
| Line | Meaning |
|---|---|
4,812 rows read |
Total parsed |
4,610 valid |
Will import |
112 duplicates |
Already present by (source, source_review_id), with a sample; skipped |
48 possible near-duplicates |
Flagged for the moderator per 16.5, still imported |
31 invalid |
With a per-row reason; downloadable as an error CSV |
11 unknown tags |
Listed by name; the rows import without those tags |
0 rows over the plan cap / 212 rows over the plan cap |
Per 16.3.6 |
| Detected encoding, delimiter, and date format | So the customer can correct a bad guess |
16.3.5 Row validation and dates #
Validation is per row, and a failing row never fails the file. Every failure carries a machine code and a human sentence:
| Code | Message |
|---|---|
BODY_EMPTY |
"Review text is required." |
BODY_TOO_LONG |
"Review text is longer than 20,000 characters." |
AUTHOR_NAME_EMPTY |
"Reviewer name is required." |
RATING_OUT_OF_RANGE |
"Rating must be between 1 and 5." |
DATE_UNPARSEABLE |
"Could not read the date «12/13/2025» — choose a date format above." |
PERMALINK_INVALID |
"Permalink must be a valid https URL." |
CONSENT_NOT_CONFIRMED |
"The consent column must be true for every row." |
DUPLICATE_IN_FILE |
"This review id appears twice in this file (rows 41 and 907)." |
Dates are never guessed when ambiguous. If the file contains a date that is valid under both
DD/MM/YYYY and MM/DD/YYYY interpretations, the pre-flight stops and asks the customer to choose
the format explicitly, showing three example values under each interpretation. ISO 8601 dates are
parsed without asking. A date in the future is clamped to the run timestamp with a warning; a date
before 2000-01-01 is treated as unparseable.
16.3.6 Partial success, the error CSV and the quota interaction #
- Partial success is the norm and is never treated as failure. Valid rows import; invalid rows do
not; the run ends
partialwith counts. - The error CSV contains every rejected row with its original columns intact, plus two
appended columns
error_codeanderror_message. The customer fixes the file and re-uploads it directly — deduplication (16.5) ensures the successful rows from the first attempt are not duplicated. - Plan cap interaction. Imported reviews count toward the workspace's lifetime testimonial cap
(Section 19). When a file would exceed the cap, the run imports rows in file order up to the cap,
marks the remainder
skipped_quota, and reports exactly how many were skipped with an upgrade link. It is never an all-or-nothing rejection, and nothing already in the workspace is touched. A workspace already at its cap cannot start an import at all and is told so before uploading.
16.4 Normalization #
Everything imported is normalized into the same shape as a collected testimonial, so one moderation pipeline, one widget renderer, one API and one export handle all of it.
16.4.1 Rating #
| Source | Native scale | Mapping to the canonical 1–5 integer |
|---|---|---|
| Enum ONE…FIVE | Direct: ONE→1 … FIVE→5 | |
| G2 | 0.5–5.0 in 0.5 steps | Round half up (3.5→4). Values below 1.0 clamp to 1 |
| Capterra | 1.0–5.0 decimal | Round half up |
| X | None | Null |
| CSV | Free | Integer taken as-is; decimal rounded half up; out-of-range rejected |
The raw value is always retained in source_rating_raw as text, exactly as received, and the
dashboard shows both ("4 stars — originally 3.5 on G2"). Widgets and the rating-summary aggregate use
the canonical integer only, and the summary widget's disclosure states that ratings from different
platforms are normalized to a five-point scale.
16.4.2 Text #
Imported body text is stored exactly as received, byte for byte, with three narrowly-scoped transformations that do not alter wording:
- Line endings normalized to
\n. - Unicode NFC normalization (a canonical-equivalence normalization that changes no visible character).
- HTML sanitization when the source delivered markup, using the sanitizer and allowlist named in
Section 13 —
p,br,strong,em,a[href]with forcedrel="nofollow noopener". Stripped markup is recorded in the import log so nothing disappears without a trace.
No trimming of profanity, no case correction, no punctuation cleanup, no spell-checking, no truncation. The text is then immutable (Section 11.4.5).
16.4.3 Author identity and avatars #
| Field | Rule |
|---|---|
| Display name | Stored as received, including forms like "Maya C." or "A Google user". Never expanded, never abbreviated, never title-cased |
| Role and company | Stored where the source provides them; otherwise empty. Never inferred |
| Avatar | Third-party avatars are never re-hosted. The default rendering is a monogram disc built from the initials with a deterministic colour, which is also what a social card uses (Section 15.4.6). A workspace setting, showSourceAvatars, default off, renders the platform-hosted image directly from its original URL with referrerpolicy="no-referrer" and loading="lazy", for the sources whose display terms permit it. If the remote image fails to load, the monogram renders instead — there is never a broken image |
| Verified badge | The source badge (16.6) is the verification signal. The product never adds a "verified" mark of its own to imported content |
16.4.4 Dates #
The source's original publication date is stored and is what the reader sees. Section 5 owns the
columns; the behaviour is: collected_at is set to the source's publication timestamp so widgets
sort chronologically the way a reader expects, while the row's own created_at remains the moment of
import. A source that provides only a date without a time is stored at 12:00 UTC on that date, and
the UI displays a date without a time for those rows rather than implying a precision that does not
exist.
16.4.5 Language #
Language is taken from the source when provided (X supplies it), and otherwise detected with a
lightweight character n-gram classifier covering the five launch languages. Detection confidence
below 0.6 stores und, which is included in every widget by default and is never hidden by a
language filter unless the customer explicitly excludes it. Language drives the full-text search
configuration and the negation list used by the validator in Section 12.4.5.
16.5 Deduplication #
16.5.1 Exact deduplication #
A unique constraint on (workspace_id, source, source_review_id) (Section 5 owns the index) makes
re-importing the same review a no-op. On conflict the importer compares the incoming payload with the
stored row:
| Situation | Behaviour |
|---|---|
| Identical payload | Skipped, counted as skipped_duplicate |
| Source text changed (the reviewer edited their review on the platform) | The stored body is not silently replaced. The row is flagged source_updated, the incoming text is stored alongside for comparison, and the moderator is shown a diff with two choices: accept the update (which replaces the body and writes an audit row of type testimonial.body_edited with actor system and reason source_updated) or keep the imported version and stop tracking updates for that review. If the review is currently published, it stays published while the moderator decides, because the source text is the reviewer's own newer words either way |
| Rating changed at source | Updated automatically, with an audit row — a rating is a fact about the source, not a quotation |
| Review removed at source | Marked source_removed per each source's integrity sweep |
16.5.2 Near-duplicate detection #
The same review often arrives twice: once by CSV before a connector existed, once by API afterwards. A cross-source identity check catches it.
- A normalized text hash is computed for every testimonial: lowercase, NFKC normalization, all punctuation and whitespace removed, then SHA-256 of the first 500 characters. An exact hash match across sources is a strong duplicate signal.
- A trigram similarity comparison over the first 500 characters, using the Postgres trigram extension, flags pairs at or above 0.85 similarity within the same workspace.
- Author-name similarity at or above 0.90 combined with a text similarity at or above 0.70 is also flagged.
Near-duplicates are never auto-resolved. They are surfaced to the moderator as a "Possible
duplicate" badge on both rows, with a side-by-side comparison and three actions: keep both, reject
this one, or link them (duplicate_of_id), which keeps the linked row out of widgets while retaining
it in the record. Auto-merging would risk destroying a genuine second review from a genuinely
different person, which is the more expensive error.
The comparison runs at import time against the workspace's existing rows and is bounded: it compares against at most the 5,000 most recent testimonials, and above that threshold it compares hashes only, with the trigram pass moved to a nightly job. This keeps a 5,000-row import from degrading into a quadratic comparison.
16.6 Attribution and display #
An imported review always carries three things, in every surface — widget, showcase page, permalink, social card, public API, oEmbed and export:
- The source platform's name, rendered as a text label, never as an icon alone.
- The platform's badge or wordmark, drawn from a bundled asset set, sized and coloured per that platform's brand guidelines, with the required clear space respected.
- A link to the original review, using the stored permalink, opening in a new tab with
rel="noopener nofollow", with an accessible name that says where it goes ("Read this review on G2, opens in a new tab").
None of these can be disabled by a widget setting, a brand control, or an API parameter. A widget configuration that attempts to hide them is rejected at save time.
| Source | Required attribution elements | Additional display rules |
|---|---|---|
| "Google" wordmark, the reviewer's display name exactly as supplied (including "A Google user"), the star rating as supplied, and a link to the review on the listing | Rating and text are shown together — never the rating alone from a review that had text, and never the text with a different rating. The customer's own public reply is stored but only displayed when the workspace opts in, and is then clearly labelled as the business's reply | |
| G2 | The G2 badge, the reviewer's name and job title as supplied, the star rating shown on the platform's own 5-point scale alongside the normalized value, and a link to the review | Where the section structure is imported, the section headings are preserved. If only the "liked" section was imported, the display states that it is an excerpt and links to the full review |
| Capterra | The Capterra (or the specific network property's) badge, the reviewer's name in the supplied "first name last initial" form, the rating, and a link to the review | The originating network property is named when it is not Capterra itself |
| X | The X mark, the author's display name and handle, the post date, and a link to the post | The post text is shown in full, never excerpted, because a partial post misrepresents it. Public metrics, if displayed at all, are labelled with the import date as a snapshot |
| CSV | The label supplied in source_platform, or "Imported" when absent, and the permalink when supplied |
No platform badge is drawn for CSV rows unless the customer explicitly selects a known platform in the mapping step and confirms they have the right to display it |
Social cards inherit these rules (Section 15.2), and imported reviews cannot be used in paid advertising creative (Section 15.7.2).
16.7 The import run #
16.7.1 States #
The six values below are the import_run_status enum owned by Section 5.2, used verbatim.
queued ──▶ running ──┬──▶ succeeded
├──▶ partial
├──▶ failed
└──▶ canceled| State | Meaning |
|---|---|
queued |
Accepted, waiting for a worker. A workspace may have only one active run at a time |
running |
Fetching and writing. Progress is reported continuously |
succeeded |
Every row processed, zero errors |
partial |
Finished; at least one row failed, was skipped, or hit the plan cap. This is a success state and is presented as such — the UI reads "Completed with issues" and links to the per-row detail, never the word "failed" |
failed |
Could not proceed at all — credentials rejected, entitlement missing, file unreadable, source unreachable after all retries. Rows already written in earlier chunks are kept |
canceled |
Stopped by a user. Already-imported rows are kept; the run reports what it managed |
16.7.2 Progress and counts #
Every run records: rows_seen, created, skipped_duplicate, skipped_quota, flagged_near_duplicate,
failed, updated, and unknown_tags. Progress is streamed to the dashboard over the SSE channel
described in Section 11.3.9 and is also pollable at the run endpoint for API consumers. The progress
UI shows a determinate bar when the total is known (CSV, Capterra feed) and a running count when it
is not (paginated APIs), never a spinner without a number.
16.7.3 Failure handling #
| Failure | Behaviour |
|---|---|
| A single row | Recorded with its code and message; the run continues |
| A page fetch | Retried per that source's backoff; after exhaustion the run ends partial and records the resume point so the next run continues rather than restarting |
| Credentials rejected mid-run | The run ends failed, the source is marked disconnected, the Owner is emailed once, and imported rows are untouched |
| Worker crash | The job is retried by the queue; chunked writes are idempotent on (source, source_review_id) so a retry cannot duplicate rows |
| Cap reached mid-run | Remaining rows counted as skipped_quota; the run ends partial with an upgrade link |
16.7.4 History and notification #
Run history is retained for 12 months and lists, per run: the source, who started it, the trigger
(manual or scheduled), start and end times, duration, every count from 16.7.2, and links to the error
CSV and the imported rows filtered in the inbox. On completion, an in-app notification always appears;
an email is sent when the run created at least one row or ended in failed, and never for a scheduled
run that found nothing new — a daily "0 new reviews" email trains people to filter the sender.
16.8 Plan gating #
Section 19 owns the plan matrix; the import-specific reading of it is:
| Capability | Free | Starter | Growth | Scale |
|---|---|---|---|---|
| CSV import | Yes | Yes | Yes | Yes |
| Google, G2, Capterra, X connectors | No | Yes | Yes | Yes |
| Scheduled daily sync | No | Yes | Yes | Yes |
| Connected sources per workspace | 0 | 3 | 10 | Unlimited |
| Rows per CSV file | 5,000 | 5,000 | 5,000 | 5,000 |
Imported reviews count toward the lifetime collected-testimonial cap on every plan, and a workspace at its cap cannot import (Section 19). A Free workspace attempting to connect a source sees the plan comparison and, immediately beside it, the CSV path — because CSV genuinely solves the problem on Free and pretending otherwise would be a dark pattern.
16.9 Legal posture #
16.9.1 What the customer warrants #
Shown at connect time for every connected source, and above the upload button for CSV, with a required checkbox that is never pre-checked:
By importing reviews you confirm that: you own or manage the listing, product or account these reviews relate to; you are permitted by the source platform's terms to display these reviews on your own properties with attribution; the reviews are genuine and were not solicited in exchange for a positive rating; you have not altered their wording; and you will remove any review promptly if the reviewer or the source platform asks you to. Vouch displays imported reviews verbatim, with the source platform named, the platform's badge shown, and a link to the original review. Vouch does not scrape review sites and will not import content obtained by scraping.
The acceptance is stored with the accepting user id, the exact text and its SHA-256, the timestamp,
and the IP, using the same mechanism the consent records use (Section 22), and is written to the
audit trail as import.source_connected.
16.9.2 The takedown path #
| Step | Action | Timing |
|---|---|---|
| 1 | An objection arrives — from a reviewer, from a source platform, or from the customer — to the published contact address, and is logged as a takedown request against the specific testimonial | On receipt |
| 2 | The testimonial is unpublished immediately via the system transition in Section 11.2.3, with an audit row and the flag reason legal_request. Unpublishing precedes any assessment, because leaving contested content live is the only genuinely bad outcome |
Within 1 business hour of triage |
| 3 | The workspace Owner is notified with the request details and the affected testimonial | Immediately |
| 4 | The request is assessed. If it is valid, the testimonial is deleted and, where the requester asks for it, hard-erased through the path in Section 22 | Within 5 business days |
| 5 | If the objection came from a source platform and concerns the syndication terms generally, the affected import source is suspended workspace-wide pending resolution | Immediately |
| 6 | The outcome is recorded, communicated to the requester, and retained in the audit trail | Within 5 business days |
16.9.3 Disconnection semantics #
Disconnecting a source presents two explicit choices, with the consequences spelled out and no default selected:
- Keep imported reviews — credentials are deleted, scheduled syncs stop, integrity sweeps stop, and every already-imported review stays exactly as it is, including published ones. This is the option most customers want and is listed first.
- Remove imported reviews — every review from that source is soft-deleted (Section 11.2.3 T11), which unpublishes any that were live and starts the 30-day restore window. The confirmation names the exact count and requires typing the source name.
Deleting the credential record always deletes the stored refresh token and revokes it at the provider where the provider supports revocation.
16.10 Endpoints owned by this section #
| Method | Path | Purpose | Permission |
|---|---|---|---|
GET |
/api/v1/import-sources |
List connected sources with status, last run, and next scheduled run | import:read |
POST |
/api/v1/import-sources |
Create a source after an OAuth callback or a key entry | import:connect |
PATCH |
/api/v1/import-sources/{id} |
Update the schedule, the selected listings, or the section-import choice | import:connect |
DELETE |
/api/v1/import-sources/{id} |
Disconnect, with ?removeReviews=true|false (16.9.3) |
import:disconnect |
POST |
/api/v1/import-sources/{id}/sync |
Start a manual run | import:run |
GET |
/api/v1/import-sources/{id}/candidates |
Mention candidates for X | import:read |
POST |
/api/v1/import-sources/{id}/candidates/select |
Import the ticked candidates | import:run |
POST |
/api/v1/imports/csv/upload |
Upload a file, returns a parse id, the detected settings, and the auto-mapping | import:upload_csv |
POST |
/api/v1/imports/csv/{parseId}/preflight |
Validate against a confirmed mapping and return the report in 16.3.4 | import:upload_csv |
POST |
/api/v1/imports/csv/{parseId}/commit |
Start the import run | import:upload_csv |
GET |
/api/v1/import-runs |
Run history with counts | import:read |
GET |
/api/v1/import-runs/{id} |
One run with progress and per-row results | import:read |
POST |
/api/v1/import-runs/{id}/cancel |
Cancel a queued or running import | import:run |
GET |
/api/v1/import-runs/{id}/errors.csv |
The error CSV | import:read |
GET |
/api/v1/import-templates/{source}.csv |
The download template and per-source presets | import:read |
The split matters and is Section 6.6.2's, not this section's: import:connect and
import:disconnect are Owner and Admin, because they create and destroy a stored third-party
credential that acts for the whole workspace. import:run and import:upload_csv reach Editor,
because running a sync or uploading a file against an existing connection is content work — the
same reason an Editor may publish but may not add an API key.
16.10.1 Error codes raised by this section #
Catalogued canonically in Section 29.3.
| Code | Status | Raised when |
|---|---|---|
IMPORT_SOURCE_NOT_ENTITLED |
409 | The syndication entitlement is absent or lapsed |
IMPORT_SOURCE_UNAUTHORIZED |
401 | Credentials rejected or revoked at the provider |
IMPORT_SOURCE_NOT_OWNED |
409 | The connected account does not manage the selected listing |
IMPORT_RUN_IN_PROGRESS |
409 | A second concurrent run was requested |
IMPORT_RATE_LIMITED |
429 | A manual sync inside the per-source cooldown; Retry-After names the remaining seconds |
IMPORT_FILE_TOO_LARGE |
413 | Over 10 MB |
IMPORT_ROW_LIMIT |
422 | Over 5,000 rows |
IMPORT_FILE_UNREADABLE |
422 | Encoding or quoting could not be resolved |
IMPORT_MAPPING_INCOMPLETE |
422 | A required column is unmapped |
IMPORT_DATE_FORMAT_AMBIGUOUS |
422 | An ambiguous date format needs an explicit choice |
IMPORT_CONSENT_NOT_CONFIRMED |
422 | The warranty checkbox or the CSV consent column is missing |
IMPORT_NOT_AVAILABLE_ON_PLAN |
409 | A connector requested on Free |
IMPORT_SOURCE_LIMIT_REACHED |
409 | More connected sources than the plan allows |
TESTIMONIAL_LIMIT_REACHED |
409 | The workspace is at its lifetime cap and cannot import |
IMPORTED_REVIEW_IMMUTABLE |
422 | An edit was attempted on imported text (Section 11.4.5) |
Only one code in this table is a 403-shaped problem and it is not here: refusing a caller who
lacks import:connect is Section 7's generic scope refusal. Everything above is either a malformed
request (4xx validation), a genuine rate limit, or — for the entitlement, ownership, plan and cap
rows — a well-formed authorized request that conflicts with stored state, which Section 19.5.6
settles as 409 for the whole product.
16.11 Acceptance criteria for Section 16 #
- A test asserts every source's fallback path is reachable and functional without any API credential, and that the connect screen renders the fallback for a customer with no entitlement.
- A test asserts the codebase contains no HTML parsing of any review platform's pages and no headless-browser dependency in the import path.
- A test asserts imported rows land in
pendingand traverse the identical transitions in Section 11.2.3 as collected rows. - A test asserts a body edit on an imported review is refused on the API, in the UI, and in bulk.
- A test asserts re-running an identical import creates zero new rows.
- A test asserts a source-side text change surfaces a diff for a human decision rather than overwriting silently, and that a rating change updates automatically with an audit row.
- A test asserts near-duplicates are flagged and never auto-merged.
- A test asserts a CSV with 200 valid and 50 invalid rows imports 200 and produces an error CSV of 50 with the original columns preserved and re-uploadable.
- A test asserts an ambiguous date format halts the pre-flight and asks rather than guessing.
- A test asserts a file exceeding the plan cap imports up to the cap and reports the remainder as
skipped_quotawithout touching existing rows. - A test asserts every rendered surface for an imported review contains the platform name, its badge, and a working permalink, and that no widget configuration can remove them.
- A test asserts a review removed at source is detected by the integrity sweep and unpublished within 24 hours.
17. Analytics — Cookie-Free Impression & Click Measurement #
17.1 The privacy stance, first #
The widget sets no cookies. It writes no localStorage, no sessionStorage, no
IndexedDB, and no Cache Storage. It performs no fingerprinting — no canvas
extraction, no font enumeration, no audio-context probing, no screen-and-plugin hashing. It
creates no cross-site identifier of any kind, and it never sends anything to a third
party. The same is true of the showcase page and the request page.
The precise scope of the no-storage rule. It governs measurement, on every surface, and it
governs everything inside the embedded widget, because that code runs on the customer's origin
where we are a guest and must leave no trace. It does not extend to a preference a user has
explicitly asked us to remember on an origin of our own. On our first-party surfaces — the
dashboard, request pages, showcase pages, and custom showcase domains — a user-set accessibility
preference such as the captions toggle is persisted in localStorage, because it is
strictly-necessary, explicitly-requested storage that needs no consent banner, and forcing
someone who relies on captions to re-set the control on every page view is a real harm imposed
for no privacy gain. Inside the embedded widget the same preference stays in memory only, since
that origin belongs to the customer. Section 21 owns the preference itself; this section owns the
boundary. Nothing in either case is an identifier, and nothing in either case is read by
analytics.
This is a product decision with three consequences, and all three are good ones.
First, the host's site needs no consent banner because of us. The consent rules that create cookie banners in Europe and the United Kingdom attach to storing information on, or gaining access to information stored on, a user's device. Reading a device identifier, writing a cookie, or writing to browser storage all trigger them. We do none of those things, so the trigger never fires and a customer's existing banner does not need a new entry for us. Data protection law still applies to any personal data we handle, and we handle IP addresses for a few milliseconds at the edge — Section 17.5 states exactly what happens to them and Section 22 owns the lawful-basis analysis. But there is nothing to consent to on the storage question, because there is no storage.
Second, the customer never has to explain us to their legal team. The sub-processor entry is one line, the data map is one arrow, and there is no "advertising identifier" conversation.
Third — and this is the honest part — the numbers are approximate. Without a persistent identifier we cannot tell whether the same person visited twice. What we count is page views in which a widget was seen, deduplicated within each page view. That is a genuinely useful number and it is not the same thing as unique visitors.
The product never pretends otherwise:
- The metric is labelled "Views (approx.)" in every chart, table, export header, and API
response field name (
viewsApprox). It is never labelled "unique visitors", "uniques", "sessions", or "users". - Every approximate metric renders with a
≈prefix and a help affordance linking to the panel in Section 17.9. - We cannot do cross-day attribution, returning-visitor analysis, or multi-session funnels, and the UI says so where a user would expect to find them rather than leaving them to wonder.
What we deliberately do not send, even though it would be technically easy: the full page URL (query strings routinely contain email addresses, order ids, and reset tokens), the referrer, the user's screen dimensions, the device's language list, the installed-font set, or any hash derived from any of these. The page context we send is the origin plus at most the first two path segments, with query string and fragment stripped — enough to answer "which section of my site is this widget on" and not enough to identify anyone.
17.2 The in-memory viewId #
// module scope inside the runtime chunk. Not on window, not exported, not persisted.
let viewId = newViewId();
let viewStartedAt = performance.now();
function newViewId(): string {
const b = new Uint8Array(16);
crypto.getRandomValues(b);
return btoa(String.fromCharCode(...b)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}| Property | Value |
|---|---|
| Size | 128 bits from crypto.getRandomValues, base64url encoded (22 characters) |
| Lifetime | The life of the JavaScript context — a page view. It disappears on navigation, on reload, and on tab close, because the variable ceases to exist. |
| Storage | None. It is never written to a cookie, storage API, URL, form field, or postMessage. |
| Scope | One per page view, shared by every widget instance on that page, so an impression on widget A and a click on widget B are known to be the same view. |
| Transmission | Sent only in the beacon body to cdn.vouch.app. It is never placed in a URL, so it cannot leak through a Referer header. |
| Regeneration | On a soft navigation in a single-page-application host — detected through the Navigation API's navigate event where available, and otherwise on popstate or when Vouch.render/Vouch.scan produces a different instance set — and unconditionally after 30 minutes of continuous page life, so an all-day dashboard tab does not become one enormous "view". |
| Server-side handling | Used to deduplicate within an ingest batch and to compute the approximate view count for a bucket. It is stored on the raw event row (retained 7 days) and is not carried into rollups: rollups store a count, not a set of identifiers. |
Stated precisely, because the imprecise version is wrong. The viewId is an identifier —
128 random bits that identify one page view. What it is not is a persistent identifier, a
cross-site identifier, or an identifier stored on anybody's device: it exists in one JavaScript
context, is never written to the terminal, is held server-side for 7 days on the raw event row
solely to deduplicate that single view, is never joined to any other record, and is never carried
into a rollup. Claiming "no identifier is created" would be a false statement in a privacy notice
and the kind of overclaim that costs a company its credibility on everything else it says; the
accurate claim — no persistent, cross-site, or terminal-stored identifier — is both true and
sufficient. Section 22 owns the lawful-basis analysis and states it in those terms, including the
route by which any site visitor may object, and the Global Privacy Control signal suppresses
beacon emission entirely (Section 17.4) rather than being acknowledged and ignored.
17.3 The event catalogue #
This section owns the analytics event vocabulary. There are exactly eleven event types, they
are listed in the table below, and they are the same eleven values as the analytics_event_type
Postgres enum in Section 5 — the enum is generated from this list, so an event named here can
always be stored and a value in the enum is always emitted by something. Adding a twelfth event
means editing this table and the enum in the same change; there is no other way to introduce one.
Two consequences worth stating explicitly, because both have been got wrong before:
- Video progress is one event type, not four. The 25 / 50 / 75 markers ride in the payload,
as
x.pcton avideo_progressevent. There is novideo_complete_25,video_complete_50,video_complete_75, orvideo_complete_100event type, and the enum contains no such value. A threshold is data; it is not a name. - The four
page_view/request_*events are first-class members of the same enum, not a separate pipeline. Section 17.7's collection funnel is built entirely on them, so if they cannot be stored the funnel cannot be built.
All events share this envelope. Field names are deliberately short because payload size is capped (Section 17.4).
// The complete vocabulary. Eleven values, matching `analytics_event_type` (Section 5) exactly.
export type EventType =
| 'impression' | 'interaction' | 'click'
| 'video_start' | 'video_progress' | 'video_complete' | 'video_error'
| 'page_view' | 'request_started' | 'request_captured' | 'request_submitted';
interface VouchEvent {
t: EventType; // event type
w: string; // widget id
i?: string; // testimonial id, when the event is about one item
v: string; // viewId
ts: number; // client Date.now() at the moment the event occurred
p: string; // page context: origin + first two path segments, no query, no fragment
x?: Record<string, string | number | boolean>; // per-type extras, ≤ 6 keys, values ≤ 64 chars
s: 1; // event schema version
}| Event | Trigger condition | Extras (x) |
Dedupe rule |
|---|---|---|---|
impression |
The widget's root intersects the viewport at ≥ 50% for a continuous ≥ 1000 ms while document.visibilityState === 'visible'. The timer is cancelled and reset if the ratio drops below 50% or the tab is hidden. Implemented with one shared IntersectionObserver (threshold: [0, 0.5, 1]) across all instances. |
{ ratio } — the observed ratio at fire time, rounded to 2 decimals |
Once per widget per viewId. A second impression for the same pair is dropped in the client and re-checked at ingest. |
interaction |
A deliberate user action inside the widget: play, next, prev, dot, filter, sort, expand (Read more), loadMore, lightboxOpen, lightboxClose, popupShow, popupDismiss. |
{ action, position? } |
At most 20 per widget per viewId; beyond that the client increments a counter and sets x.truncated = true on the last one rather than sending more. Identical (action, i) pairs fired within 400 ms are collapsed. |
click |
A click, middle-click, or Enter/Space activation on any <a> inside the widget's shadow root, captured on the shadow root itself (never on document). |
{ linkType: 'testimonial' | 'author' | 'source' | 'cta' | 'badge', destHost } — the destination host only, never the full URL, never a query string |
Once per (widget, item, linkType) per viewId. |
video_start |
The playing event fires for the first time on an item's player in this view. |
{ durationSec } |
Once per item per viewId. |
video_progress |
timeupdate crosses 25%, 50%, or 75% of duration. Each threshold fires at most once and only if the preceding thresholds have also been crossed, so seeking forward does not manufacture progress. |
{ pct: 25 | 50 | 75 } — the threshold is payload, never part of the event name |
Once per (item, pct) per viewId. |
video_complete |
The ended event, or currentTime / duration ≥ 0.98, whichever comes first. |
{ watchedSec } — total seconds actually played, not elapsed wall-clock |
Once per item per viewId. |
video_error |
The player fails to start after its fallback attempt (Section 13.7.3). | { reason: 'timeout' | 'manifest' | 'decode' | 'network' } |
Once per item per viewId. |
page_view |
Emitted by the showcase page and the request page — not by the widget — once on load. | { surface: 'showcase' | 'permalink' | 'request_page', ref? } where ref is the referring host only, when present |
Once per viewId. |
request_started |
The respondent begins a submission on a request page: presses Record or focuses the written-testimonial field. | { mode: 'video' | 'text' } |
Once per viewId. |
request_captured |
A take is finished or the written testimonial reaches the minimum length — the respondent has produced something but has not submitted. | { mode, retakes } |
Once per viewId, updated in place if retakes changes. |
request_submitted |
The submission succeeds. | { mode, hasVideo, consentOptional: number } |
Once per viewId. |
Events carry no respondent identity, no email, no name, no IP, no cookie, and no free text.
x values are constrained to a fixed enum or a number for every key defined above; anything
else is dropped at ingest by the schema (Section 4 owns the validation conventions).
The last four events are what make the request-page conversion funnel in Section 17.7 possible. They travel through exactly the same cookie-free pipeline as widget events.
Because the x map is where every varying detail lives — the intersection ratio, the video
threshold, the link type, the surface — the raw event table needs a JSONB column able to hold it
verbatim, and the rollup tables key on event_type alone rather than on any threshold baked into
a name. Section 5 owns both shapes; this section owns what goes in them.
17.4 Transport #
const ENDPOINT = 'https://cdn.vouch.app/api/v1/e';
const FLUSH_INTERVAL_MS = 5000;
const MAX_BATCH_EVENTS = 50;
const MAX_BATCH_BYTES = 32 * 1024;
function flush(): void {
if (!queue.length || suppressed) return;
const batch = takeBatch(); // applies the drop policy below
const body = new Blob([JSON.stringify(batch)], { type: 'text/plain;charset=UTF-8' });
const ok = navigator.sendBeacon?.(ENDPOINT, body) ?? false;
if (!ok) {
try {
fetch(ENDPOINT, { method: 'POST', body, keepalive: true, mode: 'no-cors', credentials: 'omit' });
} catch { /* silence is the contract */ }
}
}Rules:
| Concern | Decision |
|---|---|
| Primary transport | navigator.sendBeacon. It is queued by the browser and survives navigation, which is exactly what an analytics beacon needs. |
| Content type | text/plain;charset=UTF-8, deliberately. application/json is not a CORS-safelisted content type and would force a preflight OPTIONS, which browsers do not reliably send during unload — the batch would be lost precisely when it matters most. The server parses the body as JSON regardless of the declared type. |
| Fallback | fetch(..., { keepalive: true, mode: 'no-cors' }) when sendBeacon is absent or returns false (the browser's beacon queue is full). keepalive caps a request body at 64 KB across all in-flight keepalive requests, which our 32 KB batch cap respects with room to spare. |
| Credentials | credentials: 'omit' always. No cookie is ever attached to a beacon. |
| Cadence | At most one beacon per 5 seconds per page, across all widgets. |
| Immediate flush triggers | Queue reaches 50 events; queue reaches 32 KB; visibilitychange to hidden; pagehide. There is no beforeunload or unload listener — both harm back/forward-cache eligibility on the host page, which would be a real performance cost imposed on someone else's site. |
| Payload cap | 32 KB per beacon. |
| Failure handling | Silent. No retry queue, no exponential backoff, no console output, no error event on the host page. A dropped batch is lost, and that is the correct trade: analytics must never be able to degrade a page it does not own. |
| Idle scheduling | Batch serialisation runs inside requestIdleCallback with a 2000 ms timeout where available, and a setTimeout(0) otherwise. Flushes triggered by visibilitychange/pagehide run synchronously, because there may be no idle time left. |
| Suppression | Transmission is disabled for the entire page view when navigator.globalPrivacyControl === true or navigator.doNotTrack === '1'. We have no identifiers to begin with, so honouring these signals costs us nothing and is the right default. The dashboard notes that a small share of views is unmeasured for this reason. |
Drop policy when a batch exceeds 32 KB, applied in this order until the batch fits:
interactionevents beyond the first 5 per widget.video_progressevents (the 25/50/75 markers), oldest first.clickevents beyond the first 10 per widget.- Remaining
interactionevents, oldest first.
impression, video_start, video_complete, page_view, request_started,
request_captured, and request_submitted are never dropped — they are the events that
carry the funnel and the headline number. The batch envelope carries dropped: <count> so the
server can compute a truncation rate; the dashboard surfaces it when it exceeds 1% of events for
a workspace, because a persistent truncation rate means a widget is generating more interaction
than the cap anticipated and the cap should be revisited.
The batch envelope:
{ "s": 1, "v": "kZ8Qy3Tn0aRl7B2xJf4hVg", "sentAt": 1755583200123, "dropped": 0,
"events": [ { "t": "impression", "w": "wgt_01JQ…", "v": "kZ8Qy3Tn0aRl7B2xJf4hVg",
"ts": 1755583194001, "p": "https://northwind.example/pricing", "x": { "ratio": 0.87 }, "s": 1 } ] }17.5 Server-side processing #
17.5.1 Ingest #
POST https://cdn.vouch.app/api/v1/e, handled at the edge in the Next.js Edge runtime
(Section 3 states the framework line).
| Step | Detail |
|---|---|
| Accept | POST only. OPTIONS is answered for CORS with Access-Control-Allow-Origin: * and Access-Control-Allow-Headers: content-type. |
| Size | Body ≤ 32 KB. Larger requests are rejected with 413 and EVENT_BATCH_TOO_LARGE (envelope per Section 7.3). |
| Rate limit | 60 beacons per minute per IP at the edge, token bucket in Valkey (Section 7.10 owns the limiter). Exceeding it returns 429 with RATE_LIMITED and Retry-After; the client does not retry, by design. |
| Parse | Body parsed as JSON regardless of declared content type. A body that is not parseable JSON → 400 MALFORMED_JSON. A body that parses but is not a batch envelope → 400 INVALID_REQUEST_BODY. The two are different failures and carry different codes. |
| Validate | Zod schema in packages/validation: at most 50 events, every t in the enum, w matching the widget id format, ts an integer, p a valid origin-plus-path string ≤ 200 characters, x at most 6 keys with values ≤ 64 characters. Invalid events are dropped individually and counted; an invalid batch envelope is rejected. |
| Origin binding | The Origin header — or, when it is absent, the host component of the event's p field — is compared against the workspace's verified custom domains, its shared subdomain, and any origin in the widget's allowedOrigins (Section 13.6.1). A mismatch does not fail the request: the events are written to analytics_events_filtered with filter_reason = 'unverified_origin' and excluded from every rollup, exactly like bot traffic (Section 17.6). |
| Respond | 204 No Content for any batch that parsed, even if every event in it was dropped. The host page must never learn about our internal problems. Errors are returned only for malformed, oversized, or rate-limited requests. |
| Latency budget | p99 ≤ 25 ms at the edge. Nothing on this path touches the primary database. |
Why origin binding is not optional. This endpoint is unauthenticated by design, answers
Access-Control-Allow-Origin: *, and its only identifier is a widget id printed in plain text in
every customer's page source. Without a domain check, anyone who views source on a customer's
site can post arbitrary impressions and clicks for that customer's widgets: a competitor can bury
a conversion rate, or inflate clicks until the customer's own marketing decisions are made on
fabricated data. The per-IP bucket and the per-viewId heuristics in Section 17.6 do not close
this, because the client generates the viewId itself and a cheap proxy pool defeats a per-IP
limit — both defences assume a browser behaving normally, and this attacker is not one. Binding
an accepted event to a domain the workspace has actually proven it controls is the only check
that costs the attacker something they cannot mint.
The filtering behaviour is deliberately soft rather than a rejection, because a workspace can legitimately be running a widget on a site it has not registered as a custom domain, and silently zeroing their analytics would be worse than the attack. Unverified-origin events are counted, shown separately in the same breakdown as bot traffic, and a workspace with no verified domain sees a banner: "Verify your site to get accurate numbers — until then, impressions from unverified origins are shown separately and are not guaranteed."
17.5.2 Enrichment, and the disposal of the IP address #
Enrichment happens in the edge handler, in memory, before anything is written anywhere:
- Country is derived from the connecting IP using a country-level geolocation database bundled into the edge image and refreshed weekly. Country only — never region, never city, never coordinates, never autonomous-system number.
- Device class is derived from user-agent client hints (
Sec-CH-UA-Mobile,Sec-CH-UA-Platform) where the browser sends them, falling back to user-agent string parsing. The classes are exactlymobile,tablet,desktop,bot,unknown. - The IP address is then discarded. It is not written to
analytics_events_raw. It is not written toanalytics_events_filtered. It is not written to any rollup. It is not written to the request log for this route — the logger for/api/v1/eis configured with an explicit redaction ofremoteAddress,x-forwarded-for, andx-real-ip, and a test asserts that a request from a known address produces no log line containing it. It is not sent to the error tracker (Section 24 owns the scrubbing configuration). There is no code path that persists it. - The user-agent string itself is not stored either — only the derived device class and, for filtered events, the name of the bot rule that matched.
The batch's sentAt and each event's ts are compared against the server clock. A skew of more
than 10 minutes in either direction marks the batch for the impossible-timing filter (Section
17.6) rather than being trusted or silently corrected.
17.5.3 Write path #
The edge appends validated, enriched events to a Valkey stream (analytics:ingest). A worker
consumer (analytics.ingest, catalogued in Section 29.5) reads in batches of 500 with a 1-second
maximum wait and performs a single multi-row insert into analytics_events_raw (Section 5 owns
the schema), which is range-partitioned by day.
Decoupling the edge from Postgres this way means an ingest spike or a database failover costs queue depth, not dropped events and not host-page latency. The stream is capped at 5 million entries; if the consumer falls that far behind, the oldest entries are trimmed and the loss is recorded as a metric with an alert, because silently losing data without knowing is the one outcome worse than losing it.
Workspace resolution: the widget id determines the workspace. The worker resolves it through a
cache and writes workspace_id on every row, so every analytics row is workspace-scoped like
everything else and row-level security applies on read exactly as described in Section 6.
17.5.4 Rollups #
| Job | Schedule | Output grain |
|---|---|---|
analytics.rollup_hourly |
At 5 minutes past each hour, for the previous hour, plus a re-run of the 3 hours before that to absorb late arrivals | (workspace_id, widget_id, testimonial_id, event_type, country, device_class, hour_start_utc) → count, plus approx_views = the number of distinct viewIds in that bucket |
analytics.rollup_daily |
At 00:20 UTC, for the previous day in each workspace's reporting timezone, computed by summing hourly rows | Same grain with day_start in the workspace's timezone |
retention.sweep_analytics_raw |
Daily at 03:00 UTC | Detaches and drops raw event partitions older than 7 days |
retention.sweep_analytics_rollups |
Daily at 03:20 UTC, after the raw sweep | Deletes rollup rows older than the workspace's plan retention (Section 19: 30 days on Free, 12 months on Starter, 24 on Growth, 36 on Scale) |
Details that matter:
- Raw events are kept 7 days, for debugging and for recomputing a rollup after a bug fix. After 7 days the partition is detached and dropped — a metadata operation, not a mass delete.
approx_viewsis computed at rollup time from the distinctviewIds present in the raw events for that bucket, and theviewIds themselves are never copied into the rollup. Once the raw partition is dropped, the count survives and the identifiers do not. Because a view spanning an hour boundary is counted in both hours, summing hourlyapprox_viewsacross a day slightly overcounts; the daily rollup therefore recomputes distinct views over the whole day from raw data while it is still available, and the dashboard reads daily rows for multi-day ranges. This is why the metric is labelled approximate.- Late events arriving up to 2 hours after their
tsare absorbed by the 3-hour re-run window using an idempotent upsert keyed on the full grain. Later than that, they land in the hour they were received and the discrepancy is bounded and documented. - Rollups are idempotent. Re-running any window produces the same rows; there is no incremental accumulation that can double-count after a retry.
- Timezones. All hourly buckets are UTC. Workspaces set
reporting_timezone(default UTC); daily rollups and every chart axis use it, and every chart and export names the timezone explicitly so two people reading the same number are reading the same day.
17.6 Bot and unverified-traffic filtering #
Filtered events are counted and shown, never silently discarded. A customer who sees 40,000 impressions is entitled to know that 6,000 more were excluded and why. Two kinds of traffic land here: traffic we believe is automated, and traffic that arrived from an origin the workspace has not verified (Section 17.5.1). Both are excluded from the headline numbers, both are counted, and the breakdown names which is which — because "we think this was a bot" and "this came from a site you haven't told us about" call for entirely different actions from the customer.
17.6.1 Rules #
| Rule | Signal | Class |
|---|---|---|
| Known crawler user agent | Match against a maintained list bundled as bot-ua.json, regenerated monthly from the public crawler-user-agents dataset plus our own additions, versioned as filter_rule_version |
bot_ua |
| Declared bot client hint | Sec-CH-UA contains a brand on the known-bot list |
bot_ua |
| Missing user agent | No User-Agent header at all |
no_ua |
| Impossible timing | ts more than 10 minutes ahead of or behind server time; or events within one batch whose ts values are not monotonic per (widget, type); or an impression whose dwell is reported as less than the 1000 ms threshold that produced it |
bad_timing |
| Impossible geometry | Reported intersection ratio outside [0, 1]; or a video_complete with watchedSec greater than the item's known duration plus 10%; or video_progress thresholds arriving out of order |
bad_geometry |
| Implausible volume | More than 50 impression events sharing one viewId; or more than 200 events in a single viewId; or more than 20 beacons from one viewId |
volume |
| No-interaction sweep | A viewId producing more than 20 impressions and zero interaction, click, or video events |
no_interaction |
| Headless signature | Sec-CH-UA reporting a headless brand, or a user agent containing a known headless marker |
headless |
| Rate-limit overflow | The batch was accepted but the source exceeded the per-IP limit within the window | rate_limited |
| Unverified origin | The Origin header, or p's host when Origin is absent, is not one of the workspace's verified domains, its shared subdomain, or an entry in the widget's allowedOrigins (Section 17.5.1) |
unverified_origin |
Every rule is versioned. Filtered events are written to analytics_events_filtered with
filter_reason and filter_rule_version, retained for the same 7 days as raw events, and
counted into a parallel filtered_count column on the rollups so the count survives the raw
retention window.
17.6.2 Presentation and correction #
- Every analytics view shows a line: "≈ 6,120 events (13%) were excluded as automated or unverified traffic. See the breakdown." The breakdown lists counts by reason in plain language ("Known search-engine and monitoring crawlers", "Requests with impossible timing", "Views from a site you haven't verified", and so on), and the unverified-origin row links straight to the domain-verification flow in Section 14.2, because that one is fixable by the customer in about two minutes and the rest are not.
- A workspace can toggle a comparison view that shows totals with automated traffic included, so a customer can sanity-check the filter against their own server logs.
- Rules are conservative by design: it is better to count a bot as a human occasionally than to erase a real visitor. Any rule whose filtered share exceeds 25% of a workspace's events for three consecutive days raises an internal alert for review.
- Because
filter_rule_versionis stored, a rule found to be wrong can be reversed: a reclassification job re-evaluates filtered events still inside the 7-day raw window under the corrected rules and rebuilds the affected rollups. Beyond 7 days the historical counts stand, and the change log in the help panel records when a rule changed so a step in the chart has an explanation.
17.7 The reports #
Five views: Overview, Widgets, Testimonials, Video, and Collection. Charts
are rendered as plain SVG components in packages/ui — no charting dependency is added, because
the chart set is small (line, bar, horizontal bar, funnel, sparkline) and the dashboard's bundle
budget in Section 23 does not have room for a library. Every chart has a "View as table" toggle
and a visually-hidden table equivalent, so the data is available to assistive technology without
the toggle (Section 21).
17.7.1 Metric definitions #
These definitions are also rendered verbatim in the help panel, so two people reading the same number agree on what it means.
| Metric | Definition | Source |
|---|---|---|
| Impressions | The number of times a widget was at least 50% visible for at least one continuous second. One per widget per page view. | impression rollups |
| Views (approx.) | The number of distinct page views in which at least one of your widgets produced an impression. Approximate: without a persistent identifier, one person visiting on Monday and Tuesday counts twice, and two people on one shared device count twice — which is correct — but one person reloading the page also counts twice. | distinct viewId per bucket |
| Interactions | Deliberate actions inside a widget: play, next, previous, filter, sort, expand, load more, lightbox open, popup dismiss. | interaction rollups |
| Interaction rate | Interactions ÷ impressions, shown as a percentage. It can exceed 100%, because one impression can produce several interactions; that is not a bug and the tooltip says so. | derived |
| Clicks | Activations of a link inside a widget, including the author link, the source permalink, the call to action, and the badge. | click rollups |
| Click-through rate | Clicks ÷ impressions. Counts each (item, link type) once per view, so a user clicking the same link twice does not inflate it. |
derived |
| Video start rate | video_start ÷ impressions of widgets containing video. |
derived |
| Video completion rate | video_complete ÷ video_start. Never divided by impressions — completions of a video nobody started is a meaningless denominator. |
derived |
| Average watch time | The mean of watchedSec across video_complete and video_progress events, expressed in seconds and as a percentage of duration. |
x.watchedSec |
| Top performers | Testimonials ranked by click-through rate, restricted to those with at least 100 impressions in the selected range. Below that threshold a testimonial shows "Not enough data" rather than a noisy percentage. | derived |
| Collection funnel | Request-page views → started → captured → submitted, with the conversion rate between each pair and the drop-off count. | page_view, request_started, request_captured, request_submitted |
| Trend | The selected metric over 30, 90, or 365 days, compared to the immediately preceding period of equal length. The delta is rendered as text with a direction word ("up 12%", "down 4%", "unchanged") and an arrow icon — never colour alone. | daily rollups |
17.7.2 The views #
| View | Contents |
|---|---|
| Overview | Four stat tiles (impressions, views approx., interaction rate, click-through rate) each with a 30-day sparkline; a trend line chart with a metric selector; a country table; a device-class breakdown; the automated-traffic line from Section 17.6.2. |
| Widgets | One row per widget with impressions, views, interaction rate, CTR, and a sparkline; clicking a row opens a per-widget detail with the same charts scoped to that widget and a list of the pages (p values) it appears on, ranked by impressions. |
| Testimonials | One row per published testimonial with impressions, clicks, CTR, video start and completion rates where applicable, and which widgets it appears in. Sortable by any column; the 100-impression floor applies to rate columns. |
| Video | The completion funnel as a horizontal funnel chart (started → 25% → 50% → 75% → completed) for the whole workspace and per testimonial; average watch time; a video_error count with reasons, because a rising error rate is an operational signal the customer deserves to see. |
| Collection | The request-page funnel per request page, the drop-off between each step with an absolute count, retake counts distribution, and the split between video and written submissions. |
17.7.3 States #
Every chart and table implements four states explicitly:
| State | Behaviour |
|---|---|
| Loading | A skeleton with the chart's final dimensions so nothing shifts, aria-busy="true" on the container, and a visually-hidden "Loading analytics" message. Never a spinner that replaces the layout. |
| Empty — no data yet | For a workspace with no published widgets: an explanatory panel — "No widget has been seen yet. Analytics start the first time someone views a page with your widget on it." — plus a link to the widget builder. No zero-valued chart is drawn, because a flat line at zero reads as a failure. |
| Empty — no data in range | For a range with no events but data elsewhere: "No activity between 1 and 8 August. Try a wider date range." with a one-click widening. Distinct from the previous state, because the fix is different. |
| Error | "We couldn't load this chart." plus a retry button that refetches only that panel. Other panels keep their data. The request id is shown in small text so support can find the failure (Section 7.3.4 defines the request id and its correlation rules). |
| Partial | When the requested range extends beyond the plan's retention, the chart renders the available portion and labels the truncation: "Showing 12 months. Your plan retains 12 months of analytics — see plans." No silent truncation. |
Freshness: the dashboard reads hourly rollups for ranges up to 7 days and daily rollups beyond that. Data is therefore up to 65 minutes behind for the current hour; every view carries a "Data through 06:00 UTC · updated 12 minutes ago" line rather than implying it is live.
17.8 Export and the API #
17.8.1 CSV export #
POST /api/v1/analytics/exports with a range, a granularity, and a dimension set returns 202
with a job id (Section 7.3 defines the status codes). The analytics.export job builds the file
and emails the requester a link.
| Property | Value |
|---|---|
| Format | RFC 4180 CSV, UTF-8 with a byte-order mark so spreadsheet applications detect the encoding, \r\n line endings |
| Columns | date, hour (blank for daily granularity), timezone, widget_id, widget_name, testimonial_id, event_type, country, device_class, count, approx_views, filtered_count |
| Range | Capped at the plan's analytics retention; a longer range is clamped and the clamp is reported in the response and in a header row comment |
| Size | Up to 1,000,000 rows per file; beyond that the export is split into numbered files and delivered as a single ZIP |
| Delivery | A signed URL valid for 24 hours, single-workspace scoped, plus an in-dashboard download for the same 24 hours. The file is deleted from object storage after 7 days |
| Concurrency | One export job per workspace at a time; a second request returns 409 EXPORT_ALREADY_RUNNING |
17.8.2 API endpoints #
Section 7 owns the endpoint catalogue and the request/response envelopes; these are the analytics
entries in it. All require the analytics:read scope and are available on every plan (read-only
on Free at the reduced rate limit stated in Section 19).
| Endpoint | Returns |
|---|---|
GET /api/v1/analytics/summary |
Headline totals and rates for a range, plus the comparison period |
GET /api/v1/analytics/timeseries |
One series per requested metric at the requested granularity |
GET /api/v1/analytics/widgets |
Per-widget totals and rates, cursor-paginated per Section 7.4 |
GET /api/v1/analytics/testimonials |
Per-testimonial totals and rates, cursor-paginated |
GET /api/v1/analytics/funnels/video |
The video funnel counts |
GET /api/v1/analytics/funnels/request-page |
The collection funnel counts per request page |
POST /api/v1/analytics/exports |
Starts a CSV export; 202 with a job id |
GET /api/v1/analytics/exports/{id} |
Export job status and, when complete, the signed URL |
Shared query parameters:
| Parameter | Values | Default |
|---|---|---|
from, to |
ISO-8601 dates (2026-07-01) or date-times; interpreted in the workspace's reporting timezone; to is inclusive |
Last 30 days |
granularity |
hour, day, week, month. hour is available only for ranges of 7 days or less; a wider range with granularity=hour is refused with 422 GRANULARITY_TOO_FINE rather than silently downgraded, so an integration never mistakes daily rows for hourly ones |
day |
widgetId, testimonialId |
Filter to one entity | — |
country |
ISO 3166-1 alpha-2 | — |
deviceClass |
mobile, tablet, desktop, unknown |
— |
includeFiltered |
true returns the automated-traffic counts alongside the clean counts |
false |
compare |
previous_period, previous_year, none |
none |
Behaviour: a range beyond the plan's retention is clamped and the response's meta carries
{"rangeClamped": true, "retentionDays": 365}. Responses are cached for 60 seconds. Rate limits
follow the public API limits in Section 7.10. Every numeric field that is approximate is named
with an Approx suffix (viewsApprox), so an integration cannot mistake it for an exact count.
Available aggregation windows: hourly for the last 7 days, daily for the full retention period, weekly (ISO weeks, Monday start) and monthly derived from daily rows. The 7-day hourly window is a deliberate product limit, not a storage limit: hourly rollup rows are retained for 35 days for operational use (Section 5 owns the retention table), and the reports expose only the most recent 7 days of them because beyond a week an hourly axis is noise rather than signal. There is no minute-level granularity, because it would be noise on this volume and would tempt real-time interpretations the pipeline does not support.
17.9 Accuracy and honesty #
A help panel sits at the top of the analytics area — collapsed but always visible, never buried in documentation — headed "What these numbers can and can't tell you." Its content:
What they can tell you
- How often each widget is actually seen, and on which pages.
- Which testimonials people click, expand, and watch — the single most useful signal for deciding what to feature.
- Whether a video holds attention, and where people stop watching.
- Where visitors are, at country level, and what kind of device they use.
- Whether a change you made — a new layout, a different order, a new testimonial — moved any of the above, by comparing periods.
- How many people who reach a request page start, finish, and submit a testimonial.
What they can't tell you
- Who anyone is. We store no identifier that survives a page view, so there is no visitor profile, no returning-visitor count, and no cross-device view.
- Exact unique visitors. "Views (approx.)" counts page views in which a widget was seen. One person reloading counts twice; one person visiting on two days counts twice.
- A full attribution path. We cannot tell you that someone read a testimonial on Tuesday and bought on Friday. If you need that, connect your own analytics to the click events your site already receives.
- Anything about people who block beacons. Ad blockers, privacy browsers, and the Global Privacy Control signal all reduce what we see. Expect our numbers to run roughly 5–15% below raw server-side request counts for the same pages, and further below on privacy-focused audiences.
- Perfect bot separation. We filter conservatively and show you what we filtered (Section 17.6). Some automated traffic gets through and some real visitors are occasionally excluded.
- Real-time figures. The current hour is up to 65 minutes behind. Every view states the data cut-off.
Rules the product follows so these numbers stay trustworthy
- Approximate metrics are always labelled approximate, in the interface, in exports, and in API field names. There is no view anywhere in the product where an approximation is presented as exact.
- Filtered traffic is always shown, never silently removed.
- Ranges that exceed plan retention are labelled as truncated, never quietly shortened.
- When a filtering rule changes, the change and its date appear in this panel, so a step in a chart always has an explanation.
- We never invent a number to fill an empty chart. Empty means empty, and the panel says which kind of empty it is.
18. Integrations — Webhooks, Zapier, Slack, HubSpot #
18.1 Scope, principles, and plan gating #
Vouch integrates outward in four ways: raw webhooks for teams who write code, Zapier for teams who do not, Slack for the moderation loop where the team already lives, and HubSpot for pushing social proof into the CRM. Every one of them is built on the same three primitives so there is one thing to operate rather than four:
- The outbox. Every domain event is written to an
outbox_eventsrow (Sections 3.7 and 5.11.2) inside the same database transaction as the state change that produced it. A worker drains the outbox and fans out to webhook endpoints, Zapier subscriptions, Slack channels, and HubSpot sync jobs. Nothing is dispatched from inside a request handler, so a slow third party can never slow a moderator's click, and a rolled-back transaction can never emit a phantom event. integration_connections. One table (Section 5) holds every third-party connection for every provider: OAuth tokens, scopes granted, external account identifiers, health state, and error counters. Adding a fifth provider adds rows, not tables.- The shared signing and retry machinery. One signer (Section 7.18), one retry schedule, one delivery log, one replay action. Zapier REST hooks are ordinary webhook endpoints with a flag; Slack and HubSpot are outbox consumers with provider-specific transports.
Plan gating — the authoritative plan matrix is Section 19; this table states only which integrations each plan reaches:
| Integration | Free | Starter | Growth | Scale |
|---|---|---|---|---|
| Outbound webhooks | — | Yes | Yes | Yes |
| Zapier | — | Yes | Yes | Yes |
| Slack | — | Yes | Yes | Yes |
| HubSpot | — | — | Yes | Yes |
| Public REST API | Read-only, 30 req/min | Yes | Yes | Yes |
Attempting to create a connection the plan does not include returns 409 PLAN_FEATURE_UNAVAILABLE naming the required plan and linking to the upgrade page. A downgrade never deletes a connection: the connection is marked plan_suspended, dispatch stops, the Owner is emailed, and re-upgrading within 90 days restores it with its tokens and configuration intact. After 90 days the connection row is soft-deleted and its tokens are destroyed.
Every integration is workspace-scoped without exception. There is no cross-workspace event, no shared connection, and no global endpoint. The dispatcher loads a connection by workspace_id and sets app.current_workspace_id on its transaction exactly as a request does (Section 6), so RLS is the backstop for background dispatch too.
18.2 Webhook event catalogue #
Every event name is resource.past_tense_verb, lowercase, dot-separated, and permanent once shipped. New events may be added at any time; a subscriber to ["*"] receives them automatically, which is why "*" is offered.
This table is the single source of truth for event names. Appendix Section 29.4 is a mechanical regeneration of it with one added column, and any name that appears there and not here does not exist. Two naming decisions are load-bearing and are stated once, here: media events are media.* and never video.*, and the four events about a testimonial request sent to a respondent are invite.* — invitation.* refers exclusively to a workspace seat invitation and is used for nothing else.
| Event | Fires when | data object |
|---|---|---|
testimonial.submitted |
A respondent completes a submission, or a manual/imported testimonial is created. Always the first event for a testimonial. | testimonial |
testimonial.approved |
A moderator or API call moves pending → approved. |
testimonial |
testimonial.published |
A testimonial becomes publicly visible (approved or unpublished → published). |
testimonial |
testimonial.unpublished |
A published testimonial is deliberately pulled. | testimonial |
testimonial.rejected |
pending → rejected. Includes the moderator's reason. |
testimonial, reason |
testimonial.updated |
Any content, author, rating, tag, or feature-flag change on an existing testimonial. Not fired for status changes, which have their own events. | testimonial, changedFields |
testimonial.deleted |
A testimonial is soft-deleted and the 30-day restore window opens. | testimonial (minimal), deletedAt |
testimonial.purged |
A soft-deleted testimonial passes its restore window and is hard-purged: media assets destroyed, rows removed, CDN entries gone. The last event a testimonial ever emits, and the point after which no copy of it can be reconciled against this platform. | testimonialId, purgedAt |
testimonial.archived |
A testimonial moves to archived from rejected, unpublished, or approved. |
testimonial, previousStatus |
testimonial.erased |
A testimonial's content is destroyed by a data-subject erasure. The payload carries ids and timestamps only — never the erased content, which would defeat the erasure. | testimonialId, erasedAt, dsrReference |
media.ready |
A media asset reaches ready — transcode complete, playback id available, poster written. |
media, testimonialId |
media.failed |
A media asset reaches errored after upload or transcode failure. |
media, testimonialId, failure |
transcript.ready |
A transcript completes and its WebVTT file is written. | transcript (metadata only), testimonialId |
transcript.edited |
A human saves a transcript edit, bumping transcriptVersion. |
transcript, testimonialId, editedByUserId |
invite.sent |
An invite is handed to the email or SMS provider. | invite |
invite.opened |
The recipient loads the request page from an invite link. Named opened for familiarity; it is a page load, not a tracking pixel (Section 20.7). |
invite |
invite.submitted |
An invite results in a completed submission. Carries the resulting testimonial id. | invite, testimonialId |
invite.bounced |
The provider reports a hard bounce or an undeliverable number. Adds the address to the suppression list. | invite, bounce |
import.completed |
An import run finishes in succeeded or partial. |
importRun |
import.failed |
An import run finishes in failed, or is abandoned after exhausting retries. |
importRun, failure |
widget.published |
A widget is created, or its configuration changes in a way that alters the served payload. | widget |
domain.verified |
A custom domain reaches active with a valid certificate. |
domain |
domain.failed |
A custom domain reaches failed, or its certificate renewal fails. |
domain, failure |
consent.withdrawn |
A respondent withdraws consent from the portal, or a workspace records a withdrawal on their behalf. | testimonialIds, withdrawnAt, scope |
quota.threshold_reached |
A metered limit crosses 80 %, 90 %, 95 %, or 100 %. Fires once per threshold per metric per period; crossing back down resets it. Section 19.4 E2 owns the threshold set. | metric, threshold, used, limit |
quota.exceeded |
A metered limit reaches 100 % and the associated action is now refused. Fires in addition to the 100 % quota.threshold_reached, because "you are at the line" and "the next call will fail" are different facts an integrator acts on differently. |
metric, used, limit, blockedAction |
workspace.over_limit |
The workspace's limit_state becomes over_limit — new collection is paused while everything already published keeps serving. |
limitState, metric, overLimitSince |
member.role_changed |
A member's role changes, including a change made through ownership transfer. | member, previousRole, newRole |
webhook.endpoint_disabled |
An endpoint is auto-disabled by Section 18.5. reason is attempts_exhausted, gone, or failure_ratio. |
endpoint, reason, lastStatus, disabledAt |
export.ready |
An export job completes and its signed download URL is available. The URL is not in the payload — the consumer re-reads the export endpoint — because a webhook body is not a place to put a credential-bearing link. | exportId, dataset, rowCount, expiresAt |
erasure.completed |
A data-subject erasure finishes across every store, including the media provider and the CDN. | dsrReference, subjectEmailHash, completedAt, deletedItems |
subscription.updated |
Plan, interval, seat count, add-ons, or billing state changes, including dunning transitions. | subscription, previous |
All 32 events are workspace-scoped. There are no account-level, user-level, or global events.
Four of them are removal events, and they are not interchangeable. testimonial.deleted is a reversible soft delete. testimonial.purged is the irreversible end of that window. testimonial.erased is a data-subject erasure, which is a legal obligation rather than a housekeeping action. consent.withdrawn removes the right to display content that still exists. Any consumer that has copied testimonial content out of the platform must handle all four, because each one independently means "the copy you are holding must go".
18.3 Payload envelope and object schemas #
Every delivery body is a single JSON object with this fixed envelope. This is the canonical shape — eight keys, no more and no fewer, in this order — and any other rendering of it in this document, including Appendix Section 29.4, is a reproduction of it rather than a second definition:
{
"id": "evt_01JBQ8Q2CD2N9EJ6DF0LCD2RUV",
"type": "testimonial.published",
"apiVersion": "v1",
"createdAt": "2025-11-04T10:01:41.907Z",
"workspaceId": "wsp_01JBQ7S1C3A7X9QY3E8G1K5U7W",
"livemode": true,
"actor": { "type": "user", "id": "usr_01JBQ7W9F6D0A2TB6H1K4N8X0Z" },
"data": { }
}| Field | Meaning |
|---|---|
id |
evt_ + Crockford base32 UUIDv7. Stable across retries and replays. This is the deduplication key. |
type |
An event name from Section 18.2. |
apiVersion |
The payload version. Payload shapes follow the same versioning and 12-month sunset policy as the REST API (Section 7.11). |
createdAt |
When the event occurred, not when it was delivered. |
workspaceId |
The prefixed public workspace identifier (wsp_ + Crockford base32 UUIDv7, Section 5.1.1) — never a bare uuid. Every id anywhere in the envelope and its data follows the same rule. |
livemode |
false for events generated by a test-mode API key or by POST /webhooks/{id}/test. It is load-bearing: it is how a consumer keeps test traffic out of production side effects, and it is the wire half of the test-mode contract in Section 7.7.2. Required on every event, without exception. |
actor |
{ type: "user" | "api_key" | "system", id }, matching the audit trail's actor model (Section 11.6). |
data |
The object(s) named in the catalogue. |
Payloads embed a snapshot at event time, never a promise about current state. A consumer that needs the current state re-reads the REST API using the ids in the payload. Objects are the same shapes the REST API returns, minus fields the workspace has no business pushing to a third party: authorEmail is included only when the endpoint has explicitly opted in via includePii: true on the endpoint configuration (Section 18.4), and consent details are never included beyond the boolean summary.
testimonial.published, full payload:
{
"id": "evt_01JBQ8Q2CD2N9EJ6DF0LCD2RUV",
"type": "testimonial.published",
"apiVersion": "v1",
"createdAt": "2025-11-04T10:01:41.907Z",
"workspaceId": "wsp_01JBQ7S1C3A7X9QY3E8G1K5U7W",
"livemode": true,
"actor": { "type": "user", "id": "usr_01JBQ7W9F6D0A2TB6H1K4N8X0Z" },
"data": {
"testimonial": {
"id": "tst_01JBQ7Z4K9M2X7YV3C8N6P0R5T",
"status": "published",
"source": "collected",
"mediaType": "video",
"requestPageId": "rqp_01JBQ7U0D2B6W8PX2D7F0J4T6V",
"authorName": "Dana Okafor",
"authorRole": "Head of Support",
"authorCompany": "Northwind",
"authorAvatarUrl": "https://cdn.vouch.app/a/8f2c…/avatar.jpg",
"rating": 5,
"bodyText": "Vouch replaced three tools and a spreadsheet.",
"language": "en",
"isFeatured": true,
"tags": [ { "id": "tag_01JBQ7R4B2Z5V7NW1C6E9H3S5U", "name": "onboarding" } ],
"media": {
"id": "med_01JBQ7Z4K9M2X7YV3C8N6P0R5V",
"state": "ready",
"durationSeconds": 63.4,
"playbackId": "aBcD1234EfGh5678",
"posterUrl": "https://cdn.vouch.app/m/aBcD1234EfGh5678/poster.jpg",
"captionsUrl": "https://cdn.vouch.app/m/aBcD1234EfGh5678/en.vtt"
},
"consentSummary": { "publish": true, "advertising": false, "caseStudy": true },
"publicUrl": "https://stories.northwind.com/t/dana-okafor-onboarding",
"submittedAt": "2025-11-04T09:12:44.118Z",
"publishedAt": "2025-11-04T10:01:41.907Z"
}
}
}media.failed:
{
"id": "evt_01JBQ8R4DE3P0FK7EG1MDE3SVW",
"type": "media.failed",
"apiVersion": "v1",
"createdAt": "2025-11-04T09:31:02.004Z",
"workspaceId": "wsp_01JBQ7S1C3A7X9QY3E8G1K5U7W",
"livemode": true,
"actor": { "type": "system", "id": "worker.media" },
"data": {
"testimonialId": "tst_01JBQ7Z4K9M2X7YV3C8N6P0R5T",
"media": { "id": "med_01JBQ7Z4K9M2X7YV3C8N6P0R5V", "state": "errored", "mimeType": "video/webm;codecs=vp9,opus" },
"failure": {
"code": "TRANSCODE_FAILED",
"message": "The uploaded file could not be transcoded.",
"isRetryable": false,
"attempts": 3
}
}
}quota.threshold_reached:
{
"id": "evt_01JBQ8S6EF4Q1GM8FH2NEF4TWX",
"type": "quota.threshold_reached",
"apiVersion": "v1",
"createdAt": "2025-11-04T14:00:00.000Z",
"workspaceId": "wsp_01JBQ7S1C3A7X9QY3E8G1K5U7W",
"livemode": true,
"actor": { "type": "system", "id": "worker.quota" },
"data": {
"metric": "testimonials.collected",
"threshold": 95,
"used": 476,
"limit": 500,
"scope": "lifetime",
"state": "warn_95",
"collectionOpen": true,
"publishedSurfacesAffected": false
}
}metric is a usage_metric value from Section 5.2, written exactly as stored — the dotted key, not a camelCase paraphrase — so a consumer can switch on it against the same vocabulary the usage endpoint in Section 7.15.2 returns. publishedSurfacesAffected is present on every quota event and is always false. It exists so an integrator can assert in code what Section 19 guarantees in prose: reaching a cap never degrades anything already live.
consent.withdrawn:
{
"id": "evt_01JBQ8T8FG5R2HN9GJ3PFG5UXY",
"type": "consent.withdrawn",
"apiVersion": "v1",
"createdAt": "2025-11-04T15:12:09.331Z",
"workspaceId": "wsp_01JBQ7S1C3A7X9QY3E8G1K5U7W",
"livemode": true,
"actor": { "type": "system", "id": "respondent-portal" },
"data": {
"testimonialIds": ["tst_01JBQ7Z4K9M2X7YV3C8N6P0R5T"],
"scope": "publish",
"withdrawnAt": "2025-11-04T15:12:09.331Z",
"erasureRequested": false,
"effectiveBy": "2025-11-04T15:13:09.331Z"
}
}consent.withdrawn is the one event an integrator must handle. It means the testimonial has already been unpublished from every Vouch surface within 60 seconds, and any copy the integrator has cached, mirrored into a CMS, or synced to a CRM must be removed on their side too. The webhook documentation says exactly that, in bold, and both first-party integrations act on it rather than merely relaying it: Zapier exposes it as a trigger with an explicit delete-your-copy instruction (Section 18.6), and the HubSpot sync blanks the record's content automatically (Section 18.8). The same obligation attaches to testimonial.purged and testimonial.erased.
18.4 Endpoint management and secret rotation #
Endpoint configuration (created and edited through the endpoints in Section 7.15.20):
| Field | Type | Default | Notes |
|---|---|---|---|
url |
string | — | https:// only in production; SSRF-checked at registration and at every delivery |
events |
string[] | — | Event names, or ["*"] |
description |
string | "" |
≤ 200 characters, for the operator's own reference |
isEnabled |
boolean | true |
A disabled endpoint queues nothing; events are not backfilled on re-enable |
includePii |
boolean | false |
When true, authorEmail and authorPhone appear in payloads. Turning it on requires Owner or Admin and writes an audit event |
apiVersion |
string | current | Pinned per endpoint, so a payload change does not break an existing consumer until they opt in |
secret |
string | generated | whsec_ + 32 random base62 characters, shown in full once at creation and once per rotation |
mode |
enum | live |
live or test; a test endpoint receives only livemode: false events |
How many endpoints a workspace may register is a plan limit and lives in the Section 19.2 matrix with every other plan number; creating one past it is 409 WEBHOOK_ENDPOINT_LIMIT_REACHED. Endpoints created by Zapier count toward it like any other.
Secret rotation with an overlap window. POST /webhooks/{id}/rotate-secret body { "overlapHours": 24 } (allowed: 0, 1, 24, 168) generates a new secret and keeps the old one active for the window. During the overlap every delivery is signed with both secrets and the Vouch-Signature header carries two v1= values:
Vouch-Signature: t=1762247400,v1=6f0c1e4d…,v1=9b3d7a2c…A receiver accepts the delivery if any v1 value verifies, which is exactly what the verification helper in Section 7.18 does. The consumer therefore deploys the new secret at their leisure inside the window with no dropped deliveries and no coordinated release. When the window closes the old secret is destroyed and deliveries carry one signature again. The endpoint's Owner is emailed at rotation and at window close.
An endpoint may be tested at any time with POST /webhooks/{id}/test, which sends a synthetic testimonial.published event with livemode: false and ids prefixed evt_test_, then returns the full request and response so a misconfiguration is diagnosed in one click rather than by tailing logs.
18.5 Delivery semantics #
Transport. POST with Content-Type: application/json, the headers from Section 7.18, a 5-second connect-and-read timeout, TLS certificate verification always on, no redirect following, and a body capped at 256 KB. A payload that would exceed 256 KB (a testimonial with an enormous transcript, for example) is truncated to the object's identifying fields plus "truncated": true, and the consumer re-reads the REST API. A delivery is never dropped for size.
Success and failure. Any 2xx is success. Everything else — 3xx, 4xx, 5xx, connection refused, DNS failure, TLS failure, timeout — is failure and is retried.
Retry schedule — 9 attempts total: the first send plus 8 retries. The eight delays below are the eight retries; counting the delays as the attempts is the arithmetic error to avoid, and every other statement of the retry count in this document is derived from this table.
| Attempt | Delay after previous | Elapsed |
|---|---|---|
| 1 (first send) | — | 0 |
| 2 | 1 minute | 1 m |
| 3 | 5 minutes | 6 m |
| 4 | 30 minutes | 36 m |
| 5 | 2 hours | 2 h 36 m |
| 6 | 5 hours | 7 h 36 m |
| 7 | 10 hours | 17 h 36 m |
| 8 | 24 hours | 41 h 36 m |
| 9 (final) | 24 hours | 65 h 36 m |
Each delay carries up to ±10 % jitter so a consumer recovering from an outage is not hit by a synchronized thundering herd of every workspace's backlog at once.
Auto-disable. There are exactly three rules that disable an endpoint, and each records a distinct reason so the operator, the notification, and the webhook.endpoint_disabled event all say the same thing:
reason |
Rule |
|---|---|
attempts_exhausted |
All 9 attempts for one event failed. |
gone |
The endpoint returned 410 Gone on any attempt — the documented way for a consumer to say "stop, permanently". Disables immediately, without waiting for the remaining retries. |
failure_ratio |
More than 90 % of the endpoint's deliveries failed across a rolling 200-delivery window. This catches the endpoint that is up but broken, which the per-event rule alone never reaches because each individual event eventually gives up quietly. |
On any of the three, isEnabled is set to false, the reason is recorded, webhook.endpoint_disabled is emitted to the workspace's other endpoints, and the workspace Owner is emailed with the endpoint URL, the last response status and body excerpt, and a one-click re-enable link. Re-enabling does not backfill missed events; the delivery log lists them and each can be replayed individually or in bulk.
Delivery log. Every attempt writes a webhook_deliveries row capturing the event id, endpoint id, attempt number, request URL, request headers (with the signature redacted to its first 8 characters), the request body, the response status, the response headers, the first 4 KB of the response body, the duration in milliseconds, and the outcome. Bodies are stored for 30 days and then hard-deleted; the metadata is retained for 90 days. GET /webhooks/{id}/deliveries supports filtering by eventType, outcome (succeeded, failed, pending), eventId, and a date range, with cursor pagination per Section 7.4.
Replay. POST /webhooks/{id}/deliveries/{deliveryId}/replay re-sends the original payload byte-for-byte with a fresh signature and timestamp, a new Vouch-Delivery-Id, and the same Vouch-Event-Id — so a correctly-written consumer deduplicates it rather than double-processing. It requires an idempotency key (Section 7.9.2). Replay is available for 30 days, which is the payload retention window.
Ordering. There is no ordering guarantee across events. Retries, jitter, and parallel dispatch mean a testimonial.published can arrive before the media.ready that preceded it. Per-resource ordering is best-effort: the outbox worker processes events for a single testimonial id on one partition in sequence, so the common case is ordered, but a retry breaks it and consumers must not depend on it. Every payload carries createdAt and the object's updatedAt, so a consumer can discard a stale update by comparing timestamps — the documentation shows that pattern explicitly.
At-least-once. Delivery is at-least-once, never exactly-once. A consumer that acknowledges slowly, times out after processing, or is replayed will see the same event.id more than once. Consume idempotently: record event.id in a table with a unique constraint, ignore duplicates, and make the side effect safe to repeat. This is stated at the top of the webhook documentation, in the endpoint-creation UI, and in the example receiver in Section 7.18.
18.6 Zapier #
Integration definition. Vouch publishes a Zapier app whose definition lives in integrations/zapier/ in the repository and is deployed by the Zapier CLI from CI on tagged releases.
Authentication: API key. The Zapier auth form asks for a single field, the key, labelled with a link to https://app.vouch.app/settings/api-keys and a note that the key must carry testimonials:read, testimonials:write, and webhooks:manage. The connection test calls GET /workspaces/current; the connection label is the workspace name, so a user with several workspaces can tell their connections apart. A 401 from any subsequent call marks the connection as needing reconnection in Zapier's UI rather than failing silently.
Triggers — all six are REST hook (instant) subscriptions. The first four carry a polling fallback that Zapier uses to fetch sample data and to recover when a subscription is lost:
| Trigger | Event subscribed | Polling fallback |
|---|---|---|
| New testimonial submitted | testimonial.submitted |
GET /testimonials?sort=createdAt:desc&limit=25 |
| Testimonial published | testimonial.published |
GET /testimonials?status=published&sort=publishedAt:desc&limit=25 |
| New video ready | media.ready |
GET /testimonials?mediaType=video&sort=createdAt:desc&limit=25 |
| Import completed | import.completed |
GET /import-runs?sort=createdAt:desc&limit=25 |
| Consent withdrawn — strongly recommended for any Zap that copies testimonial content out of the platform | consent.withdrawn |
None. Sample data comes from the checked-in fixture |
| Testimonial purged — strongly recommended for any Zap that copies testimonial content out of the platform | testimonial.purged |
None. Sample data comes from the checked-in fixture |
The last two exist because the platform makes a promise on the customer's behalf that only the customer's own automations can keep. A Zap that copies a testimonial into a CMS, a spreadsheet, a CRM, or a slide deck creates a copy Vouch cannot reach. When a respondent withdraws consent, Vouch unpublishes everything it controls within 60 seconds — but the row in someone's Google Sheet keeps the quote of a named human who has revoked permission to use it, and the platform has already told that person, in plain words, that withdrawing removes their testimonial everywhere. Closing that gap is not optional politeness; a testimonial is attributed personal data, and continuing to publish it after withdrawal is exactly the exposure the consent model exists to prevent.
Both triggers therefore carry this instruction in the Zapier UI description, not only in the developer documentation: a Zap that stores testimonial content elsewhere must subscribe to these events and delete its copy when one arrives. consent.withdrawn carries the affected testimonial ids and the withdrawal timestamp; testimonial.purged carries the testimonial id and the purge timestamp. Neither can be polled for — an absence is not listable, and an unpublish for consent reasons is indistinguishable by polling from an ordinary one — so they are REST-hook-only by necessity, and their sample payloads come from the same contract-test fixtures as every other sample.
Subscribe and unsubscribe are ordinary webhook endpoint operations: Zapier calls POST /webhooks with the target URL, the single event name, and description: "Zapier: <zap name>", and Vouch returns the endpoint id, which Zapier stores and passes to DELETE /webhooks/{id} on unsubscribe. Zapier-created endpoints are flagged managedBy: "zapier", are hidden from the ordinary webhook settings list to avoid confusing a user into deleting one, and appear instead on the Zapier connection panel. Deleting the Zapier connection removes all of its endpoints.
Zapier-managed endpoints are exempt from auto-disable-on-email: Zapier's own deactivation handling takes over, and a 410 Gone from Zapier — which is how Zapier signals a deleted Zap — removes the endpoint immediately and silently.
Actions:
| Action | Calls | Notes |
|---|---|---|
| Create testimonial | POST /testimonials |
Fields: author name (required), email, role, company, rating, body text, tags, source URL. Sends an Idempotency-Key derived from the Zap run id, so a Zapier replay never duplicates a testimonial |
| Tag testimonial | PUT /testimonials/{id}/tags |
Accepts tag names and resolves them to ids, creating none — an unknown name returns a clear error naming the available tags |
| Publish testimonial | POST /testimonials/{id}/publish |
Surfaces CONSENT_MISSING as a human-readable Zap error rather than a generic failure |
| Send invite | POST /request-pages/{id}/invites |
Dynamic dropdown of request pages; one recipient per Zap run; surfaces suppression skips as a warning in the run log |
Searches:
| Search | Calls | Behaviour |
|---|---|---|
| Find testimonial by email | GET /testimonials?q={email}&limit=1 |
Returns the most recent match; pairs with "Create testimonial" as a find-or-create |
| Find contact | GET /contacts?q={email or name}&limit=1 |
Same pattern |
Sample payloads for the Zapier UI are checked into the app definition and are generated from the same fixtures as the contract tests (Section 25), so a sample can never drift from the real shape. Every sample uses obviously-fictional data and livemode: false.
App-review checklist — the list CI asserts before a release is submitted:
- Every trigger, action, and search has a sample payload and an output field schema with human labels.
- Authentication includes a connection test and a connection label.
- Every dynamic dropdown paginates with the cursor model (Section 7.4) and does not attempt
page-based paging. - Errors surface
error.messagefrom the envelope in Section 7.3.3, never a raw HTTP status. 429responses are retried withRetry-Afterhonoured; Zapier'sHaltedErroris used for permanent business failures such as a plan limit, so the user sees a clear reason instead of a retry storm.- Unsubscribe is implemented and verified to delete the endpoint.
- No action performs a destructive operation without an explicit, separately-named action; there is no "delete testimonial" action at all.
- The
consent.withdrawnandtestimonial.purgedtriggers are present, and each carries the delete-your-copy instruction in its user-facing description — asserted as a string match, because that sentence is the only thing standing between a respondent's withdrawal and a stale copy in a customer's spreadsheet. - The app requests no scope it does not use.
18.7 Slack #
Install flow. Standard Slack OAuth 2.0 (v2). The dashboard sends the admin to Slack's /oauth/v2/authorize with a signed, single-use state bound to the workspace and user and valid 10 minutes. Requested bot scopes are exactly three:
| Scope | Why |
|---|---|
chat:write |
Post moderation messages and update them after an action |
channels:read |
List public channels for the channel picker |
incoming-webhook |
The install flow's channel selection, giving a default destination without asking for broader scopes |
Vouch requests no users:read.email, no files:*, and no history scopes. Because it does not read Slack emails, the Slack-user-to-member mapping is explicit rather than inferred — see below.
The callback exchanges the code, stores the bot token in integration_connections under provider slack with envelope encryption (Section 18.9), records the Slack team id and name, and returns the admin to a configuration screen.
Channel routing. Configuration is per event type, so a busy workspace does not drown one channel:
| Event | Default channel | Configurable |
|---|---|---|
testimonial.submitted |
the install channel | Yes |
testimonial.published |
the install channel | Yes |
media.failed |
none (off by default) | Yes |
import.completed / import.failed |
none (off by default) | Yes |
domain.verified / domain.failed |
none (off by default) | Yes |
quota.threshold_reached |
the install channel | Yes |
Each row can be set to a channel, or to "off". Posting to a channel the bot is not a member of returns Slack's not_in_channel; Vouch catches it, posts nothing, and surfaces an actionable banner in the integration settings telling the admin to invite the bot with /invite @Vouch, rather than silently failing.
Block Kit layout for a new submission:
{
"channel": "C05ABCDEF12",
"text": "New testimonial from Dana Okafor (5 stars) — Northwind",
"blocks": [
{
"type": "header",
"text": { "type": "plain_text", "text": "New testimonial · pending review", "emoji": false }
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Dana Okafor* · Head of Support at Northwind\n★★★★★ 5 of 5\n\n> Vouch replaced three tools and a spreadsheet. Setup took an afternoon and the widget hasn't needed a single change since…"
},
"accessory": {
"type": "image",
"image_url": "https://cdn.vouch.app/m/aBcD1234EfGh5678/poster.jpg",
"alt_text": "Video thumbnail: Dana Okafor speaking to camera"
}
},
{
"type": "context",
"elements": [
{ "type": "mrkdwn", "text": "🎥 Video · 1:03 · captions ready" },
{ "type": "mrkdwn", "text": "Collected via *Northwind stories*" },
{ "type": "mrkdwn", "text": "Submitted <!date^1762247564^{date_short_pretty} at {time}|4 Nov 2025 09:12 UTC>" }
]
},
{
"type": "actions",
"block_id": "tst_01JBQ7Z4K9M2X7YV3C8N6P0R5T",
"elements": [
{ "type": "button", "style": "primary", "text": { "type": "plain_text", "text": "Approve" },
"action_id": "testimonial_approve", "value": "tst_01JBQ7Z4K9M2X7YV3C8N6P0R5T",
"confirm": { "title": { "type": "plain_text", "text": "Approve this testimonial?" },
"text": { "type": "mrkdwn", "text": "It will move to *approved* and become publishable." },
"confirm": { "type": "plain_text", "text": "Approve" },
"deny": { "type": "plain_text", "text": "Cancel" } } },
{ "type": "button", "style": "danger", "text": { "type": "plain_text", "text": "Reject" },
"action_id": "testimonial_reject", "value": "tst_01JBQ7Z4K9M2X7YV3C8N6P0R5T" },
{ "type": "button", "text": { "type": "plain_text", "text": "Open in Vouch" },
"action_id": "testimonial_open", "url": "https://app.vouch.app/testimonials/tst_01JBQ7Z4K9M2X7YV3C8N6P0R5T" }
]
}
]
}Layout rules that are not negotiable:
- The rating is rendered as filled stars and the text "5 of 5", never stars alone, so a screen reader in Slack conveys the value (Section 21).
- The quote is truncated to the first 240 characters on a word boundary with a single-character ellipsis. Truncation is what keeps the channel readable; the full text is one click away.
- The video thumbnail uses the Vouch-hosted poster on
cdn.— never a Mux URL — so the image survives a Mux outage, and it always carries meaningfulalt_text. - The
textfallback field is a complete sentence, because that is what appears in notifications and in clients that cannot render blocks. - The
block_idon the actions block carries the testimonial id, so an interaction can be attributed even if the button value is ever changed. - A text-only testimonial omits the image accessory rather than substituting a placeholder graphic.
Interactive components. POST /api/v1/hooks/slack/interactivity receives button clicks. The handler:
- Verifies
X-Slack-Signature(v0 scheme) overv0:{timestamp}:{rawBody}withcrypto.timingSafeEqual, rejecting a timestamp older than 5 minutes. Unverifiable requests get401and increment a security metric. - Acknowledges within 3 seconds with an empty
200, before doing any work. Slack's 3-second rule is hard; the handler does signature verification and a payload parse only, then enqueues. - Performs the moderation action in a worker and then updates the original message in place with
chat.update, replacing the buttons with a result context block: "Approved by @dana · 4 Nov 10:01". Replacing the buttons prevents double-clicks and makes the channel a truthful record. - On failure — an invalid transition, a withdrawn consent, a read-only workspace — updates the message with the plain-language reason from
error.messageand leaves the buttons in place so the user can retry after fixing the cause.
Audit attribution. An in-Slack approval is recorded in audit_events with actor_type: "user" and the workspace member's user id, resolved through the slack_user_id → workspace_member_id mapping stored on the connection. The mapping is populated by an explicit link step: the first time a given Slack user presses a button, Vouch opens a modal asking them to confirm which Vouch account they are, verified by a one-time code emailed to that account's address. Until they complete it, the action is not performed: the modal explains why, and the message is left untouched. Vouch never guesses the mapping from a display name, and it never performs a moderation action attributed to system when a human pressed the button — an audit trail that says "system approved this" when a person did is worse than no audit trail. If the mapped member is later removed from the workspace or demoted below testimonial:moderate, the button returns a "you no longer have permission" message and the mapping is marked stale.
Uninstall and token revocation. Vouch subscribes to Slack's app_uninstalled and tokens_revoked events at POST /api/v1/hooks/slack/events. On either, the connection is marked revoked, the encrypted token is destroyed immediately (not merely flagged), all channel routing is disabled, and the workspace Owner is emailed. Any Slack API call that returns token_revoked, account_inactive, or invalid_auth triggers the same path, because an uninstall event can be missed.
Rate limits. Slack's Web API is tiered per method. The Slack transport uses a dedicated BullMQ queue with a concurrency of 1 per Slack team and a token bucket sized to Slack's Tier 3 (roughly 50 requests per minute), respects Retry-After on 429 exactly, and retries with the same schedule as webhooks. If a workspace generates more Slack messages than the bucket allows for 10 consecutive minutes, Vouch switches that channel to a digest: one message every 15 minutes summarizing the count with a link to the moderation inbox, and it says so in the message. Dropping notifications silently is not an option.
18.8 HubSpot #
Install. HubSpot OAuth 2.0. Scopes requested:
| Scope | Why |
|---|---|
crm.objects.contacts.read |
Match a testimonial's author to an existing contact by email |
crm.objects.contacts.write |
Write testimonial summary properties onto the contact |
crm.schemas.custom.read |
Detect whether the Testimonial custom object already exists |
crm.schemas.custom.write |
Create the Testimonial custom object and its properties on first connect |
crm.objects.custom.read |
Read back synced records for reconciliation |
crm.objects.custom.write |
Create and update Testimonial records |
oauth |
Token refresh |
HubSpot is Growth and Scale only (Section 19). The connection stores the portal id, the granted scope list, the refresh token, and the access token with its expiry.
Object model. A Vouch testimonial maps to:
- A HubSpot Contact, matched by email (case-insensitive, trimmed). Never created blind — see the missing-contact rule below.
- A HubSpot custom object
Testimonial, created by Vouch on first connect, with this property schema:
| HubSpot property | Type | Source |
|---|---|---|
vouch_testimonial_id |
string (unique) | testimonial.id. The idempotency key for every upsert |
vouch_status |
enumeration | pending, approved, published, unpublished, rejected, archived |
vouch_author_name |
string | authorName |
vouch_author_role |
string | authorRole |
vouch_author_company |
string | authorCompany |
vouch_rating |
number | rating, 1–5 |
vouch_body_text |
string (multi-line) | bodyText, truncated to 65 000 characters |
vouch_media_type |
enumeration | text, video |
vouch_video_url |
string | Public playback URL, empty for text |
vouch_poster_url |
string | Vouch-hosted poster on cdn. |
vouch_transcript_url |
string | WebVTT URL, empty when absent |
vouch_public_url |
string | Showcase permalink |
vouch_tags |
string | Comma-separated tag names |
vouch_language |
enumeration | en, es, fr, de, pt |
vouch_source |
enumeration | collected, manual, csv, google, g2, capterra, x |
vouch_consent_advertising |
bool | consentSummary.advertising |
vouch_consent_case_study |
bool | consentSummary.caseStudy |
vouch_submitted_at |
datetime | submittedAt |
vouch_published_at |
datetime | publishedAt, empty when never published |
vouch_last_synced_at |
datetime | Set by the sync job |
On the Contact, Vouch creates and maintains four summary properties in a Vouch property group: vouch_testimonial_count (number), vouch_latest_rating (number), vouch_latest_testimonial_url (string), vouch_has_video_testimonial (bool). It touches no standard HubSpot property, ever.
Property creation happens once, on first connect, guarded by a read of the existing schema so a reconnect is a no-op. If a property of the same name exists with an incompatible type, Vouch does not modify it: the connection surfaces a clear conflict error naming the property and the expected type, and asks the admin to resolve it in HubSpot. Silently coercing a customer's CRM schema is unacceptable.
Association. Each Testimonial record is associated to its Contact using a Vouch-defined association type label Testimonial → Contact created at first connect. When no contact match exists the Testimonial record is still created, unassociated, with vouch_author_name populated — the social proof is the valuable part and it should not be lost because the person is not in the CRM.
Sync direction: one-way, Vouch → HubSpot. This is deliberate. Vouch is the system of record for testimonial content and, more importantly, for consent. A two-way sync would let a CRM edit change the wording of something a named human agreed to publish, which is precisely the failure the verbatim and consent rules in Sections 12 and 22 exist to prevent. HubSpot users may add their own properties to the Testimonial object and Vouch will never overwrite them; Vouch writes only the vouch_* properties it owns.
Sync triggers. From the outbox: testimonial.published (upsert plus contact summary refresh), testimonial.updated (upsert of changed properties only), testimonial.unpublished (sets vouch_status, clears vouch_published_at, refreshes the contact summary), testimonial.deleted and consent.withdrawn (sets vouch_status and blanks vouch_body_text, vouch_video_url, vouch_poster_url, vouch_transcript_url, vouch_public_url — the record remains as a stub so the CRM's history is not silently rewritten, but the withdrawn content is removed from it). A nightly reconciliation job re-reads records modified in the last 48 hours and repairs drift.
Conflicts. Every write is an upsert keyed on vouch_testimonial_id. If a record with that id exists, it is updated; otherwise created. If HubSpot reports a duplicate unique-property violation, the job re-reads by that property and updates the found record. A HubSpot-side deletion is detected on the next sync and the record is recreated, because Vouch's state is authoritative for vouch_* data; the reconciliation log notes it so an admin who deleted it deliberately can see why it came back.
Batch limits and backoff. Writes use HubSpot's batch endpoints at 100 records per call. The transport respects HubSpot's per-app rate limits (a 10-requests-per-second burst and a daily cap), uses a per-portal queue with concurrency 1, honours Retry-After on 429, and retries 5xx on the standard schedule. When a daily cap is exhausted the queue pauses until the cap resets and the integration status page shows the pause with the reset time. Token refresh happens at 5 minutes before expiry under the shared refresh lock in Section 18.9.
Disconnect. DELETE /import-connections/{id} for the HubSpot connection revokes the refresh token with HubSpot, destroys the stored tokens, and stops all sync. It does not delete the custom object, its properties, the association type, or any synced record. That data now belongs to the customer's CRM, and deleting it on disconnect would destroy CRM history that other HubSpot workflows, lists, and reports may depend on. The disconnect confirmation dialog says so plainly and offers a link to HubSpot's own object-deletion screen for anyone who does want it gone. Reconnecting later resumes syncing into the same object with no data loss.
18.9 Common integration concerns #
The integration_connections table. One row per (workspace, provider, external account). Columns are owned by Section 5; its role here is to be the single place any provider's connection state lives: provider, external_account_id, external_account_label, scopes_granted, access_token_ciphertext, refresh_token_ciphertext, token_expires_at, state (connected, needs_reauth, plan_suspended, revoked, error), last_success_at, last_error_at, last_error_code, consecutive_failures, config (JSONB, provider-specific settings such as Slack channel routing), and the standard timestamps. Because it is workspace-scoped and RLS-protected, no code path can load another tenant's tokens even if a job payload is wrong.
Token storage — envelope encryption. Tokens are never stored in plaintext and never in an environment variable.
- A random 256-bit data encryption key (DEK) is generated per connection.
- Tokens are encrypted with AES-256-GCM under the DEK, with a random 96-bit nonce and the connection id as additional authenticated data, so a ciphertext moved to another row fails to decrypt.
- The DEK is encrypted ("wrapped") by a key encryption key (KEK) held in the cloud KMS and never exported. The wrapped DEK is stored alongside the ciphertext.
- Decryption calls KMS to unwrap, holds the DEK in memory for at most 5 minutes, and never logs it.
- KEK rotation is a KMS operation; a background job re-wraps every DEK without touching ciphertexts. DEK rotation happens on every token refresh.
- Database backups therefore contain only wrapped keys. A stolen backup without KMS access yields nothing.
Token refresh with a shared lock. Multiple workers can process events for the same connection simultaneously; without coordination they would all notice an expired token and all call the provider's refresh endpoint, and providers that rotate refresh tokens on use would invalidate each other's, permanently breaking the connection. The refresh path therefore:
- Acquires a Valkey lock
lock:oauth-refresh:{connectionId}with a 30-second TTL and a unique fence token. - Re-reads the connection after acquiring the lock; if another worker already refreshed, it uses the new token and releases immediately.
- Refreshes, writes the new tokens in one transaction, then releases the lock with a compare-and-delete on the fence token so a slow worker cannot release someone else's lock.
- A worker that cannot acquire the lock within 10 seconds waits for the new token with a bounded poll rather than refreshing itself.
- Refresh is proactive at 5 minutes before expiry, so the common path never races an expiry at all.
A refresh that fails with an invalid-grant error sets state = needs_reauth, stops dispatch, emails the Owner and Admins, and raises a banner in the dashboard. Vouch does not retry an invalid grant — the user must re-authorize.
Connection health checks. A scheduled job probes every connected connection every 6 hours with the cheapest read each provider offers (Slack auth.test, HubSpot the token-info endpoint, Google Business Profile an account list, X a credential check). Results update state, last_success_at, and consecutive_failures. Three consecutive failures set state = error and notify; recovery clears it automatically. Health checks are staggered by a hash of the connection id so they do not all fire on the hour.
Per-workspace integration status page. Settings → Integrations shows, for every integration:
| Column | Content |
|---|---|
| Status | Connected, Needs reconnection, Paused (plan), Rate limited until …, Error, Not connected — each with an icon and the text label, never colour alone (Section 21) |
| Connected account | The external account label and the connecting user |
| Last successful sync | Absolute timestamp plus a relative hint |
| Recent activity | The last 20 deliveries or sync operations with outcome and duration |
| Last error | The error code, the plain-language message, and a "what to do" line |
| Actions | Reconnect, test, disconnect, and for webhooks, view deliveries and replay |
The same data is available at GET /api/v1/integrations/status (auth session | key, scope webhooks:manage) so a customer can monitor it externally. It is the first place support looks, so it shows the actual last error rather than a generic "something went wrong".
19. Plans, Billing & Limit Enforcement #
19.1 Scope, Ownership & Governing Principles #
This section is the single canonical definition of what each plan includes, how every limit is
counted, how every limit is enforced, which billing states exist, and how money moves. Plan
numbers, quota metric keys, quota error codes and their HTTP statuses, the billing_state and
limit_state vocabularies, and the entitlement rules derived from them are defined here and
nowhere else. Every other section — the collection flows in Section 8, the media pipeline in
Section 9, the moderation surfaces in Section 11, the widget payload in Section 13, the custom
domains in Section 14, the integrations in Section 18 — references this section rather than
restating a number. If any other section appears to contradict this one on a plan boundary, a
quota, a billing state, or an upgrade prompt, this section wins and the other section is the
defect. Error codes raised by quota enforcement are catalogued once in Section 29.3; the HTTP
envelope those errors travel in is owned by Section 7.3. Where this section names a schema object
it is naming something Section 5 defines; Section 5 owns the DDL and this section owns the
semantics.
Four principles govern everything below. They are not aspirations — each one maps to a mechanical invariant in Section 19.5 and to a test in Section 19.13.
- Enforcement lives on the server. A limit that is only enforced in the browser is not enforced. The dashboard renders disabled buttons and usage meters as a courtesy; the quota service is what actually says no.
- Billing state never reaches the public surfaces. The customer's own website carries our embed. Degrading that embed because an invoice failed would damage a third party's page to collect a debt from our customer. We do not do it, ever, under any plan or billing state.
- Limits gate creation, never retention. Passing a cap stops new things from being made. It never deletes, unpublishes, downgrades, throttles, or hides anything that already exists.
- Stripe is the source of truth for subscription state; our database is the source of truth for entitlements. We never compute entitlement by calling Stripe inline. Stripe webhooks write a projection into our own tables, and every entitlement check reads that projection.
19.2 The Plan Matrix #
Prices are USD per month when billed monthly. Annual billing is 10× the monthly price for twelve months — two months free — charged as a single up-front invoice.
| Free | Starter | Growth | Scale | |
|---|---|---|---|---|
| Monthly price (USD) | $0 | $19 | $49 | $149 |
| Annual price (USD, billed yearly) | $0 | $190 | $490 | $1,490 |
| Free trial | 14-day Growth trial, no card required | — | — | — |
| Collected testimonials (lifetime cap) | 10 | 100 | 500 | 5,000 |
| Seats | 1 | 3 | 10 | 25 |
| Request pages | 1 | 5 | 25 | Unlimited |
| Widgets | Unlimited | Unlimited | Unlimited | Unlimited |
| Widget views / impressions | Unlimited | Unlimited | Unlimited | Unlimited |
| "Powered by Vouch" badge | Shown, locked | Removable | Removable | Removable |
| Max video length per take | 2 min | 5 min | 5 min | 5 min |
| Max video resolution | 720p | 1080p | 1080p | 1080p |
| Media storage | 2 GB | 25 GB | 100 GB | 500 GB |
| Video delivery minutes / month | 1,000 | 10,000 | 50,000 | 250,000 |
| Transcription minutes / month | 30 | 300 | 1,500 | 7,500 |
| Custom domains | — | Add-on only, $10/mo each | 1 included, extras $10/mo | 1 included, extras $8/mo |
| AI highlight assistant | — | — | Yes | Yes |
| Review import | Manual CSV only | All sources | All sources | All sources |
| Public REST API | Read-only, 30 req/min | Full, standard rate limits | Full, standard rate limits | Full, standard rate limits |
| Webhooks / Zapier / Slack | — | Yes | Yes | Yes |
| Webhook endpoints per workspace | — | 5 | 20 | 50 |
| HubSpot | — | — | Yes | Yes |
| API keys per workspace | 25 | 25 | 25 | 25 |
| Contacts | 500 | 5,000 | 25,000 | 100,000 |
| Invitations sent per rolling 30 days | 100 | 1,000 | 5,000 | 25,000 |
| Tags per workspace | 200 | 200 | 200 | 200 |
| Analytics retention | 30 days | 12 months | 24 months | 36 months |
| Enforced workspace-wide 2FA | — | — | Yes | Yes |
| Support | Community | Email, 1 business day | Priority, 4 business hours |
Reading notes that are part of the contract, not commentary:
- Lifetime cap means the counter never resets. It counts every
testimonialsrow the workspace has ever held — collected through a request page, created manually, or brought in by import — including rows that have since been archived or soft-deleted, and excluding only rows carrying ausage_creditsentry (Section 19.5.3). This is exactly the recompute query in Section 19.5.1, stated in words; the two are required to agree. Deleting a testimonial does not free a slot, and restoring one does not re-consume a slot; see Section 19.5.3 for why and for the one exception. - Unlimited widgets and unlimited widget views on every plan, including Free, is a load- bearing product decision, not a growth-hack. It is what makes rule 3 in Section 19.4 possible.
- Max video length is enforced at three points: the recorder's hard stop, the submit-time server validation, and the import path for externally hosted media. Section 9 owns the recorder behaviour; the limit value is this table's.
- Seats counts workspace members whose membership row is active, including the Owner and including members whose invitation has been accepted. Pending invitations reserve a seat from the moment they are sent, and release it on expiry or revocation.
- Analytics retention applies to the rolled-up aggregates that power dashboard reporting. Raw event rows are retained for 7 days on every plan for debugging, per Section 17.
- Public REST API read-only on Free means only the
testimonials:read,widgets:read, andanalytics:readscopes may be minted. Requesting a write scope on Free returns409 PLAN_FEATURE_UNAVAILABLE. - Free trial is a 14-day Growth trial with no card, started by a Free workspace from the
dashboard, available once per workspace and once per billing email. While it runs,
billing_state = 'trialing'and every Growth entitlement in the Growth column applies. The trial length is the only plan number in this table that is configurable: it is read fromBILLING_TRIAL_DAYS(default14, catalogued in Section 29.2), which configures our own trial clock, not a Stripe trial period — a cardless trial has no Stripe subscription at all. Mechanics, emails, and expiry behaviour are in Section 19.7.4; the anti-abuse control is in Section 19.11. - API keys, contacts, invitations, and tags are capacity limits like any other and are listed
here so that no other section silently owns a plan number. API keys are 25 on every plan
including Free — the number exists to bound key-rotation sprawl, not to sell an upgrade — and
the 26th returns
409 API_KEY_LIMIT_REACHED(Section 7.7.2). Exceeding the webhook-endpoint row returns409 WEBHOOK_ENDPOINT_LIMIT_REACHED(Section 18.4), the contact row409 CONTACT_LIMIT_REACHED, and the invitation row409 INVITE_LIMIT_REACHED(Section 8.11). - Tags are 200 per workspace on every plan (
409 TAG_LIMIT_REACHED), and separately at most 25 tags may be attached to any one testimonial (422 TESTIMONIAL_TAG_LIMIT_REACHED). The per-testimonial ceiling is a platform constant owned by Section 11.5 and is not a plan boundary.
19.3 Currency, Billing Periods & Price Objects #
- Currency: USD only at launch. All prices are set, charged, invoiced, and reported in USD.
Customers outside the US are charged in USD and their card network performs any conversion.
Stripe Tax computes and displays local VAT/GST on top of the USD amount. Multi-currency
presentment is a deliberate post-v1 capability listed in Section 29.8; shipping it later
requires new Stripe Price objects and a
currencycolumn already present on the invoice mirror, so no migration of existing data is needed. - Money is stored as integer minor units in
*_centscolumns with a sibling ISO-4217currencycolumn, per Section 5. - Billing period anchor. A paid workspace's period is anchored to the subscription's
current_period_startfrom Stripe. A Free workspace has no Stripe subscription, so its period anchor is a synthetic one: the day-of-month ofworkspaces.created_at, clamped to 28 to avoid month-length drift, stored inworkspaces.usage_anchor_day. Monthly counters roll over on that anchor for both paid and free workspaces. Calendar months are never used, because a customer who upgrades on the 20th would otherwise get a full month of quota for ten days of billing. - Stripe object naming. One Product per plan (
vouch_starter,vouch_growth,vouch_scale), each with two recurring Prices (month,year). One Productvouch_custom_domainwith two Prices —$10/moused by Starter and Growth,$8/moused by Scale — attached as an additional subscription item whosequantityequals the number of billable custom domains. Free has no Stripe objects at all; a Free workspace has no Stripe Customer until its first checkout. Price IDs are configuration, supplied by the environment variables catalogued in Section 29.2, never hard-coded.
19.4 The Eight Enforcement Invariants #
These are stated as invariants because they are testable and because a reviewer will check them
one by one. Each is labelled E1–E8 and referenced by that label elsewhere in this section.
E1 — Every limit is enforced server-side, in one place #
All limit decisions are made by the quota service in packages/core/quota. No route handler,
server action, worker job, or React component contains its own comparison against a plan number.
Client-side checks exist only to disable a control and explain why before the user clicks; they
are advisory and are never the enforcement point. Every metered mutation follows this exact call
site shape:
// apps/web/app/api/v1/testimonials/route.ts
import { quota } from '@vouch/core/quota';
import { db } from '@vouch/db';
import { AppError } from '@vouch/core/errors';
export async function POST(req: Request) {
const ctx = await requireWorkspaceContext(req); // sets app.current_workspace_id
await authorize(ctx, 'testimonial:create'); // Section 6.6.3 — can(actor, action, resource)
// 1. PRE-CHECK. Cheap, read-only, outside the write transaction.
// Fails fast with the catalogued error before any expensive work happens.
const decision = await quota.check(ctx, 'testimonials.collected', { amount: 1 });
if (!decision.allowed) {
throw new AppError(decision.code, {
httpStatus: decision.httpStatus,
publicMessage: decision.publicMessage,
details: decision.details,
});
}
// 2. COMMIT. The counter increment and the metered row are written in ONE transaction.
// quota.consume re-checks under a row lock, so two concurrent requests cannot both
// slip past the cap. If the re-check fails, the transaction rolls back and the
// catalogued error is thrown.
return await db.transaction(async (tx) => {
await quota.consume(tx, ctx, 'testimonials.collected', { amount: 1 });
const testimonial = await createTestimonial(tx, ctx, await parseBody(req));
return created(testimonial, ctx.requestId);
});
}The pre-check exists purely to avoid doing expensive work (uploading media, calling Mux) that
will be thrown away. The consume call inside the transaction is the actual enforcement. A
lint rule in packages/config forbids importing the plan matrix constants anywhere outside
packages/core/quota, so the single-enforcement-point property is mechanically preserved.
E2 — Warnings at 80% and 95% in product; email at 90% and 100% #
Every metered limit — testimonials, seats, request pages, storage, delivery minutes, transcription minutes — produces graduated notice.
| Threshold crossed | Channel | Audience | Behaviour |
|---|---|---|---|
| 80% | In-product banner on the dashboard home and on the relevant feature page; usage meter turns to the warning presentation (icon + text, never colour alone — Section 21.4) |
Every member who can see the page | Dismissible; re-appears if usage rises another 5 percentage points |
| 90% | Transactional email | Owner and all Admins | One email per metric per billing period; suppressed if the workspace upgraded in the last 24 hours |
| 95% | In-product banner escalates to the critical presentation and becomes non-dismissible on the feature page it concerns |
Every member who can see the page | Still permits every action; nothing is blocked yet |
| 100% | Transactional email plus a persistent dashboard banner | Owner and all Admins | The relevant creation path now returns the catalogued error; nothing already live changes |
Threshold crossings are detected by the quota service on write and by the hourly
quota.notify_threshold job for gauge metrics that can move without a write (storage reclaimed by
a retention sweep, seats freed by a departure). Crossings are recorded in usage_notifications
with (workspace_id, metric, threshold, period_start) unique, which makes notification
idempotent: a workspace that oscillates around 80% is emailed once per period, not once per
oscillation.
The emitted threshold set is exactly 80, 90, 95, 100, and this table is the only place it is
defined. Section 18.2's quota.threshold_reached webhook fires on all four and carries the
crossed value; crossing 100 additionally fires quota.exceeded. Any other section naming a
different subset is the defect.
Warning copy, verbatim:
80% — "You've used 402 of your 500 testimonials." · Body: "You're at 80% of the Growth plan's testimonial limit. Nothing changes until you reach 100%, and everything you've already published will keep working no matter what." · Action: "See plans"
95% — "You've used 478 of your 500 testimonials." · Body: "You're close to the Growth plan's limit. When you reach it, new submissions pause — your live widgets, your showcase page, and every embed on your site keep running exactly as they are." · Action: "Upgrade to Scale"
90% email subject — "Acme Inc. is at 90% of its testimonial limit"
100% email subject — "Acme Inc. has reached its testimonial limit"
E3 — Reaching a cap NEVER degrades anything already live #
This is the product's central promise and the reason it is safe to install on a real website.
Published widgets, the public showcase page, the widget data API, and every embed snippet that
has already been pasted onto a customer's site continue to serve at full quality and full
volume, forever, on every plan including Free, and in every billing state including past_due
and canceled. There is no impression throttle, no view cap, no watermark escalation, no
delayed response, no reduced item count, no downgraded image or video quality, and no
"upgrade to continue" interstitial on any public surface — at any usage level, at any payment
state, at any time.
The reasoning deserves to be stated plainly because it constrains the whole architecture. Our widget renders inside somebody else's marketing site, frequently above the fold, frequently on a pricing or landing page that the customer spends money to drive traffic to. If we throttled impressions when a quota was exceeded, or if we degraded rendering when an invoice bounced, we would be breaking a third party's website in order to apply commercial pressure to our customer. That makes the product dangerous to install, and a product that is dangerous to install does not get installed. Every commercial lever we have therefore acts on creating new things inside our own dashboard, never on serving things on somebody else's page.
Architecturally this means the widget data path in Section 13.8 never consults billing state.
The widget data API resolves a widget id to a pre-rendered JSON payload from the CDN edge; the
only plan-derived value in that payload is the badge object described in E8, which is computed
at publish time and baked into the cached payload. There is no per-request entitlement lookup on
the public path, which is also why that path can meet the availability target in Section 23.6.
E4 — Reaching the testimonial cap blocks only new collection #
When testimonials_collected_lifetime reaches the plan cap, exactly three things stop:
- Request pages stop accepting submissions. The page still loads, still renders the workspace's branding, still returns HTTP 200. It is not an error state and must never be presented as one to the respondent, who has done nothing wrong and has no relationship with our billing.
- Invitation sending is blocked (email and SMS), returning
409 TESTIMONIAL_LIMIT_REACHEDto the dashboard actor with an upgrade action. - Import is blocked for all sources including CSV, with the same error code and status, and a note of how many rows would exceed the cap.
Reading a request page is always 200; submitting into a closed one is 409. These are two
different operations and they get two different answers, which is the whole reason a customer at
their cap never sees their own page break:
- The read returns HTTP 200 carrying the
collectionClosedobject. It is never an error response: not 402 (which this product never uses at all), not 403, and not the 409 the dashboard actor receives for the same underlying condition. A respondent who followed a link deserves a page. - The submit returns
409 COLLECTION_CLOSED(Section 8.10.5), for every closed reason including the three this section causes. A submission arriving into a closed page is a genuine state conflict, and the respondent's client shows the same neutral message rather than a raw error.
{
"data": {
"id": "rqp_01JD8F2QZ8K5R3W6M0X9YB4TVC",
"slug": "acme-launch-stories",
"status": "active",
"collectionClosed": {
"closed": true,
"reason": "quota_reached",
"headline": "We're not accepting submissions right now",
"body": "Thanks so much for coming here — it really does mean a lot. Acme Inc. has paused new testimonial submissions for the moment. If someone asked you to leave one, just reply to them and they'll let you know when it's open again.",
"reopensAt": null
},
"branding": { "...": "..." }
},
"meta": { "requestId": "req_01J8XQ5N4C7M2E9V3TAB1K6PZR" }
}Note that status is still active — the page itself is fine. What closed it is a workspace-level
condition, which is precisely why the reason is carried in the payload object rather than inferred
from the page's own state.
Respondent-facing copy, verbatim and final:
We're not accepting submissions right now #
Thanks so much for coming here — it really does mean a lot. {Workspace name} has paused new testimonial submissions for the moment. If someone asked you to leave one, just reply to them and they'll let you know when it's open again.
The respondent page shows the workspace's logo and neutral styling, no error iconography, no red,
no Vouch upgrade messaging, and no indication that the cause is a billing limit. The
collectionClosed.reason value is present in the JSON for the workspace's own debugging and for
the public API, but it is not rendered to the respondent.
Section 8.2.4 owns the collectionClosed.reason vocabulary — its values, their entry
conditions, and the reopensAt field — and this section does not restate it. Three of those
values are caused by Section 19 and only by Section 19:
quota_reached— the workspace has reached its lifetime collected-testimonial cap. This is the value the example above carries.workspace_over_limit—limit_state = 'over_limit', per E6.billing_inactive— the workspace's subscription is no longer live. This is reached atbilling_state = 'canceled'and not before. Apast_dueworkspace collects normally for the full dunning window: E7 restricts nothing during those fourteen days, and the surface matrix in Section 19.10 says so row by row. Closing collection at the first failed payment would take a customer's collection offline over a card that expired, which is the opposite of what dunning is for.
All three render the identical neutral state — the respondent is never shown which of them applied, because from their side there is no difference and all three are our commercial business, not theirs. The remaining reasons in Section 8.2.4's vocabulary are page-level conditions that Section 8 owns outright and this section never sets.
Owner-facing copy, verbatim and final — shown as a persistent dashboard banner and sent as the 100% email:
You've reached your plan's testimonial limit #
Your workspace has collected 100 of 100 testimonials on the Starter plan, so new submissions are paused.
Everything you've already published keeps working exactly as it is. Your widgets, your showcase page, and every embed on your site are unaffected and will keep serving at full speed and full quality — that does not change, on any plan, ever. Only new submissions, new invitations, and imports are paused.
Upgrade to Growth to collect up to 500 testimonials.
[ Compare plans ] [ Upgrade to Growth ]
E5 — Storage, delivery and transcription overage is soft #
These three metrics are consumption-shaped, arrive asynchronously, and can be exceeded by a single legitimate action (one long video pushes storage over). Hard-failing them mid-pipeline would lose a respondent's recording, which is unacceptable. Therefore:
- The in-flight job always completes. A transcode that pushes storage past the cap still finishes. A transcription that exceeds the monthly minutes still returns a transcript. A delivery minute served past the cap is still served (E3 requires it).
- An overage notice is raised — an
overage_noticesrow, a dashboard banner, and the E2 emails at the 100% threshold. - Nothing is blocked immediately. The workspace enters a 7-day observation window.
- Only if the workspace is over the limit by more than 20% for 7 consecutive daily
evaluations does the next new collection become blocked, with
409 STORAGE_LIMIT_EXCEEDED,409 DELIVERY_MINUTES_EXCEEDED, or409 TRANSCRIPTION_MINUTES_EXCEEDEDas appropriate. The consecutive-day counter resets to zero the moment a daily evaluation finds the workspace at or under 120% of its limit. - Even in that blocked state, E3 still holds absolutely: delivery continues, playback continues, the widget serves.
The daily quota.evaluate_overage job at 03:00 UTC per workspace anchor writes an
overage_evaluations row with (workspace_id, metric, evaluated_on, ratio, consecutive_days).
The block is derived from consecutive_days >= 7 AND ratio > 1.2, so it is recomputable and
auditable rather than a mutable flag.
E6 — Downgrading never deletes and never unpublishes #
When a workspace moves to a plan whose caps it already exceeds — by downgrade, by trial expiry, or by cancellation to Free — the following is guaranteed:
No testimonial is deleted, archived, unpublished, or hidden.
No request page is deleted or deactivated. Pages beyond the new cap become read-only: they still serve, still accept submissions (subject to the testimonial cap), but cannot be edited or duplicated, and no new page can be created until the count is under the cap.
No widget is deleted or degraded. Widgets are unlimited on every plan, so this never arises.
No custom domain is released or has its TLS allowed to lapse within the current paid period; at period end a domain that is no longer covered by the plan or an add-on item enters
suspended, which stops serving that hostname but leaves the record, the DNS instructions, and the certificate configuration intact for instant reactivation. The platform-owned host used by the embed snippet is unaffected, so no embed breaks (Section 13.2).suspendedis a real state ofdomain_state— the seventh of the seven states in Section 14.3, which owns the state machine. This billing path is the only thing in the product that enters it, and Section 19.12.5 is the only thing that leaves it. No customer action reaches it directly: a customer cannot suspend or unsuspend a domain, only upgrade or add an add-on quantity. Because a suspended hostname stops answering, entering the state also enqueueswidget.payload_buildfor every widget in the workspace and a CDN purge on theworkspace:{id}tag, so each payload'spermalinkUrl(Section 13.8.2) falls back to the workspace's{workspace-slug}.vouch.appaddress before the custom hostname goes quiet, rather than after.No member is removed. Members beyond the new seat cap are placed in
seat_suspended— the value is held inworkspace_members.seat_state, not inferred — and they can still sign in and read, but every write is refused with409 SEAT_LIMIT_REACHED. The Owner is never suspended and may re-designate which members hold the active seats at any time. If the Owner does not choose, seats are allocated by ascendingworkspace_members.created_at, Owner first, so the earliest members keep working. Entering or leavingseat_suspendedchanges a member's effective permissions without changing their role, so it is one of the session-revocation triggers in Section 7.7.1: the affected member's sessions are revoked and re-established on the next request, which stops a stale session from carrying write access it no longer has.No AI suggestion, transcript, caption track, or social card that already exists is removed when a plan loses the feature that produced it.
The workspace's limit_state column moves to over_limit, and while it is over_limit new
collection is blocked exactly as in E4, with the same respondent-facing neutrality. The
dashboard shows the owner precisely which caps are exceeded and by how much, along with the two
routes out: upgrade, or reduce (delete testimonials — noting that deleting does not free lifetime
slots, so for the testimonial cap the only route out is upgrade; this is stated bluntly in the UI
rather than implied).
E7 — Dunning, cancellation, and what a cancelled workspace can still do #
| Day | Event | Workspace effect | Notification |
|---|---|---|---|
| 0 | Invoice payment fails (invoice.payment_failed) |
billing_state → past_due. Nothing is restricted. |
Email to Owner + Admins: "We couldn't process your payment" with the Stripe hosted-invoice link |
| 3 | Retry 1 fails | No change | Email to Owner |
| 7 | Retry 2 fails | No change; dashboard banner becomes non-dismissible | Email to Owner + Admins |
| 14 | Retry 3 fails | billing_state → canceled; entitlements drop to Free; limit_state recomputed (usually → over_limit) |
Email to Owner + Admins: "Your subscription has been cancelled" listing exactly what still works |
| 14+ | — | Dashboard is read-only. Public surfaces are untouched. | — |
A cancelled workspace is read-only in the dashboard: no new testimonials, no edits, no new request pages, no widget configuration changes, no invites, no API writes. What it explicitly retains, forever, with no expiry:
- Its widgets and showcase page keep serving published testimonials indefinitely, at full quality and full volume, per E3. This is the promise that makes the product safe to install, and it does not have an asterisk for non-paying accounts.
- Full data export stays available: the complete JSON export and the media archive described in Section 22.6, downloadable by the Owner at any time.
- Read access to every testimonial, transcript, analytics report within the plan's retention window, and invoice history.
- One-click reactivation (Section 19.12.5).
During the 14-day past_due window the workspace is fully functional. Only two things are
refused: purchasing additional custom-domain add-on quantity, and switching plans without first
supplying a working payment method. Both return 409 PAYMENT_METHOD_REQUIRED. There is no 402
response anywhere in this product — the reasoning is in Section 19.5.6 — so a client must never
branch on one.
E8 — The Free badge is server-authoritative #
On the Free plan the "Powered by Vouch" badge is not a client-side decoration that a customer can hide with CSS. It is computed server-side at publish time and baked into two places:
- The widget data payload, as a first-class object the widget renders unconditionally. The
badgeobject's shape and field names are owned by Section 13.8, which defines the payload; this section owns only the rule that decides itsshowvalue:
{
"widget": { "id": "wgt_01JD8F2QZ8K5R3W6M0X9YB4TVC", "type": "wall_of_love", "theme": "auto" },
"items": [ "…" ],
"badge": {
"show": true,
"label": "Powered by Vouch",
"href": "https://vouch.app/?ref=widget&w=wgt_01JD8F2QZ8K5R3W6M0X9YB4TVC",
"accessibleName": "Powered by Vouch — collect and publish customer testimonials"
}
}- The server-rendered showcase page HTML, where it is present in the initial document response, not injected by script.
The exact badge markup the widget builds inside its shadow root:
<a class="vouch-badge"
href="https://vouch.app/?ref=widget&w=wgt_01JD8F2QZ8K5R3W6M0X9YB4TVC"
target="_blank"
rel="noopener nofollow"
aria-label="Powered by Vouch — collect and publish customer testimonials">
<svg class="vouch-badge__mark" width="14" height="14" viewBox="0 0 14 14" aria-hidden="true" focusable="false">
<path d="M1 4.2 5.6 12.4 13 1.6" fill="none" stroke="currentColor" stroke-width="2.2"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span class="vouch-badge__text">Powered by Vouch</span>
</a>Rules that make it authoritative:
badge.showis derived from the workspace's plan at the moment the payload is rendered and cached —trueon Free,falseon every paid plan, with no third value and no client input. A workspace that upgrades triggers a CDN purge on theworkspace:{id}tag (Section 13.8.6), so the badge disappears within the purge propagation time rather than at the next cache expiry.- The widget re-asserts the badge on every data refresh and on every re-render caused by a
layout change, filter change, or page-visibility restore. If the badge node is absent from the
shadow root at render time and
badge.showis true, it is re-created. There is noMutationObserverwatching for tampering — that would cost main-thread time on the customer's page and violate the performance budget in Section 23.2 for zero real benefit. - The badge's link text and accessible name are fixed strings on Free and cannot be edited via the widget editor; the editor's badge controls are disabled with the explanatory text "Removing the badge is available on Starter and above."
- The badge respects the widget's theme tokens for colour but is contrast-validated against the chosen surface by the checker in Section 21.5, so it can never be rendered invisibly.
- Posture on tampering: a determined customer can still remove the rendered node with their own script. We do not fight that with obfuscation or re-injection loops. Removing it is a Terms of Service violation, the badge returns on every refresh anyway, and the commercial answer is a $19 plan. This posture is stated so that a future developer does not build an arms race that costs the performance budget.
19.5 The Quota Service #
packages/core/quota owns counting, checking, consuming, reconciling, and threshold
notification. It is the only module that reads the plan matrix.
19.5.1 Metric catalogue #
| Metric key | Kind | Resets | Source of truth for recompute | Blocking error code |
|---|---|---|---|---|
testimonials.collected |
Accumulator, lifetime | Never | count(*) of testimonials where created_at >= workspace.created_at including soft-deleted |
TESTIMONIAL_LIMIT_REACHED |
seats.active |
Gauge | n/a | Active workspace_members + invitations where status = 'pending' and expires_at > now() |
SEAT_LIMIT_REACHED |
request_pages.active |
Gauge | n/a | count(*) of non-deleted request_pages |
REQUEST_PAGE_LIMIT_REACHED |
storage.bytes |
Gauge | n/a | sum(bytes) of non-purged media_assets + media_derivatives |
STORAGE_LIMIT_EXCEEDED |
delivery.minutes |
Accumulator, monthly | Billing anchor | Mux delivery data ingested daily into delivery_usage_days |
DELIVERY_MINUTES_EXCEEDED |
transcription.minutes |
Accumulator, monthly | Billing anchor | sum(duration_seconds) of transcription_jobs succeeded in period |
TRANSCRIPTION_MINUTES_EXCEEDED |
custom_domains.active |
Gauge | n/a | count(*) of non-deleted, non-suspended custom_domains |
CUSTOM_DOMAIN_LIMIT_REACHED |
These seven dotted keys are the complete metric vocabulary. They are the values of the
usage_metric enum in Section 5.2 and the members of the QuotaMetric union in Section 19.5.6,
and the three lists are required to be identical — a CI check asserts it. No other spelling of a
metric key exists anywhere in the product; in particular there is no api_requests metric,
because API rate limiting is a token bucket in Valkey (Section 7.10) and not a quota counter, and
nothing meters it against a plan.
The expiry predicate on seats.active matters: pending invitations reserve a seat, and an
invitation that has passed expires_at must stop reserving one the moment it expires rather than
when the hourly invitation.expire sweep happens to run. Writing the predicate into the gauge
makes the count correct at every instant and independent of sweep timing.
Two metrics are not counters but per-action validations, checked at the point of the action:
| Validation | Checked where | Error code |
|---|---|---|
| Video take length ≤ plan max | Recorder hard stop; submit-time server check on duration_seconds; import of externally hosted media |
422 VIDEO_LENGTH_EXCEEDED |
| Feature availability (AI assistant, HubSpot, webhooks, non-CSV import, write API scopes, enforced 2FA, badge removal) | The feature's own entry point, via quota.requireFeature() |
409 PLAN_FEATURE_UNAVAILABLE |
Accumulator vs. gauge is the important distinction. An accumulator only ever goes up within its period and is incremented transactionally with the action it meters. A gauge reflects present state and is recomputed rather than incremented, because the underlying rows can be created and destroyed by many paths.
19.5.2 Storage model #
CREATE TABLE usage_counters (
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
metric usage_metric NOT NULL, -- the enum in Section 5.2; never text
period_start timestamptz NOT NULL, -- epoch 'lifetime' sentinel for lifetime metrics
period_end timestamptz NOT NULL,
value bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT usage_counters_value_nonneg CHECK (value >= 0),
CONSTRAINT usage_counters_unique UNIQUE (workspace_id, metric, period_start)
);
CREATE INDEX usage_counters_ws_metric_idx ON usage_counters (workspace_id, metric, period_start DESC);Lifetime metrics use the sentinel period_start = '1970-01-01T00:00:00Z' and
period_end = 'infinity', which keeps one uniform shape and one uniform index. Section 5 owns
the full schema; the table is reproduced here because the enforcement semantics are meaningless
without it.
Increment is a single statement so that it is atomic and lock-cheap:
INSERT INTO usage_counters (id, workspace_id, metric, period_start, period_end, value)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (workspace_id, metric, period_start)
DO UPDATE SET value = usage_counters.value + EXCLUDED.value, updated_at = now()
RETURNING value;quota.consume runs this inside the caller's transaction and compares the returned value against
the limit after the increment. If the post-increment value exceeds the limit, it throws, the
caller's transaction rolls back, and the increment is undone with it. This ordering is what makes
concurrent submissions safe: two requests racing for the last free slot both increment, both read
their own post-increment value, one sees 10 and proceeds, the other sees 11 and rolls back.
No advisory lock, no SELECT … FOR UPDATE, no serialization failure.
Gauges are not incremented. quota.check on a gauge runs its recompute query directly, which is
cheap because every one of them is a single indexed count or sum scoped by workspace_id, and
results are memoized for 10 seconds per request context.
Gauges need their own concurrency control, and it is not the same one. An accumulator is safe because the increment and the comparison are the same statement. A gauge has no increment to ride on: two concurrent invite-sends, or two concurrent domain additions, would each recompute the pre-write count, each see room, and each insert — overselling the plan by one and doing it silently. Therefore:
// quota.consume on a gauge metric — inside the caller's transaction
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${`quota:${workspaceId}:${metric}`}))`);
const used = await recompute(tx, ctx, metric); // never the memoized value
if (used + amount > limit) throw new AppError(codeFor(metric), { httpStatus: 409, /* … */ });
// the caller's own insert follows, still inside this transaction and still holding the lockThe advisory lock is transaction-scoped, so it is released by commit or rollback with no cleanup
path to get wrong, and it is keyed per workspace and per metric, so two tenants never contend and
neither do seats and domains within one tenant. The 10-second memoization applies only to
quota.check reads, never to quota.consume — a consume that trusted a memoized count would
reintroduce exactly the race the lock exists to close.
19.5.3 Deletion does not free lifetime slots #
A soft-deleted testimonial still counts toward the lifetime cap. Deletion never releases a slot,
restoring never re-consumes one, and archiving changes nothing either way. testimonials.collected
counts every row the workspace has ever created, whatever its current status and whether or not
deleted_at is set. This is deliberate and this section owns it: any other section, table, or
state-transition note that says or implies a delete "releases quota", "frees a slot", or that a
restore "re-consumes" one is the defect and must be corrected to match this paragraph.
The reason is anti-abuse, not accounting convenience. If deleting freed a slot, the lifetime cap would silently become a rolling cap: a Free workspace could collect ten, delete ten, and repeat forever, which turns a 10-testimonial plan into unlimited collection with a rotating window and makes every paid tier optional. A workspace's route out of the testimonial cap is to upgrade, and the product says so plainly rather than letting the customer discover it by deleting.
Two consequences worth stating so nobody re-derives them wrongly:
- Restoring a soft-deleted testimonial within the 30-day restore window is never refused for
quota reasons, even when the workspace is at or over its cap. The slot was never released, so
there is nothing to re-acquire; a restore that returned
TESTIMONIAL_LIMIT_REACHEDwould be a bug. - Deleting to get under the cap does not work and the UI must not suggest that it might. The confirmation shown before the first delete reads, verbatim: "Deleting a testimonial frees storage but does not free a slot against your plan's lifetime limit."
A submission that never became a testimonial never consumed anything. A submission rejected by
the bot check, refused by rate limiting, or adjudicated as spam consumes no testimonials.collected,
no storage.bytes, and no transcription.minutes — the counters are written after the row exists
and are credited back when adjudication reverses that. This matters beyond bookkeeping: a
distributed flood of junk submissions against a request page must not be able to permanently consume
a victim's lifetime allowance, which would turn a spam attack into a bill. Section 22.12.2's
denial-of-service controls are the velocity limits and the bot-check escalation, not the
collection cap; the cap is the thing being protected, never the protection.
The one exception: a testimonial removed by spam adjudication or by a GDPR erasure request
initiated by the respondent does not count. Both write a usage_credits row with a reason code
(spam_adjudicated, respondent_erasure), and the effective count is
usage_counters.value - sum(usage_credits.amount). The exception is narrow on purpose: a
customer cannot self-serve a credit, and adjudicating a submission as spam is an explicit action
with an audit event (Section 11.6).
19.5.4 Period rollover #
The quota.roll_periods job runs hourly and, for every workspace whose current monthly
period_end is in the past, opens the next period by inserting a zero-valued row for each
monthly metric. Rollover is idempotent by the unique constraint. Because it is insert-only,
history is preserved and the dashboard can chart usage per period without a separate archive.
Paid workspaces take the boundary from the Stripe subscription's period; Free workspaces take it
from usage_anchor_day. A plan change mid-period does not reset counters — the new limit
simply applies to the existing counter value from that moment, which means an upgrade takes
effect instantly and a downgrade may immediately place the workspace over_limit per E6.
19.5.5 Drift detection and reconciliation #
Counters can drift: a worker crash between an external side effect and its counter write, a
manual database intervention, a bug. The quota.reconcile job runs nightly per workspace and
recomputes every metric from its source-of-truth query in the table above.
- If the recomputed value differs from the stored value, the stored value is corrected to the
recomputed value and a
usage_reconciliationsrow is written withbefore,after,delta, andreason: 'nightly_reconcile'. - A drift whose absolute value exceeds
max(5, 1% of the stored value)raises a warning-level alert to the operations channel with the workspace id and metric, because repeated drift is a bug signal, not a maintenance event. - Reconciliation never moves a counter in a direction that would retroactively block an action
the customer already completed successfully; if the recompute is lower, the counter drops and
the workspace may leave
over_limit, and if it is higher, the counter rises but no already- created row is touched (E3, E6). - The job is also exposed as an operator command,
pnpm ops quota:reconcile --workspace <id>, documented in Section 24's runbooks.
19.5.6 The decision object #
export type QuotaMetric =
| 'testimonials.collected' | 'seats.active' | 'request_pages.active'
| 'storage.bytes' | 'delivery.minutes' | 'transcription.minutes'
| 'custom_domains.active';
export type QuotaDecision =
| { allowed: true; metric: QuotaMetric; used: number; limit: number | null; remaining: number | null }
| {
allowed: false;
metric: QuotaMetric;
used: number;
limit: number;
code: 'TESTIMONIAL_LIMIT_REACHED' | 'SEAT_LIMIT_REACHED' | 'REQUEST_PAGE_LIMIT_REACHED'
| 'STORAGE_LIMIT_EXCEEDED' | 'DELIVERY_MINUTES_EXCEEDED'
| 'TRANSCRIPTION_MINUTES_EXCEEDED' | 'CUSTOM_DOMAIN_LIMIT_REACHED';
httpStatus: 409;
publicMessage: string;
details: Array<{ field: string; issue: string }>;
upgradeTo: 'starter' | 'growth' | 'scale' | null;
};limit: null means unlimited (request pages on Scale, widgets everywhere).
Status-code rule, and this paragraph is the reference the rest of the document follows. Every
quota refusal and every plan refusal is 409 Conflict with a catalogued code. The reasoning is
worth stating because it is what settles the question everywhere else:
- The request is syntactically valid, so it is not a
400and not a422. - The caller is authenticated and holds the permission and the scope, so it is not a
401and not a403.403is reserved strictly for role, scope, and read-only-workspace refusals — things about who is asking. A quota refusal is not about who is asking; the Owner gets the identical answer. - What the request conflicts with is the current state of the workspace, which is precisely
what
409means, and the conflict is resolvable by the caller (upgrade, or reduce the gauge). 402 Payment Requiredis not used anywhere in this product, on any surface, ever. It is reserved-by-spec, inconsistently handled by intermediaries and client libraries, and it invites clients to treat "you need a bigger plan" and "your card failed" as the same event when they are orthogonal (Section 19.8). A payment problem that genuinely blocks an action is409 PAYMENT_METHOD_REQUIRED.
PLAN_FEATURE_UNAVAILABLE and PAYMENT_METHOD_REQUIRED therefore both use 409. The single
exception in this area is VIDEO_LENGTH_EXCEEDED, which is 422: it is a property of the
submitted file rather than a state of the workspace, it is detectable from the payload alone, and
it carries a details array naming the offending field — all three are the marks of a validation
failure, not a conflict.
19.5.7 Complete quota error reference #
| Code | HTTP | Raised when | upgradeTo |
Respondent-visible? |
|---|---|---|---|---|
TESTIMONIAL_LIMIT_REACHED |
409 | An invite send, an import, or a dashboard/API write would exceed the lifetime cap | Next plan up | No. A respondent reading the page gets 200 with collectionClosed.reason = 'quota_reached', and a respondent submitting gets 409 COLLECTION_CLOSED (Section 8.10.5) — never this code, which names our commercial condition rather than their situation (E4) |
SEAT_LIMIT_REACHED |
409 | Invite send or invite acceptance would exceed seats; any write by a seat_suspended member |
Next plan up | No |
REQUEST_PAGE_LIMIT_REACHED |
409 | Creating or duplicating a request page beyond the cap | Next plan up | No |
STORAGE_LIMIT_EXCEEDED |
409 | New collection while over 120% storage for 7 consecutive days (E5) | Next plan up | No — the respondent path is collectionClosed / COLLECTION_CLOSED as above |
DELIVERY_MINUTES_EXCEEDED |
409 | Same window rule for delivery minutes | Next plan up | No — as above |
TRANSCRIPTION_MINUTES_EXCEEDED |
409 | Same window rule; also returned by an explicit "Retry transcription" action when over cap | Next plan up | No |
CUSTOM_DOMAIN_LIMIT_REACHED |
409 | Adding a domain beyond included + purchased add-on quantity | starter or add-on |
No |
PLAN_FEATURE_UNAVAILABLE |
409 | AI assistant, HubSpot, webhooks/Zapier/Slack, non-CSV import, write API scope, enforced 2FA, badge removal | Lowest plan with the feature | No |
VIDEO_LENGTH_EXCEEDED |
422 | Submitted or imported media longer than the plan max | Next plan up | Yes — recorder shows an inline message |
PAYMENT_METHOD_REQUIRED |
409 | Plan change or add-on purchase while past_due with no valid payment method |
— | No |
Every one of these carries a docsUrl per the envelope in Section 7.3 and is listed in the
catalogue in Section 29.3. This table is the complete set of refusals produced by the quota
service. Four further plan limits sold by the matrix in Section 19.2 are enforced at the entry
point of the feature that owns them rather than by a metered counter, and they follow the same
409 rule: API_KEY_LIMIT_REACHED (Section 7.7.2), WEBHOOK_ENDPOINT_LIMIT_REACHED (Section 18.4),
CONTACT_LIMIT_REACHED and INVITE_LIMIT_REACHED (Section 8.11), plus TAG_LIMIT_REACHED for the
200-per-workspace ceiling (Section 11.5). WORKSPACE_CREATION_LIMIT_REACHED (Section 19.11) is
also a 409 but is scoped to a user, not a workspace, which is why it is not in this table.
19.6 Feature Gating #
Feature availability is a pure function of plan, evaluated server-side and mirrored to the client as a read-only capability object so the UI can disable rather than fail.
export interface PlanCapabilities {
readonly maxTestimonials: number; // lifetime
readonly maxSeats: number;
readonly maxRequestPages: number | null; // null = unlimited
readonly maxVideoSeconds: 120 | 300;
readonly maxVideoHeight: 720 | 1080;
readonly storageBytes: number;
readonly deliveryMinutesPerPeriod: number;
readonly transcriptionMinutesPerPeriod: number;
readonly includedCustomDomains: number;
readonly extraDomainPriceCents: 800 | 1000 | null;
readonly badgeRemovable: boolean;
readonly aiAssistant: boolean;
readonly importSources: readonly ('csv' | 'google' | 'g2' | 'capterra' | 'x')[];
readonly apiWriteScopes: boolean;
readonly apiRateLimitPerMinute: 30 | 120;
readonly webhooks: boolean;
readonly maxWebhookEndpoints: number; // 0 when webhooks is false
readonly zapier: boolean;
readonly slack: boolean;
readonly hubspot: boolean;
readonly maxApiKeys: number; // 25 on every plan
readonly maxContacts: number;
readonly maxInvitesPerRollingMonth: number;
readonly maxTagsPerWorkspace: number; // 200 on every plan
readonly trialDays: number; // BILLING_TRIAL_DAYS; 0 disables the trial
readonly analyticsRetentionDays: 30 | 365 | 730 | 1095;
readonly enforceable2fa: boolean;
readonly supportTier: 'community' | 'email' | 'email_1bd' | 'priority_4bh';
}Every row of the Section 19.2 matrix has exactly one field here, and every field here has exactly
one row there. maxApiKeys and maxTagsPerWorkspace are identical on every plan today; they are
still fields rather than constants so that the enforcement point stays uniform and a future plan
can differ without a code change. A CI check asserts the field set and the matrix rows correspond,
which is what stops another section from quietly inventing a plan number.
The object is frozen at module load, exposed to the dashboard via the workspace bootstrap
payload, and re-fetched on any billing state change pushed over the dashboard's polling channel.
quota.requireFeature(ctx, 'aiAssistant') throws PLAN_FEATURE_UNAVAILABLE with a
publicMessage naming the lowest qualifying plan: "The AI highlight assistant is available on
Growth and Scale."
Feature loss is never retroactive. Suggestions already generated, transcripts already
produced, webhooks already delivered, and social cards already rendered survive a downgrade. Only
the next invocation is refused. Connected integrations that lose entitlement move to
paused rather than being disconnected, so re-upgrading restores them without re-authorizing
OAuth.
19.7 Stripe Integration #
Stripe is the payment processor and the authority on subscription state. We hold a projection.
19.7.1 Purchase and management surfaces #
| Flow | Mechanism | Notes |
|---|---|---|
| First purchase (Free → paid) | Stripe Checkout in subscription mode |
We create the Checkout Session server-side with client_reference_id = workspace.id, automatic_tax.enabled = true, tax_id_collection.enabled = true, billing_address_collection = 'required', allow_promotion_codes = true, subscription_data.metadata.workspace_id |
| Plan change (up or down) | Stripe Billing Portal, configured with the plan set and update behaviour | Portal is configured to allow plan switching, quantity update on the domain add-on, payment-method update, invoice history, and cancellation |
| Payment method update | Billing Portal | Also reachable directly via a deep link from a past_due banner |
| Cancellation | Billing Portal, with our retention offer shown before the portal redirect | See Section 19.12.4 |
| Add-on domain quantity | Our UI writes the desired quantity, we call subscriptions.update on the add-on item |
Immediate proration |
| Reactivation | Checkout for a new subscription, or Portal "renew" if within Stripe's window | See Section 19.12.5 |
We deliberately do not build a card form. No PAN, CVC, or expiry ever touches our servers, which removes the entire PCI-DSS SAQ-D surface; our posture is SAQ-A, documented in Section 22.21.
19.7.2 Webhook events handled #
Endpoint: POST /api/v1/webhooks/stripe, signature-verified with the Stripe signing secret from
the environment (Section 29.2), raw body preserved before parsing. Unrecognized event types are
acknowledged with 200 and recorded, never 400 — a 4xx would cause Stripe to retry forever on an
event we simply do not care about.
| Event | What we do |
|---|---|
checkout.session.completed |
Link stripe_customer_id to the workspace, store stripe_subscription_id, set billing_state per the subscription status, recompute entitlements, write an audit event, email the Owner a welcome-to-{plan} message |
customer.subscription.created |
Upsert the subscription projection: plan, interval, current_period_start/end, status, cancel_at_period_end, add-on quantity |
customer.subscription.updated |
Same upsert; detect plan change and recompute limit_state; if the plan lost a feature, pause the affected integrations; if the plan gained capacity, clear over_limit when the recompute allows |
customer.subscription.deleted |
billing_state → canceled; entitlements drop to Free; dashboard becomes read-only; public surfaces untouched (E3, E7); email the Owner the "what still works" message |
customer.subscription.trial_will_end |
Fires 3 days before trial end. Email Owner and Admins with the exact downgrade consequences and a one-click add-card link |
invoice.created |
Ignored for state; recorded for the ledger |
invoice.finalized |
Mirror the invoice into invoices (number, amount, currency, tax, hosted URL, PDF URL, period) |
invoice.paid |
Clear past_due if set; store paid_at; if this is the first paid invoice after reactivation, restore write access |
invoice.payment_failed |
Enter or continue dunning per E7; record the attempt count from the invoice; send the stage-appropriate email |
invoice.payment_action_required |
3DS or similar is needed. Email the Owner with the hosted invoice URL; show a dashboard banner with the same link; do not change entitlements |
invoice.upcoming |
Fires ~7 days ahead. Used to email annual customers a renewal notice with the amount, which is a legal requirement in several jurisdictions and a churn-reduction courtesy everywhere |
customer.updated |
Sync billing email, address, and tax status onto the workspace billing profile |
payment_method.attached / payment_method.detached |
Refresh the stored last-4/brand/expiry display fields (display metadata only) |
charge.refunded |
Record the refund in refunds and on the invoice mirror; email the Owner a confirmation |
charge.dispute.created |
Record the dispute against the invoice; billing_state → past_due; notify the Owner and all Admins with the evidence we will submit. Public surfaces are untouched (E3) |
charge.dispute.closed |
If the dispute was won, clear past_due when no other invoice is outstanding; if it was lost, billing_state → canceled per E7. Either way, record the outcome on the dispute record |
customer.tax_id.created / customer.tax_id.updated |
Store the VAT/GST identifier and its Stripe validation status for display on invoices |
checkout.session.expired |
Clear the pending-checkout marker so the UI stops showing "completing your purchase" |
19.7.3 Idempotency and ordering #
CREATE TABLE stripe_events (
id text PRIMARY KEY, -- Stripe's evt_… id
type text NOT NULL,
api_version text NOT NULL,
payload jsonb NOT NULL,
workspace_id uuid REFERENCES workspaces(id),
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz,
attempts integer NOT NULL DEFAULT 0,
last_error text
);
CREATE INDEX stripe_events_unprocessed_idx ON stripe_events (received_at) WHERE processed_at IS NULL;- The webhook route verifies the signature, inserts the row with
ON CONFLICT (id) DO NOTHING, and returns 200 immediately. Processing happens in a BullMQ job. A slow handler must never cause Stripe to see a timeout. - Replay of an already-processed event returns 200 and does nothing, because the insert conflicts and no job is enqueued.
- Out-of-order delivery is real. Every handler that writes subscription state compares the
event's
data.objectagainst the stored projection and ignores an event whose subscription object is older than what we already hold, using Stripe'screatedtimestamp on the event plus the subscription's owncurrent_period_start. When in doubt — specifically after anycustomer.subscription.*event that arrives out of order — the handler refetches the subscription from the Stripe API and writes that, treating the API as the tiebreaker. - Processing failures retry with the standard job backoff. After 5 failures the event is left
processed_at IS NULLwithlast_errorpopulated and raises an operator alert; a dailybilling.audit_projectionjob compares every active subscription against Stripe and reports divergence.
19.7.4 Proration, trials, coupons, tax, invoices #
- Proration: Stripe's default
create_prorations. An upgrade charges the prorated difference immediately on the next invoice and grants the new entitlements the momentcustomer.subscription.updatedis processed. A downgrade issues a prorated credit against the next invoice; entitlements drop immediately, subject to every guarantee in E6. Proration behaviour is shown in the upgrade preview (Section 19.12.3) usinginvoices.createPreview, so the customer sees the exact amount before confirming. - Trial: a 14-day Growth trial, no card required, available once per workspace and once
per billing email, and listed as its own row in the plan matrix in Section 19.2. Started from the
dashboard, it sets
billing_state = 'trialing'and grants full Growth entitlements including the AI assistant and 500 testimonials. No Stripe subscription exists during a cardless trial — it is our own state, with atrial_ends_attimestamp on the workspace — which avoids a zero-amount subscription cluttering the customer's invoice history. ConsequentlyBILLING_TRIAL_DAYS(default14, Section 29.2) configures our trial clock and nothing on Stripe: it is read whentrial_ends_atis computed at trial start, it never becomes a Stripetrial_period_days, and changing it never affects a trial already running. Setting it to0disables the trial offer entirely, which is the supported way to turn the offer off without a deploy.- Email at day 7 (mid-trial value summary), day 11 (3 days left, matching the
trial_will_endcadence), day 13 (tomorrow), and day 14 (ended). - At expiry with no card: the workspace drops to Free entitlements. E6 applies in full
— nothing is deleted, nothing is unpublished, no member is removed, no request page is
deactivated, no AI output is withdrawn. If more than 10 testimonials were collected, the
workspace enters
over_limitand cannot collect new ones. The AI assistant stops accepting new invocations. The Free badge returns to widget payloads and showcase HTML on the next render, with an immediate CDN purge so it is not delayed. - At expiry with a card added during the trial: the Growth subscription begins, first invoice charges immediately, entitlements continue unbroken.
- A trial can be upgraded to Scale or downgraded to Starter at any point during it, which starts the paid subscription immediately and ends the trial early.
- Email at day 7 (mid-trial value summary), day 11 (3 days left, matching the
- Coupons and promotion codes: Stripe Promotion Codes only.
allow_promotion_codes: trueon Checkout, and the Billing Portal is configured to preserve an existing discount across plan changes. One promotion code per subscription; stacking is not supported. Codes may beonce,repeating(n months), orforever. The dashboard shows an active discount as a line on the current-plan card with its remaining duration. We never mint discounts outside Stripe, so the invoice is always self-explanatory. - Tax: Stripe Tax,
automatic_tax: { enabled: true }on every subscription and Checkout Session. Billing address collection is required, because Stripe Tax cannot compute without it.tax_id_collection: { enabled: true }captures VAT, GST, ABN, and equivalent identifiers; Stripe performs validation and applies reverse charge where applicable. The captured tax id and its validation status appear on the invoice and in the billing settings page. If Stripe Tax returns a computation failure, Checkout blocks with Stripe's own message rather than us guessing a rate. - Invoices and receipts: every finalized invoice is mirrored into our
invoicestable with number, status, subtotal, tax, total, currency, period start/end, hosted invoice URL, and PDF URL. The dashboard lists them and links to Stripe's hosted PDF rather than rendering our own — Stripe's document is the one that is legally formatted per jurisdiction. Stripe emails the receipt; we additionally email a plain "payment received" note only for annual renewals, where the amount is large enough that silence is unfriendly.
19.7.5 Add-on custom domains #
Custom domains beyond the included allowance are a quantity on a second subscription item.
| Plan | Included | Extra price | Item behaviour |
|---|---|---|---|
| Free | 0 | Not purchasable | Adding a domain returns 409 PLAN_FEATURE_UNAVAILABLE |
| Starter | 0 | $10/mo each | Add-on item created on first domain purchase |
| Growth | 1 | $10/mo each | Quantity = max(0, activeDomains - 1) |
| Scale | 1 | $8/mo each | Quantity = max(0, activeDomains - 1), priced from the Scale add-on Price |
Adding a domain that requires a new paid slot shows a confirmation with the prorated charge for
the remainder of the current period, then calls subscriptions.update with the new quantity and
proration_behavior: 'create_prorations'. Removing a domain decrements the quantity, producing a
prorated credit. If a plan change moves the customer between the $10 and $8 add-on Prices, the
item is swapped to the new Price in the same subscriptions.update call. Domain suspension rules
on downgrade are in E6.
19.8 Billing State Model #
Two orthogonal columns on workspaces, deliberately separate so that a payment problem and a
capacity problem never have to be encoded in one enum.
billing_state : trialing | active | past_due | canceled -- enum billing_state, default 'active'
limit_state : ok | over_limit -- enum limit_state, default 'ok'There is no single status column on workspaces and no workspace_status enum. The two
columns above are the whole vocabulary, they are the only thing any entitlement check reads, and
every state sentence in this document resolves to one of them. The reason they are separate is
that the two conditions are genuinely independent and every combination is reachable and
meaningful: a paying Growth workspace that has filled its testimonial cap is
active + over_limit; a Free workspace with room to spare is active + ok; a workspace whose
card just bounced but which is well inside its plan is past_due + ok; and a cancelled workspace
that collected 240 testimonials on Growth is canceled + over_limit. Collapsing them into one
enum forces a false choice about which fact to record and was the specific defect this model
exists to prevent. A third column, usage_anchor_day (Section 19.3), carries the day-of-month the
monthly counters roll on; it is not a state and never participates in an entitlement decision.
Section 5 owns the DDL for all three.
billing_state |
Entered when | Dashboard | Public surfaces | Collection |
|---|---|---|---|---|
trialing |
Cardless Growth trial started | Full Growth | Full | Allowed to Growth caps |
active |
Subscription paid and current; also the resting state of every Free workspace | Full | Full | Allowed to plan caps |
past_due |
First failed payment | Full, with a banner; add-on purchase and plan change need a payment method | Full | Allowed |
canceled |
Dunning exhausted, or explicit cancellation at period end | Read-only; export always available | Full — indefinitely | Blocked |
A Free workspace is active. "Free" is a plan, not a billing state; conflating them is the most
common bug in this area and is called out here so it is not repeated.
limit_state is derived, never hand-set. quota.recomputeLimitState(workspaceId) runs after
every plan change, every counter reconciliation, and hourly, and sets over_limit when any gauge
metric exceeds its cap or the lifetime testimonial counter is at or above its cap. It is a
derived value so it is always recoverable after any incident.
19.9 Notifications Raised by Billing #
Section 20 owns delivery mechanics, templates, and preference handling. Section 19 owns the trigger list. Every one of these is sent to the Owner, and those marked "+Admins" also to every Admin. None of them can be unsubscribed from, because they are transactional account notices.
| Trigger | Recipients | Subject |
|---|---|---|
| Metric reaches 90% | Owner +Admins | "{Workspace} is at 90% of its {metric} limit" |
| Metric reaches 100% | Owner +Admins | "{Workspace} has reached its {metric} limit" |
| Overage observed 7 consecutive days >120% | Owner +Admins | "New collection is paused for {Workspace}" |
| Subscription started | Owner | "You're on {Plan}" |
| Trial started | Owner | "Your 14-day Growth trial has started" |
| Trial ending in 3 days | Owner +Admins | "Your Growth trial ends in 3 days" |
| Trial ended, downgraded to Free | Owner +Admins | "Your Growth trial has ended" |
| Payment failed (each of 4 attempts) | Owner (+Admins on attempts 3 and 4) | "We couldn't process your payment for {Workspace}" |
| Payment requires authentication (3DS) | Owner | "Your bank needs to confirm this payment" |
| Subscription canceled | Owner +Admins | "Your {Plan} subscription has been cancelled" |
| Plan changed | Owner +Admins | "{Workspace} moved to {Plan}" |
| Annual renewal upcoming (7 days) | Owner | "Your annual plan renews on {date}" |
| Refund issued | Owner | "We've refunded {amount}" |
| Ownership transferred | Old and new Owner | "Ownership of {Workspace} has been transferred" |
19.10 What Happens To Each Surface In Each State #
This matrix exists because it is the fastest way for a reviewer to confirm E3 and E7. "Serves" means full quality, full volume, no delay, no watermark change.
| Surface | Free at cap | over_limit |
past_due |
canceled |
|---|---|---|---|---|
| Widget loader and runtime | Serves | Serves | Serves | Serves |
| Widget data API | Serves | Serves | Serves | Serves |
| Showcase page | Serves | Serves | Serves | Serves |
| Custom domain hostname | Serves | Serves | Serves | Serves while the domain remains active; suspended only at the end of a period whose plan no longer covers it (E6) |
oEmbed / <noscript> fallback |
Serves | Serves | Serves | Serves |
| Analytics ingest beacon | Accepts | Accepts | Accepts | Accepts |
| Analytics reporting in dashboard | Read | Read | Read | Read |
| Request page (read) | 200 + collectionClosed, reason quota_reached |
200 + collectionClosed, reason workspace_over_limit |
200, open |
200 + collectionClosed, reason billing_inactive |
| Request page (submit) | 409 COLLECTION_CLOSED |
409 COLLECTION_CLOSED |
Accepts | 409 COLLECTION_CLOSED |
| Invitation send | Blocked | Blocked | Allowed | Blocked |
| Import | Blocked | Blocked | Allowed | Blocked |
| Moderation actions | Allowed | Allowed | Allowed | Blocked (read-only) |
| Widget editing | Allowed | Allowed | Allowed | Blocked (read-only) |
| Public API reads | Allowed | Allowed | Allowed | Allowed |
| Public API writes | Free: unavailable | Allowed | Allowed | Blocked |
| Data export | Allowed | Allowed | Allowed | Allowed |
19.11 Anti-Abuse On Free #
Free is genuinely useful, which makes it worth abusing. Three narrow controls, none of which touch E3:
- One cardless Growth trial per workspace and per billing email, tracked by a hash of the
normalized email in
trial_grants. A second attempt shows "This email has already used a trial" and offers Checkout. - Free workspace creation is limited to 3 per verified user account. A fourth returns
409 WORKSPACE_CREATION_LIMIT_REACHED. This exists to prevent cap-farming across throwaway workspaces. The code is named for creation on purpose: it is a limit on how many workspaces a user may own, and it must not be confused withWORKSPACE_OVER_LIMIT, which describes a single workspace's capacity state (Section 19.8). - Submission rate limits on request pages are per IP, not per workspace (Section 7.10), so a Free workspace cannot be denied collection by another tenant's traffic.
19.12 The Billing UI #
Route: /settings/billing, visible to Owner and Admin, editable only by Owner
(billing:manage per the permission matrix in Section 6.6.2). Admins see everything read-only, because
an Admin who cannot see why a feature is blocked cannot do their job.
19.12.1 Current plan card #
Shows plan name, price and interval, next renewal date and amount (including tax as computed by
Stripe), any active discount with its remaining duration, trial end date when trialing, and the
billing state with an explicit text label — never a coloured dot alone (Section 21.4). A
past_due state shows the failed amount, the retry schedule with the next retry date, and a
prominent "Update payment method" button.
19.12.2 Usage meters #
One meter per metered limit, each showing used, limit, percentage, and — for monthly metrics — the period end date. Presentation rules:
- The meter is a
<div role="meter" aria-valuenow aria-valuemin="0" aria-valuemax aria-valuetext>witharia-valuetextreading "402 of 500 testimonials, 80 percent used". - State is conveyed by an icon plus text ("Approaching limit", "Limit reached"), never by fill colour alone.
- Unlimited metrics render "Unlimited" with no bar rather than a full bar.
- Lifetime metrics are labelled "lifetime total" so nobody expects a monthly reset.
- Each meter links to the thing consuming it: storage links to a media list sorted by size, transcription minutes to the transcript list, delivery minutes to the analytics view.
19.12.3 Plan comparison, upgrade and downgrade #
The comparison view renders the matrix from Section 19.2 with the current plan marked and each row showing the customer's current usage against each plan's value, so "you have 240 testimonials" makes Starter visibly impossible.
Upgrade preview, shown before confirming, built from Stripe's invoice preview:
You're moving from Starter ($19/mo) to Growth ($49/mo).
- Today you'll be charged $21.33 — the prorated difference for the 21 days remaining in this billing period, plus tax.
- From 14 March you'll be charged $49.00 per month plus tax.
- What you gain now: 500 testimonials (up from 100), 10 seats (up from 3), 25 request pages, 100 GB storage, the AI highlight assistant, 1 included custom domain, HubSpot, and enforced two-factor authentication.
- Your existing testimonials, widgets, and pages are untouched.
Downgrade preview is blunt about consequences and is the only place the product is allowed to be slightly discouraging, because a surprise is worse:
You're moving from Growth ($49/mo) to Starter ($19/mo).
- You'll receive a prorated credit of $33.66 against your next invoice.
- Nothing is deleted and nothing is unpublished. Every testimonial, widget, page, and embed keeps working exactly as it does today.
- You currently have 240 testimonials, which is over Starter's limit of 100. Your workspace will not be able to collect new testimonials until you're under the limit or upgrade again. Deleting testimonials does not free lifetime slots, so the practical route back is to upgrade.
- You currently have 6 members, which is over Starter's 3 seats. Nobody is removed. Three members keep write access — you choose which — and the rest keep read-only access.
- You currently have 2 custom domains. One stays active; the second is suspended when this billing period ends. Your embed snippets are not affected and never need to be re-pasted.
- The AI highlight assistant and HubSpot stop accepting new work. Everything they have already produced stays.
Confirmation requires typing nothing, but the button is labelled with the outcome ("Move to Starter"), not "Confirm".
19.12.4 Cancellation with retention offer #
- "Cancel subscription" opens a step that asks one optional multiple-choice reason (too
expensive · not using it · missing a feature · switching to another tool · project ended ·
other, with a free-text field). The answer is stored in
cancellation_reasonsand is never required. - A single retention offer is presented, once per workspace per 12 months: three months at 50%, applied as a Stripe promotion code. If the customer picked "too expensive", the offer leads; otherwise it is shown below the confirmation.
- The confirmation screen states, verbatim, exactly what will and will not keep working:
Cancelling {Plan} for {Workspace} #
Your plan stays active until {period end date}. After that:
What keeps working — permanently, at no cost:
- Every widget you've embedded keeps serving your published testimonials, at full quality and full volume. Your website is not affected in any way.
- Your public showcase page keeps working.
- Your embed snippets never need to be changed or re-pasted.
- You can always sign in to read your testimonials and download a complete export of your data and media.
What stops:
- Collecting new testimonials, sending invitations, and importing reviews.
- Editing anything — your dashboard becomes read-only.
- The AI highlight assistant, webhooks, integrations, and API writes.
- Custom domains, at the end of the period. Your showcase page stays available at its Vouch address.
[ Keep my plan ] [ Cancel at {period end date} ]
- Cancellation is scheduled with
cancel_at_period_end: true, not immediate, so the customer keeps what they paid for. The card shows "Cancels on {date}" with a one-click "Resume plan" until that date.
19.12.5 Reactivation #
A canceled workspace shows a persistent, friendly banner: "Reactivate to start collecting
again — everything you had is still here." Reactivation opens Checkout for the chosen plan.
On checkout.session.completed:
billing_state→active, entitlements recomputed, dashboard writes restored in the same transaction as the projection write.limit_staterecomputed; if the new plan covers the existing volume,over_limitclears immediately.- Suspended custom domains return to
activeand their certificates are re-validated by the domain job in Section 14.5; DNS records the customer never removed mean this usually completes without customer action. This is the only exit fromsuspended, matching the only entry to it in E6, and the pair is what makes the state safe: a customer can never be stranded in it by anything they did. seat_suspendedmembers are restored —workspace_members.seat_statereturns toactive— in the same ascending-created_atorder, Owner first, up to the new seat count, and their sessions are revoked so the restored write access takes effect on the next request (Section 7.7.1).- Nothing needs to be re-pasted, re-authorized, or re-configured, because nothing was destroyed.
There is no data expiry for cancelled workspaces. A workspace that has been cancelled for three years reactivates with everything intact. The only deletion that ever happens without an explicit request is the retention schedule in Section 22.7, which applies equally to paying accounts.
19.13 Billing Edge Cases #
| Case | Decision |
|---|---|
| Currency | USD only. Prices, invoices, refunds, and reporting are USD. Stripe Tax adds local tax on top. Multi-currency is post-v1 (Section 29.8). |
| Failed 3DS / SCA | invoice.payment_action_required → email + banner with Stripe's hosted invoice URL, which carries the authentication flow. Entitlements do not change while authentication is pending. If the invoice is not authenticated before Stripe's own expiry, it becomes a payment failure and enters dunning at day 0. |
| Card expires before renewal | Stripe's card-updater handles most cases. Where it cannot, invoice.upcoming 7 days ahead with an unusable card triggers an "Update your card" email. |
| Owner leaves the company | Ownership belongs to the workspace, not the person. The Stripe Customer is the workspace. An Owner cannot delete their own user account or leave the workspace while they are the sole Owner; they must transfer ownership first, which is an explicit confirmed action (Section 6.6). If the Owner is unreachable, any Admin may request transfer: this sends a 7-day notice to the Owner's email; if the Owner does not object, the requesting Admin becomes Owner and the event is written to the audit trail with both identities. |
| Owner's email bounces permanently | Billing notices additionally go to all Admins; the dashboard shows a "your billing email is undeliverable" banner to Owner and Admins with a change-email action. |
| Refunds | No automatic refund on cancellation — access runs to period end. Annual plans are refunded pro rata on request within 30 days of the charge. Duplicate or erroneous charges are refunded in full, always. Refunds are issued in Stripe; charge.refunded mirrors them into refunds and emails the Owner. Refunding does not delete data or revoke access already used. |
| Chargeback / dispute | charge.dispute.created is recorded; the workspace moves to past_due and, if the dispute is lost, to canceled. Public surfaces still serve (E3). We respond with the invoice, the usage record, and the sign-in log. |
| Workspace deleted while subscribed | Deleting a workspace cancels the Stripe subscription immediately with no proration credit, after a confirmation that names the consequence. Widgets stop serving in this case — and only in this case — because the customer has explicitly asked for the data to be removed. The confirmation says so in those words. |
| Two Owners | Impossible by constraint: exactly one Owner per workspace, enforced by a partial unique index on workspace_members (workspace_id) WHERE role = 'owner' AND deleted_at IS NULL. |
| Upgrade during dunning | Allowed if a working payment method is supplied in the same flow; the outstanding invoice is settled first. Otherwise PAYMENT_METHOD_REQUIRED. |
| Plan change on the same day as renewal | Stripe handles the proration; our preview uses Stripe's own computed invoice preview, so the number shown is the number charged. |
| Trial started, then Checkout completed mid-trial | The trial ends immediately, the paid subscription starts, and no double-charge occurs because no Stripe subscription existed for the cardless trial. |
| Clock skew between Stripe periods and our counters | Our monthly period boundaries follow the Stripe subscription's current_period_start. If a webhook is late, the quota.roll_periods job uses the last known boundary and the reconciliation job corrects any counter attributed to the wrong period, writing a usage_reconciliations row. |
| A workspace at exactly 100% | 100% is "reached", not "exceeded": the cap is inclusive. A workspace with 10 of 10 testimonials on Free cannot collect an 11th. |
19.14 Acceptance Criteria For Section 19 #
A reviewer or an automated suite must be able to demonstrate each of these:
- A workspace at its testimonial cap returns HTTP 200 from the request-page read endpoint with
collectionClosed.closed = trueandcollectionClosed.reason = 'quota_reached', and the response body contains no error object. A submission posted to that same page returns409 COLLECTION_CLOSED. The same pair is asserted for acanceledworkspace, where the reason isbilling_inactive, and for apast_dueworkspace, where the page is open and the submission succeeds. - The same workspace's widget data API response is byte-identical to its response before the cap was reached, other than fields that changed for unrelated reasons.
- A
canceledworkspace's widget data API, showcase page, oEmbed endpoint, and CDN-cached payloads all return 200 with full item counts. - Two concurrent submissions racing for the last free slot result in exactly one success and one
TESTIMONIAL_LIMIT_REACHED, verified by an integration test with a deliberate transaction overlap. The same test shape is run for a gauge: two concurrent invite-sends racing for the last seat produce exactly one success and oneSEAT_LIMIT_REACHED, and two concurrent custom-domain additions racing for the last covered slot produce exactly one success and oneCUSTOM_DOMAIN_LIMIT_REACHED. - Downgrading a workspace with 240 testimonials, 6 members, and 2 custom domains to Starter changes no testimonial status, removes no member row, and deletes no domain row.
- A Free workspace's widget payload contains
badge.show: true; upgrading to Starter and re-fetching after purge returnsbadge.show: false. The assertion is onbadge.show— no other field name for this exists, on any surface. - Deleting 50 testimonials on a workspace at the lifetime cap does not permit a new submission,
and the recomputed
testimonials.collectedvalue after the deletions is unchanged. The same test then restores one of the deleted rows and asserts that the restore succeeds — a restore is never refused for quota reasons — and that the counter is still unchanged. - A spam-adjudicated testimonial produces a
usage_creditsrow and does permit a new submission. - Replaying a Stripe webhook event id that has already been processed produces no state change and returns 200.
- Every quota refusal in Section 19.5.7 is reachable in a test and returns the documented code and status in the envelope from Section 7.3.
- A contract test asserts that no route in the product ever responds with HTTP 402, and that
every plan or quota refusal in Section 19.5.7 responds with 409 except
VIDEO_LENGTH_EXCEEDED, which responds with 422 and carries adetailsarray. - Downgrading a Growth workspace with two custom domains to Starter leaves both
custom_domainsrows present, moves exactly one todomain_state = 'suspended'at period end, and — asserted before the hostname stops answering — every widget payload in that workspace has apermalinkUrlon the platform host rather than the suspended hostname. - The three metric-key lists — the
usage_metricenum, the Section 19.5.1 catalogue, and theQuotaMetricunion — are asserted identical by a test that fails on any addition, removal, or rename in any one of them. - A workspace in every combination of
billing_stateandlimit_stateis constructed, and the Section 19.10 surface matrix is asserted row by row against a live request to each surface.
20. Notifications & Transactional Messaging #
20.1 Principles #
Vouch sends messages to two very different audiences: customers (the workspace's members, who chose to be here) and respondents (people a customer asked for a testimonial, who did not choose Vouch at all). The rules differ, and conflating them is how products end up spamming strangers.
- Respondents receive the minimum. One invite, at most two reminders, one thank-you, and — only if they act — a portal or erasure confirmation. Nothing else, ever. No newsletters, no product marketing, no re-engagement campaigns. Vouch does not market to a customer's customers.
- Every message identifies the workspace first and Vouch second. A respondent should immediately understand who is asking. The
Fromname is the workspace name; Vouch appears as the sending service, not the sender. - Transactional and marketing are strictly separated at the infrastructure level — separate sending subdomains, separate reputation, separate suppression semantics. A complaint about one must never suppress the other.
- Nothing is sent from inside a request handler. Every message is enqueued to the transactional outbox (Sections 3.7 and 5.11.2, whose integration-facing behaviour Section 18.1 describes) and delivered by a worker, so a provider outage never blocks a moderator's click and a rolled-back transaction never sends a phantom email.
- Opt-out is honoured at the service layer, not the UI (Section 20.8). If code can send a message, that code checks the suppression list.
- No tracking pixels, ever. Open tracking is measured by link clicks and page loads, for the reasons in Section 20.7. This is consistent with the cookie-free analytics stance in Section 17.
- Every template is accessible: a real plain-text alternative, alt text on every image, a logical heading order, and no meaning conveyed by colour alone (Section 21).
20.2 The notification matrix #
Every event that reaches a human, in one table. "Opt-out" means the recipient can turn it off; mandatory messages are those a service legally or operationally must send.
| # | Event | Audience | In-app | SMS | Slack | Opt-out? | Default | |
|---|---|---|---|---|---|---|---|---|
| 1 | Testimonial request invite | Respondent / contact | — | Yes | Optional | — | Unsubscribe link, mandatory | On |
| 2 | Invite reminder (×2) | Respondent / contact | — | Yes | Optional | — | Unsubscribe link + cancelled on submit | On |
| 3 | Thank-you after submission | Respondent | — | Yes | — | — | No (transactional receipt) | On |
| 4 | New submission alert | Owner, Admin, Editor | Yes | Yes | — | Yes | Yes, per user | Email on for Owner/Admin, off for Editor; in-app on for all |
| 5 | Daily moderation digest | Owner, Admin, Editor | — | Yes | — | — | Yes, per user | On when ≥1 pending item |
| 6 | Media processing failed | Owner, Admin | Yes | Yes | — | Yes (off by default) | Yes | On |
| 7 | Transcription failed | Owner, Admin, Editor | Yes | No | — | — | Yes | In-app only |
| 8 | Invitation to join a workspace | Invited person | — | Yes | — | — | No (initiated by them being invited) | On |
| 9 | Email verification | New user | — | Yes | — | — | No (mandatory) | On |
| 10 | Magic-link sign-in | User | — | Yes | — | — | No (mandatory) | On |
| 11 | Password reset | User | — | Yes | — | — | No (mandatory) | On |
| 12 | New sign-in from a new device | User | Yes | Yes | — | — | Yes | On |
| 13 | Role changed / removed from workspace | Affected member | Yes | Yes | — | — | No (security) | On |
| 14 | Ownership transferred | Old and new Owner | Yes | Yes | — | — | No (security) | On |
| 15 | Quota warning at 80 % / 95 % | Owner, Admin | Yes | No | — | — | Yes | In-app only |
| 16 | Quota warning at 90 % | Owner, Admin | Yes | Yes | — | Yes (if routed) | Yes for email | On |
| 17 | Quota reached at 100 % | Owner, Admin | Yes | Yes | — | Yes (if routed) | No (billing-relevant) | On |
| 18 | Payment failed (each dunning attempt) | Owner | Yes | Yes | — | — | No (billing) | On |
| 19 | Subscription changed | Owner | Yes | Yes | — | — | No (billing) | On |
| 20 | Custom domain verified | Owner, Admin | Yes | Yes | — | Yes (if routed) | Yes | On |
| 21 | Custom domain failed | Owner, Admin | Yes | Yes | — | Yes (if routed) | No (action needed) | On |
| 22 | TLS certificate renewal warning (14 d / 7 d) | Owner, Admin | Yes | Yes | — | — | No (action needed) | On |
| 23 | Webhook endpoint auto-disabled | Owner | Yes | Yes | — | — | No (action needed) | On |
| 24 | API key expiring (14 d / 1 d) | Owner, Admin | Yes | Yes | — | — | Yes | On |
| 25 | Integration needs reconnection | Owner, Admin | Yes | Yes | — | — | Yes | On |
| 26 | Import completed / failed | Owner, Admin, Editor | Yes | Yes (failed only) | — | Yes (off by default) | Yes | On |
| 27 | Weekly analytics summary | Owner, Admin, Editor, Viewer | — | Yes | — | — | Yes, per user | On for Owner/Admin, off for Editor/Viewer |
| 28 | Data export ready | Requesting user | Yes | Yes | — | — | No (they asked) | On |
| 29 | Consent withdrawn by a respondent | Owner, Admin | Yes | Yes | — | Yes (if routed) | No (compliance) | On |
| 30 | Erasure confirmation | Respondent | — | Yes | — | — | No (compliance) | On |
| 31 | Workspace deletion scheduled / cancelled | Owner | Yes | Yes | — | — | No (destructive) | On |
Per-user preferences are stored on user_notification_preferences and edited at PATCH /me/notification-preferences (Section 7.15.2). A preference toggle that the matrix marks non-opt-out is rendered visibly disabled with an explanatory tooltip, never hidden — a user should be able to see that a message exists and why they cannot turn it off. Preferences are per user per workspace, so someone in two workspaces is not forced into one setting.
20.3 Email infrastructure #
Provider. Resend (Section 3 states the version line). The provider is wrapped behind an EmailProvider interface in packages/core/messaging with send, sendBatch, and getStatus, so the provider is replaceable without touching a template. All sends go through a worker queue, never a request handler.
Sending domains. Three subdomains, three reputations:
| Subdomain | Purpose | Reputation impact |
|---|---|---|
mail.vouch.app |
Platform transactional mail to customers (auth, billing, alerts, digests) | Vouch's own |
send.vouch.app |
Respondent-facing invites, reminders, thank-yous sent on behalf of workspaces | Shared, monitored per workspace |
bounce.vouch.app |
Return-Path / bounce collection for both | — |
Separating respondent mail from platform mail means a workspace with a bad contact list can never damage the deliverability of password-reset emails.
DNS records. These are the exact records the operator creates once, at deployment (Section 26 owns the deployment procedure):
| Host | Type | Value | TTL |
|---|---|---|---|
send.vouch.app |
TXT |
v=spf1 include:_spf.resend.com ~all |
3600 |
mail.vouch.app |
TXT |
v=spf1 include:_spf.resend.com ~all |
3600 |
resend._domainkey.send.vouch.app |
TXT |
The 2048-bit DKIM public key issued by the provider (p=MIIBIjANBgkqhkiG9w0…) |
3600 |
resend._domainkey.mail.vouch.app |
TXT |
The 2048-bit DKIM public key issued by the provider | 3600 |
bounce.send.vouch.app |
CNAME |
The provider's bounce host | 3600 |
bounce.mail.vouch.app |
CNAME |
The provider's bounce host | 3600 |
_dmarc.vouch.app |
TXT |
v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@vouch.app; ruf=mailto:dmarc-forensics@vouch.app; fo=1; adkim=s; aspf=s; pct=100 |
3600 |
_mta-sts.vouch.app |
TXT |
v=STSv1; id=20251104000000 |
3600 |
_smtp._tls.vouch.app |
TXT |
v=TLSRPTv1; rua=mailto:tls-reports@vouch.app |
3600 |
DMARC starts at p=none for the first 14 days of a new deployment while aggregate reports are reviewed, then moves to p=quarantine, then to p=reject once alignment is confirmed at 100 % for 30 consecutive days. Strict alignment (adkim=s; aspf=s) is used because both the SPF domain and the DKIM d= are under Vouch's control.
Custom sending domain (Growth and Scale): a workspace may send respondent mail from its own domain. The dashboard shows the same three records scoped to their domain (send.customer.com SPF, vouch._domainkey.send.customer.com DKIM, bounce.send.customer.com CNAME) and polls verification on the same cadence as custom domains (Section 14). Until verified, mail continues to send from send.vouch.app; verification never interrupts sending.
From, Reply-To, and how a reply reaches the customer. This is the detail that decides whether the feature is useful, because respondents reply to testimonial requests constantly — with questions, with the testimonial itself pasted into the reply, with "sure, but can you remind me what this is for".
| Header | Respondent mail | Platform mail |
|---|---|---|
From |
Northwind <invites@send.vouch.app> — display name is the workspace name, or Northwind via Vouch when the workspace has no verified custom sending domain, so the display is never misleading |
Vouch <notify@mail.vouch.app> |
Reply-To |
The workspace's verified replyToEmail (default: the Owner's address) |
support@vouch.app |
Return-Path |
bounce+{invite-id-hmac}@bounce.send.vouch.app |
bounce+{message-id-hmac}@bounce.mail.vouch.app |
List-Unsubscribe |
<https://v.vouch.app/u/{token}>, <mailto:unsubscribe+{token}@bounce.send.vouch.app> |
Present on digests and summaries only |
List-Unsubscribe-Post |
List-Unsubscribe=One-Click |
Same |
Message-ID |
<{uuid}@send.vouch.app> |
<{uuid}@mail.vouch.app> |
Auto-Submitted |
auto-generated |
auto-generated |
A respondent pressing Reply therefore lands directly in the customer's inbox — no relay, no address rewriting, no message held on Vouch's side. replyToEmail must be verified before use: the workspace enters an address, Vouch emails a confirmation link, and until it is clicked the reply-to falls back to the Owner's verified account address. Verifying prevents a workspace from setting a reply-to they do not control and using Vouch as a laundering relay.
Threading is preserved with References and In-Reply-To on reminders, so a reminder threads under the original invite in every major client rather than starting a new conversation. That single detail measurably reduces "who are you?" replies.
20.4 Email template catalogue #
Templates live in packages/core/messaging/templates, are authored as typed React components rendered to table-based HTML at build time, and are covered by snapshot tests plus a rendering test per client in the matrix (Section 20.5). Every template has a machine key, and the key is what the API, the logs, and the delivery records refer to.
| Key | Trigger | Audience | Subject | Preheader |
|---|---|---|---|---|
invite.request |
POST /request-pages/{id}/invites dispatch |
Respondent | {workspaceName} would love your feedback |
It takes about two minutes — write a few lines or record a short video. |
invite.reminder_1 |
Reminder offset 1 (default day 3), invite not submitted | Respondent | A quick reminder from {workspaceName} |
Your testimonial link is still open — two minutes is all it takes. |
invite.reminder_2 |
Reminder offset 2 (default day 7), invite not submitted | Respondent | Last reminder — {workspaceName} would still love your feedback |
This is the final reminder we'll send. |
submission.thank_you |
A draft is submitted (Section 8.10.5) | Respondent | Thank you — {workspaceName} received your testimonial |
Here's what you shared, and how to change or remove it any time. |
moderation.new_submission |
testimonial.submitted |
Owner, Admin, Editor (per preference) | New testimonial from {authorName} |
{ratingStars} · {mediaTypeLabel} · pending review |
moderation.daily_digest |
Daily at 09:00 in the workspace timezone, when ≥1 pending | Owner, Admin, Editor (per preference) | {pendingCount} testimonials waiting for review |
Oldest has been waiting {oldestAgeLabel}. |
member.invitation |
POST /invitations |
Invited person | {inviterName} invited you to {workspaceName} on Vouch |
Accept within 7 days to join as {roleLabel}. |
auth.verify_email |
Sign-up, or email change | User | Verify your email address |
This link expires in 24 hours. |
auth.magic_link |
Magic-link sign-in request | User | Your sign-in link for Vouch |
This link works once and expires in 15 minutes. |
auth.password_reset |
Password reset request | User | Reset your Vouch password |
This link expires in 1 hour. Ignore this email if you didn't ask. |
auth.new_device |
Sign-in from an unrecognized device | User | New sign-in to your Vouch account |
{deviceLabel} · {approxLocation} · {signInTime} |
quota.warning_90 |
A metered limit crosses 90 % | Owner, Admin | You've used 90% of your {metricLabel} |
{used} of {limit}. Nothing published is affected. |
quota.reached_100 |
A metered limit reaches 100 % | Owner, Admin | You've reached your {metricLabel} limit |
Everything already live keeps working. New collection is paused. |
billing.payment_failed |
Stripe dunning attempt 1, 2, 3 | Owner | We couldn't process your Vouch payment |
Attempt {attempt} of 3 · we'll retry on {nextAttemptDate}. |
billing.subscription_changed |
Plan, interval, seat, or add-on change | Owner | Your Vouch plan changed to {planName} |
Effective {effectiveDate} · {amountFormatted} per {intervalLabel}. |
domain.verified |
domain.verified |
Owner, Admin | {hostname} is live |
Your showcase page and links now use your own domain. |
domain.failed |
domain.failed |
Owner, Admin | Action needed: {hostname} could not be verified |
{failureSummary} — here's exactly what to change. |
analytics.weekly_summary |
Mondays 08:00 in the workspace timezone | Per preference | Your testimonials last week: {impressions} views |
{topTestimonialAuthor} performed best with {topCtr} click-through. |
export.ready |
An export job completes | Requesting user | Your Vouch export is ready |
The download link expires in 24 hours. |
privacy.erasure_confirmed |
An erasure request completes | Respondent | Your data has been deleted |
Reference {dsrReference} · completed {completedAt}. |
privacy.consent_withdrawn_notice |
consent.withdrawn |
Owner, Admin | A respondent withdrew consent |
{authorName}'s testimonial was unpublished automatically. |
integration.webhook_disabled |
Webhook auto-disable | Owner | Your webhook endpoint was turned off |
{disabledReasonSummary} — here's the last response we got. |
integration.needs_reauth |
Connection enters needs_reauth |
Owner, Admin | Reconnect {providerName} to keep syncing |
The connection expired and sync is paused. |
security.api_key_expiring |
14 days and 1 day before key expiry | Owner, Admin | API key "{keyName}" expires {expiryRelative} |
Rotate it before {expiryDate} to avoid interruption. |
workspace.deletion_scheduled |
DELETE /workspaces/current |
Owner | {workspaceName} is scheduled for deletion |
You have until {purgeDate} to cancel. Widgets keep serving until then. |
Body outline and variables — every template shares a chrome (logo, greeting, body, primary action, footer with the sender identity, a physical postal address, and, on non-mandatory mail, an unsubscribe link):
| Key | Body outline | Variables |
|---|---|---|
invite.request |
Personal greeting → one sentence naming the workspace and why they were asked → what is being asked for (a few lines of text, or a short video, their choice) → time estimate → single primary button "Share your feedback" → a plain URL beneath the button for clients that strip buttons → a one-line note that they can review and withdraw anything they submit | recipientName, workspaceName, workspaceLogoUrl, inviteUrl, customMessage, maxVideoMinutes, senderName, unsubscribeUrl, plus any per-recipient variables supplied at send time |
invite.reminder_1 / _2 |
Two sentences, no re-pitch → the same button and plain URL → "reply to this email if now isn't a good time" → reminder-2 additionally states it is the last one | Same as invite.request plus daysSinceInvite |
submission.thank_you |
Thanks → what happens next (a human reviews it before anything is published) → an echo of what they submitted (their name as it will appear, the first 300 characters, a video poster if any) → a link to their portal to view, download, or withdraw | recipientName, workspaceName, submittedExcerpt, posterUrl, portalUrl, submittedAt |
moderation.new_submission |
Author line → rating as stars and "5 of 5" → first 240 characters → poster thumbnail with alt text → buttons "Review" (primary) and "Open inbox" | authorName, authorRole, authorCompany, ratingStars, ratingText, excerpt, posterUrl, posterAlt, reviewUrl, mediaTypeLabel |
moderation.daily_digest |
Count headline → a list of up to 10 pending items, each one line with author, rating, and age → "Review all" button → a line stating the count omitted when more than 10 | pendingCount, items[], oldestAgeLabel, inboxUrl |
member.invitation |
Inviter and workspace → role in plain words with a one-line description of what it allows → "Accept invitation" button → expiry date → "if you weren't expecting this, ignore it" | inviterName, workspaceName, roleLabel, roleDescription, acceptUrl, expiresAt, customMessage |
auth.verify_email |
One sentence → button → plain URL → expiry → "if this wasn't you" line | userName, verifyUrl, expiresAt |
auth.magic_link |
Button → plain URL → single-use and 15-minute expiry stated → "if this wasn't you" line | userName, magicUrl, expiresAt, requestIp, requestApproxLocation |
auth.password_reset |
Button → plain URL → 1-hour expiry → "ignore if you didn't ask; your password is unchanged" | userName, resetUrl, expiresAt |
auth.new_device |
Device, approximate location, and time → "this was me" / "secure my account" buttons → link to active sessions | deviceLabel, approxLocation, signInTime, sessionsUrl |
quota.warning_90 / quota.reached_100 |
Metric, used, limit, and a text progress description (never a bare bar) → an explicit sentence that nothing already published is affected → what is actually paused → "View usage" and "Compare plans" buttons | metricLabel, used, limit, percent, scopeLabel, usageUrl, plansUrl |
billing.payment_failed |
What failed, without card details beyond the last four → attempt number and next retry date → what happens at the end of dunning, including that widgets keep serving → "Update payment method" button | attempt, nextAttemptDate, amountFormatted, cardBrand, cardLast4, portalUrl, gracePeriodEndsAt |
billing.subscription_changed |
Old plan → new plan → effective date → next charge amount and date → proration line if any → "Manage billing" button | previousPlanName, planName, effectiveDate, amountFormatted, intervalLabel, prorationFormatted, portalUrl |
domain.verified |
Confirmation → the hostname as a link → certificate expiry and auto-renewal note → a line confirming no embed needs re-pasting | hostname, showcaseUrl, certificateExpiresAt |
domain.failed |
The failing state in plain words → the exact records required versus observed, as a two-column table → the single next action → "Check again" button | hostname, state, failureSummary, recordDiff[], domainUrl |
analytics.weekly_summary |
Four headline numbers with week-over-week deltas expressed as text and an arrow glyph → top three testimonials by click-through → "View analytics" button | periodStart, periodEnd, impressions, interactions, clicks, ctr, deltas, topTestimonials[], analyticsUrl |
export.ready |
What was exported and the row count → "Download" button → 24-hour expiry → note that the link is single-workspace and should not be forwarded | datasetLabel, rowCount, downloadUrl, expiresAt |
privacy.erasure_confirmed |
Confirmation that the data is deleted → what was deleted, itemized → reference code → completion timestamp → contact address for questions | dsrReference, completedAt, deletedItems[], privacyContactEmail |
privacy.consent_withdrawn_notice |
Who withdrew, when, and which testimonials → confirmation it is already unpublished everywhere → a reminder to remove any copies held outside Vouch | authorName, withdrawnAt, testimonials[], inboxUrl |
integration.webhook_disabled |
Endpoint host → which of the three rules in Section 18.5 fired, in plain words → the last response status and a body excerpt → the attempts and their times → "Re-enable" and "View deliveries" buttons | endpointHost, disabledReason, disabledReasonSummary, lastStatus, lastBodyExcerpt, attempts[], webhookUrl |
integration.needs_reauth |
Provider and account → why it stopped → what stops working while disconnected → "Reconnect" button | providerName, accountLabel, reasonLabel, reconnectUrl |
security.api_key_expiring |
Key name and display prefix (never the secret) → expiry date → rotation instructions with the overlap window explained → "Rotate key" button | keyName, displayPrefix, expiryDate, expiryRelative, keysUrl |
workspace.deletion_scheduled |
What was scheduled, by whom, and when → what continues to work during the grace period → the purge date → "Cancel deletion" button | workspaceName, requestedByName, requestedAt, purgeDate, cancelUrl |
Worked example — the plain-text alternative for invite.request. Every template ships a hand-written text part; none is auto-generated by stripping tags, because stripped HTML reads like broken furniture:
Hi Dana,
Northwind would love your feedback.
You can write a few lines or record a short video — up to 5 minutes,
whichever is easier. It usually takes about two minutes.
Share your feedback:
https://v.vouch.app/r/northwind-stories?i=inv_01JBQ8G7V6UE27A9WY3DV6XBJN
Anything you send is reviewed by someone at Northwind before it is
published, and you can view, download, or remove it at any time from the
link we'll email you afterwards.
If now isn't a good time, just reply to this email — it goes straight to
Northwind.
— Sent by Northwind using Vouch
Unsubscribe: https://v.vouch.app/u/9f2a1c7e4b0d8a6c
Vouch, 1 Example Street, London, EC1A 1AA, United Kingdom20.5 Email rendering rules and the tested client matrix #
| Rule | Detail |
|---|---|
| Layout | Nested <table> layout with role="presentation", 600 px maximum content width, fluid below 600 px. No flexbox, no CSS grid, no position, no external stylesheet. |
| CSS | Inlined at build time by the renderer. A single <style> block carries only media queries and prefers-color-scheme rules, since neither can be inlined. |
| Size | ≤ 102 KB of HTML so Gmail never shows "message clipped" — which would hide the unsubscribe footer and is therefore a compliance problem, not just a cosmetic one. CI fails a template that exceeds 95 KB, leaving headroom for variable substitution. Total message size including images is kept under 300 KB. |
| Images | Hosted on cdn.vouch.app, https only, explicit width and height attributes, alt text on every image, and alt="" with role="presentation" on purely decorative ones. No image carries information that is not also in text — many clients block images by default. |
| Dark mode | <meta name="color-scheme" content="light dark">, <meta name="supported-color-schemes" content="light dark">, and a @media (prefers-color-scheme: dark) block. Logos ship in two variants swapped by media query, with the light variant as the fallback. Backgrounds avoid pure #FFFFFF and text avoids pure #000000, because Outlook's forced dark-mode inversion mangles both. Contrast is verified at 4.5:1 in both schemes. |
| Buttons | Bulletproof buttons: a <table> with a background colour, padding, and a nested <a> — never a background image. The destination URL is also printed as plain text beneath every primary button. |
| Fonts | System font stack only, no web fonts. Minimum body size 16 px, line height 1.5. |
| Text part | Hand-written text/plain alternative for every template, sent as multipart/alternative. |
| Links | No link shorteners in email, no redirect-through-tracking-domain wrappers. Recipients see the real destination on hover, which raises trust and lowers spam scoring. |
| Preheader | A hidden preheader element followed by enough zero-width whitespace to stop the client pulling body text into the preview. |
| Language | lang attribute set from the recipient's locale; dir="rtl" supported in the chrome for future locales. |
| Accessibility | One <h1> per email, correct heading order, role="presentation" on every layout table, and no meaning conveyed by colour alone — status is always words as well as colour (Section 21). |
Tested client matrix. Every template is rendered and visually diffed in CI against these clients on every change; a diff above the tolerance fails the build:
| Client | Platforms |
|---|---|
| Gmail | Web, Android, iOS, plus a Google Workspace account |
| Apple Mail | macOS, iOS (light and dark) |
| Outlook | Windows desktop (Word engine), Outlook Web, Outlook for Mac, Outlook Android/iOS |
| Yahoo Mail | Web |
| Proton Mail | Web |
| Thunderbird | Desktop |
| Samsung Mail | Android |
| Plain-text-only reader | Text part rendered alone |
The Windows Outlook Word engine is the constraint that drives the table layout and the bulletproof buttons; if a design cannot survive it, the design changes.
20.6 SMS #
When SMS is used: invites and reminders only. Nothing else. There are no SMS alerts, no SMS digests, no SMS two-factor codes (2FA is TOTP, per Section 6), and no SMS billing notices. SMS is expensive, intrusive, and heavily regulated, and a testimonial request is the only message where it earns its place — and only when the customer explicitly chooses it.
Templates. Kept within a single GSM-7 segment (160 characters) wherever the variables allow; the sender is warned at compose time when a workspace name pushes a template over, and the exact segment count and cost are shown before sending.
| Key | Text | Notes |
|---|---|---|
sms.invite.request |
{workspaceName}: we'd love a quick testimonial — text or a short video, ~2 min. {shortUrl} Reply STOP to opt out. |
108 characters plus the workspace name and the 24-character link |
sms.invite.reminder_1 |
{workspaceName}: friendly reminder, your testimonial link is still open. {shortUrl} Reply STOP to opt out. |
— |
sms.invite.reminder_2 |
{workspaceName}: last reminder — share your feedback here {shortUrl} Reply STOP to opt out. |
Final message; no further SMS is sent to this recipient for this invite |
sms.help_response |
Vouch sends testimonial requests for {workspaceName}. Reply STOP to opt out. Help: support@vouch.app |
Auto-reply to HELP |
sms.stop_confirmation |
You're opted out and won't get further messages from {workspaceName} via Vouch. |
Sent once, then silence |
Every message contains the workspace name, a link, and an opt-out instruction. A message that would not fit all three is rejected at validation with 422 SMS_TEMPLATE_TOO_LONG rather than being truncated.
Link shortener. Vouch operates its own shortener on v.vouch.app/s/{code} — never a third-party shortener, which would hand recipient click data to another company and is a common spam signal. Codes are 8 characters of base62, single-invite scoped, 301-free (a 302 so the target can change if a request page slug changes), and they expire when the invite is submitted or revoked. Following one records clicked on the invite, and the request page load that follows records opened (Section 20.7). Shortened links carry no query parameters and no tracking identifiers beyond the invite binding.
Compliance.
| Region | Requirement |
|---|---|
| United States | 10DLC registration is mandatory. Vouch registers as the brand and registers a "Low Volume Mixed" or "Standard" campaign per volume tier; the customer's workspace is a sub-account under it. Sending is blocked with 409 SMS_REGISTRATION_INCOMPLETE until registration is approved. Message templates are submitted with the campaign, and every message includes the brand name and STOP instructions. |
| Canada | CASL: express consent required and recorded, sender identification and an unsubscribe mechanism in every message. |
| United Kingdom | Alphanumeric sender ID permitted; PECR requires prior consent for the recipient. |
| European Union | Alphanumeric sender ID permitted in most member states; GDPR lawful basis is consent, recorded with the contact. |
| Australia | Spam Act: sender identification and functional unsubscribe in every message. |
| Elsewhere | Sender-ID registration requirements vary; the country availability table below is the operative control. |
Opt-out keywords, matched case-insensitively after trimming, on the inbound webhook at POST /api/v1/hooks/twilio: STOP, STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT, REVOKE, OPTOUT. Any of them adds the number to the workspace suppression list permanently and sends the single confirmation above. HELP and INFO return the help template. START, YES, and UNSTOP re-subscribe, and only the recipient can do that — no customer action can remove a number from suppression (Section 20.8).
Quiet hours. No SMS is delivered outside 08:00–20:00 in the recipient's inferred timezone. The timezone is inferred, in order, from: an explicit timezone on the contact; the phone number's country and area code; the country of the workspace. A message enqueued outside the window is held, not dropped, and released at the next window opening, with the delay visible on the invite record. US 10DLC quiet-hour rules and state-level restrictions are applied as the stricter of the two where they differ.
Per-country availability. SMS is enabled for: United States, Canada, United Kingdom, Ireland, Germany, France, Spain, Portugal, Netherlands, Belgium, Denmark, Sweden, Norway, Finland, Italy, Austria, Switzerland, Poland, Australia, and New Zealand. A number outside the list returns 422 SMS_COUNTRY_UNSUPPORTED at validation, before any cost is incurred, with a message suggesting email instead. The list is a configuration value the operator can extend once registration in a new country is complete; it is not a code change.
Cost controls. Every one of these is enforced server-side:
- A per-workspace monthly SMS spend cap, defaulting to $25, editable by the Owner up to $500. Reaching it pauses SMS (email continues) and emails the Owner.
- A per-send segment estimate shown before dispatch; a send whose estimated cost exceeds the remaining cap is rejected with
422 SMS_QUOTA_EXCEEDED. - A hard per-recipient cap of 3 SMS per invite (initial plus two reminders) and 5 SMS per recipient per 30 days across all invites in a workspace.
- Destination-price guardrails: a per-country maximum price per segment, above which sending is refused rather than silently expensive.
- Duplicate suppression: the same template to the same number within 60 seconds is dropped and logged.
- A platform-wide anomaly alert if SMS volume exceeds 3× the trailing 7-day average in an hour, which is the signature of a compromised key being used for toll fraud.
20.7 Invite delivery tracking and reminder cadence #
The status vocabulary is the invite_status enum defined in Section 5.2. These eleven values are the whole set, used identically by the API (Section 7.15.6), the dashboard, and the invite.* webhooks (Section 18.2); this table gives each one its operational meaning and says who sets it:
| Status | Set when | Source |
|---|---|---|
queued |
The invite row is created and enqueued | Vouch |
sent |
Handed to the email or SMS provider with a provider message id | Vouch |
delivered |
The provider confirms delivery to the receiving server or handset | Provider webhook |
clicked |
The invite link — or its v.vouch.app/s/{code} short form — was followed |
Vouch redirect |
opened |
The request page actually loaded. A page load, never a tracking pixel; this is the status behind invite.opened |
Vouch page load |
started |
A submission was created from this invite | Vouch |
submitted |
The submission completed and produced a testimonial | Vouch |
bounced |
Hard bounce, invalid number, or blocked recipient; the address is suppressed | Provider webhook |
failed |
Could not be sent after retries | Vouch |
revoked |
Cancelled by the workspace, superseded by a newer invite, or ended because the recipient unsubscribed or replied STOP | Vouch / provider webhook |
expired |
The invite passed its expiry without a submission | Vouch |
A soft bounce deliberately has no status of its own: the provider is still retrying, the invite is still legitimately sent, and the deferral is recorded on the delivery record where an operator can see it. Inventing a twelfth status for a transient provider condition would put a non-terminal state into a vocabulary the whole product switches on.
Pixel-free open tracking, and why. Vouch embeds no tracking pixel in any email. The opened status is recorded when the invite's unique link is followed and the request page actually loads — it means a page was rendered, not that a mail client fetched an image. Four reasons, stated in the product documentation so customers understand what the number means:
- Pixel opens are no longer true. Apple Mail Privacy Protection pre-fetches remote images for a large share of recipients, so pixel-based "opens" are inflated and uncorrelated with human attention. A metric that is wrong is worse than a metric that is missing.
- It is consistent with the rest of the product. Section 17 commits to cookie-free, identifier-free measurement on the widget. Embedding a per-recipient tracking pixel in email while claiming privacy on the web would be incoherent.
- A link click is a real signal. It means a person decided to act, which is what the customer actually wants to know.
- It reduces spam scoring. 1×1 tracking images in a transactional email are a well-known filter signal.
The dashboard therefore reports Sent → Delivered → Opened → Started → Submitted as a funnel, labels the third step "Link opened" rather than "Opened", and includes a one-line explanation of the difference so nobody mistakes it for an open rate.
Reminder cadence. Default: a reminder on day 3 and a reminder on day 7, maximum 2 reminders — the ceiling is the respondent-protection principle in Section 20.1 and it is enforced in the column constraint, the validator, and the scheduler, not just in the default. Configurable per request page via reminderSchedule (Section 7.15.5) and overridable per send:
| Setting | Default | Range |
|---|---|---|
enabled |
true |
— |
offsetsDays |
[3, 7] |
1–30, ascending, unique, at most 2 entries |
maxReminders |
2 |
0–2 |
channel |
inherits the invite's channel | email, sms, same |
sendWindowLocalHours |
[8, 20] |
Enforced for SMS; advisory for email, where sends are scheduled inside the window when a timezone is known |
Reminders are cancelled immediately when the invite reaches submitted, bounced, revoked, failed, or expired; when the contact is suppressed for any reason; when the request page is deactivated or deleted; when the workspace hits its collection cap (Section 19 blocks new collection, so chasing a respondent for a submission that will be refused would be cruel); and when the workspace becomes read-only. Cancellation removes the delayed job from the queue rather than sending and discarding.
Reminders thread under the original message with References and In-Reply-To, reuse the same short link, and never re-send to an address that bounced.
20.8 Suppression list #
suppressions (Section 5) is workspace-scoped and holds an email address or a phone number, a reason, a source, and a timestamp.
| Reason | Created by | Removable by the workspace? |
|---|---|---|
hard_bounce |
Provider webhook | No |
complaint |
Provider spam-complaint webhook | No |
unsubscribe |
Recipient clicking unsubscribe, one-click list-unsubscribe, or replying STOP | No |
manual |
A workspace member adding an address | Yes |
Rules:
- Enforcement is at the service layer.
MessagingService.send()checks suppression before every single send, for every channel, for every template except the mandatory security and compliance messages in Section 20.2 rows 8–14 and 30. The UI also hides suppressed recipients, but the UI is not the control — an API-key caller, a Zapier action, and a scheduled reminder all pass through the same check. - Suppression is permanent unless the contact themselves re-subscribes. A recipient may re-subscribe from the unsubscribe confirmation page or by texting START. No customer action, no CSV re-import, no support request, and no plan upgrade removes a
hard_bounce,complaint, orunsubscribeentry. Re-importing a suppressed address silently skips it and reports the skip. - Scope is the workspace, not the platform. Unsubscribing from one customer's requests does not stop another customer the person actually wants to hear from. The unsubscribe page states this explicitly so the recipient is not misled.
- Complaints are also platform-visible. A complaint additionally increments a platform-level counter used by the deliverability dashboard in Section 20.10, without leaking the address across tenants.
POST /api/v1/suppressionsaccepts bulk additions so a customer can import their own do-not-contact list before their first send, which is the single most effective way to avoid a bad first sending reputation.
20.9 In-app notification centre #
The data model is notifications and is owned by Section 5. Behaviour:
- Read state per user per notification:
unread→read→archived. Opening the centre does not mark everything read; only opening an individual item, or pressing "Mark all read", does. Read state is per user, so one Admin reading an alert does not hide it from another. - Grouping. Notifications of the same type for the same resource within a rolling 60-minute window collapse into one row with a count: "12 new testimonials". Expanding shows the individual entries. Grouping happens at read time from a
group_keycolumn, so the underlying rows stay individually addressable and individually readable. - Ordering is
created_at DESC, cursor-paginated exactly as every other collection (Section 7.4). - Badge count is the number of unread rows, capped in display at "99+", read from a counter maintained in Valkey and reconciled nightly against the table.
- Live updates arrive over Server-Sent Events on
GET /api/v1/notifications/streamwith a heartbeat every 25 seconds and automatic reconnection with exponential backoff. There is no WebSocket, because one-way server-to-client is all this needs. - Retention: read notifications are hard-deleted after 90 days, unread after 180 days, by the nightly retention sweep. The events they refer to remain in the audit trail (Section 11.6), which is the durable record; the notification centre is an inbox, not an archive.
- Accessibility: the badge carries an accessible name ("12 unread notifications"), new items are announced with
aria-live="polite", and every item's type is conveyed by an icon plus text, never colour alone (Section 21).
20.10 Deliverability operations #
Inbound provider webhooks. POST /api/v1/hooks/resend receives email.sent, email.delivered, email.delivery_delayed, email.bounced, email.complained, and email.opened (which Vouch ignores, per Section 20.7). POST /api/v1/hooks/twilio receives message status callbacks and inbound messages. Both verify signatures per Section 7.15.23, are idempotent on the provider's message and event ids, and translate into the invite status vocabulary in Section 20.7 plus suppression writes.
Reputation dashboard. An internal operator view, refreshed every 15 minutes, per sending subdomain and per workspace:
| Metric | Target | Alert threshold |
|---|---|---|
| Delivery rate | ≥ 98 % | < 95 % |
| Hard bounce rate | < 2 % | ≥ 3 % |
| Soft bounce rate | < 5 % | ≥ 8 % |
| Complaint rate | < 0.08 % | ≥ 0.1 % |
| Unsubscribe rate | < 1 % | ≥ 2 % |
| DMARC alignment pass rate | 100 % | < 99.5 % |
| TLS delivery rate | ≥ 99 % | < 97 % |
| Median time to delivery | < 20 s | > 120 s |
It also ingests Google Postmaster Tools and Microsoft SNDS where available, and DMARC aggregate reports parsed from dmarc-reports@vouch.app into a weekly alignment summary.
When the complaint rate exceeds 0.1 % — the runbook, owned operationally by Section 24 and stated here because it is the messaging system's most important failure mode:
- Identify the source. Break the rate down by workspace, by template, and by 24-hour window. In almost every case a single workspace and a single template account for it.
- Throttle that workspace's respondent sending immediately to 50 messages per hour. Platform mail — auth, billing, security — is on a separate subdomain and is untouched.
- Notify the workspace Owner with the specific numbers, the affected template, and the likely cause (a purchased list, an old contact list, a mismatch between who they said they were and who the recipients expected).
- Suspend and require review if the rate does not fall below 0.08 % within 48 hours: respondent sending for that workspace is paused, and an operator reviews the contact source with the customer before re-enabling.
- Protect the shared pool. If two or more workspaces breach in the same week, move new and low-volume workspaces to a separate IP pool so established senders are insulated while the problem is worked.
- Never mass-unsubscribe on the customer's behalf, but do offer, in one click, to suppress every contact that has not engaged in 12 months — usually the actual cause.
- Post-incident, record the cause and the remedy in the operations log, and if the cause was a product affordance (an invite flow that made bulk-importing a stale list too easy), file the product change. A deliverability incident caused by a product design is a product bug.
Warm-up. A new deployment or a new dedicated IP follows a 30-day warm-up: 50 messages on day 1, doubling every third day to the steady-state volume, with respondent mail warmed before platform mail. The scheduler enforces the ramp automatically and holds excess in the queue rather than dropping it; the operator sees the held volume and the projected drain time on the same dashboard.
21. Accessibility — WCAG 2.2 Level AA #
21.1 Conformance Target, Scope & Evaluation Method #
Target: WCAG 2.2 Level AA. Every Level A and Level AA success criterion in WCAG 2.2 applies. Three Level AAA criteria are adopted voluntarily because they materially improve the hardest surface in this product — the video recorder — and they are marked as such wherever they appear: 2.4.13 Focus Appearance, 2.5.5 Target Size (Enhanced) for primary recorder controls on touch, and 1.4.8 Visual Presentation for the transcript editor's reading column.
Scope — full conformance is required, with no exceptions, on:
| Surface | Why it is in scope without exception |
|---|---|
| Request pages (all custom field types, all branding configurations) | A respondent did not choose this software and cannot switch tools |
| The video recorder | Same, and it is the most interaction-dense component in the product |
| The consent and submit screen | Consent that cannot be read or operated is not consent |
| The respondent portal (Section 22.4) | It is how a person exercises a legal right |
| The public showcase page | It is the customer's public marketing page |
| Every widget type and every preset (Section 13.4) | It renders on other people's websites; an inaccessible widget makes the customer's site inaccessible |
The <noscript> and oEmbed fallbacks |
They exist precisely for constrained environments |
| Every transactional email (Section 20) | Plain-text alternative, semantic HTML, no image-only content |
The dashboard targets the same Level AA bar. Where a v1 gap is accepted it is listed explicitly in Section 21.12 with a named remediation milestone in Section 27. There are no silent gaps: a criterion is either satisfied, or it is in that table with a date.
Evaluation method — conformance is claimed on the basis of all four of these, not any one:
- Automated: axe-core via
@axe-core/playwright(Section 3) on every request-page fixture, every widget fixture, every recorder state, and every dashboard route, in CI on every pull request. Zero violations atseriousorcriticalis a hard gate — the build fails.moderateandminorviolations are reported and triaged, not gated, because axe'smoderatebucket contains advisory rules that produce false positives inside shadow roots. - Keyboard-only: a scripted Playwright pass that never dispatches a mouse event, asserting that every interactive element is reachable, operable, and has a visible focus indicator.
- Assistive technology: the manual matrix in Section 21.13.2, run against the release candidate before every minor release and against the recorder before every release of any size.
- Zoom and reflow: the 200% zoom and 400% reflow passes in Section 21.13.3.
Accessibility is a release gate, not a backlog category. A pull request that introduces a
serious or critical axe violation cannot merge. A release with an unresolved AT-matrix
regression on the recorder does not ship.
21.2 The Recorder — The Hard Case, Specified First #
The recorder is where accessibility work is usually abandoned: it is a media-capture UI with timers, permission prompts, device selection, transient states, and a hard time limit. It is specified first and in the most detail because everything else in this product is easier.
Section 9 owns the recorder's capture behaviour, codec negotiation, and upload mechanics. This section owns how it is perceived and operated.
21.2.1 Structure and landmarks #
The recorder is a single <section> with aria-labelledby pointing at the visible prompt
heading. It is not a dialog and does not trap focus, because trapping focus in the primary
content of a page is hostile and because the respondent must be able to reach the prompt text,
the consent controls, and the "switch to writing instead" escape at all times.
<section class="recorder" aria-labelledby="recorder-heading" aria-describedby="recorder-prompt">
<h2 id="recorder-heading">Record your testimonial</h2>
<p id="recorder-prompt">What problem were you trying to solve when you found Acme?</p>
<div id="rec-status" role="status" aria-live="polite" aria-atomic="true" class="sr-only"></div>
<div id="rec-alert" role="alert" aria-live="assertive" class="sr-only"></div>
<video id="rec-preview" playsinline muted
aria-label="Camera preview. This shows what your camera sees; it has no sound."></video>
</section>Two live regions, with strictly separated duties:
#rec-status(role="status",aria-live="polite") — every non-urgent state change: recording started, paused, resumed, stopped, take discarded, upload progress milestones, time-remaining warnings.#rec-alert(role="alert",aria-live="assertive") — only three things: permission denied, no capture device found, and recording stopped by error. Nothing else is ever assertive, because assertive announcements interrupt and an interrupted person loses their place.
The preview <video> element is muted and carries an aria-label that says what it is and that
it has no sound, because an unlabelled video element is announced by screen readers as a media
player the user might reasonably try to play.
21.2.2 Explaining the permission prompt before it appears #
The browser's camera and microphone permission prompt is rendered by the browser, is not in our DOM, and cannot be styled, labelled, or described by us. A screen-reader user who receives it without warning may not know what is being asked or which application asked.
Therefore getUserMedia is never called on page load. The flow is:
- The page renders an explanation panel before any permission is requested:
Before we start #
Your browser — not this page — will ask for permission to use your camera and microphone. Your screen reader will announce it as a dialog from your browser; if you are looking for it visually, it usually appears near the address bar.
- Nothing is recorded until you press Start recording.
- Nothing is sent anywhere until you press Submit.
- You can re-record as many times as you like.
- You can change your mind at any point and write your testimonial instead.
[ Allow camera and microphone ] [ Write instead ]
getUserMediais called only from the activation of the "Allow camera and microphone" button, so the browser prompt is a direct, expected consequence of a deliberate action.- While the browser prompt is open,
#rec-statusannounces: "Waiting for your browser's camera and microphone permission. Look for a permission request from your browser." - On grant:
#rec-statusannounces "Camera and microphone ready. Camera: FaceTime HD Camera. Microphone: MacBook Pro Microphone. Press Start recording when you're ready." - On denial:
#rec-alertannounces "Camera and microphone permission was blocked. You can change this in your browser's site settings, or write your testimonial instead." Focus moves to a recovery panel containing per-browser instructions and the "Write instead" button. The panel is reachable again later; the state is not a dead end. - If
enumerateDevicesreturns no video input,#rec-alertannounces "No camera was found on this device," and the UI offers audio-only recording and the written path.
21.2.3 Keyboard map #
Every control is a real <button> or <select> — no div with a click handler anywhere in the
recorder. Tab order follows visual order, which follows DOM order; no positive tabindex exists
in the codebase and a lint rule forbids it.
| Key | Context | Action | Announcement in #rec-status |
|---|---|---|---|
Tab / Shift+Tab |
Anywhere | Move between controls in DOM order | — |
Enter / Space |
On any button | Activate | Per action below |
Enter / Space |
Allow camera and microphone | Request permission | "Waiting for your browser's camera and microphone permission." |
Enter / Space |
Start recording | Begin 3-2-1 countdown, then record | "Starting in 3. 2. 1." then "Recording." |
Enter / Space |
Pause | Pause capture | "Paused at 1 minute 12 seconds." |
Enter / Space |
Resume | Resume capture | "Recording resumed." |
Enter / Space |
Stop | End the take | "Recording stopped. 2 minutes 04 seconds recorded. Review your take, or record again." |
Enter / Space |
Record again | Discard and return to ready | "Take discarded. Ready to record." |
Enter / Space |
Play / Pause (review) | Play or pause the review player | "Playing." / "Paused." |
Enter / Space |
Captions toggle | Toggle caption display | "Captions on." / "Captions off." |
Enter / Space |
Submit | Move to the consent and details step | "Moving to the final step." |
Escape |
During countdown | Cancel the countdown before recording begins | "Countdown cancelled." |
Escape |
While recording | Does nothing. Escape must not destroy a take | — |
Escape |
In the device-settings popover | Close and return focus to its trigger | — |
← / → |
On the review scrubber | Seek −5 s / +5 s | "12 seconds of 2 minutes 4 seconds." |
Shift+← / Shift+→ |
On the review scrubber | Seek −1 s / +1 s | Same format |
Home / End |
On the review scrubber | Seek to start / end | Same format |
↑ / ↓ |
On the review scrubber | Volume +10% / −10% | "Volume 70 percent." |
↑ / ↓ / Home / End |
On a device <select> |
Native option navigation | Native |
M |
Review player focused | Mute / unmute | "Muted." / "Unmuted." |
? |
Anywhere in the recorder | Open the keyboard-shortcuts panel | "Keyboard shortcuts opened." |
The review scrubber is a native <input type="range"> with aria-label="Playback position" and
a live aria-valuetext of the form "12 seconds of 2 minutes 4 seconds". Native range inputs give
every platform's expected key behaviour, touch behaviour, and high-contrast rendering for free;
a custom slider would have to reimplement all three and would get one of them wrong.
Stop is never the only way out. Stop, Pause, and Record again are always simultaneously present during a take — never collapsed into one toggle button whose meaning changes — because a button whose accessible name changes under the user is disorienting for screen-reader and cognitive-accessibility users alike.
21.2.4 Focus management #
- Focus is never moved automatically except at four defined moments: after permission denial
(to the recovery panel heading), after a take is stopped (to the "Play" button in the review
panel), after a validation error on submit (to the error summary, Section 21.10.4), and after
successful submit (to the thank-you page's
<h1>). Every one of these is a context change the user initiated. - The focus indicator is a 2 px solid ring in the theme's focus colour plus a 2 px contrasting
outer ring, giving at least 3:1 against both the focused control and the adjacent background in
every theme. It uses
:focus-visible, and it is never removed by the branding editor — customer branding cannot restyle the focus ring, only the accent colour it derives from, and that derivation is contrast-clamped by the checker in Section 21.5. - 2.4.11 Focus Not Obscured (Minimum) is satisfied structurally: the recorder has no sticky header, no sticky footer, and no floating action bar on any viewport. The only overlaying elements are the device-settings popover and the shortcuts panel, both of which are modal, both of which contain the focus while open, and both of which return focus to their trigger on close. The submit bar on narrow viewports is in normal flow at the end of the document, not fixed. A Playwright assertion verifies for every focusable element that its bounding box is fully within the viewport and not intersected by any element with a higher stacking context.
- 2.4.13 Focus Appearance (adopted, Level AAA): the indicator's area is at least the perimeter of a 2 px ring around the control, and its contrast against adjacent colours is at least 3:1 in both the focused and unfocused states.
- Focus is never placed on a non-interactive element except the four programmatic moves above,
each of which targets an element given
tabindex="-1"for that purpose and removes it afterwards.
21.2.5 Time limits and announcements #
The plan's maximum take length (Section 19.2) is a hard stop. WCAG 2.2.1 Timing Adjustable normally requires that a time limit be adjustable, extendable, or turned off. Our position, and it is a defensible one that must be documented rather than assumed:
2.2.1 is not engaged at all, because the recording length limit is not a timing mechanism. A timing mechanism is one where elapsed time invalidates work; here reaching the limit finalizes and preserves the take, exactly as pressing Stop would, and the respondent may review it, keep it, or record again. No content is lost, no session expires, and nothing must be redone. The constraint itself belongs to the media pipeline in Section 9.
We deliberately do not rest this on the 2.2.1 "Essential" exception. Essential means extending the limit would invalidate the activity — and because a shorter cap on a cheaper plan can be lifted by paying us (Section 19.2), a limit that money can extend is by definition not essential, and an auditor would be right to say so. Where a plan sets a cap shorter than the pipeline maximum, the request page states that cap in text before recording begins and the respondent may use the written path instead, which has no time limit of any kind.
The limit is stated before recording begins, in text: "You have up to 5 minutes."
Warnings are announced at 60 seconds, 30 seconds, and 15 seconds remaining, and not on every tick. Announcing every second makes the region unusable and drowns out everything else.
The visible timer updates every second, but the element that carries it is
aria-live="off"witharia-hidden="true"on the digits; the accessible announcements come exclusively from the three scheduled messages written into#rec-status.
| Moment | #rec-status text |
|---|---|
| Countdown | "Starting in 3." → "2." → "1." |
| Recording begins | "Recording." |
| 60 s remaining | "One minute remaining." |
| 30 s remaining | "30 seconds remaining." |
| 15 s remaining | "15 seconds remaining." |
| Hard stop reached | "Time limit reached. Recording stopped and saved. 5 minutes recorded. Review your take, or record again." |
| Upload progress | Announced at 25%, 50%, 75%, and 100% only: "Upload 50 percent complete." |
| Upload complete | "Upload complete. Your recording is safe. Continue to the last step." |
| Connection lost mid-upload | "Connection lost. Your recording is saved and will finish uploading when you're back online." (#rec-status, not #rec-alert — it is recoverable and not urgent) |
There is no session timeout on a request page. A respondent may sit on the page for an hour. Nothing expires under them, so WCAG 2.2.1 and 2.2.6 have nothing to bite on. The upload session row has a 24-hour sweep (Section 9.10.2), which is far beyond any plausible sitting.
21.2.6 Full screen-reader script #
This is the expected experience end to end. It is written as a test script: each row is asserted in the manual AT matrix and, where the announcement originates in our DOM, in an automated test that reads the live-region text content after the triggering action.
| # | User action | Expected announcement |
|---|---|---|
| 1 | Page loads | "Share your Acme story, page. Heading level 1, Share your Acme story." Then the landmark structure: banner, main, contentinfo |
| 2 | Tab to skip link | "Skip to the recording form, link" |
| 3 | Activate skip link | "Record your testimonial, heading level 2" |
| 4 | Read the prompt | "What problem were you trying to solve when you found Acme?" |
| 5 | Tab | "Before we start, heading level 3" then the explanation text |
| 6 | Tab | "Allow camera and microphone, button" |
| 7 | Tab | "Write instead, button" |
| 8 | Shift+Tab, activate "Allow camera and microphone" | "Waiting for your browser's camera and microphone permission. Look for a permission request from your browser." |
| 9 | Grant permission in the browser prompt | "Camera and microphone ready. Camera: FaceTime HD Camera. Microphone: MacBook Pro Microphone. Press Start recording when you're ready." |
| 10 | Tab | "Camera, combo box, FaceTime HD Camera, 1 of 2" |
| 11 | Tab | "Microphone, combo box, MacBook Pro Microphone, 1 of 3" |
| 12 | Tab | "Show the prompt while I record, switch, off. When on, the prompt scrolls over the camera preview." |
| 13 | Tab | "Start recording, button" |
| 14 | Activate Start recording | "Starting in 3." … "2." … "1." … "Recording." |
| 15 | Tab during recording | "Pause, button" → "Stop, button" → "Record again, button" |
| 16 | At 60 s remaining | "One minute remaining." |
| 17 | At 30 s remaining | "30 seconds remaining." |
| 18 | At 15 s remaining | "15 seconds remaining." |
| 19 | Activate Stop | "Recording stopped. 2 minutes 04 seconds recorded. Review your take, or record again." Focus lands on Play |
| 20 | Play focused | "Play, button" |
| 21 | Activate Play | "Playing." |
| 22 | Tab | "Playback position, slider, 0 seconds of 2 minutes 4 seconds" |
| 23 | Press → | "5 seconds of 2 minutes 4 seconds" |
| 24 | Tab | "Captions, toggle button, pressed" (captions default on — Section 21.3) |
| 25 | Tab | "Record again, button" |
| 26 | Tab | "Use this take, button" |
| 27 | Activate Use this take | "Moving to the last step." then "About you, heading level 2" |
| 28 | Tab | "Your name, edit, required. This is shown with your testimonial." |
| 29 | Tab | "Role or job title, edit, optional" |
| 30 | Tab | "Company, edit, optional" |
| 31 | Tab | "Email, edit, required. We'll only use this to send you a copy and a link to manage your testimonial." |
| 32 | Tab | The consent group: "Permissions, group" then each checkbox by its full text (Section 22.2) |
| 33 | Tab past an ungranted required consent, then activate Submit | "There is 1 problem with this form, heading level 2" and focus moves to the error summary |
| 34 | Tab in the summary | "Permission to publish is required to submit, link" |
| 35 | Activate that link | Focus moves to the checkbox; "Permission to publish… checkbox, not checked, invalid entry. You need to give permission to publish before you can submit." |
| 36 | Check it, activate Submit | "Uploading. Please keep this page open." then "Upload complete. Thank you." |
| 37 | Thank-you page | "Thank you, page. Heading level 1, Thank you." then the confirmation text and the respondent-portal link |
21.2.7 Non-visual and non-auditory alternatives #
- The written path is always available and equally prominent, on every request page, from first paint, without needing to fail at recording first. It is not a fallback presented after an error; it is a peer choice. This single decision resolves most of the residual risk in the recorder for users who cannot record video at all.
- Audio-only recording is offered when no camera is available or when the respondent chooses it. It produces a media asset with the same transcript pipeline and a static branded poster.
- The prompt text is always present as text on the page, not only as the teleprompter overlay, and the teleprompter is off by default (Section 9.3) so it never obscures the preview for someone with low vision who has zoomed in.
21.3 Captions & Media Players #
Section 10 owns transcript generation, the segment editor, and VTT production. This section owns player behaviour.
- Captions are ON by default in every player surface: the recorder's review player, the moderation inbox preview, the showcase page, the oEmbed player, and — the one that is usually missed — inside every widget. There is no plan, theme, or embed configuration in which captions default to off.
- A visible caption toggle is present in every player's control bar, implemented as a
<button aria-pressed>with the accessible name "Captions" and pressed state reflecting the current setting. It is never an icon without an accessible name. - The caption preference persists on our own origins and is memory-only inside the widget. On
app.vouch.app,v.vouch.apprequest pages, showcase pages, and custom showcase domains, the choice is written tolocalStorageunder the keyvouch.a11y.captions. This is not a tracking identifier and must not be treated as one: it holds a single boolean the viewer set themselves, it is never joined to an event, a session, a person, or any other record, it carries no random value that could distinguish one visitor from another, and it is never transmitted anywhere. It is a user-requested accessibility preference, exempt from consent under ePrivacy Art. 5(3) as strictly necessary for a service the user explicitly asked for; it is named in the privacy notice; and it is the only client-side storage on those origins. Section 10.10 owns the player-side persistence and Section 17.1 scopes its cookie-free rule to leave exactly this gap; this paragraph is the accessibility half of the same boundary. All three must agree, or a disabled viewer is left re-setting the control on every page view of a site we own. Inside the embedded widget the preference remains in JavaScript memory for the page view only — no cookie, nolocalStorage, nosessionStorage— because that origin belongs to the customer and Section 17.1's no-client-storage rule governs it. The distinction is deliberate: 17.1 exists to keep us out of a third party's terminal storage, not to make a disabled user re-set a control on every page view of a site we own. - OS-level caption preferences are respected where the browser exposes them. In Safari and
other WebKit browsers,
::cuestyling is deliberately left unset for font family, size, and colour so the platform's user caption settings apply. Where the browser exposeswindow.matchMedia('(prefers-reduced-transparency)')the caption background becomes fully opaque. Our only unconditional caption styling is a minimum 4.5:1 contrast floor achieved by a solid backdrop behind the text, applied through::cuebackground only when the platform has not supplied its own. - Caption text is rendered by the browser's native track rendering, not by a custom overlay, so it inherits the platform's caption engine, its user settings, and its screen-magnifier behaviour.
- 1.2.1 / 1.2.2 / 1.2.3 / 1.2.5. Every video testimonial has synchronized captions from the
transcript (1.2.2). The full transcript is available as a visible, selectable text alternative
adjacent to the player on the showcase page and expandable in the widget (1.2.1, 1.2.3, 1.2.8).
For 1.2.5 Audio Description, the platform constrains the medium only for video the recorder
captured: a single speaker, no slides, no screen share, no on-screen text, so there is no visual
information absent from the audio. That premise does not hold for the upload-a-video control in
Section 9.2.1 or for video imported from an external source in Section 16, both of which accept
arbitrary footage — a screen-cast product demo is a perfectly ordinary thing for a customer to
upload. Therefore: the moderation inbox exposes a checkbox — "This video shows something that
isn't said out loud" — and for any testimonial whose
sourceisuploadorimported_*that question must be answered explicitly, yes or no, before approval; it may not be left unanswered, and the publish gate refuses an unanswered value with422 AUDIO_DESCRIPTION_UNDETERMINED. Answering yes requires a text description before publish. That description is rendered beneath the player on every surface and included in the widget payload. For recorder-captured video the checkbox remains optional and unchecked by default, because there the single-speaker premise is enforced by the capture path itself. - If transcription failed (Section 10.8), the player renders without a caption track, the moderator is notified, and the item carries a visible "Captions unavailable" note with a "Retry transcription" action in the dashboard. The item is still publishable — blocking publication would push customers toward not using the platform for video at all, which is worse for accessibility overall. The note is text, not an icon.
- No autoplay, ever, on any surface, at any time, muted or not (Section 21.8). Video begins only on explicit activation, which also satisfies 1.4.2 Audio Control trivially.
21.4 No Colour-Only Meaning — Complete Enumeration #
WCAG 1.4.1 is the criterion this product is most likely to fail, because status is everywhere. Every place the product could use colour alone is listed here with its mandated alternative. This table is normative: a component that shows one of these states without its listed alternative is a defect.
| Where | The states | Required non-colour indicator |
|---|---|---|
| Moderation status pills (Section 11) | pending, approved, published, unpublished, rejected, archived |
The status word as visible text inside the pill, plus a distinct icon per status (clock, check, globe, eye-off, cross, box). Icons are aria-hidden; the text is the accessible name |
| Domain verification states (Section 14.4) | pending_dns, dns_detected, verifying, issuing_tls, active, failed |
Full-sentence status text ("Waiting for DNS records to appear"), a distinct icon, and a next-action link. The six states are also distinguishable in monochrome by icon shape |
| Transcript confidence highlighting (Section 10.5) | Word confidence < 0.75 | A dotted underline and a <mark> element with an accessible name of "low confidence word", plus a "3 words to check" count above the editor and ←/→ jump-to-next-flag buttons. Never a background tint alone |
| Analytics deltas (Section 17.7) | Up, down, flat | An arrow glyph and a signed number and the word "up"/"down"/"no change" in the accessible name: "Impressions 12,480, up 8.2 percent versus the previous 30 days" |
| Quota meters (Section 19.12.2) | Normal, approaching, reached | Text label "Approaching limit" / "Limit reached" plus an icon, plus aria-valuetext on the meter carrying the numbers |
| Star ratings (imported reviews, rating summary widget) | 1–5 | A text equivalent adjacent or as the accessible name: "4.8 out of 5, from 132 reviews". Individual stars are aria-hidden; the container carries the text. Half-stars are still readable as text |
| Required-field indicators (all forms) | Required vs optional | The word "required" in the visible label, aria-required="true", and — because "everything without an asterisk is optional" fails for anyone who cannot see the asterisk — the word "optional" on optional fields too. The asterisk is decorative and aria-hidden |
| Form validation state | Valid, invalid | aria-invalid="true", a text error message referenced by aria-describedby, and an error icon. Never a red border alone |
| Widget theme "featured" testimonial | Featured vs normal | A visible "Featured" text chip, not merely a heavier border or tinted card |
| Job/import row states (Section 16) | queued, running, succeeded, partial, failed | Status word plus icon plus, for partial and failed, a row count ("Imported 412 of 500 — 88 rows had problems") |
| Billing state (Section 19.12.1) | trialing, active, past_due, canceled | Full text label plus explanatory sentence. No coloured dot alone |
| Consent grant state (Section 22.3) | Granted / not granted | The word "Granted" or "Not granted" plus icon, in both the dashboard and the respondent portal |
| Caption availability | Available / unavailable | The text "Captions unavailable" plus icon, never a greyed toggle alone |
| Widget carousel position | Current slide | aria-current="true" on the active dot, a visible numeric "3 of 8" label, and a shape difference between active and inactive dots |
| Recorder recording state | Idle, counting down, recording, paused | Text ("Recording", "Paused") beside the indicator, the live-region announcements in Section 21.2.5, and a shape change (filled circle vs. two bars), not just red vs. grey |
| A11y contrast warnings in the branding editor | Pass / warn / fail | The computed ratio as text ("2.9:1 — needs at least 4.5:1"), plus an icon and a plain-language sentence |
| Imported-review source badge (Sections 16, 13.6.7) | Google, G2, Capterra, X, CSV | The platform name as visible text adjacent to the mark; the mark itself is aria-hidden; the container's accessible name is "Imported from Google". Never a logo alone — a bare "G" glyph is both a 1.1.1 and a 1.4.1 failure, and the badge carries provenance meaning that Section 22.20.3 treats as an integrity control, so it must survive greyscale and screen-reader rendering alike |
A CI check enforces the principle mechanically where it can: a Playwright pass renders every
status component in a fixture page with a CSS filter of grayscale(1) applied and asserts that
each distinct state's accessible name differs from every other state's. It cannot prove the
whole criterion, but it catches the common regression of someone "simplifying" a pill to a dot.
21.5 Contrast & The Built-In Contrast Checker #
21.5.1 The contrast requirements #
| Content | Minimum ratio | Criterion |
|---|---|---|
| Body text, and any text under 24 px (or under 19 px bold) | 4.5:1 | 1.4.3 |
| Large text — 24 px+, or 19 px+ bold | 3:1 | 1.4.3 |
| UI component boundaries needed to identify a control (input borders, toggle tracks, unchecked checkbox borders) | 3:1 | 1.4.11 |
| Graphical objects required to understand content (star glyphs, status icons, chart series, the recording indicator) | 3:1 | 1.4.11 |
| Focus indicator against both the control and the adjacent background | 3:1 | 1.4.11, and 2.4.13 as adopted |
| Placeholder text | 4.5:1 — placeholders are text, and low-contrast placeholders are a routine failure | 1.4.3 |
| Disabled controls | Exempt from 1.4.3 by WCAG, but we hold them to 3:1 anyway and always pair a disabled control with visible explanatory text, because "why is this greyed out" is an accessibility problem even where it is not a conformance failure | — |
Contrast is computed with the WCAG 2.x relative-luminance formula against the actual composited background, resolving alpha over the real underlying colour rather than assuming white.
21.5.2 The checker in the widget editor and the branding editor #
Both the widget editor (Section 13.5) and the request-page branding editor (Section 8.6) embed
the same checker from packages/core/a11y/contrast. It is a shared module so the two editors can
never diverge.
When a customer changes any colour, the checker enumerates every foreground/background pair the choice produces — not just the one they are editing — and evaluates each:
| Pair | Requirement |
|---|---|
| Body text on card surface | 4.5:1 |
| Muted/secondary text on card surface | 4.5:1 |
| Attribution text (name, role, company) on card surface | 4.5:1 |
| Card surface on page background (the card's own boundary) | 3:1 |
| Accent on card surface (used for links, active states, star fill) | 3:1 for graphical use; 4.5:1 where the accent is used as link text |
| Text on accent (buttons, chips) | 4.5:1 |
| Border/divider on card surface | 3:1 where the border is the only thing defining a control |
| Focus ring on card surface, and focus ring on accent | 3:1 both |
| Badge text on card surface (Section 19.4 E8) | 4.5:1 |
| Star glyph on card surface | 3:1 |
| Caption text on its backdrop | 4.5:1 |
Every one of the above in both the light and dark resolution of the auto theme |
Same values |
Results are presented as a list with the computed ratio as text, the requirement, and a pass/warn/fail label — never a coloured swatch alone:
Attribution text on card — 3.1:1. Needs at least 4.5:1. ✗ Fails
[ Use the nearest accessible colour: #5A6270 (4.6:1) ]
The "nearest accessible colour" suggestion converts the customer's colour to OKLCH, holds hue and chroma, and walks lightness in 0.5% steps toward the direction that increases contrast until the requirement is met, then returns the first passing value. Holding hue and chroma means the suggestion still looks like the brand colour, which is what makes customers accept it instead of overriding it. If no lightness within the sRGB gamut satisfies the requirement at that chroma, chroma is reduced in 2% steps and the walk repeats; a result is always produced.
21.5.3 What may and may not be saved #
This is stated precisely because "warn and allow" and "hard block" are both defensible and the implementer must not have to guess.
| Failing pair type | Behaviour |
|---|---|
| Any text pair below its 1.4.3 threshold (body, muted, attribution, text-on-accent, badge, captions) | Hard block. The Save button is disabled while any text pair fails. The reason is stated inline next to Save: "Two colour combinations don't have enough contrast to be readable. Fix them, or use the suggested colours, to save." There is no override. Unreadable text on a customer's public website is a defect we are shipping on their behalf, and there is no business reason to permit it |
| Focus indicator pairs below 3:1 | Hard block. Same treatment. An invisible focus ring makes the widget keyboard-unusable |
| Non-text graphical pairs below 3:1 where the graphic is required to understand content (star glyphs, status icons, the carousel's active dot) | Hard block |
| Non-text pairs below 3:1 where the element is purely decorative and duplicated by text — a divider line, a card shadow edge, a decorative accent bar | Warn and allow with an explicit override. The customer must tick "I understand this decorative element will be hard to see" to enable Save. The override records an audit_events row (Section 11.6) with the actor, the pair, the computed ratio, the element role, and the timestamp; the workspace's accessibility settings page lists all active overrides with a one-click revert |
Overrides are per-pair, not global, and are re-evaluated on every subsequent save: changing a colour clears the override for any pair it affects, so an override can never silently outlive the decision that produced it.
Enforcement is unconditional and is not a customer setting. The hard blocks above apply to
every workspace on every plan with no toggle, no "enforce accessibility" preference, and no
per-widget exception — a refusal is 422 WIDGET_ACCENT_CONTRAST_INSUFFICIENT. Anything that made
enforcement opt-in would ship unreadable text onto customers' public sites by default and would
make the accessibility statement this product generates on their behalf (Section 21.15.1) false.
The decorative override in the last row is the single permitted deviation, it is a different
condition, and it carries its own outcome code, WIDGET_DECORATIVE_CONTRAST_OVERRIDE, recorded to
audit_events — it never suppresses a text or focus-indicator failure.
21.5.4 Themes, and things the customer cannot change #
- The
autotheme resolves viaprefers-color-schemeand both resolutions are validated, so a customer cannot ship a widget that is readable in light mode and unreadable in dark. - Windows High Contrast Mode / forced-colors: every widget and dashboard component responds
to
@media (forced-colors: active)by dropping custom colours in favour of system colours, settingforced-color-adjust: auto, replacing colour-carried borders with1px solid CanvasText, and ensuring icons drawn withcurrentColorinherit the system colour. Images used as posters getforced-color-adjust: noneso they do not become unrecognizable. This is verified in the pass in Section 21.13.3. - The customer cannot restyle: the focus ring's structure, the caption backdrop's contrast floor, the minimum target sizes, or the accessible names of any control. Branding controls surface, accent, radius, and font scale only.
- Font size scale is a multiplier (0.875×, 1×, 1.125×, 1.25×) applied to a base of 16 px with a hard floor of 14 px computed for any text, so no branding choice can produce 11 px attribution text.
21.6 Target Size, Pointer & Dragging #
- 2.5.8 Target Size (Minimum), Level AA: every interactive target is at least 24 × 24 CSS px, including its padding, in every surface — dashboard, request page, showcase, and every widget. Where a visual affordance is smaller (a 16 px close glyph), the hit area is expanded with padding or a pseudo-element to reach 24 × 24. Adjacent targets have at least 24 px between their centres.
- Primary recorder controls on touch are at least 44 × 44 CSS px (Start, Stop, Pause, Record again, Use this take, Submit), which also satisfies 2.5.5 Target Size (Enhanced) for those controls — adopted above the AA bar because these are pressed by people who may be holding a phone at arm's length and pointing a camera at themselves.
- Inline links within testimonial body text are exempt under 2.5.8's inline exception, but the showcase page sets a minimum line-height of 1.6 so that inline link targets are still comfortably separated.
- 2.5.7 Dragging Movements, Level AA: the carousel widget's swipe gesture always has an
equivalent single-pointer alternative — visible Previous and Next buttons, present at all
breakpoints, never hidden behind a hover state, each at least 44 × 44 on touch. The transcript
editor's timing handles, which are dragged, have
←/→keyboard nudge (±0.1 s, ±1 s with Shift) and numeric time inputs as the non-dragging path. The image cropper in the branding editor has numeric offset and zoom inputs. There is no drag-only interaction anywhere in the product. - 2.5.1 Pointer Gestures: no multipoint or path-based gesture exists anywhere. Pinch-to-zoom
on the browser is never intercepted;
user-scalable=noandmaximum-scaleare forbidden by lint in every viewport meta tag. - 2.5.2 Pointer Cancellation: every control acts on
pointerup/click, never onpointerdown, and moving the pointer off a control before release cancels the action. The one place this matters most is the recorder's Stop button, which must never fire on press. - 2.5.3 Label in Name: every control's accessible name begins with its visible text. Icon
buttons with a visible tooltip use the tooltip text as the start of the accessible name. A lint
rule flags
aria-labelvalues on elements with visible text where the label does not contain that text. - 2.5.4 Motion Actuation: nothing in the product responds to device motion. There is no shake-to-undo, no tilt interaction.
21.7 Redundant Entry & Accessible Authentication #
3.3.7 Redundant Entry (Level A) — information the user already provided in the same process is not asked for again:
- On a request page, if the invitation link carries a recipient token, the respondent's name, email, role, and company are pre-filled from the invitation record and clearly marked as editable ("We've filled this in from your invitation — change anything that's wrong").
- The multi-step submission (record → details → consent) never re-asks for a value captured in an earlier step. Moving back and forward preserves every entered value in component state; a browser back navigation restores it from the history entry rather than clearing the form.
- If a submission fails validation, every previously entered value is retained. Nothing is ever cleared on error — including the recorded take, which is retained in its upload session.
- In the dashboard, workspace name and billing email captured at signup are pre-filled into the billing profile.
- The one deliberate exception permitted by 3.3.7 is re-entry required for security: confirming the current password when changing a password. It is labelled as such.
3.3.8 Accessible Authentication (Minimum), Level AA — no cognitive function test is required to authenticate:
- Sign-in supports magic links, which require no password recall at all.
- Password fields have
autocomplete="current-password"(and"new-password"on signup), and paste is never blocked, so password managers work end to end. A lint rule forbidsonpastehandlers on any input. - Email fields carry
autocomplete="email",inputmode="email"; name fields carryautocomplete="name"; all address fields in billing carry their correctautocompletetokens. - There is no CAPTCHA on the sign-in flow, ever. Abuse on sign-in is handled by rate limiting and breached-credential checks (Section 6.5), which are invisible to the user.
- On the submission flow, there is no CAPTCHA either. Submission abuse is handled by per-IP
rate limits (Section 7.10), a server-side honeypot field that is
aria-hiddenandtabindex="-1"(so no assistive-technology user can encounter it), and a minimum-time-on-page heuristic. If an anti-automation challenge is ever required for a specific abused page — which is what Section 8.13'scaptchaModeescalation turns on — the only permitted mechanism is a non-interactive, device-based attestation challenge that the respondent never has to solve, paired with an email-link alternative for any device the attestation cannot evaluate. A puzzle, an object-recognition grid, a distorted-text transcription, an arithmetic question, or any other challenge that tests a cognitive function is never acceptable on any surface, at any threat level, including during an active flood. Section 8.13.4 configures which provider implements that mechanism; it does not get to choose a different kind of mechanism, and this paragraph is the constraint it is configured within. - TOTP two-factor (Section 6.5) is a "device-based" test, explicitly permitted by 3.3.8. Recovery codes may be pasted, and the setup screen offers both the QR code and the plain secret as selectable text for users who cannot scan.
- A "show password" toggle is present on every password field, implemented as
<button aria-pressed>with the accessible name "Show password".
21.8 Motion, Animation & prefers-reduced-motion #
Under @media (prefers-reduced-motion: reduce):
- The carousel does not auto-advance. Not slower — not at all. It becomes a purely user-driven component.
- No autoplay of video, on any surface. (Autoplay is off for everyone regardless of this media query; the reduced-motion rule is belt-and-braces.)
- No parallax, no scroll-linked animation, no auto-scrolling teleprompter. The recorder's teleprompter, already off by default, is additionally disabled and replaced with a static, manually scrollable prompt panel.
- No transform, scale, slide, or translate transitions. The only permitted animation is opacity, at a maximum duration of 150 ms, used for state cross-fades where an abrupt swap would be more disorienting than a fade.
- Loading indicators become static text ("Loading…") plus a determinate progress element where a percentage is known, rather than a spinner.
- The widget's masonry reflow on resize becomes an immediate layout change with no transition.
Even without the reduced-motion preference:
- 2.2.2 Pause, Stop, Hide: the auto-advancing carousel has a visible Pause control, pauses on hover and on focus of any of its contents, and does not resume until the user resumes it. Auto-advance interval is a minimum of 6 seconds.
- 2.4.11 Focus Not Obscured, on the host page. The popup widget is our author content sitting
on somebody else's page, and 2.4.11 applies to the composed result — so a popup that appears on a
timer over a form field the visitor is currently typing in is our defect, not the customer's.
Before showing, and again on every
focusinwhile shown, the popup comparesdocument.activeElement's bounding rect against its own intended footprint. On intersection it defers the show — or moves to the opposite corner where the layout allows — and re-checks on the nextfocusout. This is stricter than the pre-show hit test in Section 13.6.8, which detects only competingposition: fixed | stickyelements and would happily cover a normal-flow input in the bottom-right of the host page. A Playwright fixture asserts it against a host page carrying a focusable control in each corner. - Nothing flashes more than three times per second anywhere in the product (2.3.1). The recording indicator pulses at 1 Hz, well under the threshold, and under reduced-motion it is static.
- The popup/slide-in widget never appears within the first 5 seconds of page load, is dismissible
by keyboard (
Escapeand a 44 × 44 close button), is frequency-capped, never covers more than 25% of the viewport, never blocks interaction with the host page, and isrole="complementary"with an accessible name, notrole="alert"orrole="dialog"— it is not urgent and it must not steal focus. Under reduced-motion it appears without a slide, with a 150 ms opacity fade.
21.9 Shadow DOM & ARIA Across Boundaries #
The widget renders into an open shadow root (Section 13.4.1). Open mode is chosen specifically so that assistive technology tooling, the customer's own automated tests, and axe-core can all traverse into it. Closed mode would make the widget untestable from outside and is forbidden.
The critical constraint, which is the single most common source of broken ARIA in web components:
IDREF-based ARIA attributes do not cross shadow boundaries.
aria-labelledby,aria-describedby,aria-controls,aria-owns,aria-activedescendant,aria-details,aria-errormessage, andforon a<label>resolve only within the same tree scope. Anaria-labelledby="x"inside the shadow root will not findid="x"in the light DOM, and vice versa — the reference silently resolves to nothing, producing an unlabelled element with no error anywhere.
Rules that follow, and they are absolute in the widget codebase:
- Every IDREF relationship is fully contained within one tree scope. Both the referencing element and the referenced element live inside the same shadow root. There is no relationship from the widget's shadow content to the host page's DOM, and none from the host page into the shadow root.
- Where a name would naturally come from outside, use
aria-labelinstead ofaria-labelledby. The widget's root container is<div role="region" aria-label="Customer testimonials from Acme Inc.">— a string, not a reference to the host page's heading, which would not resolve. - IDs inside the shadow root are namespaced anyway (
v-{widgetId}-{local}) even though shadow scoping already isolates them, because multiple widgets on one page each have their own root and a developer reading a DOM dump should be able to tell them apart. aria-activedescendantis not used at all. Rovingtabindexis used for the carousel's slide list and the tag-filter chip group, because roving tabindex moves real focus and therefore works identically inside and outside shadow roots, on every AT.- The widget never applies
aria-*to any node it does not own, never sets aroleon the host page's elements, and never modifies the host<body>or<head>(Section 13.4.1). - Landmark and heading semantics: the widget's root is
role="region"with anaria-label, which places it in the landmark list so a screen-reader user can jump to it. It emits no headings by default, because it cannot know the host page's heading level and an out-of-order heading is worse than none. The widget editor offers an optional heading with an explicit level selector (h2–h4) that the customer sets to match their page, with helper text explaining why. - Live regions inside a shadow root are announced correctly by current AT, but only if they
exist in the DOM before the content changes. The widget creates its
role="status"region at render time, empty, and writes into it afterwards — never creating region and content in the same frame. - The
<slot>element is not used for testimonial content. All content is created inside the shadow root from JSON, usingtextContent(Section 13.10), so there is no slotted light-DOM content whose accessibility tree position could surprise anyone. :hoststyles includeforced-color-adjust: autoso forced-colors mode penetrates the shadow boundary correctly, and a:host { all: initial }-style reset is not used because it would also reset inherited text direction and language; instead the widget explicitly sets the properties it needs and inheritsdirandlangfrom the host, which is correct behaviour.- axe-core runs with shadow-DOM traversal enabled in every widget fixture test, and a fixture exists for every widget type × theme × layout combination.
21.10 Forms #
Applies to the request page, the consent screen, the dashboard, and the branding and widget editors.
21.10.1 Labels #
- Every input has a visible
<label>with aforattribute matching the input'sid. Placeholder-as-label is forbidden by lint. Floating labels are permitted only if the label text is present at 4.5:1 contrast at all times, including when the field is empty and unfocused. - Groups of related controls use
<fieldset>with a<legend>— the consent group, the rating input, radio groups in custom fields. - Custom field types defined by the workspace (Section 8.4) generate correct markup for each
type:
text,textarea,email,url,tel,number,select,radio,checkbox,date,rating. The rating input is a<fieldset>of radios, not a set of clickable stars, with the stars as CSS presentation over real radio inputs — so it is keyboard-operable and screen-reader-legible for free. - Every input carries the correct
autocompletetoken and, on mobile, the correctinputmodeandenterkeyhint.
21.10.2 Instructions and help text #
- Help text is associated with
aria-describedby, not placed adjacent and unassociated. - Format requirements are stated before the user types, not only in the error: "Use your work email if you can — we'll send your copy there."
- Character limits are announced as a live-region update at 20 characters remaining and again at
0, never on every keystroke, and the counter itself is
aria-hiddenwith arole="status"sibling carrying the scheduled announcements.
21.10.3 Errors — identification, description, suggestion #
- 3.3.1 Error Identification: every invalid field gets
aria-invalid="true"and an error message element referenced byaria-describedby(appended to any existing help-text reference, never replacing it). - 3.3.3 Error Suggestion: every message says what to do, not just what is wrong.
| Field | Message |
|---|---|
| Name empty | "Enter your name. This is shown with your testimonial." |
| Email invalid | "Enter an email address in the format name@example.com." |
| Email empty | "Enter your email address. We'll send you a copy and a link to manage your testimonial." |
| Required consent not granted | "You need to give permission to publish before you can submit." |
| Written testimonial too short | "Write at least 20 characters so your testimonial is useful." |
| Written testimonial too long | "Shorten your testimonial to 5,000 characters or fewer. You're currently at 5,412." |
| Rating not selected | "Choose a rating from 1 to 5 stars." |
| URL field invalid | "Enter a full web address starting with https://" |
| Custom required field empty | "Enter {field label}." |
| Video too long (Section 19.5.7) | "This recording is longer than the 2-minute limit for this page. Record a shorter take, or write your testimonial instead." |
| Upload failed after retries | "We couldn't finish uploading your recording. Your take is still here — press Try again, or write your testimonial instead." |
- 3.3.4 Error Prevention (Legal, Financial, Data): the consent step is reversible (the respondent portal in Section 22.4 permits withdrawal), the submission is reviewed on a confirmation step showing exactly what will be submitted, and every destructive dashboard action (delete testimonial, delete workspace, cancel plan, transfer ownership) has a confirmation naming the specific object and its consequence.
- Validation runs on blur for individual fields and on submit for the whole form. It never runs on every keystroke, because a message that appears and disappears while typing is announced repeatedly and is unusable with a screen reader.
21.10.4 The error summary pattern #
On a failed submit, the form renders an error summary at the top of the form:
<div class="error-summary" role="alert" tabindex="-1" id="error-summary">
<h2>There are 2 problems with this form</h2>
<ul>
<li><a href="#field-email">Enter an email address in the format name@example.com.</a></li>
<li><a href="#consent-publish">You need to give permission to publish before you can submit.</a></li>
</ul>
</div>- Focus moves to the summary container programmatically on submit failure. Because it carries
role="alert", its content is announced; because it hastabindex="-1", focus can land on it; because the heading states the count, the user immediately knows the scale of the problem. - Each list item is a link whose
hreftargets the offending field'sid; activating it moves focus to that field, whose ownaria-describedbythen supplies the message again in context. - The summary is re-rendered on each failed attempt, with the count updated. If a resubmission fails for a different reason, focus returns to the summary.
- The page
<title>is updated to "Error: Share your Acme story" so the failure is perceivable to users who navigate by title or who have the tab bar as their only signal. - The dashboard uses the identical component, imported from the shared UI package, so the behaviour is consistent between the surfaces.
21.11 Semantic Structure, Language & Titles #
- Headings: exactly one
<h1>per page, matching the page's main purpose. Levels never skip. A lint rule and an axe rule both enforce it. The showcase page's testimonial cards use<h3>under a section<h2>; the widget emits no heading by default (Section 21.9, rule 6). - Landmarks: every page has
<header role="banner">,<main>, and<footer role="contentinfo">. Multiple landmarks of the same type carry distinguishingaria-labels. The widget's root region is a landmark with anaria-labelnaming the workspace. - Skip links: the first focusable element on every page is a skip link. The request page's is "Skip to the recording form"; the showcase page's is "Skip to testimonials"; the dashboard's is "Skip to main content". It is visually hidden until focused, then rendered at 4.5:1 contrast in the top-left, at least 44 px tall.
- Page titles (2.4.2): unique and descriptive, most-specific-first:
"Share your Acme story — testimonial request","Testimonials — Acme Inc.","Moderation inbox (12 pending) — Acme Inc. — Vouch". Titles update on client-side navigation in the dashboard, and the route announcer writes the new title into arole="status"region so the change is announced. - Language (3.1.1): every page sets
<html lang>. The dashboard and request pages areenat launch (Section 23.11). - Per-testimonial language (3.1.2 Language of Parts) — this is the one that is routinely
missed and that matters here, because the product's whole point is publishing other people's
words: every testimonial's detected or declared language is stored on the row
(
testimonials.language, BCP-47), and every surface that renders a testimonial whose language differs from the page language wraps it in an element carryinglang:
<blockquote lang="es" dir="ltr">
<p>Vouch nos ahorró semanas de trabajo manual.</p>
</blockquote>
<footer>— María López, Directora de Marketing, Acme</footer> This applies to the showcase page, every widget, the moderation inbox, the oEmbed payload, and
the caption track (<track srclang>). The widget payload carries lang per item so the
runtime can set it without a second request. Language is taken from the transcript's detected
language for video (Section 10.1) and from a detector on written submissions, with the
respondent able to correct it on the submit screen and the moderator able to correct it in the
inbox.
- Text direction:
dir="auto"is set on every element rendering respondent-authored text, so a right-to-left testimonial renders correctly even though the surrounding UI is left-to-right. See Section 23.11. - Reading order: DOM order matches visual order everywhere. CSS
order,grid-auto-flow: dense, and absolute positioning are forbidden in any place that would reorder focusable content; the masonry widget layout uses a column-distribution algorithm that preserves DOM order within each column and labels each column, rather than a visual reflow that scrambles it. - 1.3.4 Orientation: no surface locks orientation. The recorder works in portrait and landscape (Section 23.10).
- 1.3.5 Identify Input Purpose: every field collecting information about the user carries its
autocompletetoken (Section 21.10.1). - 1.4.4 Resize Text / 1.4.10 Reflow / 1.4.12 Text Spacing: all layout uses relative units;
nothing is sized in
pxthat carries text. Content reflows to a single column at 320 CSS px equivalent with no horizontal scrolling and no loss of function. A user stylesheet applying line-height 1.5, paragraph spacing 2em, letter-spacing 0.12em, and word-spacing 0.16em causes no clipping or overlap — verified by a Playwright test that injects exactly that stylesheet into every fixture and asserts no element hasscrollWidth > clientWidthand no two text nodes overlap. - 1.4.13 Content on Hover or Focus: every tooltip and popover is dismissible with
Escape, hoverable (the pointer can move onto it without it disappearing), and persistent until dismissed, focus moves away, or the information becomes invalid.
21.12 Dashboard: Accepted v1 Gaps #
The dashboard targets the same Level AA bar as the public surfaces. Two gaps are accepted for v1. They are listed here so that they are known, bounded, and dated — and they are the only two. Any other dashboard accessibility defect is a bug, not a gap.
| # | Gap | Why accepted for v1 | Impact | Interim mitigation | Remediation |
|---|---|---|---|---|---|
| G-A11Y-1 | The analytics trend chart (Section 17.7) conveys its series comparison visually; a screen-reader user gets the underlying figures from an adjacent data table rather than from the chart itself. The chart is not independently navigable point-by-point. | Building a fully navigable chart with per-point keyboard traversal is a substantial component; the data is fully available another way | Low — no information is unavailable, only a second presentation of it | An accessible <table> with the same data is rendered immediately below every chart, is not visually hidden, and is referenced from the chart's aria-describedby. A "Download CSV" action exports the same series |
Point-navigable chart component, Section 27 milestone M7 |
| G-A11Y-2 | The transcript segment editor's waveform (Section 10.5) is a canvas with no accessible representation of the waveform shape itself | The waveform is an aid to finding speech boundaries, not a carrier of unique information | Low — every editing operation is available via the segment list, numeric time inputs, and keyboard nudge, with no waveform interaction required | The canvas is aria-hidden="true" and the segment list beneath it is the full-fidelity, keyboard-complete editing interface. Segment boundaries are announced as text times |
Audio-boundary announcements and a keyboard "jump to next silence" control, Section 27 milestone M7 |
Both gaps are recorded in the published accessibility statement (Section 21.15) rather than being kept internal. Neither gap exists on any respondent-facing or public surface.
21.13 Testing #
21.13.1 Automated, in CI #
| Check | Tool | Scope | Gate |
|---|---|---|---|
| axe-core scan | @axe-core/playwright (Section 3) |
Every request-page fixture (each custom field type, each branding config, each state: ready, recording, review, consent, error, collectionClosed, thank-you), every widget fixture (6 types × light/dark/auto × 3 layouts), every showcase layout, every dashboard route, every email HTML template |
Zero serious or critical violations. Build fails. moderate/minor reported to the PR |
| Keyboard-only traversal | Playwright, mouse events disabled | Every fixture above | Every interactive element reachable, operable, and with a computed non-none focus outline. Build fails on any unreachable control |
| Focus-not-obscured | Playwright geometry assertion | Every focusable element in every fixture | Focused element's rect fully within viewport and not intersected by a higher-stacking element. Build fails |
| Target size | Playwright geometry assertion | Every interactive element | ≥ 24 × 24 CSS px; ≥ 44 × 44 for the recorder's primary controls in the touch viewport. Build fails |
| Text spacing (1.4.12) | Playwright with injected user stylesheet | Every fixture | No overflow, no overlap. Build fails |
| Reflow (1.4.10) | Playwright at 320 × 256 CSS px | Every fixture | No horizontal scrollbar, no loss of content or function. Build fails |
| Greyscale state distinction (1.4.1) | Playwright with grayscale(1) |
Every status component fixture | Accessible names of all states distinct. Build fails |
| Contrast token audit | Unit test over the design-token set | Every token pair in both themes | Meets the table in Section 21.5.1. Build fails |
| Heading order and landmarks | axe rules heading-order, region, landmark-one-main |
Every page fixture | Zero violations. Build fails |
lang and dir on foreign-language content |
Custom Playwright assertion | Showcase and widget fixtures seeded with a Spanish, an Arabic and a Hebrew testimonial, plus one LTR testimonial inside an RTL host page | Each rendered testimonial's wrapper carries the correct lang from the payload and dir="auto", and the widget's resolved direction matches its locale rather than a hardcoded ltr. Build fails |
| No forbidden patterns | ESLint rules | Whole repo | No positive tabindex, no user-scalable=no, no onpaste blockers, no role on a <div> where a native element exists, no aria-label on a non-interactive element without a role, no placeholder-only labels. Build fails |
| No position-only instructions (1.3.3) | Copy-review rule over the user-facing string catalogue | Every respondent-facing and dashboard string | A string containing a positional or shape word ("above", "below", "to the right", "the round button", "top of the window") must also contain a non-sensory identifier — the control's accessible name or a role. Flags for human review rather than failing the build, because the check cannot be exact |
21.13.2 Manual assistive-technology matrix #
Run before every minor release across the full matrix, and before every release for the
recorder and the submission flow. Results are recorded in docs/a11y/at-matrix-{version}.md in
the repository, with the date, the AT and browser versions, and pass/fail per scenario.
| Combination | Platform | Scenarios |
|---|---|---|
| NVDA + Firefox | Windows | Full recorder script (Section 21.2.6), written submission, consent, respondent portal, showcase page, all 6 widget types, moderation inbox |
| JAWS + Chrome | Windows | Same |
| VoiceOver + Safari | macOS | Same, plus rotor navigation of landmarks and headings on the showcase page |
| VoiceOver + Safari | iOS | Recorder in portrait and landscape, camera switching, submission, widget on a mobile host page, caption toggle |
| TalkBack + Chrome | Android | Same as iOS |
| Windows Narrator + Edge | Windows | Recorder and submission only — smoke pass |
| Dragon NaturallySpeaking | Windows | "Click Start recording", "Click Submit" by visible label — verifies 2.5.3 Label in Name across the recorder and the submit form |
| ZoomText / macOS Zoom | Both | Recorder at 400% magnification; focus follows magnification correctly |
| Switch Control | iOS | Recorder operable with a two-switch configuration |
Two scenarios are added to the NVDA + Firefox and JAWS + Chrome rows because they cover the
single-key bindings that 2.1.4 turns on: the moderation inbox in browse mode (confirm that no
bare character key reaches an action while the screen reader is in browse mode, that enabling the
bindings in Settings → Keyboard scopes them to the list region, and that Esc releases it) and
the transcript editor (the same, plus that every binding has the visible control Section 10.8
requires).
The matrix does not wait for the last milestone. Deferring every manual pass to the end means
the recorder and the widget reach real members of the public long before any assistive technology
has touched them, and retrofitting a recorder is far more expensive than building it right. Two
pairs therefore move earlier and are gates, not activities: a NVDA + Firefox pass over the
written-submission and consent flow runs at the milestone that first ships collection, and a
VoiceOver + Safari (iOS) pass over the full recorder script in Section 21.2.6 runs at the
milestone that first ships the recorder — each committed to docs/a11y/manual-runs/{date}.md with
zero blocking defects before that milestone can close. The remaining seven combinations run at the
release-readiness milestone as the full matrix. Section 27 carries these as exit criteria.
21.13.3 Manual passes a reviewer actually runs #
The checklist below is the release checklist. It is written as instructions, not as principles, because a checklist that requires interpretation does not get run.
Keyboard-only pass (unplug the mouse; 15 minutes):
- Load the request page. Press
Tabonce. Confirm the skip link appears and is readable. - Activate it. Confirm focus lands on the recorder heading.
Tabthrough every control to the end of the page. At each stop, confirm you can see where you are without looking for the cursor.- Confirm the tab order matches the visual order exactly.
- Grant camera permission using only the keyboard.
- Record a take, pause it, resume it, stop it — all by keyboard.
- On the review player, seek with
←/→, jump withHome/End, toggle captions. - Discard the take and record another.
- Submit with a required consent unchecked. Confirm focus lands on the error summary, that its heading states the number of problems, and that its link takes you to the field.
- Complete the submission. Confirm focus lands on the thank-you heading.
Tabthrough the entire showcase page and every widget type on a host fixture. Confirm the carousel is operable with←/→and its Pause button works.- Confirm
Escapecloses every popover and returns focus to its trigger, and thatEscapeduring recording does nothing.
200% zoom pass (browser zoom, desktop viewport):
- Set browser zoom to 200% on the request page. Confirm nothing is clipped, nothing overlaps, no horizontal scrollbar appears, and every control is still reachable.
- Repeat at 200% on the showcase page and on each widget in a host fixture.
- Repeat on the dashboard's moderation inbox and billing page.
400% reflow pass (1280 × 1024 viewport at 400% zoom = 320 × 256 CSS px):
- Confirm every surface reflows to a single column.
- Confirm no content and no function is lost — every control that exists at desktop width still exists and is operable.
- Confirm no two-dimensional scrolling is required for any content except the transcript editor's waveform, which is exempt as a data-representation requiring 2D layout.
- Confirm the recorder is usable: preview visible, controls reachable, timer readable.
Windows High Contrast pass (Windows, forced-colors active, both the light and dark system high-contrast themes):
- Confirm every button has a visible boundary.
- Confirm the focus indicator is visible on every control.
- Confirm status pills remain distinguishable — icon and text, not fill colour.
- Confirm the recording indicator is visible and distinguishable from idle.
- Confirm widget cards have visible boundaries and readable text in both system themes.
- Confirm poster images and avatars are still recognizable (they carry
forced-color-adjust: none). - Confirm no element disappears entirely.
Reduced-motion pass: enable the OS setting, reload every surface, confirm the carousel does not auto-advance, no element slides or scales, the teleprompter does not scroll, and spinners are replaced by text.
Screen-magnifier pass: at 400% magnification with a magnifier that follows focus, Tab
through the recorder and confirm focus is never off-screen and the magnifier is never left
looking at the wrong place after a programmatic focus move.
21.14 Per-Criterion Conformance Table #
Only criteria this product can plausibly fail are listed. Criteria that trivially do not apply (1.2.4 Captions Live — there is no live media; 2.3.1 is listed because it is close to the recording indicator; 1.4.2 is listed because it is close to autoplay) are included only where the product genuinely touches them. Level is WCAG 2.2.
| SC | Level | How it is satisfied | Where tested |
|---|---|---|---|
| 1.1.1 Non-text Content | A | Every image has alt text or is aria-hidden when decorative. Avatars use the person's name. Video posters use a description derived from the testimonial. Icons are aria-hidden beside text. Social cards carry AI-drafted alt text reviewed by a human (Section 12.3) |
axe CI; AT matrix |
| 1.2.1 Audio-only / Video-only | A | Full transcript rendered adjacent to every video and available in the widget | axe CI; manual |
| 1.2.2 Captions (Prerecorded) | A | WebVTT from the transcript, on by default in every player (Section 21.3) | Playwright track assertion; AT matrix |
| 1.2.3 Audio Description or Media Alternative | A | The full transcript is the media alternative and is rendered visibly. For recorder-captured video that is sufficient, because the capture path guarantees a single speaker with no visual-only content. For upload and imported_* sources it is not assumed sufficient: the same explicit yes/no determination required for 1.2.5 gates approval, and a yes requires a text description that is rendered with the player (Section 21.3) |
Publish-gate integration test; moderation manual pass |
| 1.2.5 Audio Description | AA | For recorder-captured video the medium is constrained to a single speaker with no visual-only information. For upload and imported_* sources — which accept arbitrary footage, including screen recordings — the moderator's "shows something not said out loud" question must be answered explicitly before approval, and answering yes forces a rendered text description before publish (Section 21.3) |
Publish-gate integration test asserting AUDIO_DESCRIPTION_UNDETERMINED on an unanswered upload; moderation manual pass |
| 1.3.1 Info and Relationships | A | Semantic HTML throughout; fieldset/legend for groups; table headers; heading hierarchy; ARIA only where no native element exists | axe CI |
| 1.3.2 Meaningful Sequence | A | DOM order equals visual order; no CSS reordering of focusable content (Section 21.11) | Keyboard pass; axe |
| 1.3.3 Sensory Characteristics | A | No instruction anywhere refers to shape, size, visual location, or orientation alone. Every positional reference is paired with a non-sensory identifier — the recorder's permission panel names the browser dialog and how a screen reader will announce it before mentioning where it appears (Section 21.2.2) | Copy review in the ESLint/copy-review gate; AT matrix |
| 1.3.4 Orientation | AA | No orientation lock; recorder works in both (Section 23.10) | Playwright device rotation |
| 1.3.5 Identify Input Purpose | AA | autocomplete tokens on every user-information field |
ESLint rule + axe |
| 1.4.1 Use of Color | A | The complete enumeration in Section 21.4 | Greyscale CI check; manual |
| 1.4.2 Audio Control | A | No autoplay of any media anywhere; playback requires activation | Playwright autoplay assertion |
| 1.4.3 Contrast (Minimum) | AA | Token audit; the branding contrast checker with a hard block on failing text pairs (Section 21.5) | Token unit test; editor integration test |
| 1.4.4 Resize Text | AA | Relative units throughout | 200% zoom pass |
| 1.4.5 Images of Text | AA | No text is rendered as an image in any UI. The social card generator (Section 15) produces images of text by nature; it is an exception under "essential" for sharing on platforms that only accept images, and every card is accompanied by the same text in the page and in its alt text | Manual review |
| 1.4.10 Reflow | AA | Single-column reflow at 320 CSS px | 400% reflow CI + manual |
| 1.4.11 Non-text Contrast | AA | 3:1 for control boundaries, graphical objects, and focus rings; enforced by the checker | Token unit test; manual |
| 1.4.12 Text Spacing | AA | Injected-stylesheet test asserting no clipping or overlap | Playwright CI |
| 1.4.13 Content on Hover or Focus | AA | Every tooltip dismissible, hoverable, persistent (Section 21.11) | Playwright; manual |
| 2.1.1 Keyboard | A | Everything operable by keyboard; full recorder map in Section 21.2.3 | Keyboard-only CI pass + manual |
| 2.1.2 No Keyboard Trap | A | Only two modal surfaces exist (device popover, shortcuts panel); both release on Escape |
Keyboard pass |
| 2.1.4 Character Key Shortcuts | A | Satisfied by three conditions applied together to every single-character binding in the product, not by scoping alone. The recorder's M and ? are active only while the recorder's player has focus. The moderation inbox's single-key bindings (Section 11.3.6) and the transcript editor's (Section 10.7.4) are off by default, remappable or modifier-assignable per user in Settings → Keyboard, and active only while their own list region holds focus, which Esc releases. No bare character key is ever globally live, because a/r/p/u acting on published customer content by a stray keystroke from a speech-input or browse-mode user is an irreversible public change |
Playwright per binding; AT matrix scenarios for the inbox and the transcript editor |
| 2.2.1 Timing Adjustable | A | No session timeouts on request pages. The recording length limit does not engage 2.2.1 at all: it is a media-pipeline constraint that finalizes and preserves the take rather than invalidating work, it is stated in text before recording begins, and the written path has no limit. The "essential" exception is deliberately not relied on, because a cap a customer can raise by paying is not essential (Section 21.2.5) | Documented; recorder integration test |
| 2.2.2 Pause, Stop, Hide | A | Carousel has a Pause control, pauses on hover and focus, and does not auto-advance under reduced motion (Section 21.8) | Playwright; manual |
| 2.3.1 Three Flashes | A | Nothing flashes above 1 Hz; recording indicator is static under reduced motion | Manual |
| 2.4.1 Bypass Blocks | A | Skip link on every page; landmark structure | axe; keyboard pass |
| 2.4.2 Page Titled | A | Unique descriptive titles, updated on client navigation and on validation error | Playwright title assertions |
| 2.4.3 Focus Order | A | DOM order; no positive tabindex; four defined programmatic moves (Section 21.2.4) |
Keyboard pass |
| 2.4.4 Link Purpose (In Context) | A | No "click here"; the badge link's accessible name states its destination and purpose; testimonial outbound links carry the destination in their accessible name | axe; manual |
| 2.4.5 Multiple Ways | AA | Dashboard has navigation plus search; the showcase page has tag filtering plus a sitemap-linked index | Manual |
| 2.4.6 Headings and Labels | AA | Descriptive headings and labels; no heading level skipping | axe heading-order; manual |
| 2.4.7 Focus Visible | AA | :focus-visible ring, uncustomizable by branding, 3:1 both sides |
Keyboard CI pass |
| 2.4.11 Focus Not Obscured (Min) | AA | No sticky chrome on respondent surfaces; geometry assertion on every focusable element. On a customer's page the criterion applies to the composed result, so the popup widget also tests document.activeElement's rect against its own footprint before showing and on every focusin, deferring or relocating on intersection (Section 21.8) |
Playwright geometry CI; host-page fixture with a focusable control in each corner |
| 2.4.13 Focus Appearance | AAA (adopted) | 2 px ring with 3:1 contrast and perimeter area (Section 21.2.4) | Token unit test; manual |
| 2.5.1 Pointer Gestures | A | No multipoint or path-based gestures anywhere | Manual |
| 2.5.2 Pointer Cancellation | A | Activation on up-event; off-target release cancels | Playwright; manual |
| 2.5.3 Label in Name | A | Accessible name starts with visible text; ESLint rule | ESLint; Dragon pass |
| 2.5.4 Motion Actuation | A | No motion-actuated behaviour exists | Documented |
| 2.5.7 Dragging Movements | AA | Carousel Prev/Next buttons; keyboard nudge and numeric inputs on transcript timings and the image cropper (Section 21.6) | Playwright; manual |
| 2.5.8 Target Size (Minimum) | AA | ≥ 24 × 24 everywhere; ≥ 44 × 44 for primary recorder controls on touch | Geometry CI |
| 3.1.1 Language of Page | A | <html lang> set on every page |
axe |
| 3.1.2 Language of Parts | AA | Per-testimonial lang on every rendering surface (Section 21.11) |
Custom Playwright assertion |
| 3.2.1 On Focus | A | Focus never triggers navigation, submission, or a context change | Keyboard pass |
| 3.2.2 On Input | A | No onchange auto-submit; device <select> changes the stream but does not navigate, and announces the change |
Manual |
| 3.2.3 Consistent Navigation | AA | Dashboard navigation is identical in order across routes | Manual |
| 3.2.4 Consistent Identification | AA | Shared component library; the same action has the same label everywhere | Design review |
| 3.2.6 Consistent Help | A | The help/contact affordance is in the same place — the footer — on every respondent surface, and in the same place in the dashboard | Manual |
| 3.3.1 Error Identification | A | aria-invalid + described message + error summary (Section 21.10) |
axe; Playwright |
| 3.3.2 Labels or Instructions | A | Visible labels, help text before entry, required/optional stated in words | axe; manual |
| 3.3.3 Error Suggestion | AA | Every message in Section 21.10.3 states the fix | Snapshot test of the message catalogue |
| 3.3.4 Error Prevention | AA | Reversible consent, confirmation step, named destructive confirmations | Manual |
| 3.3.7 Redundant Entry | A | Pre-fill from invitation; state preserved across steps and errors (Section 21.7) | Playwright multi-step test |
| 3.3.8 Accessible Authentication (Min) | AA | Magic links; paste permitted; autocomplete; no CAPTCHA; TOTP is device-based (Section 21.7) |
ESLint; manual |
| 4.1.2 Name, Role, Value | A | Native elements first; correct ARIA where not; state reflected in aria-pressed/aria-expanded/aria-current; shadow-boundary rules in Section 21.9 |
axe; AT matrix |
| 4.1.3 Status Messages | AA | role="status" for progress and state; role="alert" reserved for the three urgent recorder conditions; error summary is role="alert" |
Playwright live-region assertions; AT matrix |
21.15 Accessibility Statement & VPAT Posture #
21.15.1 The customer-publishable statement template #
Every workspace can generate a statement for its own showcase page and request pages from Settings → Accessibility. The template is filled with the workspace name and the current conformance date; the customer may edit it before publishing, and the generator warns if they remove a factual claim's supporting sentence.
Accessibility statement for {Workspace name}'s testimonial pages #
We want everyone to be able to read our customers' stories and to share their own.
Conformance status. The testimonial pages, recording tool, and testimonial displays provided by Vouch on this site — they do not cover the rest of this website — aim to conform to the Web Content Accessibility Guidelines (WCAG) version 2.2, Level AA. They are partially conformant: most of the content meets Level AA, and the known exceptions are listed below.
What we have done. These pages are built to be operated entirely by keyboard, to work with screen readers, to reflow to a single column on small screens and at high zoom, to respect your operating system's reduced-motion and high-contrast settings, and to show captions on every video by default. Colour is never the only way we show status, and we check every colour combination for readable contrast.
If you cannot record a video, you can always write your testimonial instead. That option is available on every page, from the start, without needing to try recording first.
Known exceptions. {Generated list — for public surfaces this list is empty. If a customer's own branding override introduced a decorative-contrast exception (Section 21.5.3), it is listed here with its element.}
Feedback. If you find something you cannot use, please contact {workspace contact email}. We aim to respond within 5 working days.
Assessment approach. These pages are assessed by automated testing on every release, keyboard-only testing, and manual testing with screen readers on Windows, macOS, iOS, and Android. Based on Vouch platform version {version}, assessed {assessment date} using the methods published at {public ACR URL}.
Statement prepared on {date}.
The generator refuses to emit the phrase "fully conformant" and refuses to emit a WCAG level
higher than AA, because both are claims we cannot make on the customer's behalf. {version} and
{assessment date} are filled from the platform build and the date of the last completed matrix
run in Section 21.13.2 — never from the moment the customer clicks Generate — so a statement can
never assert testing that has not happened. If the last matrix run is older than the current minor
release, the generator says so in the statement rather than omitting it.
21.15.2 Our own posture #
- We maintain an ACR (Accessibility Conformance Report) using the VPAT 2.5 INT format, covering WCAG 2.2 AA, Section 508, and EN 301 549. It is published at a stable public URL and regenerated at every minor release.
- The ACR reports honestly: "Supports", "Partially Supports", or "Does Not Support" per criterion, with remarks. The two dashboard gaps in Section 21.12 are reported as "Partially Supports" with their remediation milestones, not omitted.
- Every claim in the ACR is generated from the table in Section 21.14, never written by hand. A VPAT is a representation, and a representation that outruns the tested reality is a misrepresentation rather than a bug — which is why 2.1.4 is stated there in terms of the conditions that actually hold across the inbox and the transcript editor rather than the recorder alone. A criterion whose row in 21.14 cites a test that has not run in the current release is emitted as "Partially Supports" with that fact in the remarks.
- We do not claim third-party certification, because none exists that would be meaningful. We claim our own testing, name the methods, and publish the results.
- An external accessibility audit by a specialist vendor is scheduled before the first major
release and annually thereafter, covering the recorder, the request page, the showcase page,
and all six widget types. Findings are triaged like security findings:
criticalandseriousblock the next release,moderategets a milestone. - The accessibility statement for our own product is maintained at
docs/accessibility.mdin the repository and published on the marketing site. - Accessibility regressions found in production are treated as P2 by default and P1 when they affect the recorder or the submission flow, because those surfaces are used by people who did not choose this software and cannot work around it.
22. Security, Privacy, Consent & Compliance #
22.1 Scope & Governing Principles #
This product holds three categories of data that make it a meaningfully sensitive system, and the security posture is designed around them specifically rather than around a generic checklist:
- Respondent personal data — name, role, company, email, phone, IP-derived country, a photograph of their face, a recording of their voice, and a signed statement of consent. The respondent is not our customer and has no account. They gave this to somebody they trust, and we are the third party holding it.
- Customer business data — testimonials as marketing assets, analytics, and the credentials that reach into their Google, G2, Capterra, X, HubSpot, and Slack accounts.
- Money and identity — Stripe customer references, API keys, session material.
Five principles:
- The respondent is the most vulnerable party in the system, and is treated as the primary protected subject. Where a customer's convenience and a respondent's control conflict, the respondent wins. This is why the respondent portal in Section 22.4 exists and why it does not require the customer's cooperation to work.
- Consent is a record, not a checkbox. We store what was actually shown, verbatim, at the moment it was agreed to, so that a consent can be evidenced years later.
- Erasure means the bytes are gone, including at every third party and every CDN edge, and we can prove it with a query.
- Defence in depth with a small attack surface. No card data, no passwords we invented the hashing for, no raw SQL, no user-supplied URLs fetched without an allowlist, no user content on an origin that holds a session cookie.
- Security controls are code and tests, not documents. Every control in this section maps to a middleware, a database constraint, a CI check, or a runbook.
Section 6 owns authentication mechanics and the permission model. Section 7 owns rate-limit numbers, API-key format, and webhook signing. Section 13.10 owns the widget's own XSS posture. Section 11.6 owns the audit-event schema. This section owns everything else and does not restate those.
22.2 Consent Capture #
22.2.1 Rules #
Consent is captured on the same screen as submit, immediately above the submit button, after the respondent has seen exactly what they are submitting. It is never on a prior step, never in a modal, never behind a link, and never inferred from continuing.
- Nothing is pre-checked. Every checkbox renders unchecked on first paint and after any validation failure. Pre-ticked consent is not consent under GDPR Article 4(11), and a pre-ticked box is a defect that fails the test in Section 22.2.5.
- One required grant, two optional grants. The required one gates submission; the optional ones never do. Declining both optional grants must produce an identical submission experience — no extra friction, no "are you sure", no delay.
- The consent block is a
<fieldset>with<legend>Permissions</legend>. Each grant is a<input type="checkbox">with a visible<label>carrying the full consent text. The text is never truncated with an expander; a person must not have to click "read more" to see what they are agreeing to. - Links inside consent text (to the workspace's privacy policy and to ours) open in a new tab with
rel="noopener"and carry a visible "(opens in a new tab)" in their accessible name. - The required grant's failure message is "You need to give permission to publish before you can
submit." (Section 21.10.3), the checkbox gets
aria-invalid="true", and the error summary links to it. - The workspace may append to the consent text — for example a specific brand-usage clause — but may not remove or reword any of the mandated sentences. Appended text is stored as part of the rendered text and is therefore part of the hash. The append field is limited to 1,000 characters, is sanitized on save with the allowlist in Section 22.13.3, and is rendered as plain text in the consent label.
- Consent capture happens identically on every path that produces a testimonial from a person:
the video recorder, the written form, and an invitation-linked submission. Imported reviews
do not carry a consent record — the author never interacted with us — and this is represented
honestly: imported rows have
consent_id = NULLand aprovenanceof the source, and the moderation inbox shows "Imported from Google — publication rights derive from the source platform's terms, not from a consent given here." (Section 16.3).
22.2.2 The default consent copy, in full #
This is version 1 of the consent text. It is stored in consent_versions as three separate
grant texts plus a preamble, and it is what is rendered unless the workspace has appended to it.
Permissions
☐ I give {Workspace name} permission to publish my testimonial. This includes my name, my role and company as I've entered them, my photo or video, and my words as I've written or spoken them. {Workspace name} may show it on their website, in their app, on their social media, and in their marketing materials. My words will not be edited or reworded — only shortened, and only in a way that keeps my meaning. (Required)
☐ {Workspace name} may also use my testimonial in paid advertising. For example in ads on social media, on search engines, or in printed materials they pay to distribute. (Optional — you can submit without this)
☐ {Workspace name} may contact me about a longer case study. They'd get in touch by email to ask if you'd be interested. You can say no then too. (Optional — you can submit without this)
If {Workspace name} ever wants to publish a version that changes what you actually said — beyond fixing a typo or trimming for length — we'll ask you again first, and show you exactly what changed. Nothing goes out with altered words unless you agree to it.
You can change your mind at any time. We'll email you a private link where you can see exactly what was published, download a copy of everything you gave us, and ask for it to be taken down or deleted — without needing an account.
Your information is processed by {Workspace name} and by Vouch, the testimonial platform they use, as described in Vouch's privacy notice (opens in a new tab) and {Workspace name}'s privacy policy.
The sentence about words not being edited is a factual claim we enforce in code, and because it is a claim rather than a sentiment, every path that can alter a respondent's words has to be constrained or the sentence has to go. Three paths exist and all three are constrained:
The AI assistant is limited to selection, never generation — Section 12.1's exact-substring validator refuses any highlight that is not verbatim.
Imported text is immutable (Section 16.3).
The transcript editor (Section 10.7) is the one a reader will miss, and it is the dangerous one: a moderator can retype any segment, and that transcript becomes the captions rendered on by default on every surface and the source of the published quote. Section 10.7.2 specifies the mechanism and this section states the rule it serves: every transcript revision carries an
edit_classoftiming_only,punctuation_only,verbatim_correction, orsubstantive, chosen by the editor with the diff shown at the moment of saving. A testimonial carrying asubstantivetranscript revision cannot be published until the respondent re-consents through the flow in Section 22.2.4, and the diff is shown to them in the portal's "What was published" view (Section 22.4.4) so a divergence is visible to the person who made the claim rather than only to the workspace that edited it.This is why the consent copy above carries the sentence beginning "If {Workspace name} ever wants to publish a version that changes what you actually said". A gate the respondent is never told about protects them procedurally but leaves the consent record itself incomplete: they agreed to publication of their words, and they are entitled to know in advance both that an alteration is possible and that it cannot be published without coming back to them. The copy and the gate ship together or neither ships.
If any of the three constraints is ever removed, this sentence must be removed from the consent copy in the same change. Publishing altered words under a consent record that says they were not altered is the precise evidentiary failure this whole section exists to prevent, and it is worse than having no consent record at all, because it looks like proof.
22.2.3 What is stored #
Per submission, one consents row and one consent_grants row per grant. Section 5 owns the
full schema; the fields that carry the legal weight are enumerated here because the record is
useless if any of them is missing.
| Field | Type | Notes |
|---|---|---|
id |
uuid | UUIDv7, application-generated |
workspace_id |
uuid | Tenancy, non-null, RLS-scoped (Section 6.2) |
testimonial_id |
uuid | The submission this consent belongs to |
request_page_id |
uuid | Which page the text was shown on — required, because branding and appended text are per-page |
consent_version |
integer | Monotonic per workspace |
consent_text |
text | The exact rendered text, verbatim, in full, including the preamble, all three grant texts, any workspace-appended text, and the interpolated workspace name. Not a template. Not a reference. Not a key into a table of texts |
consent_text_sha256 |
char(64) | SHA-256 of consent_text after NFC normalization, for cheap comparison and tamper evidence |
locale |
text | BCP-47 of the text as rendered |
ip_truncated |
inet | IPv4 truncated to /24, IPv6 truncated to /48. The full address is never written |
user_agent |
text | Truncated to 512 characters |
granted_at_page_load |
timestamptz | When the consent text was rendered to them |
submitted_at |
timestamptz | When they submitted |
created_at |
timestamptz | Row creation |
consent_grants field |
Type | Notes |
|---|---|---|
id |
uuid | |
consent_id |
uuid | Parent |
workspace_id |
uuid | Denormalized for RLS, as every table carries it (Section 6.2) |
grant_type |
enum | publish | advertising | case_study |
granted |
boolean | Explicitly stored for declines too — a decline is a record, not an absence |
granted_at |
timestamptz | Null when granted = false |
withdrawn_at |
timestamptz | Set on withdrawal; the row is never deleted |
source |
enum | submission | respondent_portal | workspace_admin — who changed it |
Storing the text verbatim rather than by reference is the single most important decision in
this subsection. A reference to a version row is only as good as that row's immutability, and
version rows get edited by well-meaning people. A verbatim copy plus its hash means that
producing evidence of what somebody agreed to is a single SELECT, forever, even if every
template in the system has been rewritten twice.
22.2.4 Versioning #
- Changing the consent preamble, any grant text, or a workspace's appended text creates a new
version:
consent_versions.version = previous + 1, with the new text and its hash. - Historical
consentsrows are never rewritten. They keep the text they actually showed. A migration that touchedconsents.consent_textwould destroy the record's evidentiary value, so the application role has noUPDATEprivilege onconsents.consent_textorconsents.consent_text_sha256— enforced by a column-levelREVOKE, not by convention. - A version change does not invalidate prior consents and does not re-prompt existing respondents. Consent given under version 3 remains valid for what version 3 said.
- If a workspace materially expands what it wants to do with a testimonial — for instance turning
on advertising use for testimonials collected before that grant existed — the platform will not
silently apply the new scope. The dashboard offers a re-consent request: an email to the
respondent with a portal link showing the new text and the specific new grant, which they may
accept or ignore. Ignoring is a decline. The re-consent creates a new
consentsrow linked to the same testimonial withsource = 'respondent_portal'. - The consent version in force for a request page is shown in the page's settings, with a preview of exactly what a respondent will see.
22.2.5 Consent tests #
These are integration tests, and they are not optional:
- Rendering any request page produces zero checked checkboxes in the consent fieldset.
- Submitting with the required grant unchecked returns 422 with a
detailsentry naming the consent field, and creates no testimonial row. - Submitting with both optional grants unchecked succeeds, and the resulting
consent_grantsrows foradvertisingandcase_studyexist withgranted = false. consents.consent_textin the created row contains the full rendered text including the workspace name and any appended clause, and its SHA-256 matchesconsent_text_sha256.consents.ip_truncatednever equals the request's source IP for a /32 IPv4 address.- An
UPDATEonconsents.consent_textas the application role raises a privilege error. - Changing the workspace's appended text creates version n+1 and leaves every existing row's
consent_textbyte-identical. - A transcript revision saved with
edit_class = 'substantive'blocks publication: the publish action is refused while the revision stands, a re-consent request can be sent, and publication succeeds only after the respondent accepts it through the portal. The same test asserts thattiming_only,punctuation_only, andverbatim_correctionrevisions do not block, so the gate is proven to discriminate rather than merely to exist. - The rendered consent text contains the sentence telling the respondent that a change to what
they said will come back to them for agreement. This is asserted against the stored
consent_text, not against the template, so that removing the gate without removing the promise — or the reverse — fails the build.
22.3 The Consent Ledger in the Dashboard #
Every testimonial's detail view has a Consent panel showing, per grant: the grant type, the
state as words ("Granted" / "Not granted" / "Withdrawn on 3 March 2026"), the timestamp, and a
"View the exact text they agreed to" disclosure rendering consent_text verbatim in a
monospaced, selectable block with a copy button. The panel also shows the request page and the
consent version.
Rules the UI enforces:
- Advertising use is gated in-product. If a workspace member tries to add a testimonial to a
"paid advertising" collection or to export it via an integration flagged as advertising use,
and the
advertisinggrant is notgranted, the action is blocked withADVERTISING_CONSENT_MISSING(409) and the message "This person didn't give permission for use in paid advertising. You can ask them — we'll send a request to the email they gave." Consent that is recorded but not enforced is decoration. - Case-study contact is gated the same way: the "Contact for case study" action is disabled with an explanatory sentence when the grant is absent.
- Nobody in the workspace can toggle a grant to
grantedon a respondent's behalf. Theworkspace_adminvalue ofconsent_grants.sourceexists for exactly one operation — recording a withdrawal the respondent communicated out of band (by replying to an email, for instance) — and that operation requires the actor to enter a free-text note that is stored on the audit event. Granting is only eversubmissionorrespondent_portal. - Every consent view, export, and change writes an
audit_eventsrow (Section 11.6).
22.4 The Respondent Portal — Access, Withdrawal & Erasure #
22.4.1 The link #
Every submission generates a stable respondent link:
https://v.vouch.app/p/{token}tokenis 32 bytes from a CSPRNG, encoded base64url (43 characters). The database stores onlysha256(token)inrespondent_tokens.token_hashwith a unique index. The plaintext token exists in the email we send, in the respondent's own records, and transiently in request paths — which is not a place we can wish away, because a path segment is recorded by browser history, by any intermediary proxy, and by CDN and edge access logs. Two controls follow from saying so honestly rather than claiming two places: edge logging is configured to mask path segments matching/p/*,/reset-password/*,/invite/*, and/r/*/draft/*before the log line is written (asserted by a CI check against the deployed edge configuration, and reflected in the CDN-access-log row of Section 22.7), and the token alone is never sufficient for a destructive action — see 22.4.2. Anyone reading an edge log still cannot erase a stranger's testimonial.- The link is emailed to the respondent immediately on submission, in the confirmation email, alongside a copy of what they submitted. If they gave a phone number and the workspace uses SMS invitations, the link is also sent by SMS.
- The link never expires. A person's right to withdraw does not have a shelf life. Tokens are rotated only on explicit request from the portal ("Send me a fresh link"), which invalidates the old hash and emails a new one.
- The link is per-submission, not per-person. A person who left three testimonials for three different workspaces has three links, because each is a separate relationship with a separate controller.
- The page carries
X-Robots-Tag: noindex, nofollowand<meta name="robots" content="noindex">, and the path is excluded inrobots.txt. - The page is served from
v.vouch.app, which is a separate origin from the dashboard and holds no session cookie, so a leaked token cannot be combined with a dashboard session.
22.4.2 Identity verification #
The token is a bearer credential. A bearer credential in an email is appropriate for viewing what was published — the respondent already has that content, and it is about to be public — but it is not sufficient for destructive actions. So the portal is tiered:
| Action | Verification required |
|---|---|
| View what was published (text, video, name, role, company, the consent text they agreed to, where it is currently displayed) | Token only |
| Download their data export (JSON + original media) | Token + a one-time code emailed to the email address on the submission, valid 15 minutes, 6 digits, 5 attempts, then invalidated |
| Withdraw consent / unpublish | Token + one-time code |
| Request full erasure | Token + one-time code + an explicit typed confirmation of the word DELETE and a second screen naming exactly what will be destroyed |
| Correct their name, role, or company | Token + one-time code |
| Rotate the token | Token only (the new token goes to the email on file, so this cannot be used to hijack) |
This table is the canonical statement of what each portal action requires, and it binds every
surface. Section 7 owns the HTTP envelope, the status codes, and the session mechanics; it does
not own which actions need step-up, and an endpoint catalogue that lists a portal write as
reachable with the bare token is describing an unimplemented requirement rather than overriding
one. Stated in the terms Section 7 uses: the token alone mints a read-only portal session
carrying the scope portal:read. Every state-changing portal endpoint —
/public/portal/{erasure,withdraw,export,correct} and any endpoint that writes a consent field
such as metricsPublishable — additionally requires a portal_step_up claim, which is issued only
by POST /public/portal/verify-code after the one-time code below is verified. A read-only session
presenting itself to a state-changing endpoint is refused with 401 STEP_UP_REQUIRED. If any API
catalogue elsewhere shows a portal write reachable with the bare link, that catalogue is the
defect: an emailed URL is forwarded, shared, scanned by corporate mail security, and read years
later out of a compromised mailbox, and the link never expires — a destructive action reachable
from it alone would let any of those destroy a member of the public's data forever.
- The one-time code is always sent to the email captured at submission, never to an address supplied in the request. This means possession of the link alone cannot destroy anything; an attacker also needs the mailbox.
- If the submission has no email (the workspace made it optional and the respondent skipped it), the destructive actions are unavailable through self-service and the portal shows: "To take this down or delete it, contact {workspace contact email}, or email privacy@vouch.app and we'll handle it for you." A manual request through either route is honoured with the same 30-day SLA in Section 22.5.2, actioned by an operator with a recorded justification.
- The code is stored hashed with a 15-minute expiry and is single-use.
22.4.3 Abuse protections on the portal #
| Risk | Control |
|---|---|
| Token brute force | 32 bytes of entropy makes guessing infeasible; additionally, per-IP limit of 10 portal requests/minute and a global limit of 60/minute on unknown-token responses, with a constant-time hash comparison and an identical response shape and timing for "not found" and "found" at the pre-code stage |
| Token enumeration by timing | Lookup is by indexed hash with a fixed-cost path; the not-found branch performs an equivalent-cost dummy hash |
| Email bombing via the one-time code | 3 code requests per token per hour, 10 per email address per hour, with a plainly worded cooldown message |
| Stolen link used to delete out of spite | The email one-time code requirement; plus a 24-hour "we're processing your deletion" notice email to the respondent with a cancel link, so an unauthorized deletion can be stopped by the real person. The cancel link is a GET to a confirmation page that changes nothing on load: it carries a single-use 24-hour token in the path, renders what cancelling will do, and requires an explicit POST from a button press carrying the double-submit CSRF token. This is not pedantry — Section 20.7 documents that mail clients and corporate scanners pre-fetch remote resources, so a state-changing GET here would let a mail-security appliance silently cancel a genuine erasure request with no human involved, and it would violate Section 22.17's rule that no state-changing GET exists anywhere |
| Malicious workspace suppressing the link | The link is emailed by the platform, not by the workspace; the workspace cannot disable the confirmation email, cannot alter the link, and cannot remove the portal reference from the published testimonial's metadata. The showcase page and every widget expose a small "Manage your testimonial" affordance to the respondent only via the emailed link — it is not rendered publicly, so it leaks nothing to viewers |
| Scraping the portal for PII | noindex, no directory, no listing endpoint, no sequential ids anywhere (UUIDv7 primary keys are not exposed in portal URLs at all — only the token is) |
| Replay of the code | Single-use, hashed, expiring; consumed inside the transaction that performs the action |
| CSRF on portal actions | Double-submit token plus SameSite=Strict on the short-lived portal session cookie issued after code verification (Section 22.17) |
The tests that prove the tiering, because a security model nobody asserts is a paragraph:
- A valid token alone, with no code exchange, is presented to each of
/public/portal/{erasure,withdraw,export,correct}and to the consent-field write. Every one returns401 STEP_UP_REQUIRED, and a follow-up query asserts that nothing changed — no status transition, nowithdrawn_at, no erasure job enqueued. - The same token after a successful code exchange reaches all five and succeeds, so the test distinguishes "blocked correctly" from "broken".
- A
portal_step_upclaim minted for one token is replayed against a different respondent's token and is refused; step-up is bound to the submission it was issued for, not to the browser. - An expired, already-used, or wrong-submission one-time code is refused, and the fifth wrong attempt invalidates the code rather than allowing a sixth.
- A token that has been rotated no longer authenticates, and the rotation email went to the address on file rather than to any address in the request.
22.4.4 What the respondent can see and do #
The portal's landing view, in plain language, shows:
What was published — the exact text or video, their name, role, and company as displayed, and a thumbnail. Rendered read-only, exactly as the public sees it.
Where it appears — the workspace name, the showcase page URL if published there, and the count of widgets currently displaying it. It does not list the customer's website URLs, because we do not reliably know them and guessing would be worse than silence. The copy says, verbatim: "Acme Inc. stops showing your testimonial on every Vouch surface within about a minute. If Acme copied it into their own systems using an integration, we tell those systems to remove it, but Acme controls what happens there — you can also contact them directly at {workspace contact email}."
The earlier phrasing — "removes it from every one of those places within a minute" — is forbidden, because it is a statement to a data subject exercising a legal right that we cannot keep: a testimonial pushed into a customer's own CMS or spreadsheet by a Zap is beyond our reach, and telling someone their removal is complete when it is not is the failure most likely to become a regulator complaint. We say what we control, we say what we signal, and we name who controls the rest.
The consent they gave — the verbatim text, the date, and each grant's state. Where the published version rests on a transcript that was edited after capture, the diff is shown here too (Section 22.2.2), so the person who made the claim can see any divergence from their own words rather than only the workspace being able to.
Their data — a "Download everything" button producing the export in Section 22.6.1.
Their choices — three clearly separated actions with honest descriptions:
Change what's shown Fix your name, role, or company. Your words stay exactly as you wrote or said them.
Take it down Acme Inc. stops showing your testimonial everywhere, within about a minute. We keep a copy so that Acme has a record of what you agreed to and when — nothing is published from it.
[ Take my testimonial down ]Delete everything We permanently destroy your testimonial, your video and photos, your transcript, and your contact details. This cannot be undone, and Acme Inc. cannot get it back. We keep only a minimal record that a deletion happened, which the law requires us to keep.
[ Delete everything ]
The distinction between "take it down" and "delete everything" is stated in exactly those words because conflating them is how platforms end up either destroying evidence of consent or pretending to delete while retaining.
22.4.5 Withdrawal mechanics — the 60-second guarantee #
Withdrawal must complete on the public surfaces within 60 seconds of confirmation. The path:
- The portal action commits a transaction that sets
testimonials.status = 'unpublished', setsconsent_grants.withdrawn_aton thepublishgrant, and writes theaudit_eventsrow. - In the same transaction it writes an outbox row for
cdn.purge. - The worker consumes it immediately (the queue is drained continuously, and this job class has
the highest priority): it purges the CDN by the
widget:{id}tag for every widget containing the item and by theworkspace:{id}tag, revalidates the showcase page's cached HTML, invalidates the oEmbed cache, and soft-deletes and purges everysocial_cardsrow derived from the testimonial, so that both the content-addressed URL and the alias URL return410 Gone— on the same 60-second budget. Social cards are the surface most easily forgotten here: they are immutable, content-addressed, cached for a year, and generated for testimonials that were merelyapproved, so an omitted card is a withdrawn person's face still being served long after the widget stopped showing them. It also emits the withdrawal event on the outbound integration path (Section 18.2), which is what gives a customer's Zap or CMS sync the chance to act — a chance, not a guarantee, which is exactly how 22.4.4's copy describes it. - Because the widget data payload's
Cache-Controlismax-age=60, stale-while-revalidate=300(Section 13.8.6), a tag purge is the mechanism that makes the guarantee real rather than waiting for expiry; the 60-second figure is the purge propagation budget with headroom, and it is monitored as an SLO in Section 23.7. The 60 seconds is end-to-end and decomposes as: ≤ 10 s to enqueue, runcdn.purge, and issue every invalidation (the job budget in Section 23.7), plus edge propagation to every point of presence (the ≤ 60 s p95 figure in Section 23.2.5). The two numbers are not in conflict — one is the part we execute and one is the part we wait for. - The workspace is notified: an in-product notification and an email to Owner and Admins, "A respondent has withdrawn their testimonial", naming the testimonial and the time, with no ability to reverse it from the dashboard.
- The testimonial remains in the workspace's dashboard in
unpublishedstate with a permanent "Withdrawn by the respondent" badge. It cannot be re-published — the publish action is disabled with the explanation "This person withdrew their permission. You'd need their consent again." Re-publication is only possible if the respondent re-grants through the portal. - Withdrawal is idempotent: a second withdrawal request on an already-withdrawn item returns success without side effects.
Asserted end to end, because 22.4.4 makes a promise to a member of the public and a promise
that is not tested is a hope: a Playwright test withdraws a published testimonial that is live in
three widget types, on the showcase page, and has a generated social card, then polls until every
one of them stops serving it — and specifically asserts that both the content-addressed card URL
and the alias URL return 410 Gone, since an immutable year-long cache entry is the one surface
that will still be serving a withdrawn person's face if this step is skipped. The whole sequence
must complete inside the 60-second budget, and the test fails on the budget, not merely on the
outcome.
22.4.6 Workspace visibility #
The workspace sees:
- A notification on every portal-initiated withdrawal, correction, or erasure.
- The consent panel state, including
withdrawn_at. - A "Respondent activity" line on the testimonial's audit trail showing portal events by type and
time, with the actor recorded as
respondent. - Nothing about the respondent's session — not their IP beyond the truncated form already stored, not their device, not whether they opened the link and did nothing. Read receipts on a privacy portal would chill its use.
The workspace cannot: disable the portal, remove the link from the confirmation email, block a withdrawal, delay a withdrawal, or contact the respondent through the portal.
22.5 Lawful Bases & Data Subject Rights #
22.5.1 Lawful bases by data class #
Two controllers exist in this system, and being precise about which is which is what makes the rest coherent. The workspace is the controller for testimonial content and respondent data; Vouch is the processor for it. Vouch is the controller for account data, billing data, and security logs.
| Data class | Controller | Lawful basis (GDPR Art. 6) | Notes |
|---|---|---|---|
| Testimonial content, respondent name/role/company/likeness | Workspace (Vouch = processor) | Consent, Art. 6(1)(a), evidenced per Section 22.2 | Special-category data is not solicited; see below |
| Respondent email and phone | Workspace (processor: Vouch) | Consent, and legitimate interest for the transactional confirmation containing the portal link | The portal link email is sent even if the respondent declines optional grants, because it is how they exercise rights |
| Consent records themselves | Joint — both parties need them | Legal obligation, Art. 6(1)(c), and legitimate interest in evidencing consent | This is why "take it down" retains the consent record |
| Customer account data (name, email, workspace) | Vouch | Contract, Art. 6(1)(b) | |
| Billing data | Vouch | Contract, and legal obligation for tax records | Card data never reaches us (Section 19.7.1) |
| Security and audit logs, sign-in records | Vouch | Legitimate interest, Art. 6(1)(f), documented in an LIA | Retention in Section 22.7 |
| Analytics events | Workspace (processor: Vouch) | Legitimate interest — the measurement is cookie-free and non-tracking (Section 17.1) | No consent banner is required because no terminal storage of any kind is accessed and no persistent, cross-site, or terminal-stored identifier exists. Being exact rather than flattering: a random per-page-view id is generated in memory and held server-side for 7 days solely to de-duplicate a single view, is never joined to any other record, and is dropped with the raw event partition. Calling that "identifier-free" would be a claim the schema contradicts |
| Transcripts and captions | Workspace (processor: Vouch) | Consent, derived from the testimonial | |
| AI-generated suggestions | Workspace (processor: Vouch) | Consent, derived from the testimonial | Customer testimonial content is never used to train any model (Section 12.7); provider zero-retention terms are contracted |
| Imported reviews | Workspace (processor: Vouch) | Legitimate interest of the workspace, plus the source platform's terms | The author never contracted with us; Section 16.3 forbids editing their words, and erasure requests from a review author are honoured by deleting our copy and are also referred to the source. See the Art. 14 determination below |
Special-category data (Art. 9). We do not solicit it. But a person speaking freely into a camera about a healthcare product may disclose health data, and a person's face and voice are biometric-adjacent. Our position, stated rather than hoped:
- We never perform biometric identification, face matching, voice printing, or emotion inference. No feature does this and none is planned; the AI boundary in Section 12.1 permits selection of the respondent's own words only.
- The consent text makes publication of the respondent's likeness and words explicit, which is the Art. 9(2)(a) explicit-consent path where any special-category content is volunteered.
- Workspaces in regulated verticals are advised in the request-page settings help text not to prompt for health, financial, or other sensitive detail, and the prompt editor shows a non-blocking notice when a prompt contains obvious sensitive-topic terms.
Article 14 and the imported reviewer. A person whose Google or G2 review a workspace imports never interacted with us, never saw a consent screen, and has no portal token — yet we hold their name, avatar, words and, for X, their handle. Art. 14 requires that they be told within one month unless doing so would involve disproportionate effort, and that determination has to be made rather than assumed. We make it, and we rely on Art. 14(5)(b): the review is already published by its author on a public platform under that platform's terms, we hold no contact address for the author and would have to acquire one specifically in order to notify them, and acquiring contact details for every imported review in order to send unsolicited mail would be both more intrusive than the processing itself and, at import volumes, disproportionate.
Because 14(5)(b) requires compensating measures rather than silence, three are provided and are
not optional: a public section of the privacy notice stating plainly that workspaces may import
reviews from named platforms and how to object; the takedown route in Section 16.9.2 plus
privacy@vouch.app, both listed in the data subject rights matrix below; and a per-testimonial
"how did this get here" disclosure rendered on the showcase page and in the widget alongside the
source badge (Section 21.4), so the provenance is visible to the author if they ever encounter it
rather than being discoverable only by asking us.
Children. The service is not directed at children. The submission page states that respondents must be 16 or older, and the workspace's request-page settings cannot disable that statement. A submission identified as coming from a child is erased on notice without requiring a verification dance.
22.5.2 The data subject rights matrix #
SLA: 30 calendar days from a verified request, for every right, for both respondents and customer users. Most are automated and complete in minutes.
| Right | GDPR Art. | Who can exercise | How | Automated? | Notes |
|---|---|---|---|---|---|
| Access | 15 | Respondent, customer user | Respondent portal → "Download everything"; dashboard → Settings → Privacy → "Export my personal data" | Fully | Delivered as the JSON+media archive in Section 22.6.1 |
| Rectification | 16 | Respondent, customer user | Portal → "Change what's shown" (name/role/company); dashboard profile | Fully | Testimonial wording is not rectifiable by anyone, including the respondent — it is a record of what they said. They may withdraw and resubmit |
| Erasure | 17 | Respondent, customer user | Portal → "Delete everything"; dashboard → delete account | Fully | Runbook in Section 22.6.2 |
| Restriction | 18 | Respondent | Portal → "Take it down" | Fully | Unpublish is the restriction mechanism: retained, not processed for publication |
| Portability | 20 | Respondent, customer user | Same export as Access | Fully | Machine-readable JSON plus original media files |
| Objection | 21 | Respondent | Portal, or privacy@vouch.app | Manual triage within 30 days | Objection to legitimate-interest processing (security logs) is balanced and answered in writing |
| Objection (analytics) | 21 | Any site visitor, with no relationship to us | privacy@vouch.app, or the Global Privacy Control signal, which suppresses beacon emission entirely |
Fully, for GPC | A person measured under legitimate interest must have a route to object even though we cannot identify them; suppressing the beacon is that route and needs no identity |
| Access / erasure of an imported review | 15, 17 | Author of a review imported from Google, G2, Capterra or X | privacy@vouch.app, and the takedown address in Section 16.9.2 |
Manual, 30-day SLA | The author never interacted with us and has no portal token; the request is honoured by deleting our copy and is referred to the source platform, which remains the original controller |
| Withdraw consent | 7(3) | Respondent | Portal → "Take it down" or per-grant toggles | Fully | Withdrawal is as easy as granting was — one screen, one action, no account |
| Not be subject to automated decision-making | 22 | Anyone | n/a | n/a | No automated decision with legal or similarly significant effect is made about any person. AI suggestions require an explicit human approval click on every plan (Section 12.6) |
| Complain to a supervisory authority | 77 | Anyone | Stated in the privacy notice and the portal footer | n/a | We name the lead supervisory authority for our establishment |
Requests arriving by email to privacy@vouch.app are ticketed, acknowledged within 3 business
days, and completed within 30 calendar days, with a one-time 60-day extension for genuinely
complex requests — the extension itself must be notified within the first 30 days with reasons.
Requests routed to us that are actually for a workspace-as-controller are forwarded to that
workspace within 3 business days and tracked to completion, because as processor we must assist.
22.6 Export & the Erasure Runbook #
22.6.1 The export format #
Two exports exist, with different scopes:
A. Respondent export (from the portal) — everything we hold about that person, for that submission:
{
"export": {
"version": 1,
"generatedAt": "2026-03-14T09:21:04.118Z",
"subject": "respondent",
"scope": { "testimonialId": "01JD8F2QZ8K5R3W6M0X9YB4TVC", "workspaceName": "Acme Inc." }
},
"person": {
"name": "María López",
"role": "Directora de Marketing",
"company": "Northwind",
"email": "maria@example.com",
"phone": null,
"avatarUrl": "media/avatar.webp"
},
"testimonial": {
"id": "01JD8F2QZ8K5R3W6M0X9YB4TVC",
"type": "video",
"language": "es",
"bodyText": null,
"rating": 5,
"status": "published",
"submittedAt": "2026-01-08T14:02:51.004Z",
"publishedAt": "2026-01-09T10:14:00.000Z",
"tags": ["onboarding", "support"],
"media": [
{ "kind": "video_original", "file": "media/original.mp4", "bytes": 84102391, "durationSeconds": 128 },
{ "kind": "poster", "file": "media/poster.webp", "bytes": 41022 },
{ "kind": "captions_vtt", "file": "media/captions.es.vtt", "bytes": 4180 }
]
},
"transcript": {
"version": 2,
"language": "es",
"confidence": 0.94,
"file": "transcript.json",
"editedByModerator": true,
"editedAt": "2026-01-09T09:40:12.000Z"
},
"consent": {
"version": 3,
"textFile": "consent.txt",
"textSha256": "9f2c…",
"requestPage": "acme-launch-stories",
"submittedAt": "2026-01-08T14:02:51.004Z",
"grants": [
{ "type": "publish", "granted": true, "grantedAt": "2026-01-08T14:02:51.004Z", "withdrawnAt": null },
{ "type": "advertising","granted": false, "grantedAt": null, "withdrawnAt": null },
{ "type": "case_study", "granted": true, "grantedAt": "2026-01-08T14:02:51.004Z", "withdrawnAt": null }
]
},
"processingRecord": {
"ipTruncated": "203.0.113.0/24",
"userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) …",
"countryDerived": "ES",
"subProcessorsInvolved": ["Mux", "Deepgram", "Cloudflare R2", "Resend"]
},
"yourRights": {
"portalUrl": "https://v.vouch.app/p/{token}",
"controller": "Acme Inc.",
"processor": "Vouch",
"privacyContact": "privacy@vouch.app"
}
}Delivered as a ZIP: export.json, consent.txt (the verbatim consent text), transcript.json,
and a media/ directory with the original files at original quality. Generated by a job, made
available at a signed URL valid for 7 days, and emailed to the address on file. Never generated
synchronously in a request, because media can be hundreds of megabytes.
B. Workspace export (dashboard, available in every billing state including canceled per
Section 19.7 E7) — every testimonial, transcript, consent record, tag, request page definition,
widget configuration, and analytics rollup, as newline-delimited JSON per entity plus a media/
tree. Documented column-for-column in docs/exports.md in the repository.
22.6.2 The erasure runbook #
Erasure is a hard delete, always, with no soft-delete step, and it destroys assets at every third party. The 30-day statutory maximum is the outer bound; the automated path completes in minutes and the operational SLA is 24 hours, with the 30-day figure reserved for the case where a third-party provider is unavailable.
Trigger: respondent portal erasure (verified per Section 22.4.2), a workspace deleting its account, an operator actioning an emailed request, or a legal takedown.
Steps — orchestrated by the gdpr.erase job, which is idempotent, resumable, and retried
until every step reports success:
| # | Step | Detail | Failure handling |
|---|---|---|---|
| 1 | Freeze and capture the proof identifiers | Set testimonials.erasure_pending_at. All read paths immediately exclude the row; the widget payload is regenerated without it and CDN tags are purged. The content stops being served before anything is destroyed. In the same transaction, write the erasure_log row capturing provider_asset_ids, storage_prefix, and cdn_urls while those rows still exist — every later step and the whole external half of the verification depend on identifiers that steps 5 and 9 are about to destroy, so capturing them afterwards is not possible and capturing them here is what makes the guarantee provable |
Any failure aborts before destruction; nothing is half-deleted |
| 2 | Notify | Email the respondent "We're deleting your testimonial" with a 24-hour one-click cancel link (Section 22.4.3). For workspace-initiated erasure of the workspace's own account, notify the Owner | Non-blocking; failure logged, erasure proceeds |
| 3 | Wait | 24 hours for respondent-initiated erasure only. Skipped for operator/legal takedowns and for workspace-account deletion | — |
| 4 | Destroy video at the provider | DELETE /video/v1/assets/{muxAssetId}. Verify with a follow-up GET returning 404 |
Retry with backoff; after 3 failures raise a P1 operator alert; job stays open |
| 5 | Destroy object-storage keys, every version | Delete every key under the deterministic prefix w/{workspaceId}/t/{testimonialId}/ — original upload, poster, every derivative, every social card, every VTT. Enumerate with ListObjectVersions, never ListObjectsV2, and issue DeleteObjects with an explicit VersionId for every version and every delete marker, paginating until exhausted. Also AbortMultipartUpload for any incomplete upload. The bucket has versioning enabled (Section 22.16), so a DeleteObjects without a VersionId writes a delete marker and leaves every prior version intact and downloadable by anyone holding s3:GetObjectVersion — the erased person's video would survive a deletion that reported success |
Retry; alert after 3 failures |
| 6 | Verify emptiness | ListObjectVersions on the prefix must return zero Versions and zero DeleteMarkers. This is the verification, not the delete call's return code. A ListObjectsV2 KeyCount: 0 is explicitly not sufficient: it is satisfied by a delete marker sitting on top of an intact object, which is a clean-looking pass over surviving data |
Failure re-enters step 5 |
| 7 | Purge CDN | Purge by tags testimonial:{id}, widget:{id} for every widget that contained it, and workspace:{id}; purge the poster and VTT URLs by path |
Retry; alert after 3 |
| 8 | Purge derived state | Delete transcript rows, every transcript_revisions row for the testimonial including version 1, caption records, AI suggestion rows, social card rows, analytics rows keyed to the testimonial, search index entries, and any queued jobs referencing it. There is no conflict with Section 10.4.3: its never-prune rule on version 1 is scoped to the retention sweep, which trims revision history on a living testimonial, and says nothing about erasure. Erasure deletes every revision, and Section 9.10.2's purge step 7 names transcript_revisions for the same reason — a retained "untouched provider output" is a verbatim copy of the erased person's words |
Transactional |
| 8a | Erase at the sub-processors | Issue a delete/purge call to the transcription provider for the source audio and the transcript, and to the AI provider wherever its API exposes deletion, recording each response in the erasure record. Where a provider offers no deletion API, record that fact and the contracted zero-retention window instead, and state that window to the respondent in the step-15 completion email. A contractual zero-retention term is a commitment, not a mechanism, and the difference is exactly what the data subject is entitled to be told | Retry; after 3 failures record the outcome and continue — a provider outage must not stall the destruction of our own copy, but the unresolved call is listed on the operations dashboard until it succeeds |
| 9 | Delete the row and its children | Hard DELETE on testimonials, cascading to media assets, tags, and derived rows |
Transactional |
| 10a | Minimize the audit trail | On every historical audit_events row for the erased subject, the migrator role sets ip = NULL, replaces user_agent with its coarse family (Safari/iOS), and nulls actor_label, in addition to the before/after payload replacement in Section 11.6.6. Actor type, action, resource id, and timestamp are retained — that is the minimum needed for accountability under Art. 17(3)(b). Without this step the erased person's IP and full user-agent string survive their erasure by seven years, in a store this document describes as immutable, which is the opposite of what the runbook promises |
Transactional; the write is asserted by the verification query |
| 10 | Handle the consent record | Retained in a minimized form: consents keeps consent_text, its hash, the version, timestamps, and the truncated IP, but testimonial_id is nulled and a tombstones row records { testimonialId, workspaceId, erasedAt, reason }. The respondent's name, email, and phone are removed from the consent row. This is the Art. 17(3)(b)/(e) retention of the minimum needed to evidence that a lawful basis existed and that an erasure was performed |
Transactional |
| 11 | Erase the person's contact data | Delete respondents row (name, email, phone, avatar) if no other non-erased submission references it |
Transactional |
| 12 | Invalidate the token | Delete the respondent_tokens row so the portal link 404s |
Transactional |
| 13 | Scrub logs and telemetry | Purge Sentry events tagged with the testimonial id via the provider's API; drop matching rows from the 7-day raw analytics table; log retention (Section 22.7) handles the rest by expiry | Best-effort with a recorded outcome |
| 14 | Backups | Backups are not rewritten. Instead the erasure is recorded in erasure_log, and replaying it is a mandatory step of any restore, not an optional follow-up: before a restored instance accepts traffic, erasure.replay runs against every erasure_log entry whose erasure_completed_at is later than the snapshot, and the verification query below must return zero rows. A restore that has not completed replay is not a completed restore, and this applies to the monthly restore drill exactly as it applies to a real disaster. The true exposure bound is 35 days of point-in-time recovery plus monthly snapshots retained 12 months — 12 months, not 35 days, is the honest figure and is the one stated in the privacy notice |
Documented in the restore runbook (Section 24), whose drill success criteria include the replay assertion |
| 15 | Record and confirm | Write the audit_events row (append-only, actor respondent or system), mark erasure_completed_at, and email the requester "Your testimonial and everything with it has been deleted", listing what was destroyed |
— |
A row is never considered erased while its assets survive. erasure_completed_at is written
only after steps 4–7 have each verified success. An erasure job that cannot complete stays open
and visible on the operations dashboard; it never silently gives up.
The verification query — this is what proves the guarantee, and it runs nightly as
gdpr.verify-erasures and is also run by hand during an audit:
-- Any erasure claimed complete in the last 90 days that still has surviving state.
-- Expected result: zero rows. A non-empty result is a P1 incident.
--
-- Every identifier this query needs comes from erasure_log, which step 1 wrote BEFORE
-- anything was destroyed. Reading them from media_assets would be reading a row step 9
-- deleted: the columns would be NULL for every completed erasure, the external job would
-- have nothing to check, and the whole thing would return zero rows and look like a pass.
SELECT
e.testimonial_id,
e.workspace_id,
e.erasure_completed_at,
(t.id IS NOT NULL) AS testimonial_row_survives,
(ma.id IS NOT NULL) AS media_asset_row_survives,
(tr.id IS NOT NULL) AS transcript_row_survives,
(trv.id IS NOT NULL) AS transcript_revision_survives,
(rt.id IS NOT NULL) AS respondent_token_survives,
(ai.id IS NOT NULL) AS ai_suggestion_survives,
(sc.id IS NOT NULL) AS social_card_survives,
(ae.id IS NOT NULL) AS raw_analytics_survives,
(av.id IS NOT NULL) AS audit_row_still_carries_ip,
e.provider_asset_ids, -- captured at freeze; the external job checks these
e.storage_prefix,
e.cdn_urls
FROM erasure_log e
LEFT JOIN testimonials t ON t.id = e.testimonial_id
LEFT JOIN media_assets ma ON ma.testimonial_id = e.testimonial_id
LEFT JOIN transcripts tr ON tr.testimonial_id = e.testimonial_id
LEFT JOIN transcript_revisions trv ON trv.testimonial_id = e.testimonial_id
LEFT JOIN respondent_tokens rt ON rt.testimonial_id = e.testimonial_id
LEFT JOIN ai_suggestions ai ON ai.testimonial_id = e.testimonial_id
LEFT JOIN social_cards sc ON sc.testimonial_id = e.testimonial_id
LEFT JOIN analytics_events_raw ae ON ae.testimonial_id = e.testimonial_id
LEFT JOIN audit_events av ON av.resource_id = e.testimonial_id
AND av.ip IS NOT NULL
WHERE e.erasure_completed_at IS NOT NULL
AND e.erasure_completed_at > now() - interval '90 days'
AND (t.id IS NOT NULL OR ma.id IS NOT NULL OR tr.id IS NOT NULL OR trv.id IS NOT NULL
OR rt.id IS NOT NULL OR ai.id IS NOT NULL OR sc.id IS NOT NULL OR ae.id IS NOT NULL
OR av.id IS NOT NULL);The database half of the proof is above. The external half is a companion job that, for each row
in erasure_log from the last 90 days, issues a GET to each id in e.provider_asset_ids
(expecting 404), a ListObjectVersions on e.storage_prefix (expecting zero versions and zero
delete markers), and a conditional GET on each URL in e.cdn_urls (expecting 404, or a cache
miss resolving to 404). Any non-conforming result opens a P1. Both halves read only columns
captured at freeze, which is what makes the proof executable at all; a proof that depends on the
data it is proving was destroyed is not a proof, it is a query that always passes.
The verification itself is verified, by three integration tests that deliberately break an erasure and require the query to notice. A verifier that has never been shown a failure is indistinguishable from one that cannot detect failure:
- Surviving object version. Erase a testimonial normally, then use the storage API to restore
a non-current version under the prefix. The nightly job must open a P1. Running the old
ListObjectsV2/KeyCount: 0check against the same state must pass — the test asserts both, so it documents exactly why the mechanism changed. - Surviving provider asset. Erase a testimonial with the provider delete stubbed to return
success without deleting. The external half must fail on the
GETagainstprovider_asset_ids, proving the identifiers survived step 9 and are still queryable. - Surviving audit IP. Erase a testimonial with step 10a disabled. The query's
audit_row_still_carries_ippredicate must return the row.
A fourth test covers the honest-reporting case end to end: erasing a text-only testimonial
leaves zero rows across submissions, testimonials, consents, respondent_tokens,
transcript_revisions, and audit_events.ip, and the verification query returns zero rows for it.
Also erased: a workspace-account deletion runs this path for every testimonial in the
workspace, then deletes workspace rows, Stripe customer (via customers.del), OAuth tokens at
each provider (calling each provider's revoke endpoint), webhook endpoints, API keys, and custom
domain records and certificates. The Owner is warned explicitly that widgets will stop serving —
the single case in the entire product where that happens (Section 19.13).
22.7 Retention Schedule #
Every data class the system holds, with its retention, the trigger that starts the clock, and the mechanism that enforces it. There is no class not on this list; a new table without a retention decision fails code review.
| Data class | Retention | Clock starts | Enforced by |
|---|---|---|---|
Testimonial content (all statuses incl. archived, rejected) |
Until deleted by the workspace or erased on request | — | No automatic deletion. Retention is the customer's decision |
Soft-deleted testimonials (deleted_at set) |
30 days, then hard-deleted through the Section 22.6.2 path | deleted_at |
retention.purge_soft_deleted daily |
| Original uploaded video (raw source) | 30 days after the asset reaches ready, unless "keep originals" is enabled (Growth+, counts against storage) |
media_assets.ready_at |
retention.sweep_originals daily (Section 9.10.1) |
| Delivered/derived media (Mux asset, poster, VTT, social cards) | Life of the testimonial | — | Destroyed by erasure/delete only |
| Abandoned upload sessions and their parts | 24 hours | upload_sessions.created_at |
retention.sweep_uploads hourly, with AbortMultipartUpload |
| Transcripts and caption files | Life of the testimonial | — | Cascade |
| AI suggestion rows (proposals, accepted and rejected) | 180 days | created_at |
retention.purge_ai_suggestions monthly. Accepted suggestions' outputs live on the testimonial and are not affected |
| Consent records, full | Life of the testimonial | — | — |
| Consent records, minimized after erasure | 6 years from erasure, then deleted | erasure_log.erasure_completed_at |
retention.purge_consent_tombstones monthly. Six years matches the common statute-of-limitations window for a contractual/consent dispute |
| Raw analytics events | 7 days | occurred_at |
retention.sweep_analytics_raw daily, by partition drop (Section 17.5) |
| Analytics hourly rollups | 35 days stored | Bucket time | retention.sweep_analytics_rollups daily, by partition drop. The dashboard deliberately exposes only the last 7 days at hourly granularity (Section 17.8); the extra 28 days exist for operational investigation, not for the customer-facing UI, and the two numbers are different on purpose |
| Analytics daily rollups | Per plan: 30 days / 12 / 24 / 36 months (Section 19.2) | Bucket time | retention.sweep_analytics_rollups daily, plan-aware. Downgrading shortens retention prospectively; already-deleted data does not return on upgrade, and the UI says so |
Audit events (audit_events) |
7 years | occurred_at |
Append-only; partitioned by year; archived to cold object storage after 2 years, still queryable |
| Sign-in and session records | 90 days | Session end | Daily job |
| Sessions (active) | 30-day rolling, 90-day absolute (Section 6.5) | Issue | Session store expiry |
| Security logs (WAF, rate-limit rejections, auth failures) | 12 months | Event | Log pipeline retention policy |
| Application logs (pino, structured) | 30 days hot, 12 months cold | Emission | Log pipeline |
| Sentry error events | 90 days | Event | Provider retention setting |
Job run records (job_runs) |
30 days for succeeded, 180 days for failed | Completion | retention.purge_job_runs daily |
| Webhook delivery attempts and payload snapshots | 30 days | Attempt | Daily job |
| Idempotency keys and stored responses | 24 hours (Section 7.5) | Creation | Hourly job |
Stripe event mirror (stripe_events) |
7 years — financial record | received_at |
Archived, not deleted |
| Invoices mirror | 7 years — tax record | Issue | Archived |
| Email/SMS delivery records (recipient hash, template, status) | 12 months | Send | Daily job. The message body is never stored, only the template id and merge-field keys |
| Respondent contact rows with no surviving testimonial | Deleted immediately | — | Erasure step 11 |
| Respondent portal one-time codes | 15 minutes | Issue | Store TTL |
Invitations (workspace seat), any terminal status including accepted |
90 days from status_changed_at |
Terminal state | invitation.expire daily. Accepted invitations are retained, not hard-deleted on acceptance: the audit trail in Section 6.8 depends on the row, and INVITATION_ALREADY_USED cannot be returned for a row that no longer exists |
| Database backups | 35 days point-in-time plus monthly snapshots retained 12 months | Snapshot | Provider lifecycle policy. The 12-month figure — not the 35-day one — is the true bound on how long erased data remains restorable, and it is what the privacy notice states. Mandatory erasure replay on every restore, Section 22.6.2 step 14 |
| Deleted workspace records | 30 days grace (recoverable by support with Owner verification), then the full erasure path | deleted_at |
retention.purge_workspaces daily |
| Feature flag evaluation logs | 7 days | Evaluation | Partition drop |
| CDN access logs (if enabled) | 30 days, with IP truncated at ingest and token-bearing path segments masked before the line is written (/p/*, /reset-password/*, /invite/*, /r/*/draft/* — Section 22.4.1) |
Request | Provider policy; a CI check asserts the masking rule is present in the deployed edge configuration |
Imported reviews not converted to a testimonial (skipped, rejected, duplicate) |
90 days | import_runs.completed_at |
imports.sweep daily. Without this row a non-converted review — a real person's name and words — is retained forever by omission |
submissions (the respondent's raw submitted payload and PII) |
Life of the testimonial: soft-deleted with it, hard-deleted on erasure | testimonials.deleted_at / erasure |
Cascade through the Section 22.6.2 path. It carries full respondent PII and must never outlive the testimonial it belongs to |
invites and invite_batches (contact PII and the rendered body_snapshot) |
90 days after expires_at or terminal status |
Terminal state | Daily job |
notifications (may embed testimonial excerpts and respondent names) |
180 days | created_at |
Daily job |
analytics_events_filtered (events rejected as bot or unverified-origin) |
7 days | occurred_at |
Partition drop, with the raw table |
erasure_log |
6 years from erasure_completed_at |
erasure_completed_at |
retention.purge_consent_tombstones monthly, alongside the minimized consent record it evidences |
tombstones |
6 years | erasedAt |
Same job as above |
Every retention job writes a summary row (retention_runs) with the class, the count deleted, and
the duration, so that a retention claim can be evidenced rather than asserted.
22.8 Data Residency & the EU Deployment Path #
- The primary region is a deployment decision owned by Section 26. The default deployment runs in a single primary region with a same-region read replica, and every sub-processor is configured to keep data in a matching region where the provider supports region pinning.
- Data that leaves the primary region at all is limited to: the CDN's edge caches (which hold only already-public content — published testimonial payloads, posters, VTT files — and never respondent contact data or consent records), and error telemetry, which is scrubbed of PII before transmission (Section 22.18.4).
- The EU-region deployment path is a first-class, documented configuration, not a promise:
- The application is region-agnostic. All region-specific values — database URL, object-storage endpoint and bucket, Valkey URL, Mux environment, Deepgram endpoint, Resend region, the CDN zone — are environment variables (Section 29.2). No region is hard-coded anywhere.
- An EU deployment is a complete, independent stack: EU Postgres, EU object storage bucket with region-locked replication disabled, Mux configured for EU-only storage and delivery, Deepgram EU endpoint, Resend EU region, an EU-hosted Sentry instance or Sentry's EU data region, and an EU-restricted CDN configuration for the origin (the edge itself is global by nature, and only serves public content).
- A workspace is pinned to a stack at creation via
workspaces.region(us|eu). The dashboard host routes to the correct stack by workspace lookup at the edge. There is no cross-region data path and no cross-region replication; a workspace cannot be in two stacks. - Migration between regions is an offline, operator-run procedure, documented in Section 24's runbooks: export, verify, import, re-point domains, purge source. It is not self-service, because a partially migrated tenant is worse than a scheduled outage.
- Transfers out of the EEA, where they occur at all (a US-based sub-processor with no EU region), rely on the EU Standard Contractual Clauses plus a documented Transfer Impact Assessment, and the sub-processor table in Section 22.9 names the mechanism per provider.
- Customer-facing statement of residency is generated from
workspaces.regionand shown in Settings → Privacy, so a customer never has to ask where their data is.
22.9 Sub-Processors #
Published at a stable public URL and mirrored in docs/subprocessors.md in the repository.
Customers are notified by email 30 days before a new sub-processor is added or an existing one
is changed, and may object; an unresolved objection entitles them to terminate without penalty
for the remainder of a prepaid term with a pro-rata refund.
| Sub-processor | Purpose | Personal data shared | Region options | Transfer mechanism | DPA |
|---|---|---|---|---|---|
| Mux | Video ingest, transcoding, storage, adaptive delivery, thumbnails | Video and audio of the respondent (face, voice), derived thumbnails. No name, email, or phone — assets are keyed by our opaque ids only | US or EU storage/delivery region, set per stack | SCCs for US region; EU region avoids transfer | Mux DPA, referenced in our DPA Annex |
| Deepgram | Speech-to-text for transcripts and captions | Audio of the respondent's voice, and the resulting text | US or EU endpoint | SCCs for US endpoint | Deepgram DPA. Zero-retention mode is contractually required: audio is not retained after processing and is not used for model training |
| Object storage — AWS S3 or Cloudflare R2 | Original uploads, posters, derivatives, VTT, social cards, export archives | Video, audio, images of the respondent; export archives containing name and email | Region-pinned per stack | SCCs where applicable | Provider DPA |
| Stripe | Payments, subscriptions, tax, invoicing | Customer (workspace) billing contact name, email, address, tax id. No respondent data ever | Global, Stripe-managed | Stripe DPA + SCCs | Stripe DPA |
| Resend | Transactional email — invitations, confirmations, portal links, billing notices | Recipient email address, recipient display name, and the rendered message content at send time | US or EU region | SCCs for US | Resend DPA |
| Twilio | SMS invitations and SMS delivery of portal links | Recipient phone number and message content at send time | US, with regional routing | SCCs | Twilio DPA |
| Sentry | Error tracking and performance tracing | Scrubbed: no request bodies, no headers other than an allowlist, no respondent name/email/phone, no tokens. Retains user id and workspace id as opaque UUIDs, plus IP which is disabled at the SDK level | US or EU data region | SCCs for US | Sentry DPA |
| AI provider — Anthropic (default) | Highlight, clip, and tag suggestions; social-card alt text | The transcript text or written testimonial body, and the workspace's tag vocabulary. No name, email, phone, or media | US | SCCs | Provider DPA. Zero data retention is contractually required and customer content is never used for model training (Section 12.7). The provider is swappable behind the AiProvider interface, and swapping one triggers the 30-day notice |
| CDN / edge platform | Widget loader and payload delivery, showcase page caching, TLS termination, WAF | Only already-public content, plus request IP in edge logs which are truncated at ingest | Global edge, origin region-pinned | SCCs | Provider DPA |
| AssemblyAI | Speech-to-text, the documented alternative provider behind the same interface as Deepgram | Audio of the respondent's voice, and the resulting text | US or EU | SCCs for US | Provider DPA, zero-retention mode contractually required. Engaged only if the primary transcription provider is unavailable or a deployment selects it — but listed unconditionally, because a register that omits a provider the system can call at any moment is not a register |
| Cloud infrastructure provider | Compute, managed Postgres, managed Valkey, backups | All of the above at rest | Region-pinned per stack | SCCs where applicable | Provider DPA |
| Bot-defence / challenge provider | The non-cognitive device attestation in Section 21.7, used only where a workspace's page is under abuse | Visitor IP and browser signals of anyone loading a challenged request page | US or EU | SCCs for US | Provider DPA. Engaged only while a workspace's captchaMode is active |
| Malware-scanning provider | Scanning uploaded images and CSV files (Section 22.16) | The uploaded file bytes, which may contain anything the uploader supplied | Region-pinned where offered | SCCs | Provider DPA |
| Log aggregation provider | Structured application and security logs | Logs redacted per Section 22.18.4 — no secrets, no respondent name/email/phone — plus workspace and user ids and truncated IPs | US or EU | SCCs for US | Provider DPA. Named here because application logs are retained 30 days hot and 12 months cold, which is processing on somebody's behalf however unglamorous |
| Google (Business Profile API) | Review import, per workspace OAuth | The workspace's own Google credentials and the reviews they own | Global | SCCs | Google Cloud DPA. Only engaged when a workspace connects it |
| X (API v2) | Import of tweets by URL or approved mention list | Public tweet content and author handle | Global | SCCs | Only engaged when a workspace connects it |
| HubSpot | CRM sync of testimonials, Growth+ | Testimonial content and respondent name/email where the customer maps those fields | Region per customer's HubSpot | SCCs | Only engaged when a workspace connects it |
| Slack | Notification delivery to a customer channel | Testimonial excerpt and respondent display name | Global | SCCs | Only engaged when a workspace connects it |
Sub-processors engaged only on customer opt-in (Google, X, HubSpot, Slack, Twilio when SMS is unused) are marked as such in the published list, because a customer who never connects them has a materially smaller processing footprint and deserves to see that.
This table is a published legal document incorporated into the customer DPA, so its
completeness is a contractual matter and not an editorial one. Two mechanisms keep it complete
rather than merely accurate on the day it was written. First, a CI reconciliation asserts that
every external provider named anywhere in this document appears here and that every row here is
named somewhere — both directions empty — so an alternative provider mentioned in a fallback
paragraph cannot quietly become an unlisted processor. Second, the zero-retention and
data-residency settings this table asserts are verified rather than assumed: a monthly
ops.verify_subprocessor_retention_settings job reads each provider's configuration through its
API, compares it against the commitment recorded here, and pages on drift. A contractual retention
term that nobody checks is a sentence, not a control.
22.10 Records of Processing, DPAs & Breach Notification #
22.10.1 Records of processing (Art. 30) #
- Two records are required: an Art. 30(2) processor record for testimonial processing
performed on behalf of workspaces, and an Art. 30(1) controller record for account, billing,
and security-log processing. Write both into
docs/privacy/ropa.mdin the repository, review them quarterly, and update them in the same pull request as any change that adds a data class, a purpose, a recipient, or a transfer. A PR that adds a table topackages/dband does not touch that file or the retention table in Section 22.7 is rejected in review. - The required contents of
docs/privacy/ropa.md, per processing activity, are: the purpose; the categories of data subject; the categories of personal data; the categories of recipient; the transfers and their mechanism; the retention period, copied from Section 22.7 rather than restated independently; and a description of the technical and organizational measures, which for every activity in this product is a reference to Sections 22.12 through 22.19. Seed it from the data-class rows in Section 22.5.1 and the sub-processor rows in Section 22.9 — between them they already contain every column the record needs. - A Data Protection Impact Assessment is maintained for video collection specifically, because it is systematic processing of biometric-adjacent media from members of the public. It is reviewed annually and on any material feature change to the recorder or the AI assistant.
- Legitimate Interest Assessments exist in writing for the two legitimate-interest bases claimed in Section 22.5.1: security logging, and cookie-free analytics.
22.10.2 The customer DPA #
- A Data Processing Addendum is offered to every customer, incorporated by reference into the Terms for all plans (not gated behind an enterprise tier), and signable electronically from Settings → Legal for customers who need a countersigned copy.
- It includes the SCCs as an annex, the sub-processor list from Section 22.9 with the 30-day change-notice commitment, the security measures from this section as Annex II, the audit cooperation terms, and the deletion-on-termination commitment referencing Section 22.6.2.
- Customer-supplied DPAs are accepted for review; the standard one is offered first because a bespoke DPA per customer is unmaintainable and the important thing is that one exists.
22.10.3 Breach notification #
Roles, named by function so the process survives staff changes:
| Role | Held by | Responsibility |
|---|---|---|
| Incident Commander | The on-call engineer who declares the incident | Owns the timeline, makes containment calls, is the only person who declares severity changes |
| Privacy Lead | Named in docs/oncall.md |
Determines whether the incident is a personal-data breach, drives the 72-hour clock, drafts regulator and customer notices |
| Communications Lead | Named in docs/oncall.md |
Owns all external wording; nothing goes out without their sign-off |
| Technical Lead | Assigned by the Commander | Investigation, containment, eradication, recovery |
| Scribe | Assigned by the Commander | Contemporaneous log with timestamps — this becomes the evidence |
The timeline, with the clock starting at awareness, not at confirmation:
| Time from awareness | Action |
|---|---|
| 0 h | Declare. Open the incident channel and the incident record. Assign roles. Begin the scribe log |
| ≤ 1 h | Contain: revoke credentials, rotate keys, block the vector, isolate affected components. Preserve evidence before remediating where the two conflict, and record the decision |
| ≤ 4 h | Initial scope assessment: which data classes, which workspaces, which respondents, how many records, what the exposure window was |
| ≤ 24 h | Privacy Lead's determination: is this a personal-data breach under Art. 4(12)? Is it likely to result in a risk to rights and freedoms (regulator notice), and is it likely to result in a high risk (data-subject notice)? Both answers are recorded with reasoning even when negative |
| ≤ 72 h | Notify the lead supervisory authority if the risk threshold is met, using Art. 33(3) content: nature, categories and approximate numbers of subjects and records, DPO/contact point, likely consequences, and measures taken. If full information is unavailable, notify within 72 hours with what is known and supplement in phases — a late complete notice is worse than a prompt partial one |
| ≤ 72 h | Notify affected customers (controllers) without undue delay, per our processor obligation under Art. 33(2). In practice: within 24 hours of the determination, because they have their own 72-hour clock that starts when we tell them |
| As determined | Notify affected data subjects where high risk exists, in clear plain language, per Art. 34 |
| ≤ 5 business days | Preliminary written report to affected customers |
| ≤ 30 days | Post-incident review, published internally, with dated remediation items entered into the backlog as blocking work |
Customer notification template (Communications Lead adapts; the structure is fixed):
Subject: Security incident affecting {Workspace name} — action may be required
We're writing to tell you about a security incident that affected data you hold in Vouch. We are the processor for this data and you are the controller, so this notice is intended to give you what you need for your own obligations.
What happened. {Plain description of the event and the vector, without speculation.}
When. We became aware at {time, UTC} on {date}. Based on our investigation, the exposure window was {start} to {end}.
What data was involved. {Specific classes and counts — e.g. "the names, email addresses, and testimonial text of 412 respondents in your workspace. No video or audio files were accessed. No payment information is stored by Vouch and none was involved."}
What we have done. {Containment, eradication, and verification steps, with times.}
What we are doing next. {Remediation with dates.}
What you may need to do. {Concrete: e.g. "If you determine that notification of the affected individuals is required, we can supply the list of affected respondent records in your workspace on request. Their contact details are in your export."}
Your regulatory clock. As controller, your notification obligation runs from your awareness — that is, from this message.
Contact. security@vouch.app, and {named individual} for direct escalation.
- A tabletop exercise of this process runs twice a year, including a simulated regulator notice drafted against the clock.
- The notification decision, including a decision not to notify, is always recorded with its reasoning in the incident record. An unrecorded decision not to notify is indistinguishable from a cover-up.
22.11 CCPA / CPRA — Deltas From the GDPR Posture #
Only what differs. Everything in Sections 22.5–22.7 already satisfies the substantive requirements; these are the additional or differently-shaped obligations.
| Topic | Delta |
|---|---|
| Role | For testimonial data we are a Service Provider to the customer, under a contract that prohibits retaining, using, or disclosing personal information for any purpose other than performing the services. That prohibition is in the DPA verbatim, as CPRA requires |
| Sale / sharing | We do not sell or share personal information, and "share" is used in the CPRA sense of cross-context behavioural advertising. This is structurally true, not merely promised: the analytics system creates no cross-site identifier, sets no cookie, and writes no client-side storage (Section 17.1), so there is nothing to sell or share |
| "Do Not Sell or Share My Personal Information" link | Not required, because there is no sale or sharing. We publish a plain statement to that effect in the privacy notice rather than a link that would imply otherwise. A Global Privacy Control signal suppresses analytics beacon emission entirely — it is not treated as a no-op. There is no sale to stop, but a visitor sending GPC has expressed a preference not to be measured, and honouring it costs us a row of aggregate data and gives them the only objection route available to someone we cannot identify (Section 22.5.2) |
| Notice at collection | The consent screen and the privacy notice together satisfy it: categories collected, purposes, retention, and whether sold (no) are stated at or before the point of collection |
| Right to know | Same mechanism as GDPR access, with the CPRA-specific requirement to cover the preceding 12 months and to state categories of sources, purposes, and third parties. The export in Section 22.6.1 includes a processingRecord.subProcessorsInvolved array precisely for this |
| Right to delete | Same erasure path (Section 22.6.2). The CPRA exceptions we rely on are limited to security records and legal-compliance records, both listed in Section 22.7 |
| Right to correct | Same rectification path (Section 22.5.2) |
| Right to limit use of sensitive personal information | We do not use sensitive personal information for inference or profiling at all, so there is nothing to limit. Stated explicitly rather than omitted |
| Non-discrimination | Exercising any right has no effect on service, price, or quality. The respondent portal is free and requires no account; the customer's plan is unaffected by a data-subject request |
| Verification | Same tiered verification as Section 22.4.2. CPRA's "reasonable degree of certainty" is met by the email one-time code for the destructive tier |
| Authorized agents | Accepted at privacy@vouch.app with written authorization from the consumer plus verification of the consumer directly |
| Response time | 45 days, extendable once by 45 with notice. Our 30-day SLA is inside it, so one process serves both regimes |
| Minors | The service is not directed at anyone under 16, and we do not knowingly collect from them, so the CPRA opt-in-for-sale rules for minors do not engage |
22.12 Threat Model #
STRIDE, applied to this product's actual assets rather than to a generic web application.
22.12.1 Assets, ranked by what an attacker would want #
| # | Asset | Why it is valuable | Blast radius |
|---|---|---|---|
| A1 | Respondent media (video/audio of a real person's face and voice) | Deepfake source material, harassment, extortion | Per-testimonial; catastrophic reputationally if bulk |
| A2 | Respondent contact PII (name, email, phone) | Phishing lists targeted at people who trust a specific brand | Per-workspace |
| A3 | Consent records | Their integrity is the customer's legal defence; forging or destroying them is high-value in a dispute | Per-workspace |
| A4 | Workspace OAuth tokens (Google, X, HubSpot, Slack) | Lateral movement into the customer's own systems | Per-workspace, extends beyond us |
| A5 | Public API keys | Read and write access to testimonials; ability to publish fabricated content on the customer's site | Per-workspace |
| A6 | Session cookies / account takeover | Everything the user can do | Per-user |
| A7 | The widget serving path | It executes on thousands of third-party websites. Compromise here is a supply-chain attack on our customers' visitors | Global — the highest-consequence asset in the system |
| A8 | Stripe customer references and billing contact data | Fraud, social engineering | Per-workspace |
| A9 | Published testimonial content integrity | Fabricated or altered testimonials damage the customer's credibility and create legal exposure | Per-workspace |
| A10 | The custom-domain / TLS configuration | Domain takeover, phishing under a customer's brand | Per-domain |
22.12.2 STRIDE table #
| Category | Threat | Affected assets | Controls |
|---|---|---|---|
| Spoofing | Attacker submits a testimonial impersonating a real customer, or fabricates a named person's endorsement | A9 | Invitation-linked submissions carry a recipient token binding the submission to a known address; email verification of the respondent address before publication is an optional per-workspace setting (default on for invitation-linked pages); provenance is displayed in the moderation inbox (invited / open link / imported); the portal link is sent to the claimed address, so a person impersonated will receive it. See Section 22.20.3 |
| Attacker guesses or replays a respondent portal token | A1, A2 | 32-byte CSPRNG token, hashed at rest, constant-time compare, rate limits, email one-time code for anything destructive (Section 22.4) | |
| Attacker signs in as a workspace user | A6 | better-auth with breached-password checking, argon2id, TOTP 2FA, workspace-enforceable on Growth+ (Section 6.5); sign-in rate limits; new-device email notification | |
| Attacker forges a webhook to us | A8, A9 | Stripe signature verification with the raw body; Mux webhook signature verification; our own inbound integration webhooks verified per provider (Section 18) | |
| Attacker claims a custom domain they do not own | A10 | _vouch-challenge DNS TXT proof required before activation; one domain claimable by exactly one workspace (Section 14.6) |
|
| Tampering | Modification of a published testimonial's words | A9, A3 | Every content edit writes an append-only audit event with before/after (Section 11.6); the AI assistant is structurally incapable of rewording (Section 12.1, enforced by exact-substring validation); imported reviews are immutable (Section 16.3) |
| Tampering with consent records | A3 | Column-level REVOKE UPDATE on consents.consent_text and its hash; audit_events is append-only, enforced by a Postgres rule plus revoked UPDATE/DELETE from the application role |
|
| Compromise of the widget bundle on the CDN | A7 | Build provenance: the bundle is built in CI from a tagged commit, its SHA-256 is recorded in the release record, and a post-deploy job fetches the deployed asset and compares the hash. The loader pins the lazily-loaded runtime chunk by content hash (Section 13.10). Deploy credentials for the CDN are separate from application credentials and require a second approver. The loader is ≤5 KB and is reviewed line-by-line on every change | |
| SQL tampering | All | Drizzle parameterized queries only; the raw-SQL rule in Section 22.14 | |
| Man-in-the-middle | All | HSTS with preload on every host, TLS 1.2 minimum with 1.3 preferred, no mixed content, certificate monitoring on custom domains | |
| Repudiation | Customer denies approving a publication; respondent denies consenting | A3, A9 | audit_events with actor id, actor type, IP, user agent, before/after snapshot, append-only, 7-year retention (Section 22.7); verbatim consent text with hash |
| Operator action denied | All | Operator actions run through the same audit path with actor_type = 'system' and an operator id; break-glass access requires a recorded justification |
|
| Information disclosure | Cross-tenant data leak | A1–A5, A9 | Postgres RLS on every workspace-scoped table, application role cannot bypass; SET LOCAL app.current_workspace_id per transaction; an automated test asserts that every table with a workspace_id has an enabled policy, and a second test attempts a cross-tenant read for every resource type and expects zero rows (Section 6.2) |
| Unpublished testimonial exposed publicly | A1, A9 | The widget payload is built from published rows only, at publish time; a test asserts that no non-published id can appear in any payload; the public API filters by status server-side, never by a client-supplied parameter |
|
| PII in logs | A2 | The logging redaction list in Section 22.18.4, enforced by a pino serializer and a CI test that runs the log pipeline over a fixture containing every PII field and asserts none appears | |
| PII in error tracking | A2 | Sentry beforeSend scrubber, request bodies disabled, sendDefaultPii: false, header allowlist |
|
| Media accessible by URL guessing | A1 | Object keys contain a 128-bit random component; poster and VTT URLs are public by design but unguessable; original uploads are never publicly readable and are only ever reached via short-lived signed URLs | |
| Directory/enumeration via ids | All | UUIDv7 primary keys are not sequential integers; list endpoints are always workspace-scoped; cursors are opaque and signed | |
| Denial of service | Flood of submissions | A9, cost | Per-IP and per-request-page velocity limits (Section 7.10); automatic escalation to the non-cognitive bot check in Section 21.7 when a page's rate exceeds its own trailing baseline; upload part-size and count limits; and the spam-adjudication path, which credits back the quota it consumed (Section 19.5.3). The per-workspace collection cap is deliberately not listed as a control here — it is the thing the attacker is exhausting, not the defence. Because testimonials.collected is a lifetime accumulator that ordinary deletion does not free, citing it as a mitigation would mean a distributed flood permanently consumes the victim's paid allowance |
| Widget data API flood | A7, cost | CDN-cached with a 60-second TTL, so origin load is bounded by cache misses; per-IP edge limit; the origin is stateless and horizontally scalable (Section 23.5) | |
| Expensive-query DoS | Availability | Statement timeout of 5 s on the application role, 30 s on the worker role; every list endpoint is cursor-paginated with a hard limit of 100 (Section 7.4); no unbounded IN clauses |
|
| Storage exhaustion via abandoned uploads | Cost | 24-hour sweep with AbortMultipartUpload (Section 22.7) |
|
| Zip-bomb / decompression bomb in CSV import | Availability | Uncompressed-size limit enforced during streaming decompression, row-count limit, per-row size limit (Section 22.16) | |
| Elevation of privilege | Editor performs an Owner action | A8 | can(actor, action, resource) called on every route and server action, with a test asserting each of the four roles against each of the permission strings (Section 6.6.3) |
| API key with a broader scope than granted | A5 | Scopes checked per endpoint; keys carry a workspace and a scope set; a test enumerates every endpoint × every scope | |
| Suspended-seat member writing | A9 | seat_suspended checked in the same authorization path, not as a UI condition (Section 19.4 E6) |
|
| RCE in a media decoder reaching the worker's KMS grant | A4, A1 | Respondent-supplied media bytes are decoded only inside the media-sandbox process described in Section 22.16 — no KMS grant, no database credential, no network egress. Without it, a heap overflow in an audio or image decoder handed to us by anyone holding a public request-page URL would execute in the process that can unwrap every tenant's OAuth tokens |
|
| SSRF pivot into internal services | All | The SSRF controls in Section 22.15 | |
| Worker job crossing tenants | A1–A5 | Every job payload carries a workspace id, and the worker opens its transaction with SET LOCAL app.current_workspace_id from that payload, so RLS applies to jobs identically (Section 6.2) |
22.12.3 Trust boundaries #
- Browser ↔ our origin — everything from the browser is untrusted, including from an authenticated dashboard user.
- Host page ↔ widget — the widget trusts nothing from the host page and gives it nothing: no globals beyond one non-enumerable property, no prototype patching, no data written into the host DOM (Section 13.4.1). Symmetrically, the host page can read the open shadow root, so the widget never holds anything there that is not already public.
- Our app ↔ Postgres — the application connects as a non-superuser role that cannot bypass RLS, so a compromised application query still cannot cross a tenant boundary without also setting the tenant variable.
- Our app ↔ sub-processors — every outbound call is to a fixed, allowlisted host with a scoped credential; no sub-processor's response is trusted as HTML or executed.
- Worker ↔ queue — job payloads are treated as untrusted input and validated with the same Zod schemas as HTTP input, because a queue is a data path like any other.
22.13 Input Validation & Output Encoding #
22.13.1 Validation at every boundary #
One schema library, one schema per boundary, shared between client, server, and worker (Section 3, Section 4). The schema is the contract; there is no hand-written validation.
| Boundary | Validated with |
|---|---|
| HTTP request body, query, and path params | Zod schema from packages/validation, applied in middleware before the handler runs. Unknown keys are stripped, not passed through |
| Server actions | The same schemas, applied inside the action — never trusting that a client component validated |
| Job payloads | The same schemas, applied on consume |
| Webhook payloads (Stripe, Mux, integrations) | Signature verified first, then schema-validated. A valid signature does not make a payload well-formed |
| CSV import rows | Per-column schema with explicit coercion rules and a row-level error report (Section 16.7) |
| External API responses (Google, G2, X, HubSpot) | Schema-validated on receipt. A provider changing its shape must fail loudly, not corrupt our data |
| Environment variables | A Zod schema parsed at boot; the process refuses to start on a missing or malformed variable (Section 26) |
| Widget configuration | Validated on write and re-validated on payload render |
Global limits applied before any parsing:
| Limit | Value |
|---|---|
| JSON request body | 1 MB (415/413 beyond) |
| Multipart part size | 8 MB per part (Section 9.6.2) |
| Total upload per submission | Plan-derived from max length and resolution, hard ceiling 2 GB |
| Written testimonial body | 5,000 characters after NFC normalization |
| Any single text field | 1,000 characters unless explicitly larger |
| Array parameters | 100 items |
| Query string | 4 KB |
| Header size | Platform default, with X-Forwarded-* handling restricted to the trusted proxy |
| CSV file | 10 MB compressed, 100 MB uncompressed, 10,000 rows |
| Nesting depth in any JSON body | 8 |
Text normalization on every user-supplied string: Unicode NFC, control characters stripped except
\n and \t, zero-width and bidirectional-override characters (U+200B–U+200F, U+202A–U+202E,
U+2066–U+2069) stripped, and leading/trailing whitespace trimmed. Bidi-override stripping matters
here specifically: a testimonial containing an RTL override can visually reorder surrounding text
on the customer's page.
22.13.2 Output encoding #
- React escapes by default, and
dangerouslySetInnerHTMLappears in exactly one place in the codebase — the rich-text testimonial renderer — where its input has passed the sanitizer in Section 22.13.3. An ESLint rule forbids it everywhere else and requires an inline justification comment at the single permitted site. - The widget never parses HTML at all, and this is enforced by a build-time AST scan rather
than by review habit: Section 13.10.1 forbids
innerHTML,outerHTML,insertAdjacentHTML,document.write,DOMParser,Range.createContextualFragment,setHTMLUnsafe,eval, andnew Function, among the twelve sinks it names. Text goes in viatextContent, elements are created withdocument.createElementfrom thebodyNodestoken array (Section 13.8.2), and attributes are set withsetAttributefrom validated values. BanninginnerHTMLalone would not be enough —DOMParserandcreateContextualFragmentare the sinks a well-meaning refactor reaches for next (Section 13.10 owns this). - JSON responses set
Content-Type: application/json; charset=utf-8and are never rendered as HTML. - The oEmbed endpoint returns HTML; every interpolated value is HTML-entity-encoded, URLs are
validated against a strict scheme allowlist (
https:only), and the embedded iframe'ssrcis constructed from ids, never from customer-supplied strings. - CSV export escapes formula-injection prefixes: any cell beginning with
=,+,-,@, tab, or carriage return is prefixed with a single quote. A testimonial that begins with a hyphen is common, so this is not theoretical. - Email templates escape all merge values; the plain-text alternative is generated from the same escaped values.
- Filenames in
Content-Dispositionare RFC 5987 encoded and sanitized to a safe character set. - Log output is structured JSON via pino (Section 3); no string interpolation of user input into a log message, so log injection has nowhere to land.
22.13.3 The XSS posture and the sanitizer allowlist #
Testimonial bodies may contain limited rich text (a respondent pasting from a document, or a moderator adding a paragraph break). The rule:
Sanitize on write and again on serve. Sanitizing only on write means a bug in a later migration, an import path, or a direct database change can serve unsanitized content forever. Sanitizing only on serve means the stored data is dirty and every consumer must remember.
Server-side DOMPurify (Section 3) in both places, running in a Node DOM implementation so it never depends on a browser.
The allowlist is exactly:
p,br,strong,em,a[href]. Nothing else. Nospan, nodiv, noimg, noul/li, nostyleattribute, noclass, noid, nodata-*.a[href]is restricted tohttps:only.mailto:is deliberately excluded: a mail link in published testimonial text on a customer's site is a harvesting and phishing affordance with no product use case, and "a respondent might want to share their address" is not one, because the address they gave us is not for publication. Every other scheme includingjavascript:,data:, andvbscript:is dropped along with the whole attribute.Links are forced to carry
rel="nofollow ugc noopener noreferrer"andtarget="_blank", and this exact string is used identically in Section 8.3.3 and Section 13.10.1.ugcis the correct declaration for user-generated content and its absence is a small SEO-integrity leak on the customer's own domain;noreferrerstops the destination learning which customer page the link sat on.ALLOW_DATA_ATTR: false,ALLOW_ARIA_ATTR: false,SAFE_FOR_TEMPLATES: true,FORBID_TAGSincludesstyle,script,iframe,object,embed,form,input,svg,math, andtemplate;USE_PROFILES: { html: true }only — no SVG, no MathML profile.Mutation-XSS is addressed by re-serializing the sanitized output and re-parsing it once, comparing the two serializations; a mismatch rejects the content and raises an alert, because a serialization that is not a fixed point is the signature of an mXSS payload.
The widget's own posture is not a stricter sanitizer — it is the absence of the sink. This distinction is the whole point and is worth stating precisely, because "we sanitize carefully" and "there is nothing to sanitize" are different security properties and only the second one survives a mistake. The widget payload contains no HTML at all. Rich text arrives as the
bodyNodestoken array defined in Section 13.8.2 — objects of the shape{ t, text?, href?, children? }— and the runtime walks that array, creating each element withdocument.createElementand setting its text withtextContent. There is no markup in the payload, therefore no string to parse, and Section 13.10.1 additionally forbids the twelve HTML-parsing sinks outright, so there is no parser to reach even if a string somehow appeared. A stored-XSS payload that survived every layer above would arrive as a token with atextfield and be rendered as visible text on the customer's page, which is a content-moderation problem rather than a code-execution one.The rich-text rendering path — the one that does produce HTML from sanitized input — exists only on our own showcase page and in the dashboard, both of which are origins we control and both of which carry the CSP in Section 22.19. Nothing on a customer's origin ever runs it.
A CI test runs a corpus of ~300 known XSS payloads through the sanitizer and asserts that none produces a script-executing result. A Playwright test then submits each payload as testimonial text, stores it, and renders it on the showcase page and in every widget type on a host fixture, with
window.alertanddocument.cookietraps: zero payloads execute, and zero produce a DOM node outside the allowlist above. The widget half of that test is the one that matters most — it is the only surface where a failure would execute on thousands of other companies' origins simultaneously — and it also proves the token array is doing what this subsection claims, by asserting every rendered node came fromcreateElementand carries only text set viatextContent.
22.14 Injection Prevention & the Raw-SQL Rule #
All database access goes through Drizzle (Section 3), which parameterizes. String concatenation into a query is a review-blocking defect.
Raw SQL is permitted only via Drizzle's
sqltemplate tag with interpolated values as parameters — never via string building. The tag produces placeholders; a value can never become syntax.Where raw SQL is genuinely required (recursive CTEs for tag trees, the analytics rollup aggregations, the erasure verification query in Section 22.6.2), it lives in a named function in
packages/db/queries/raw/, carries a comment explaining why the query builder is insufficient, and has a unit test. A lint rule forbids thesqltag outside that directory.Dynamic identifiers — a column name for sorting, a table name — are never interpolated. Sort fields are mapped from a fixed allowlist to a static column reference; an unrecognized
sortvalue returns 422, never a fallback that silently ignores it.LIKE/ILIKEpatterns escape%,_, and\in user input before interpolation as a parameter.Full-text search uses
websearch_to_tsquery, which cannot be made to emit syntax errors from arbitrary user input, rather thanto_tsquery.NoSQL/queue injection: BullMQ job names are from a closed enum, never derived from user input. Valkey keys are constructed from a fixed prefix plus validated ids, never from raw user strings, and every key-building function is in one module.
Command injection: the application never shells out. Image and video processing happen at Mux and through the sharp library (Section 3) with programmatic arguments, not a command line. There is no
child_processusage inapps/weborapps/worker, and a lint rule enforces it, with exactly one named exception: themedia-sandboxspawn in Section 9.9.The exception is written as a named allowance rather than as a softening of the rule, because an unbounded exception is worse than no rule at all — a lint rule that permits "sandboxed spawns" in general is a lint rule that permits anything a developer is willing to call a sandbox. The allowance is therefore conditional on all of the following, and the lint rule checks the call site, not the intent: the spawn is of the single
media-sandboxbinary; the child runs with no KMS grant, no database credential, and no network egress; it is confined by a seccomp/AppArmor profile and CPU and memory cgroups; it is killed at a 60-second hard timeout; its only interface is a pipe carrying the object key in and the derived artifact out, never a shell string; and it is one process per job, never a reused daemon. Any spawn not meeting every condition fails the lint rule, and any change to the sandbox's argument construction requires the security-review checklist in Section 22.21.5.The reason a decoder is worth this much apparatus is in Section 22.12.2: respondent-supplied media bytes reaching a decoder is the classic remote-code-execution path, the attacker needs nothing but a public request-page URL, and the process that would otherwise do the decoding holds KMS unwrap rights for every workspace's OAuth tokens.
Template injection: email templates are compiled at build time from files in the repository. A customer-supplied string is never used as a template, only as a merge value.
Header injection: any user-supplied value that reaches a header (a filename, a redirect target) is validated against a strict pattern and stripped of CR/LF.
Prototype pollution: all parsed JSON goes through Zod, which constructs new objects and drops
__proto__; aJSON.parsereviver rejecting__proto__,constructor, andprototypekeys is applied at the parse boundary as a second layer.
22.15 SSRF Protection #
Customer-supplied URLs and customer-supplied hostnames exist in five places, and every one of them is a potential pivot into our internal network:
- Avatar / logo import by URL (branding editor, and respondent avatars from imported sources).
- Webhook endpoint registration (Section 18) — the highest-risk one, because it is a URL we are asked to POST to, repeatedly, with retries.
- Import permalinks and media URLs from Google, G2, Capterra, X, and CSV.
- oEmbed and link-preview fetching of a customer-supplied "read the full story" URL.
- Custom-domain verification and TLS pre-flight probes (Sections 14.3.2 and 14.4.4). This one
is easy to miss because the customer supplies a hostname rather than a URL, but the effect is
identical and the trust position is worse: the customer controls the DNS, so they can point the
name at
169.254.169.254or any RFC1918 address and press "check now". Two rules therefore apply to it specifically. The probe is issued throughpackages/core/http/safe-fetch.tsunder the full rule set below — including rule 6's blocked-CIDR list, checked against every resolved address, and rule 5's resolve-then-pin defence against DNS rebinding, which is the rule that matters most here because the customer controls the DNS for the name they are asking us to check. And no upstream response header is ever reflected back to the customer: the diagnostic returns only a value from the closed enum{ours, known_cdn, unknown, unreachable}. Echoing aServerorViaheader would turn a blind SSRF into an authenticated read oracle against cloud metadata and internal services.
One fetch client, used by all five. packages/core/http/safe-fetch.ts is the only outbound
HTTP client permitted for customer-supplied URLs and hostnames; a lint rule forbids fetch and any
HTTP library outside it in paths that handle user URLs, and the SSRF test suite in Section 25.8
exercises every one of the five entry points, not only the four that look like URLs.
Its rules, applied in order:
| # | Rule |
|---|---|
| 1 | Scheme allowlist: https: only. Not http:, not ftp:, not file:, not data:, not blob:, not gopher: |
| 2 | Port allowlist: 443 only |
| 3 | No credentials in the URL — a URL containing user:pass@ is rejected |
| 4 | Hostname must be a public DNS name. Reject IP literals entirely (both IPv4 and IPv6, including decimal, octal, hexadecimal, and IPv4-mapped IPv6 forms), reject localhost and any name ending in .localhost, .local, .internal, .home.arpa, or the cloud metadata hostnames |
| 5 | Resolve DNS ourselves, then connect to the resolved address, pinning it for the connection. This closes DNS-rebinding, where a name resolves public at validation time and private at connect time. Implemented by resolving A/AAAA, filtering, and passing a custom lookup to the agent that returns only the vetted address |
| 6 | Blocked CIDR list, checked against every resolved address, for both the initial request and every redirect hop |
| 7 | Redirects: maximum 3 hops, each re-validated through rules 1–6. A redirect to a private address is a rejection, not a follow |
| 8 | Timeouts: 5 s to connect, 10 s total, and a hard response cap of 10 MB with streaming abort — no await response.text() on an unbounded body |
| 9 | Response content-type allowlist per use: images accept image/png, image/jpeg, image/webp, image/gif, image/avif only, and the bytes are then magic-byte-validated (Section 22.16). Webhook responses are not parsed, stored, returned, or rendered at all beyond the status code, the content-type, and the byte count — this applies to the delivery log and the "test endpoint" response in Section 18 exactly as it applies to the fetch itself. Storing a few kilobytes of an arbitrary endpoint's response body would upgrade any residual SSRF bypass from blind to fully readable, with the exfiltrated bytes rendered into the attacker's own dashboard |
| 10 | Egress from a dedicated network path. The worker's outbound traffic for user-supplied URLs uses a NAT egress with no route to the VPC's private subnets, so even a bypass of rules 1–9 reaches nothing. Defence in depth, because SSRF filters have a long history of being bypassed |
| 11 | No automatic retry of a rejected URL. Outbound fetches are limited to 60/minute per workspace, 600/minute globally across all workspaces, and 10/minute per destination host. The global budget is the one that matters: a per-workspace limit alone is multiplied by however many trial workspaces an attacker creates, which turns our egress IPs into a scanning and amplification proxy against third parties. Exceeding the global budget pages |
| 12 | The ACME HTTP-01 validation fetch is the single documented exception to rules 1 and 2: it uses http: on port 80, because that is what the protocol requires. It is issued only to a hostname already present in custom_domains for the requesting workspace, and rules 3–11 apply to it unchanged. No other path may use http: |
The blocked CIDR list, checked on every resolved address:
IPv4: 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16,
172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.88.99.0/24, 192.168.0.0/16,
198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4,
255.255.255.255/32
IPv6: ::/128, ::1/128, ::ffff:0:0/96, 64:ff9b::/96, 100::/64, 2001::/32,
2001:db8::/32, 2002::/16, fc00::/7, fe80::/10, ff00::/8
Named: 169.254.169.254 (cloud instance metadata, all providers), metadata.google.internalWebhook endpoints specifically carry three extra controls, because they are the one case where the attacker chooses a URL we will call on a schedule:
- Registration requires a verification handshake: we POST a signed challenge and require the endpoint to echo it, so a registrant must actually control the endpoint.
- Endpoints are re-validated (rules 1–6) before every delivery attempt, not only at registration, because DNS changes.
- The delivery schedule of nine attempts — the first send plus eight retries — with auto-disable (Section 18.5) bounds the amplification an attacker could achieve by pointing a webhook at a third party.
22.16 File Upload Security #
Uploads arrive from two places: the recorder's chunked multipart upload (Section 9.6) and direct image uploads for logos and avatars.
| Control | Detail |
|---|---|
| Content-type is never trusted | The client-declared Content-Type and the file extension are treated as hints only, never as facts |
| Magic-byte validation | The first bytes are checked against the expected container signature before anything else happens: ftyp box for MP4, 1A 45 DF A3 for WebM/Matroska, 89 50 4E 47 for PNG, FF D8 FF for JPEG, RIFF…WEBP for WebP, GIF8 for GIF. A mismatch rejects the upload with 415 |
| Container inspection | Video containers are further probed (via the transcode provider's ingest validation) and rejected if they contain a stream type outside the expected audio/video codecs — no attachment tracks, no subtitle tracks, no data streams |
| Extension is derived, never accepted | The stored key's extension is chosen by us from the validated type. A file named x.mp4.svg cannot become an SVG |
| SVG is never accepted anywhere | Not for logos, not for avatars, not for import. SVG is an executable document format. Logos are PNG/JPEG/WebP/AVIF only, and the UI says so |
| Size limits | Per part 8 MB; per submission the plan-derived ceiling with a hard cap of 2 GB; images 5 MB; CSV 10 MB compressed / 100 MB uncompressed |
| Dimension limits | Images are rejected above 8,000 px on either axis before decoding, to prevent decompression-bomb memory exhaustion. sharp (Section 3) is configured with limitInputPixels |
| Re-encoding | Every uploaded image is re-encoded by sharp to a normalized format and size set. The original bytes are never served. Re-encoding destroys any polyglot or appended payload |
| EXIF and metadata stripping | All metadata is stripped on re-encode — including GPS coordinates, which routinely appear in phone photos and would otherwise publish a respondent's home location. Orientation is applied to the pixels before stripping, so the image is not rotated wrongly. Video metadata is normalized by the transcode provider; the raw original is never publicly served |
| Separate origin for user content | All user-uploaded and user-derived media is served from cdn.vouch.app, which holds no cookies, is never a session origin, and carries X-Content-Type-Options: nosniff and a restrictive CSP (Section 22.19). Even if a malicious file were served, it could not reach a session |
Content-Disposition: attachment |
Applied to any downloadable original or export archive, so a browser never renders it inline |
| Signed URLs for originals | Original uploads are never publicly readable. Access is via a signed URL valid 15 minutes, issued only to an authorized dashboard actor or to an authenticated respondent-portal export |
| Object keys are unguessable | w/{workspaceId}/t/{testimonialId}/{kind}-{128-bit random}.{ext} |
| Malware scanning | Uploaded images and CSVs are scanned before processing. Video is not scanned as a file — it is transcoded, and the output is a new file produced by the provider from decoded frames, which is a stronger control than signature scanning |
| All decoding of respondent bytes is sandboxed | Waveform generation, image re-encoding, and any container probe run in a dedicated media-sandbox process: no KMS grant, no database credential, no network egress, a seccomp/AppArmor profile, CPU and memory cgroups, and a 60-second hard timeout. It talks to apps/worker over a pipe carrying only the object key in and the derived artifact out. One process per job, never a reused daemon. Section 9.9 owns the sandbox itself; this row states the security properties it must hold, and Section 22.14 carries the matching named exception to the no-child_process rule. This exists because the alternative is decoding attacker-chosen media in-process in the worker that holds KMS unwrap rights for every workspace's OAuth tokens (Section 22.18.2) — media decoders are a top-tier memory-safety target, and the attacker needs nothing but a public request-page URL |
| Bucket configuration | Public-read is disabled at the bucket level; the CDN reads via a signed origin identity. Public listing is disabled. Versioning is on, which is why the erasure path in Section 22.6.2 enumerates with ListObjectVersions and deletes every version and every delete marker explicitly — a plain delete on a versioned bucket writes a delete marker and preserves the object. The lifecycle rule expiring non-current versions after 7 days is a backstop, not the erasure mechanism; erasure never waits for it |
| CSV specifics | Streaming parse with an uncompressed-size counter that aborts past the limit; row and column count limits; per-cell length limit; formula-injection prefixing on export (Section 22.13.2); the mapping UI never evaluates a cell |
| Upload authorization | A pre-signed part URL is issued only after the quota pre-check (Section 19.4 E1) and only for the specific upload session. It expires in 15 minutes (PRESIGN_TTL_SECONDS = 900, the same value Section 9.6.2 states — there is one TTL, not two), and it carries a storage-enforced content-length-range of exactly [PART_SIZE_BYTES, PART_SIZE_BYTES] for non-final parts and [1, PART_SIZE_BYTES] for the flagged final part, plus a fixed Content-Type condition. The range condition is the control that makes the size limit real: without it the 413 gate is computed from a client-declared size, so an anonymous respondent can PUT an arbitrarily large body and the mismatch is caught only afterwards by HeadObject. If HeadObject at Complete reports more than VIDEO_MAX_UPLOAD_BYTES, the job issues DeleteObject immediately rather than deferring to the orphan sweep |
22.17 CSRF, Clickjacking & Open Redirects #
CSRF. Cookie-authenticated routes are the dashboard's server actions and its /api/v1
routes when called with a session cookie.
- The session cookie is
__Host-vouch_sessionwithSameSite=Lax(Section 6.5), which blocks cross-site POST by default in every current browser.Laxrather thanStrictbecauseStrictbreaks the magic-link and invitation flows, where the user arrives from an email client. - Because
SameSitealone is not a complete control (older browsers, and the top-level GET exception), every state-changing request additionally requires a double-submit token: a cryptographically random value in a__Host-vouch_csrfcookie that must be echoed in theX-Vouch-Csrfheader. Server actions carry the equivalent token in their payload. The comparison is constant-time. Originheader validation on every unsafe method: theOrigin(orRefererifOriginis absent) must match an allowlisted host for that surface. A request with neither is rejected.- No state-changing GET exists anywhere. A lint rule and a route test enforce it, and the rule is not excepted for one-click email links — those are exactly where it matters most. Both such links in this product (the erasure-cancel link in Section 22.4.3 and the "this wasn't me" link in Section 22.20.2) resolve to a GET confirmation page that changes nothing on load, carries a single-use 24-hour token in the path, describes what will happen, and requires an explicit POST from a button press carrying the double-submit token. Section 20.7 documents that mail providers and corporate security appliances pre-fetch remote resources; a state-changing GET here would let a scanner cancel a genuine erasure request or force a password reset on delivery, with no human action anywhere in the chain.
- The public REST API is exempt from CSRF because it authenticates with a bearer token, not a
cookie, and a bearer token is not attached automatically by the browser. The API explicitly
rejects cookie authentication: presenting a session cookie to
/api/v1without a bearer token returns 401, so the two auth modes can never be confused. - The respondent portal issues a short-lived
SameSite=Strictsession cookie after one-time code verification, plus the same double-submit token, because its actions are destructive. - Public submission endpoints are intentionally unauthenticated and cross-origin-capable (a
request page may be embedded), so CSRF is not meaningful for them; they are protected by rate
limits, the honeypot, and the fact that a forged submission creates a
pendingrow that a human must approve.
Clickjacking.
| Surface | frame-ancestors |
Reasoning |
|---|---|---|
Dashboard (app.vouch.app) |
'none' |
Nothing legitimate frames the dashboard |
Request pages (v.vouch.app/r/*, {slug}.vouch.app) |
'self' plus the workspace's verified custom domains only, emitted per request from custom_domains where the state is active. A workspace with no verified domain gets 'self' |
Customers do legitimately embed their request page, so framing is permitted — but only by an origin the workspace has proved it controls. The previous reasoning (that camera access needs allow="camera; microphone" on the framing iframe) had the threat backwards: the attacker is the framing page, and it grants that attribute to itself freely. With https: any site could frame a real workspace's live recorder inside "Record a 30-second clip to claim your prize", or overlay it transparently over a decoy button, and harvest a stranger's face, voice, name, email and company — submitted with a genuine consent record against a workspace that never solicited it. This is the most sensitive capture surface in the product and it gets the strictest framing policy, not the loosest. A framed page whose ancestor origin is not verified renders a full-page interstitial naming the real workspace before getUserMedia may be called |
Respondent portal (v.vouch.app/p/*) |
'none' |
It has destructive actions and must never be framed |
| Showcase pages | 'self' https: |
Framing a public marketing page is harmless and sometimes wanted |
Widget data API (cdn.vouch.app/api/*) |
'none' |
It is JSON |
| oEmbed iframe endpoint | 'self' https: |
Framing is its entire purpose |
X-Frame-Options is set alongside frame-ancestors for older agents where the value maps
cleanly (DENY where frame-ancestors 'none'), and omitted where it cannot express the CSP
value.
Open redirects.
- The redirect-after-submit URL on a request page is the one place a customer supplies a
destination we will send a respondent to. It is validated at save time, not at redirect
time:
https:scheme only, no credentials, no IP literal, a resolvable public host, and — the important part — the host must either match one of the workspace's verified custom domains or be explicitly confirmed by the customer in a dialog naming the exact hostname ("Respondents will be sent tothanks.example.com. Only add a domain you control."). - At redirect time the stored URL is re-validated against the same rules before use, so a later change to the domain list takes effect.
- The redirect is a normal 302 to the stored absolute URL — never a redirect to a URL taken
from a query parameter. There is no
?next=or?returnTo=parameter anywhere in the product that accepts an absolute URL. - Internal post-sign-in redirects accept only a path, validated to start with a single
/and not//or/\, resolved against our own origin. A test asserts that//evil.example,/\evil.example,https://evil.example, and percent-encoded variants are all rejected. - Outbound links in testimonial content carry
rel="nofollow ugc noopener noreferrer"and the badge link carriesrel="noopener nofollow"; neither is ever rendered as a redirect through our own domain, so we never become an open-redirect service for someone else's phishing.
22.18 Secrets, Keys & Encryption #
22.18.1 Handling #
- Every secret is an environment variable, catalogued once in Section 29.2, injected by the platform's secret manager at deploy time. No secret is in the repository, in a Docker image, in a build argument, or in a client bundle.
- A boot-time schema validates that every required secret is present and non-placeholder; the process refuses to start otherwise, so a misconfigured deploy fails loudly instead of running with a missing integration.
NEXT_PUBLIC_*is the only prefix that reaches the browser bundle, and a CI check greps the built client bundles for every known secret-variable name and for high-entropy patterns matching our own key formats, failing the build on a hit..envfiles are git-ignored, and a pre-commit hook plus a CI secret scanner (gitleaks-style rules, plus provider-specific patterns for Stripe, Mux, Deepgram, Twilio, and our ownvch_live_/whsec_formats) runs on every commit and on the full history nightly.- A leaked secret is rotated, not assessed. The runbook in Section 24 is: rotate first, investigate second.
22.18.2 Encryption at rest #
| Data | Mechanism |
|---|---|
| Database, whole-disk | Provider-managed encryption at rest, AES-256 |
| Object storage | SSE with provider-managed keys, AES-256; bucket policy denies unencrypted PUT |
| Backups | Encrypted with a separate key from the live database |
| OAuth access and refresh tokens (Google, X, HubSpot, Slack) | KMS-backed envelope encryption, additionally, on top of disk encryption |
Webhook endpoint secrets (whsec_…) |
Same envelope encryption |
| Integration API credentials supplied by customers | Same envelope encryption |
| Public API keys | Not encrypted — hashed with SHA-256, plus a 12-character display prefix stored in clear (Section 7.7). They are never needed in plaintext after issue |
| Passwords | argon2id, never encrypted (Section 6.5) |
| Respondent portal tokens, one-time codes, invitation tokens | SHA-256 hashed, never recoverable |
| Consent text | Not encrypted at the column level — it must be readable for evidence — but protected by RLS and by column-level REVOKE UPDATE |
Envelope encryption, for the three classes that need to be recoverable in plaintext:
plaintext secret
→ generate a 256-bit data key (DEK) from a CSPRNG
→ AES-256-GCM encrypt the secret with the DEK → ciphertext + IV + auth tag
→ KMS.Encrypt(DEK) under the Customer Master Key → wrapped DEK
→ store { ciphertext, iv, authTag, wrappedDek, kmsKeyId, keyVersion } in the column- The application never holds the CMK; it calls KMS to unwrap. KMS access is granted to the worker and web roles by instance identity, not by a static credential, and every unwrap is logged by the provider — so decryption of a customer's OAuth token is auditable outside our own logs.
- AAD (additional authenticated data) binds the ciphertext to its row:
workspace_idand the column name are passed as AAD, so a ciphertext copied from one row to another fails authentication. This defeats the "swap the encrypted token between tenants" attack that plain encryption permits. - Decrypted values live in memory for the duration of the call only, are never logged, never serialized into an error, and never stored in a cache.
22.18.3 Rotation #
| Secret | Cadence | Procedure |
|---|---|---|
| KMS CMK | Annual automatic rotation, plus on demand | KMS handles versioning; wrapped DEKs re-wrap lazily on next write, and a security.rewrap job re-wraps everything within 30 days |
| Data keys (DEK) | Per secret, per write | A new DEK on every write; there is no long-lived DEK |
| Session signing key | 90 days, with a 30-day overlap accepting the previous key | Rolling deploy; sessions signed with the old key remain valid until the overlap ends |
Public API keys (vch_live_) |
Customer-controlled, with a "roll" flow that issues a new key and keeps the old one valid for 24 hours | Dashboard action; both keys shown during overlap; the old key's last-used timestamp is displayed so the customer knows when it is safe |
| Webhook endpoint secrets | Customer-controlled roll with a 24-hour dual-signing window — during it, deliveries carry two v1= signatures so the receiver can migrate without downtime |
Dashboard action |
| Third-party provider keys (Stripe, Mux, Deepgram, Twilio, Resend, AI provider) | 180 days, and immediately on any suspicion | Runbook per provider in Section 24; each supports overlapping keys, so rotation is zero-downtime |
| OAuth tokens | Refreshed per provider expiry; refresh failures surface as a reconnection prompt in the dashboard rather than a silent stop | Automatic |
| Database credentials | 180 days | Managed-credential rotation with a connection-pool drain |
| TLS certificates (custom domains) | Auto-renew at 30 days remaining, alert at 14, page at 7 (Section 14.7) | Automatic |
| Break-glass admin credential | Rotated after every use, without exception | Runbook |
22.18.4 No secret is ever logged #
- The pino logger (Section 3) is configured with a redaction path list covering:
req.headers.authorization,req.headers.cookie,req.headers["x-vouch-csrf"],*.password,*.token,*.accessToken,*.refreshToken,*.secret,*.apiKey,*.clientSecret,*.signature,*.otp,*.consentText,*.email,*.phone,*.ipAddress,*.body. - A value-level scrubber runs after path redaction and replaces any string matching a known
secret shape (
sk_live_,whsec_,vch_live_,vch_test_, a JWT-shaped triple, a 43-char base64url token, a 64-char hex string) with[redacted], so a secret logged under an unexpected key name is still caught. - Request bodies are never logged. Response bodies are never logged. Query strings are logged with values stripped from an allowlist of safe keys.
- Sentry:
sendDefaultPii: false, request bodies off, abeforeSendhook applying the same scrubber to the event'sextra,contexts,breadcrumbs, and exception messages, and a header allowlist ofuser-agent,content-type,x-request-idonly. - A CI test feeds a fixture object containing every secret shape and every PII field through the full logging and error-reporting pipeline and asserts that no original value appears in the output.
- Database query logging in production logs the parameterized statement text only, never the bound parameters, because parameters are where the PII is (Section 23.4).
- Error messages returned to clients use the
publicMessagefield of the error class (Section 4), never a raw exception message, so a stack trace or a driver error can never reach a browser.
22.19 Security Headers #
Every response carries a X-Request-Id (Section 7.4) and the headers below. They are set in one
place — a shared middleware in apps/web with a per-surface profile — so they cannot drift per
route, and a CI test asserts the exact header set for a sample URL from every surface.
"Every surface" is enumerated, not implied, because the surfaces that are easiest to omit are
the ones not served by apps/web at all: the CI test covers app.vouch.app, a request page, the
respondent portal, a showcase page on v.vouch.app, a showcase page on a customer custom
domain, cdn.vouch.app (both a payload and a hashed runtime chunk), the oEmbed endpoint,
and the public REST API. The profiles below are the specification; where a surface is terminated at
the CDN or edge rather than by the application, the edge configuration in Section 26.6 must
reproduce the same profile, and the CI test is what proves it did — headers that exist in this
section and are configured in neither place are the failure mode this paragraph exists to prevent.
22.19.1 Dashboard (app.vouch.app) #
| Header | Value |
|---|---|
Content-Security-Policy |
default-src 'self'; script-src 'self' 'nonce-{RANDOM}' 'strict-dynamic'; style-src 'self' 'nonce-{RANDOM}'; img-src 'self' https://cdn.vouch.app data: blob:; media-src 'self' https://cdn.vouch.app https://stream.mux.com blob:; font-src 'self'; connect-src 'self' https://cdn.vouch.app https://api.stripe.com https://*.ingest.sentry.io https://*.litix.io; frame-src https://js.stripe.com https://hooks.stripe.com; worker-src 'self' blob:; form-action 'self'; frame-ancestors 'none'; base-uri 'none'; object-src 'none'; upgrade-insecure-requests; report-uri /api/v1/csp-report; report-to csp |
Strict-Transport-Security |
max-age=63072000; includeSubDomains; preload |
Referrer-Policy |
strict-origin-when-cross-origin |
Permissions-Policy |
camera=(), microphone=(), geolocation=(), payment=(self "https://js.stripe.com"), usb=(), magnetometer=(), accelerometer=(), gyroscope=(), interest-cohort=(), browsing-topics=() |
X-Content-Type-Options |
nosniff |
X-Frame-Options |
DENY |
Cross-Origin-Opener-Policy |
same-origin |
Cross-Origin-Resource-Policy |
same-origin |
Cache-Control |
private, no-store on every authenticated HTML response |
A per-request nonce with 'strict-dynamic' is used rather than 'unsafe-inline'. There is no
'unsafe-eval' anywhere.
22.19.2 Request pages & the recorder (v.vouch.app/r/*, {slug}.vouch.app) #
| Header | Value |
|---|---|
Content-Security-Policy |
default-src 'self'; script-src 'self' 'nonce-{RANDOM}' 'strict-dynamic'; style-src 'self' 'nonce-{RANDOM}'; img-src 'self' https://cdn.vouch.app data: blob:; media-src 'self' blob: https://cdn.vouch.app; connect-src 'self' https://cdn.vouch.app https://*.amazonaws.com https://*.r2.cloudflarestorage.com https://*.ingest.sentry.io; font-src 'self'; form-action 'self'; frame-ancestors 'self' {verified custom domains}; base-uri 'none'; object-src 'none'; upgrade-insecure-requests; report-uri /api/v1/csp-report — frame-ancestors is composed per request from the workspace's active rows in custom_domains, and is exactly 'self' when there are none |
Permissions-Policy |
camera=(self), microphone=(self), display-capture=(), geolocation=(), payment=(), usb=() — camera and microphone are granted to this origin only, and explicitly denied everywhere else in the product |
Strict-Transport-Security |
max-age=63072000; includeSubDomains; preload |
Referrer-Policy |
strict-origin-when-cross-origin |
X-Content-Type-Options |
nosniff |
Cross-Origin-Opener-Policy |
same-origin-allow-popups |
Cross-Origin-Embedder-Policy |
Not set — it would break the media element's cross-origin poster loading for no gain here |
Cache-Control |
no-store on the page HTML |
connect-src includes the object-storage host because the chunked upload PUTs parts directly to
pre-signed URLs (Section 9.6). The exact host is taken from configuration so a self-hosted or
EU-region deployment sets its own.
Note on framing: embedding is permitted only from an origin the workspace has verified, per
Section 22.17. The allow="camera; microphone" iframe attribute is not a control here and must not
be presented as one — it is granted by the framing page, which in an attack is the attacker. The
verified-origin list is the control; the attribute is only the browser's plumbing.
Two tests hold this in place, and they are part of the header suite named at the top of Section
22.19: a request page served for a workspace with two verified domains emits exactly
frame-ancestors 'self' https://a.example https://b.example, and the same page served for a
workspace with none emits exactly frame-ancestors 'self' — asserted as a string equality on the
directive, not a substring match, because 'self' https: contains 'self' and a substring
assertion would pass on the vulnerable value. A Playwright case then loads the page inside an
unverified attacker origin and asserts the browser refuses the frame.
22.19.3 Respondent portal (v.vouch.app/p/*) #
Identical to the request page profile except:
| Header | Value |
|---|---|
Content-Security-Policy |
Same, but frame-ancestors 'none' and no media-src blob: |
X-Frame-Options |
DENY |
Permissions-Policy |
camera=(), microphone=(), geolocation=(), payment=() |
X-Robots-Tag |
noindex, nofollow, noarchive |
Cache-Control |
private, no-store |
22.19.4 Showcase pages (v.vouch.app/s/* and customer custom domains) #
| Header | Value |
|---|---|
Content-Security-Policy |
default-src 'self'; script-src 'self' 'nonce-{RANDOM}' 'strict-dynamic'; style-src 'self' 'nonce-{RANDOM}'; img-src 'self' https://cdn.vouch.app data:; media-src https://cdn.vouch.app https://stream.mux.com; connect-src 'self' https://cdn.vouch.app; font-src 'self'; frame-ancestors 'self' https:; base-uri 'none'; object-src 'none'; form-action 'none'; upgrade-insecure-requests |
Strict-Transport-Security |
max-age=63072000; includeSubDomains — preload is not sent on customer custom domains, because preloading a domain we do not own is not ours to do |
Referrer-Policy |
strict-origin-when-cross-origin |
Permissions-Policy |
camera=(), microphone=(), geolocation=(), payment=() |
X-Content-Type-Options |
nosniff |
Cross-Origin-Resource-Policy |
cross-origin |
Cache-Control |
public, max-age=60, stale-while-revalidate=300 |
22.19.5 Widget data API & static assets (cdn.vouch.app) #
| Header | Value |
|---|---|
Content-Security-Policy |
default-src 'none'; frame-ancestors 'none'; base-uri 'none'; sandbox — it serves JSON and static files; nothing should ever execute from a response of this origin |
Access-Control-Allow-Origin |
* — the payload is public by definition and must load from any customer site |
Access-Control-Allow-Methods |
GET, OPTIONS |
Access-Control-Allow-Headers |
Content-Type |
Access-Control-Max-Age |
86400 |
Cross-Origin-Resource-Policy |
cross-origin |
X-Content-Type-Options |
nosniff |
Strict-Transport-Security |
max-age=63072000; includeSubDomains; preload |
Cache-Control |
public, max-age=60, stale-while-revalidate=300 for payloads; public, max-age=31536000, immutable for content-hashed runtime chunks; public, max-age=300 for the unversioned e.js loader |
Content-Disposition |
attachment on original media and export archives |
This CSP constrains only documents served from this origin — a directly navigated JSON URL, an asset opened in a tab. It does not constrain the widget bundle when a customer's page loads it as a subresource, because CSP governs the document that loads a script, never the response that carries it. Presenting it as protection for the highest-consequence asset in the system (A7) would be a false assurance; what actually governs the bundle on a customer's page is the customer's own CSP (Section 13.9) and the loader-integrity controls in Section 13.10.4.
No cookie is ever set on cdn.vouch.app, and the origin has no authenticated route, so a
compromise there cannot reach a session.
22.19.6 Public REST API (app.vouch.app/api/v1) #
| Header | Value |
|---|---|
Content-Security-Policy |
default-src 'none'; frame-ancestors 'none' |
Access-Control-Allow-Origin |
Not set — the API is server-to-server with a bearer token; browser CORS access is deliberately not enabled, because an API key in a browser is an exposed API key |
Cache-Control |
private, no-store |
X-Content-Type-Options |
nosniff |
Strict-Transport-Security |
max-age=63072000; includeSubDomains; preload |
CSP violation reports are collected at /api/v1/csp-report, sampled at 5%, rate-limited, and
aggregated by directive and blocked-URI. A new blocked-URI appearing in volume is an alert,
because it is either a regression or an injection.
22.20 Abuse Prevention #
22.20.1 Rate limiting #
Section 7.10 owns the numbers. This section owns the shape: token bucket in Valkey, keyed by
workspace for authenticated API traffic, by IP for submission and portal traffic, and by IP at
the edge for the widget data path. Every 429 carries Retry-After and the
X-RateLimit-{Limit,Remaining,Reset} headers. Limits are per-endpoint-class, not global, so a
noisy analytics beacon cannot starve a submission.
Additional protections layered on top:
- This section owns the failed-sign-in policy, and the policy is progressive delay, never account
lockout. Repeated authentication failures for the same account are delayed server-side by 0 s,
1 s, 2 s, 4 s, then 8 s, capped at 8 s. This slows credential stuffing without handing anyone a
free denial of service: with a per-email lockout, anybody who knows a target's address can keep
them permanently signed out from any IP at zero cost, and it compounds with a shared
authrate limit bucket to block their password reset and 2FA at the same time. There is therefore no lockout state, nolocked_untilbehaviour, and no "account temporarily locked" message anywhere in the product; any other section describing one is the defect. Separately, the auth rate-limit class is split intoauth-signin,auth-reset, andauth-2fasub-buckets (Section 7.10) so that exhausting one cannot starve the other two — otherwise the attacker gets the lockout back through the rate limiter. - A global anomaly rule: more than 100 submissions to one workspace from one /24 in an hour raises a review flag on those submissions and notifies the workspace, without blocking them, because a genuine event (a conference, an office) can look identical.
22.20.2 Account-takeover protections #
| Control | Detail |
|---|---|
| Breached-password check | On signup and on every password change, via a k-anonymity range query (Section 6.5). A matched password is refused with an explanation, not silently accepted |
| Password minimum | 12 characters, no composition rules, no forced rotation — both of which are known to reduce real-world strength |
| TOTP 2FA | Available on every plan; enforceable workspace-wide by the Owner on Growth and Scale (Section 19.2). Recovery codes are single-use, hashed, and re-issuable |
| New-device sign-in notice | Email to the user on a sign-in from an unrecognized device fingerprint (user-agent family + coarse geo from IP, never stored beyond the notice), with a "this wasn't me" link. That link is a GET to a confirmation page that changes nothing on load — it carries a single-use 24-hour token, states that continuing will end every session and force a password reset, and performs the action only on an explicit POST from a button press with the double-submit token (Section 22.17). A mail-security appliance that pre-fetches links on delivery would otherwise lock a user out of their own account with nobody having clicked anything |
| Session invalidation | Changing a password, disabling 2FA, or changing the account email terminates every other session immediately |
| Email change | Requires the current password and confirmation at both the old and new addresses; the old address gets a "your email is being changed" notice with a cancel link valid 72 hours |
| Magic links | Single-use, 15-minute expiry, invalidated on use, bound to the requesting browser by a paired cookie so a forwarded link cannot be used by the recipient of the forward |
| Owner-action step-up | Ownership transfer, workspace deletion, and API-key creation require a password or 2FA re-authentication within the last 15 minutes, regardless of session age |
| Enumeration resistance | Sign-in, password reset, and magic-link request return an identical response and take an equivalent amount of time whether or not the account exists |
| API key display | Shown once at creation. Thereafter only the 12-character prefix, the creation date, the last-used timestamp, and the scopes are visible |
| Session security | __Host- prefix, HttpOnly, Secure, SameSite=Lax, 30-day rolling with a 90-day absolute maximum (Section 6.5); an active-session list in settings with per-session revoke |
22.20.3 Fake and impersonated testimonials #
This is the product's most specific integrity risk: the whole point of a testimonial is that a real person said it, and the platform is a machine for publishing claims about real people.
What the product does:
- Provenance is recorded and displayed, always. Every testimonial carries a
provenancevalue —invited(arrived via a tokenized invitation to a known address),open_link(a public request page),imported_{source}, orapi— shown as a labelled field in the moderation inbox. A moderator always knows how a claim reached them. - Nothing publishes without a human. Every testimonial lands in
pendingand requires an explicit approval action (Section 11.2). There is no auto-publish setting, on any plan, and this is deliberate: an auto-publish toggle would be the single feature most likely to put a fabricated endorsement on a customer's homepage. - Respondent email verification is available per request page and defaults on for invitation-linked pages: the respondent must click a link sent to the address they supplied before the testimonial can be approved. The inbox shows "Email verified" or "Email not verified" as text.
- The portal link is the check on impersonation. Every submission's confirmation email — containing the portal link — goes to the address supplied. A person impersonated by someone using their real email address will receive that email and can take the testimonial down themselves without contacting anybody.
- The AI assistant cannot fabricate. It selects the respondent's own words and nothing else, validated by exact-substring match server-side (Section 12.4). There is no code path in the product that generates testimonial text.
- Editing is audited and constrained. Imported reviews are immutable (Section 16.3). For
collected testimonials, a workspace can correct typography and trim length, and every such
edit writes a before/after audit event; the consent copy in Section 22.2.2 tells the
respondent exactly that, and the portal shows them the current published version so a
divergence is visible to them. A transcript edit is an edit of the respondent's words and is
bound by the same rule: a revision classed
substantiveblocks publication until the respondent re-consents (Section 22.2.2), because captions and the published quote are both generated from the transcript and a moderator retyping a sentence there would otherwise change what a person is recorded as having said. - We do not claim to verify identity, and the product never displays a "verified" badge that implies we did. Marketing copy for the product is constrained not to claim identity verification. Overclaiming here would be worse than the gap.
22.20.4 Platform-abuse reporting #
- A "Report this testimonial" link is present on every showcase page footer and in the
respondent portal, routing to
abuse@vouch.appwith the testimonial id pre-filled. The widget does not carry a report link, because it renders on a customer's page and an abuse-report affordance there would be a UI we impose on their site; the showcase page and the portal cover the need. - Report categories: impersonation, harassment or abusive content, spam, copyright, illegal content, "this is about me and I didn't consent".
- The last category is the priority path: a report claiming non-consensual publication of a person's likeness or words is triaged within 24 hours and, if credible on its face, the testimonial is unpublished pending investigation. Unpublishing is reversible; leaving a non-consensual video of somebody up for a week is not.
- Other categories are triaged within 3 business days.
- Repeated confirmed abuse by a workspace escalates: warning, then suspension of new collection, then account termination with data export offered. Termination for abuse is the only circumstance besides an explicit customer request in which we stop serving published widgets, and it requires a documented decision by two people.
- A public transparency note in
docs/abuse.mdstates the categories, the SLAs, and the escalation path, so customers and respondents both know what to expect.
22.21 Compliance Posture, Vulnerability Management & Disclosure #
22.21.1 SOC 2 — a trajectory, stated honestly #
We do not claim SOC 2 compliance until a report exists. Claiming it early is both untrue and counterproductive, because the first enterprise prospect will ask for the report. What we publish is the trajectory, with what is already in place:
| Stage | What it means | What is already true |
|---|---|---|
| Now — controls in place | The technical controls a Type I would examine already exist and are enforced in code | Access control (Section 6), audit logging with 7-year retention (Section 22.7), encryption at rest and in transit (Section 22.18), change management via protected main and required review (Section 4), backup and restore with tested procedures (Section 24), vendor management via the sub-processor register (Section 22.9), incident response (Section 22.10.3), and this document as the security policy baseline |
| +3 months — policy set | Written policies covering information security, access control, change management, incident response, business continuity, vendor management, secure development, and acceptable use, all version-controlled in docs/policies/ and reviewed annually |
Drafted from the controls above rather than written aspirationally |
| +6 months — Type I | A point-in-time attestation that the controls are designed appropriately | Scope: Security and Availability trust services criteria. Confidentiality added if a customer requires it |
| +12 months — Type II | An observation-period attestation that the controls operated effectively | 6-month observation window minimum |
| Ongoing | Annual Type II, continuous control monitoring | Evidence collection is automated where possible: access reviews from the database, change records from git, incident records from the incident tool |
Until a report exists, security questionnaires are answered with this document, the sub-processor register, the pentest summary, and a completed CAIQ-Lite — not with a claim.
The audit-log retention that supports it: audit_events is append-only (enforced by a
Postgres rule and by revoking UPDATE and DELETE from the application role), retained 7 years,
partitioned by year, archived to immutable cold storage after 2 years, and covers every status
transition, content edit, tag change, consent change, permission change, API key lifecycle event,
billing state change, and operator action — with actor id, actor type, before/after snapshot, IP,
user agent, and timestamp (Section 11.6).
22.21.2 Vulnerability management & patch SLAs #
Severity is assigned from CVSS v3.1 base score adjusted for exploitability in our deployment — a critical CVE in a code path we do not execute is downgraded with a written justification, and a medium CVE reachable from an unauthenticated endpoint is upgraded.
| Severity | Definition | Triage | Patch/mitigate in production |
|---|---|---|---|
| Critical | Remote code execution, authentication bypass, cross-tenant data access, or anything affecting the widget serving path (asset A7) | 4 hours | 24 hours, out-of-band release if needed |
| High | Privilege escalation, significant data exposure, stored XSS on a surface we serve | 1 business day | 7 days |
| Medium | Limited-impact issues requiring unusual preconditions | 3 business days | 30 days |
| Low | Defence-in-depth gaps, informational findings | 5 business days | 90 days, or accepted with a written, dated risk acceptance |
An SLA that will be missed is escalated before it is missed, with a compensating control and a new date recorded — a silently missed SLA is worse than a renegotiated one.
Dependency scanning:
- Automated dependency updates open pull requests continuously; patch and minor updates with green CI are merged automatically, and majors are batched into a scheduled maintenance PR.
pnpm auditruns on every CI run and nightly onmain. High and critical advisories fail the build; a documented, expiring exception (maximum 30 days, requiring a named owner) is the only way past it.- The lockfile is committed, and CI installs with a frozen lockfile so a build can never resolve an unreviewed version.
- A Software Bill of Materials (CycloneDX) is generated at every release and retained with the release artifact.
- Container images are scanned for OS-level CVEs on build and re-scanned weekly, because a base image that was clean at build time is not clean forever. Base images are rebuilt monthly regardless of whether application code changed.
- The widget's dependency graph is held to zero runtime dependencies. The bundle contains
only our own code (Section 13.5), which makes supply-chain risk on the highest-consequence
asset structurally near-zero rather than managed. A CI assertion fails the build if the widget
bundle's module graph contains any
node_modulespath. - Static analysis (CodeQL-class) and secret scanning run on every pull request and nightly on the full history.
22.21.3 Penetration testing #
| Test | Cadence | Scope |
|---|---|---|
| Full external penetration test by an independent firm | Annually, and before the first major release | Dashboard, request page and recorder, respondent portal, public REST API, widget serving path, custom-domain handling, multi-tenancy isolation |
| Targeted retest of findings | Within 30 days of remediation | The specific findings |
| Multi-tenancy isolation test | Quarterly, internal, automated plus manual | Attempt cross-tenant read and write for every resource type as every role, with RLS both enabled and (in a scratch environment) disabled, to prove RLS is doing the work rather than the application's WHERE clauses |
| Widget supply-chain review | Every release of the loader or runtime | Line-by-line review of the loader diff, bundle hash verification against the CI-recorded hash, and a check that the deployed asset matches the tagged commit |
| Continuous automated scanning (DAST) | Nightly against staging | OWASP-style crawl and active scan |
A summary letter from the annual test — findings by severity, remediation status, and the tester's identity — is available to customers under NDA. Critical and high findings block the next release.
22.21.4 Responsible disclosure #
Published at https://vouch.app/.well-known/security.txt and at https://v.vouch.app/.well-known/security.txt:
Contact: mailto:security@vouch.app
Contact: https://vouch.app/security
Expires: 2027-01-01T00:00:00.000Z
Encryption: https://vouch.app/.well-known/pgp-key.txt
Acknowledgments: https://vouch.app/security/thanks
Preferred-Languages: en
Canonical: https://vouch.app/.well-known/security.txt
Policy: https://vouch.app/security/policyThe policy, stated plainly:
- Safe harbour. Good-faith research conducted under this policy will not be pursued legally and will not result in an account being terminated. This is stated first because researchers read it first.
- Scope: all our own hosts, the widget bundle, the public API, and the respondent portal. Customer custom domains are in scope only for issues in our handling of them.
- Out of scope: denial-of-service testing, social engineering of staff or customers, physical attacks, spam or automated-scanner output with no demonstrated impact, missing headers with no demonstrated exploit, and anything requiring access to another person's device.
- Rules: use your own test workspace; never access, modify, or exfiltrate another party's data — if you can demonstrate access, stop and tell us rather than proving the scale; do not publish before we have fixed it or 90 days have passed, whichever is first.
- Our commitments: acknowledge within 2 business days, provide a triage decision with a severity within 5 business days, keep the reporter updated at least every 14 days until resolution, credit the reporter publicly if they want it, and never ask for silence as a condition of anything.
- Rewards: we operate a discretionary reward for valid findings, scaled to severity, and we say clearly that we are a small product with a modest budget rather than implying a bug-bounty-scale programme.
- A private, monitored
security@vouch.appinbox with a PGP key, separate from support, paging the on-call engineer for anything triaged critical.
22.21.5 Secure development #
- Every change goes through a pull request on a protected
mainwith at least one review (Section 4). Direct pushes tomainare impossible. - The CI pipeline is the gate: type check, lint, unit and integration tests, E2E, axe accessibility gate (Section 21.13.1), performance budget gate (Section 23.2), secret scan, dependency audit, static analysis, and bundle-size limits. All must pass.
- A security review checklist is required on any PR touching authentication, authorization, RLS policies, the widget bundle, file upload, outbound HTTP, cryptography, or the billing webhook. The checklist is a PR template section, not a wiki page nobody opens.
- Migrations are reviewed for RLS coverage specifically: a new workspace-scoped table without an enabled policy fails an automated check, not just review.
- Production access is role-based, requires 2FA, is logged, and is time-limited; standing production database access is not granted to anyone. Break-glass access requires a recorded justification and is reviewed weekly.
- Quarterly access reviews cover the cloud provider, the database, Stripe, the CDN, the code
host, and every third-party console, with the review recorded in
docs/security/access-reviews/.
23. Performance Budgets & Non-Functional Requirements #
23.1 How Budgets Work Here #
A performance budget that is not measured in CI is a wish. Every number in this section has three things attached: a target, a measurement method, and a gate — what happens when it is missed. Three gate levels are used:
| Gate | Meaning |
|---|---|
| Hard fail | The CI job fails and the pull request cannot merge. Used where a regression would be visible on a third party's website or would break a promise in Section 19.4 E3 |
| Soft fail | CI posts a comment with the regression and the PR requires an explicit acknowledgement from a reviewer to merge. Used where variance is real and a single run should not block work |
| Alert | Not gated in CI; monitored in production with an alert threshold and an owner. Used for numbers only observable at real scale |
Two measurement contexts are used consistently and must not be conflated:
- Lab — Lighthouse CI and Playwright on a fixed CI machine, with a throttled 4G profile (1.6 Mbps down, 750 Kbps up, 150 ms RTT) and 4× CPU slowdown, emulating a mid-tier Android device. Every "throttled 4G mid-tier mobile" figure in this section means exactly this. Every lab run starts from a cold HTTP cache and a fresh browser profile unless the row explicitly says "warm".
- Field — real-user monitoring from actual traffic, reported as p50/p75/p95/p99 over a 28-day rolling window, segmented by surface and by device class.
The sample-size rule. This subsection defines it, it is binding on every numeric budget in this document, and Section 13.3's widget budgets cite it rather than restating it:
| Statistic the row quotes | Lab runs required | How the budget is checked |
|---|---|---|
| Median | 5 | The median of all 5 runs is compared against the budget |
| Percentile (p75, p95, p99) | 21 | That percentile, computed over all 21 runs, is compared against the budget |
Two properties matter more than the specific numbers. First, the statistic is computed over the whole sample — no trimming of high and low runs. Trimming plus a percentile is two outlier controls stacked on each other, and unless the trim is specified as precisely as the percentile it just moves the ambiguity somewhere less visible; the run count is the outlier control. Second, the sample size is a property of the statistic, not of the engineer's patience. A p75 computed from three runs is not a percentile, it is a rounding of three numbers, and two people measuring identical code with different run counts would get different pass/fail verdicts — which would make every number in this section advisory rather than binding. Where a row does not name its statistic, it is a median and takes 5 runs; where a row names a percentile, it takes 21.
Tool versions are pinned, in .tool-versions and recorded in every report: the Chrome build,
the Lighthouse version, and the Playwright version. A budget compared across two Chrome majors is
comparing two different measurements.
Where a Core Web Vital is quoted as a field target it is the p75, matching how the metric is defined. Lab numbers are medians unless the row says otherwise. Mixing the two is the most common way a performance budget becomes meaningless.
23.2 Per-Surface Budgets #
23.2.1 The widget — summary only #
Section 13.3 owns the widget's performance budget table in full and is canonical. It is not reproduced here, because two copies of a table diverge. The aggregate that this section asserts, and that the CI gate checks, is one row:
| Metric | Budget | Measurement | Gate |
|---|---|---|---|
| Total JavaScript the widget adds to a host page | ≤ 27 KB gzip (loader ≤ 5 KB + lazy runtime ≤ 22 KB) | size-limit on the built bundles in CI |
Hard fail |
Everything else about the widget's budget — render-blocking resources, CLS and INP contribution, LCP delta against a widget-free control, long tasks, request count before first paint, web fonts, framework bytes, and time to first paint on throttled 4G — is specified with its own target, method, and gate in Section 13.3, and every one of those is a hard CI gate. The reason the widget is held to the strictest budget in the product is that it executes on other people's websites: a regression there degrades a page we do not own and cannot fix on their behalf.
23.2.2 Request page and recorder #
The respondent is on a phone, on mobile data, often prompted by a link in an email, and has no investment in getting through a slow page. Every second of load is measurable drop-off.
| Metric | Target | Measurement | Gate |
|---|---|---|---|
| Total JS, initial (before the recorder is activated) | ≤ 140 KB gzip | size-limit on the route bundle |
Hard fail |
| Total JS including the recorder chunk | ≤ 200 KB gzip | size-limit |
Hard fail |
| Total CSS | ≤ 18 KB gzip | size-limit |
Hard fail |
| Web fonts | ≤ 1 family, ≤ 2 weights, ≤ 60 KB total, font-display: swap, subset to Latin + Latin-1 Supplement |
Request-log assertion in Playwright | Hard fail |
| LCP, throttled 4G mid-tier mobile | ≤ 2.0 s (lab median); ≤ 2.5 s field p75 | Lighthouse CI; RUM | Hard fail (lab), Alert (field) |
| First Contentful Paint | ≤ 1.2 s lab | Lighthouse CI | Hard fail |
| First meaningful paint — the prompt text and the primary action are both readable | ≤ 1.5 s lab | Playwright element-visible timestamp assertion | Hard fail |
| INP | ≤ 200 ms field p75; ≤ 150 ms lab | Lighthouse CI; RUM | Soft fail (lab), Alert (field) |
| CLS | ≤ 0.1 field p75; ≤ 0.05 lab | Lighthouse CI; RUM | Hard fail (lab) |
| TTFB | ≤ 400 ms p95 (page is edge-rendered where cacheable) | RUM; synthetic | Alert |
| Total transferred bytes, first load, no media | ≤ 320 KB | Playwright request log | Hard fail |
| Number of requests before FCP | ≤ 6 | Playwright request log | Soft fail |
| Third-party requests on the page | 0 before the recorder is activated | Playwright request log — no analytics vendor, no font CDN, no tag manager | Hard fail |
| Time to camera ready — from activating "Allow camera and microphone" to a live preview frame, excluding the time the user spends in the browser permission prompt | ≤ 800 ms p75 on a mid-tier device | Playwright with a fake media device, measuring from click to first requestVideoFrameCallback; RUM timing in production |
Hard fail (lab), Alert (field) |
| Recorder chunk load time | ≤ 600 ms on throttled 4G, and it is prefetched on requestIdleCallback after FCP so it is usually already cached |
Lighthouse CI | Soft fail |
| Time from "Stop" to a playable review | ≤ 300 ms (the local blob is used; no round trip) | Playwright | Hard fail |
| Upload completion after Stop, 2-minute 1080p take, 4G | ≤ 8 s p75 — because chunked upload means ~95% is already transferred (Section 9.6) | Integration test with a throttled network; RUM | Alert |
| Main-thread long tasks during recording | 0 tasks > 50 ms | Playwright PerformanceObserver |
Hard fail |
| Dropped frames during recording | < 1% of expected frames | Playwright with getStats on a fixed-length take |
Soft fail |
| Memory growth over a 5-minute take | ≤ 150 MB heap growth | Playwright heap sampling | Soft fail |
Design consequences of the 140 KB figure, stated so a future developer does not treat it as
aspirational: the request page ships no charting library, no date library beyond Intl, no icon
font, no animation library, and no component library beyond the small shared layer; the recorder,
the Mux player, and the transcript preview are all dynamically imported; and the page is
server-rendered with the prompt, branding, and consent text in the initial HTML so it is readable
before any JavaScript executes.
23.2.3 Showcase page #
Public, crawlable, often linked from a customer's navigation, and on a custom domain it is the customer's page.
| Metric | Target | Measurement | Gate |
|---|---|---|---|
| Total JS | ≤ 45 KB gzip (progressive enhancement only — the page is fully readable with JavaScript disabled) | size-limit |
Hard fail |
| LCP, throttled 4G mobile | ≤ 1.8 s lab; ≤ 2.5 s field p75 | Lighthouse CI; RUM | Hard fail (lab) |
| CLS | ≤ 0.05 lab; ≤ 0.1 field p75 | Lighthouse CI | Hard fail |
| INP | ≤ 200 ms field p75 | RUM | Alert |
| TTFB, cache hit at edge | ≤ 80 ms p95 | Synthetic from 5 regions | Alert |
| TTFB, cache miss to origin | ≤ 400 ms p95 | Synthetic | Alert |
| Edge cache hit ratio | ≥ 95% for showcase HTML | CDN analytics | Alert. This is the showcase-page figure; the widget-data figure in Section 23.2.5 is separate and higher |
| Lighthouse Performance score | ≥ 95 | Lighthouse CI | Hard fail below 90 |
| Lighthouse SEO score | ≥ 95 | Lighthouse CI | Soft fail |
| Images | AVIF with WebP fallback, responsive srcset, explicit width/height, loading="lazy" below the fold |
Playwright markup assertion | Hard fail |
| Video | Never loaded until interaction; poster only (Section 13.7.1) | Playwright request log | Hard fail |
| Server-rendered testimonial content in the initial HTML | 100% of the first page of items | Playwright HTML assertion on a JS-disabled context | Hard fail |
23.2.4 Dashboard #
The dashboard is authenticated, used repeatedly by the same people, and can tolerate more weight than the public surfaces — but "more" is not "unbounded".
| Metric | Target | Measurement | Gate |
|---|---|---|---|
| Initial JS for the app shell | ≤ 220 KB gzip | size-limit |
Hard fail |
| Largest route chunk | ≤ 120 KB gzip | size-limit |
Hard fail |
| Total JS on the heaviest route (moderation inbox with media previews) | ≤ 400 KB gzip | size-limit |
Hard fail |
| LCP, cable/desktop | ≤ 1.5 s lab | Lighthouse CI | Soft fail |
| LCP, throttled 4G mobile | ≤ 3.0 s lab | Lighthouse CI | Soft fail |
| INP | ≤ 200 ms field p75 | RUM | Alert |
| CLS | ≤ 0.1 | Lighthouse CI | Hard fail |
| Client-side route transition | ≤ 200 ms to first meaningful content, with a skeleton within 100 ms | Playwright | Soft fail |
| Moderation inbox: time to interactive with 50 items | ≤ 1.5 s on desktop | Playwright | Soft fail |
| Moderation inbox: list virtualization threshold | Lists over 100 rows are virtualized | Code review + a render-count assertion | Hard fail |
| Transcript editor: keystroke-to-paint on a 5,000-word transcript | ≤ 16 ms p95 | Playwright with an input-latency harness | Hard fail |
| Analytics view: chart render with 365 daily points | ≤ 400 ms | Playwright | Soft fail |
| Memory after 30 minutes of heavy use | ≤ 350 MB heap, and no monotonic growth across the last three cycles of the sequence | A Playwright + CDP heap-snapshot run over a named, versioned interaction sequence (fixtures/dashboard-soak.ts: 30 cycles of — open inbox, scroll 200 rows, open a testimonial, play 10 s of video, tag it, approve it, open analytics, change the date range, return to inbox), snapshotting after every fifth cycle with a forced GC |
Soft fail |
23.2.5 Widget data API #
The single most latency-sensitive path in the product, because it blocks the first paint of a component on somebody else's page. Section 13.8.6 owns its caching semantics; this section owns its numbers.
| Metric | Target | Measurement | Gate |
|---|---|---|---|
| Edge, cache hit — p50 | ≤ 15 ms | Synthetic probes from 8 regions, every 60 s | Alert |
| Edge, cache hit — p95 | ≤ 40 ms | Same | Alert |
| Edge, cache hit — p99 | ≤ 90 ms | Same | Alert |
| Origin, cache miss — p50 | ≤ 45 ms | Server-side histogram | Alert |
| Origin, cache miss — p95 | ≤ 120 ms | Server-side histogram | Hard fail in the load test (Section 23.9) |
| Origin, cache miss — p99 | ≤ 300 ms | Server-side histogram | Alert |
| Edge cache hit ratio, widget data | ≥ 98% target | CDN analytics | Alert below 95%; below 90% is an incident. Three numbers with three meanings — 98 is what a healthy system does, 95 is when somebody looks, 90 is when somebody is paged. Any other section quoting a single cache-hit figure should cite which of the three it means |
| Payload size, 24-item wall with media | ≤ 60 KB uncompressed, ≤ 14 KB brotli | Assertion in CI against a seeded fixture | Hard fail |
Payload size, batched multi-widget request (?ids=…) |
≤ 120 KB uncompressed for 4 widgets | Same | Hard fail |
Time from publish action to purged-and-fresh at every edge |
≤ 60 s p95 | Synthetic: publish, then poll edges until the item appears | Alert. This number is also the withdrawal guarantee in Section 22.4.5 |
| Availability | 99.99% (Section 23.6) | Synthetic + edge logs | Alert |
The origin path for a cache miss does exactly one thing: read a pre-rendered payload row keyed by widget id and serve it. The payload is materialized at publish/unpublish/edit time by a job, not assembled per request. There is no join, no aggregation, and no entitlement lookup on this path (Section 19.4 E3), which is what makes both the latency target and the availability target achievable.
23.2.6 Public REST API #
| Endpoint class | p50 | p95 | p99 | Notes |
|---|---|---|---|---|
Single-resource read (GET /api/v1/testimonials/{id}) |
≤ 40 ms | ≤ 120 ms | ≤ 250 ms | Indexed primary-key read within an RLS transaction |
Collection read, 25 items (GET /api/v1/testimonials) |
≤ 60 ms | ≤ 180 ms | ≤ 400 ms | Cursor pagination only (Section 7.4); the query is index-only on (workspace_id, created_at DESC, id DESC) |
| Collection read with filters (tag, status, rating) | ≤ 80 ms | ≤ 220 ms | ≤ 500 ms | Composite indexes leading with workspace_id |
| Full-text search | ≤ 120 ms | ≤ 350 ms | ≤ 800 ms | GIN index on the tsvector column |
Simple write (PATCH, tag add/remove) |
≤ 80 ms | ≤ 250 ms | ≤ 500 ms | Includes the audit-event write in the same transaction |
Write with side effects (publish) |
≤ 120 ms | ≤ 350 ms | ≤ 700 ms | Enqueues the payload rebuild and purge; does not wait for them |
Async-initiating write (POST /testimonials with media) |
≤ 150 ms to 202 | ≤ 400 ms | ≤ 900 ms | Returns 202 with a job reference |
| Analytics aggregate read | ≤ 150 ms | ≤ 450 ms | ≤ 1,000 ms | Served from rollup tables, never from raw events |
| Auth/token validation overhead, included in every figure above | ≤ 4 ms | ≤ 10 ms | ≤ 25 ms | Key hash lookup is cached in Valkey for 60 s |
Method and gate for every row above, because a number with neither is decoration: each figure
is a server-side histogram over a trailing 5-minute window, origin-measured, tagged by route,
exported to the metrics backend. The p95 column is a hard fail in the load-test scenarios in
Section 23.9; p50 and p99 are alerting thresholds with the owner named in Section 24.7. These are
origin-measured server timings, excluding client network. Every response carries a Server-Timing
header with db, cache, and total so a customer debugging latency has the same numbers we do.
23.2.7 Analytics beacon endpoint #
POST https://cdn.vouch.app/api/v1/e — the highest-volume endpoint in the system by request
count, and the one whose failure must be least consequential.
| Metric | Target | Measurement | Gate |
|---|---|---|---|
| p50 response time at edge | ≤ 8 ms | Edge logs | Alert |
| p95 | ≤ 25 ms | Edge logs | Alert |
| p99 | ≤ 60 ms | Edge logs | Alert |
| Response | 204 No Content, empty body, no cookies, no Set-Cookie |
Contract test | Hard fail |
| Processing model | Accept, validate shape, enqueue, return. No database write on the request path | Code review + a test asserting the handler performs no DB call | Hard fail |
| Beacon payload size | ≤ 32 KB per beacon — the cap owned by Section 17.4, chosen against the keepalive 64 KB ceiling — with ≤ 50 events per beacon and a typical real beacon under 2 KB; batched, max one beacon per 5 s per page |
Widget-side assertion against the 32 KB cap; the sub-2 KB typical figure is reported, not gated | Hard fail |
| Effect of endpoint failure on the host page | Zero. sendBeacon failures are silent, unretried, and never block or delay anything |
Playwright test with the endpoint returning 500, asserting the widget renders and behaves identically | Hard fail |
| Ingest throughput | 10,000 events/second sustained per region | Load test (Section 23.9) | Hard fail in the load test |
23.3 Backend Latency Budgets by Endpoint Class #
Applies to internal dashboard endpoints and server actions as well as the public API. Measured at the origin, excluding client network, as p95 unless stated.
Method and gate, applying to every row in this table: a server-side histogram over a trailing 5-minute window, origin-measured and tagged by route or server-action name; hard fail in the load-test scenarios of Section 23.9, and an alert in production at the same threshold. A budget that no test can fail and no alert can fire on is a comment.
| Class | Budget (p95) | Examples |
|---|---|---|
| Cached read (Valkey hit) | 15 ms | Workspace capabilities, feature flags, API-key validation |
| Simple indexed read | 120 ms | Fetch a testimonial, a widget config, a request page |
| Paginated list | 180 ms | Moderation inbox page, testimonial list, invoice list |
| Filtered/aggregated read | 450 ms | Analytics dashboard panel, usage meters |
| Simple write | 250 ms | Approve, tag, rename, reorder |
| Transactional write with side effects | 350 ms | Publish, unpublish, invite send |
| Write initiating async work | 400 ms to 202 | Submit a testimonial, start an import, request an export |
| Third-party synchronous call | 2,000 ms hard timeout, with a circuit breaker at 5 consecutive failures | Stripe Checkout session creation, Mux direct-upload creation |
| Auth flows | 500 ms — argon2id is deliberately slow, and that cost is intentional | Sign-in, password change |
| Webhook receipt (ours, inbound) | 100 ms to 200 — verify signature, insert, enqueue, return (Section 19.7.3) | Stripe, Mux |
Rules that make these achievable rather than aspirational:
- No N+1 queries. A test harness runs every list endpoint against a seeded fixture and asserts the query count is constant as the row count grows from 1 to 100. A growing count fails the build.
- No unbounded queries. Every query has a
LIMIT. Cursor pagination with a maximum of 100 (Section 7.4) is the only pagination model, so there is no deep-offset scan anywhere. - Statement timeout of 5 s on the web application role and 30 s on the worker role, set at the role level so it cannot be forgotten per query.
- Connection pooling through a transaction-mode pooler, sized in Section 23.8. Every request
opens exactly one transaction (required anyway by the RLS
SET LOCALin Section 6.2), so connection churn is bounded. - Third-party calls never happen inside a database transaction. A test asserts this structurally by failing if the HTTP client is invoked while a transaction is open.
- Every response carries
Server-Timing, and every log line carries the request's total duration and its database time, so a latency regression is attributable without a profiler.
23.4 Database Query Budgets & Slow-Query Alerting #
| Query class | Budget | Notes |
|---|---|---|
| Primary-key or unique-index lookup | 2 ms | |
| Workspace-scoped indexed list, ≤ 100 rows | 15 ms | Composite index leading with workspace_id (Section 5.13) |
| Workspace-scoped filtered list with a join | 40 ms | |
| Full-text search within a workspace | 80 ms | |
| Analytics rollup read | 120 ms | Never touches raw events |
| Aggregate across a workspace's whole history | 250 ms | Pre-aggregated where it would otherwise exceed this |
| Any write | 20 ms | Excluding lock wait |
| Any query in a request path | 500 ms absolute ceiling | Beyond this the statement timeout is the backstop at 5 s, but 500 ms is the alerting threshold |
| Any query in a worker path | 5,000 ms | Batch operations are chunked rather than run long |
The slow-query rule, stated as an invariant:
Any query that exceeds its class budget in production emits a structured warning log containing the parameterized SQL text, the query class, the duration, the row count, the request id, the workspace id, and the route or job name. Any query exceeding 500 ms in a request path additionally raises an alert to the engineering channel with the same payload plus the
EXPLAIN (ANALYZE, BUFFERS)plan captured on a follow-up run against a replica.
- Bound parameters are never logged — the SQL text is the parameterized statement with
$1,$2placeholders, because the parameters are where the personal data is (Section 22.18.4). The workspace id is logged because it is an internal identifier, not personal data, and it is what makes the alert actionable. pg_stat_statementsis enabled, and a weekly report ranks statements by total time, mean time, and call count. The top 20 by total time are reviewed; anything new in that list is investigated.auto_explainis enabled withlog_min_duration = 500msandlog_analyze = onon the replica used for the follow-up capture, not on the primary, so plan capture never adds latency to a live request.- Missing-index detection: a monthly job compares sequential-scan counts per table against index-scan counts and reports any workspace-scoped table where sequential scans exceed 1% of scans. Every such table is either given an index or given a written justification.
- Index requirements are asserted in tests: a migration test runs
EXPLAINon every first-class query inpackages/db/queriesagainst a seeded database of 100,000 testimonials across 500 workspaces and fails if any plan contains a sequential scan on a table over 10,000 rows. - Lock contention: long-running DDL uses
CONCURRENTLYfor index creation, and every migration is reviewed for lock class. A migration acquiringACCESS EXCLUSIVEon a table over 1 million rows must be split or scheduled, and the migration template asks the question explicitly.
23.5 Throughput & Scale Targets #
The launch-scale envelope the system must handle without re-architecture:
| Dimension | Target | Notes |
|---|---|---|
| Workspaces | 10,000 | Of which ~70% Free, based on a typical mix |
| Published testimonials | 2,000,000 | ~200 per workspace at the high end; the median workspace has far fewer |
| Total testimonials including all statuses | 3,000,000 | |
| Widget impressions per month | 500,000,000 | ~190 requests/second average, ~1,900/second at peak |
| Widget data API requests per month | 25,000,000 | Impressions vastly exceed payload fetches because of the 60 s CDN TTL and the batched multi-widget fetch (Section 13.8.3) |
| Analytics beacons per month | 150,000,000 | Batched at one per 5 s per page |
| Video minutes stored | 400,000 | |
| Video delivery minutes per month | 5,000,000 | |
| Transcription minutes per month | 150,000 | |
| Media stored | 40 TB | |
| Dashboard monthly active users | 25,000 | |
| Peak dashboard concurrent sessions | 1,200 | Concentrated in US and European business hours |
| Submissions per day | 20,000 | With bursty peaks — a customer emailing 5,000 people at once is a normal Tuesday |
| Peak submission burst | 200/minute sustained for 10 minutes | |
| Concurrent video uploads | 300 | |
| Background jobs per day | 2,000,000 | Dominated by analytics rollups and webhook deliveries |
| Public API requests per month | 20,000,000 |
Peak concurrency assumptions: traffic is not uniform. The widget data path peaks with our customers' own marketing traffic, which clusters on weekday mornings in each region; the dashboard peaks in business hours; submissions peak within two hours of a customer's invitation email send. The system is sized for a 10× burst over the 5-minute rolling average on the widget path and a 20× burst on the submission path, because an invitation campaign is a step function.
23.5.1 Where the first bottleneck appears #
Named in order, with the trigger and the mitigation, so that the first person to hit one does not have to rediscover it:
| # | Bottleneck | Appears at | Symptom | Named mitigation |
|---|---|---|---|---|
| 1 | Widget data API origin, on a cache-miss storm — a workspace publishes, purging workspace:{id} across every edge, and every subsequent request for every one of its widgets misses simultaneously |
A workspace with 200+ widgets and high traffic | Origin p95 rises; CPU spikes on the web tier | Purge at the widget:{id} granularity rather than workspace:{id} wherever the change affects one widget; stale-while-revalidate 300 s means edges serve stale while one request revalidates; add a short origin-side singleflight so N concurrent misses for the same widget produce one payload read. This is the first thing that will break and the mitigation is already in the design |
| 2 | Analytics ingest write amplification | ~5,000 events/second sustained | Queue depth grows; rollups fall behind | Beacons are already batched client-side and enqueued rather than written on the request path; next step is batch-insert with COPY in the consumer at 1,000 rows per statement, then partition analytics_events_raw by hour rather than day |
| 3 | Analytics rollup jobs contending with live reads on the primary | ~50M events/month | Dashboard analytics latency rises | Move all rollup reads to the read replica; the aggregation writes go to rollup tables only, which are not on any hot read path |
| 4 | Postgres connection exhaustion | ~800 concurrent dashboard sessions with a naive pool | too many connections errors |
Transaction-mode connection pooler in front of Postgres; application pool sized to the pooler, not to Postgres; every request is one short transaction (Section 23.3) |
| 5 | Object-storage egress cost and latency for posters | ~40 TB stored | Cost, then latency on poster loads | Posters are already cached to cdn. and served from the CDN rather than from Mux or from storage directly (Section 9.8); add an image-derivative cache with long-lived immutable URLs |
| 6 | BullMQ single-queue head-of-line blocking — a 10,000-row import starves webhook delivery | First large import | Webhook delivery latency breaches its budget | Separate queues per job class with separate concurrency, already the design (Section 24); priority lanes for cdn.purge and gdpr.erase; imports are chunked into 200-row jobs so no single job is long |
| 7 | Transcription provider rate limits | ~500 concurrent transcriptions | Jobs retry and back off | Per-provider concurrency cap in the worker, a queue that respects it, and the documented alternative provider behind the same interface (Section 10.2) |
| 8 | Testimonial table growth affecting list queries | ~3M rows | List p95 creeps up | Composite indexes lead with workspace_id, so per-tenant queries stay proportional to tenant size, not table size. Beyond 10M rows, partition testimonials by workspace_id hash — the schema is already compatible because every query is workspace-scoped |
| 9 | Single-region latency for globally distributed widget traffic | Immediately for non-US visitors, mitigated by the CDN | Origin latency for cache misses from distant regions | Already mitigated by edge caching at ≥98% hit ratio; beyond that, a read-only origin replica per region serving the pre-rendered payloads |
23.6 Availability Targets & Error Budgets #
The asymmetry is deliberate and is the point. The widget data path and showcase pages run on other people's websites. When they fail, a customer's marketing page has a hole in it, and the customer cannot fix it. The dashboard, by contrast, is a tool our own customers use; an outage is an inconvenience they can wait out. We therefore spend our reliability budget where the blast radius lands on someone who did not choose us.
| Surface | Availability target | Monthly error budget | Why this number |
|---|---|---|---|
Widget loader (e.js) and runtime chunk |
99.99% | 4 m 23 s | Static assets on the CDN. A failure means a blank space on a customer's page |
| Widget data API (edge) | 99.99% | 4 m 23 s | Same reasoning. Achievable because it is a cached static payload with no database on the hot path |
| Showcase pages | 99.95% | 21 m 54 s | Often a customer's own domain and sometimes linked from their navigation. Slightly lower than the widget because a page can be retried by a human, whereas an embedded widget just silently does not appear |
| Analytics beacon ingest | 99.9% | 43 m 50 s | Failure is invisible to the host page by design (Section 23.2.7); losing a few minutes of impression counts is not a customer-visible harm |
| Request pages and the recorder | 99.9% | 43 m 50 s | A respondent hitting an outage may never come back, so this is high — but a respondent can retry, and the invitation link is durable |
| Public REST API | 99.9% | 43 m 50 s | Integrations retry |
| Dashboard | 99.5% | 3 h 39 m | Deliberately the lowest. Deploys, migrations, and maintenance are absorbed here rather than on the public path |
| Background job processing | 99.5% of jobs complete within their budget (Section 23.7) | — | Jobs are retried; lateness matters more than availability |
| Webhook delivery | 99.9% of events delivered within nine attempts — the first send plus eight retries (Section 18.5) | — |
Error budget policy:
- "Up" is defined, because this system deliberately serves stale cached content during an origin
outage and an undefined "up" would let that count as success everywhere. A probe counts as up
only when the response is fresh (
Age<max-age) or the surface's stated degraded-mode contract in the table below is being met. Stale-while-revalidate serving therefore counts as up for the widget loader, the widget data API, and showcase pages — that is exactly the resilience the design bought — and as down for the dashboard and the public REST API, where stale data is a wrong answer rather than a resilient one. - Availability is measured from synthetic probes in 8 regions at 60-second intervals plus server-side success ratios, whichever is worse. Measuring only from our own servers would miss exactly the failures that matter.
- Burning 50% of a surface's monthly error budget freezes non-essential deploys to that surface until a written analysis is complete.
- Burning 100% freezes all deploys to that surface except reliability fixes for the remainder of the month, and reliability work takes priority over feature work in the next planning cycle.
- The widget and showcase budgets are reported weekly regardless of consumption, because a four-minute budget is exhausted by a single bad deploy and needs attention before it is spent.
- Planned maintenance counts against the budget. A maintenance window is not free; if we need one on the widget path, that is a design failure, and the budget accounting is what forces us to say so. The widget path is architected to have no maintenance window: it serves static payloads from a CDN, and origin deploys are rolling.
- Degradation modes are defined, so a partial outage does not become a total one:
| Failure | Degraded behaviour |
|---|---|
| Postgres primary unavailable | Widget data API and showcase continue from CDN cache and the replica; dashboard becomes read-only; submissions return a friendly "we can't accept this right now, your recording is saved locally — please try again in a few minutes" and the recorder retains the take |
| Valkey unavailable | Rate limiting fails open for the widget path (availability wins) and fails closed for authentication and submission (security wins). This asymmetry is deliberate and is documented at the call site |
| Mux unavailable | New video submissions queue; posters and playback continue from the CDN cache; the moderator sees "processing" rather than an error |
| Transcription provider unavailable | Jobs retry with backoff; testimonials publish without captions and are flagged for retry (Section 10.8) |
| Object storage unavailable | Uploads fail with a resumable error and the recorder retains the take; already-delivered media continues from the CDN |
| AI provider unavailable | Suggestions are unavailable; manual curation is unaffected, which is the default anyway (Section 12.6) |
| Stripe unavailable | Entitlements continue from our own projection (Section 19.1); only new purchases and plan changes are blocked |
| The whole origin unavailable | Widgets and showcase pages continue serving from the CDN for the duration of the stale-while-revalidate window and beyond, because the CDN is configured to serve stale on origin error indefinitely rather than to return 5xx. This is the single most important resilience decision in the system |
23.7 Job Processing Budgets #
Measured from enqueue to completion, as p95, on a queue at normal depth. Method and gate for every row: a per-job histogram of enqueue-to-completion duration, tagged by job name, exported from the worker; the budget column is an alert threshold with the owner named in Section 24.7, and the hard-ceiling column is a hard fail in the load-test scenarios of Section 23.9 — a job exceeding its ceiling there fails the build rather than merely being noted.
| Job | Budget (p95) | Hard ceiling | Notes |
|---|---|---|---|
cdn.purge (publish, unpublish, withdrawal) |
≤ 10 s | 60 s | This is the withdrawal guarantee in Section 22.4.5. Highest priority lane |
media.transcode (via Mux, measured to ready webhook) |
≤ 1.5× the video's duration, minimum 45 s | 10 minutes | A 2-minute take is playable within ~3 minutes of submission |
media.poster (thumbnail extract and cache) |
≤ 15 s after ready |
2 minutes | |
transcription (2-minute video) |
≤ 90 s after the asset is ready | 10 minutes | A 5-minute video budgets ≤ 3 minutes |
transcription.vtt (VTT generation and upload after a transcript save) |
≤ 5 s | 30 s | |
ai.suggest (highlights, clips, tags for one testimonial) |
≤ 20 s | 90 s | Includes the exact-substring validation pass in Section 12.4 |
social.card (render one card) |
≤ 2 s | 15 s | Satori render plus raster post-processing (Section 15) |
webhook.deliver (first attempt, from the triggering event) |
≤ 5 s | 30 s | Retry schedule in Section 18.5 |
email.send |
≤ 10 s | 60 s | |
sms.send |
≤ 10 s | 60 s | |
import.batch (200 rows) |
≤ 30 s | 5 minutes | Throughput target: ≥ 400 rows/minute end to end, including dedupe and normalization |
import.sync (scheduled OAuth source, incremental) |
≤ 2 minutes | 15 minutes | |
analytics.rollup.hourly |
≤ 3 minutes after the hour closes | 15 minutes | |
analytics.rollup.daily |
≤ 10 minutes after the day closes | 60 minutes | |
quota.reconcile (per workspace) |
≤ 2 s | 30 s | Whole-fleet nightly run ≤ 45 minutes at 10,000 workspaces |
domain.verify (one DNS + TLS check) |
≤ 8 s | 60 s | Repeatable job every 5 minutes for 72 hours (Section 14.4) |
gdpr.export (one respondent, with media) |
≤ 5 minutes | 60 minutes | |
gdpr.erase (one testimonial, full path) |
≤ 5 minutes | 24 hours operational SLA, 30 days statutory (Section 22.6.2) | |
retention.* sweeps |
≤ 30 minutes each | 4 hours | Run in a low-traffic window per region |
stripe.event.process |
≤ 3 s | 60 s |
Queue health targets:
| Metric | Target | Alert |
|---|---|---|
| Queue depth, any queue | < 1,000 | Alert at 5,000, page at 25,000 |
| Oldest waiting job age, priority lanes | < 30 s | Page at 5 minutes |
| Oldest waiting job age, standard lanes | < 5 minutes | Alert at 30 minutes |
| Job failure rate, rolling 1 hour | < 0.5% | Alert at 2% |
| Dead-letter queue depth | 0 | Alert on any entry — a dead-lettered job is a bug, not a statistic |
| Retry rate | < 3% | Alert at 10% |
Every job records a job_runs row with the queue, name, attempt number, duration, and outcome
(Section 22.7 for retention), so budget conformance is queryable rather than anecdotal.
23.8 Capacity Planning #
The launch envelope in Section 23.5, expressed as a resource footprint. These are starting points with named scaling triggers, not permanent sizing.
| Component | Launch size | Scaling trigger | Scale action |
|---|---|---|---|
| Web tier (Next.js, Node runtime) | 4 instances × 2 vCPU / 4 GB, autoscaling 4–20 | CPU > 60% for 5 minutes, or p95 request latency > 1.5× budget | Horizontal; the tier is stateless |
| Edge/serverless routes (widget payload, beacon) | Platform-managed, no fixed capacity | Concurrency limits from the platform | Raise the concurrency ceiling; the code path is I/O-bound and tiny |
| Worker tier | 3 instances × 2 vCPU / 4 GB, autoscaling 3–15 | Queue depth > 1,000 for 5 minutes, or oldest-job age > 60 s on a priority lane | Horizontal, per-queue concurrency tuned separately |
| Postgres primary | 8 vCPU / 32 GB / 500 GB NVMe, IOPS provisioned for 6,000 sustained | CPU > 60% sustained, or replication lag > 10 s, or storage > 70% | Vertical first (it is a single-writer system), then read-replica offload, then table partitioning per Section 23.5.1 item 8 |
| Postgres read replica | 1 × same size | Replica CPU > 60%, or analytics query latency breach | Add a second replica; route rollup reads and export reads to it |
| Connection pooler | 2 instances, transaction mode, 200 server connections | Pool wait time > 5 ms p95 | Raise pool size to the database's limit, then add poolers |
| Valkey | 2 vCPU / 8 GB, single primary + replica | Memory > 70%, or p99 command latency > 2 ms | Vertical, then shard by key prefix (rate limits, cache, and queue are already separate prefixes and can be split into separate instances without code change) |
| Object storage | 40 TB with lifecycle rules (Section 22.7) | Growth rate exceeding forecast by 25% | Elastic; the trigger is cost review, not capacity |
| CDN | Platform-managed | Cache hit ratio < 95%, or origin egress above forecast | Investigate purge granularity (Section 23.5.1 item 1) before adding capacity |
Cost-shaped observations that belong in capacity planning, because they are what actually forces an architecture change:
- Video delivery is the largest variable cost and is metered per plan (Section 19.2), so revenue scales with it.
- Storage is the second largest, and the 30-day original-retention sweep (Section 22.7) is what keeps it bounded; disabling that sweep would roughly triple storage.
- The widget data path is nearly free at the margin because it is CDN-cached at ≥98%, which is why unlimited widget views on every plan (Section 19.2) is affordable rather than reckless.
Forecasting: a monthly capacity review compares actual against the envelope in Section 23.5 for every dimension, projects 6 months forward at the trailing 3-month growth rate, and flags any dimension projected to cross a scaling trigger within 90 days.
23.9 Load Testing #
Tooling: k6 for HTTP load generation, Playwright for browser-level scenarios under load, and a seeded database fixture representing the Section 23.5 envelope at 10% scale (1,000 workspaces, 200,000 testimonials) with the option to seed at 100% for a pre-major-release run.
When it runs:
| Trigger | Scenarios | Duration |
|---|---|---|
| Nightly against staging | S1, S5, S7 | 15 minutes each |
| Every release candidate | S1–S7 | 30 minutes each |
| Before any major release | All, at 100% seed scale | 2 hours each |
| After any change to the widget payload path, the beacon path, or the database schema | S1, S2, S5 | 30 minutes |
| Quarterly soak | S1 + S5 concurrently | 8 hours |
The scenarios:
| # | Scenario | Shape | Pass criteria |
|---|---|---|---|
| S1 | Widget data API at peak | Ramp to 2,000 req/s over 2 minutes, hold 10 minutes, with a 98% edge hit ratio simulated and 2% forced to origin | Origin p95 ≤ 120 ms, p99 ≤ 300 ms, error rate < 0.01%, no origin instance above 70% CPU |
| S2 | Cache-miss storm | Purge every widget for 50 large workspaces simultaneously, then drive 2,000 req/s | Origin p95 ≤ 300 ms during the storm, error rate < 0.1%, recovery to steady state within 60 s. This scenario exists specifically to exercise Section 23.5.1 item 1 |
| S3 | Submission burst | 200 submissions/minute for 10 minutes, 60% video with a 2-minute take, 40% written | All submissions accepted, no 5xx, upload success rate 100%, transcode queue depth returns below 1,000 within 10 minutes of the burst ending, quota enforcement correct at the boundary (no over-count, no under-count) |
| S4 | Concurrent uploads | 300 concurrent chunked uploads of a 2-minute 1080p take | Zero failed uploads, per-part p95 ≤ 2 s, no worker OOM, storage multipart sessions all completed or cleanly aborted |
| S5 | Analytics ingest | 10,000 beacons/second for 10 minutes | p99 ≤ 60 ms, zero dropped events beyond the documented bot filter, queue drains within 5 minutes of the burst ending, hourly rollup still meets its budget |
| S6 | Dashboard concurrency | 1,200 concurrent authenticated sessions performing a realistic mix (list, open, approve, tag, view analytics) | p95 within the Section 23.3 budgets, no connection-pool exhaustion, no RLS-related lock contention |
| S7 | Public API sustained | 500 req/s across the endpoint classes, respecting the documented rate limits, plus a burst that exceeds them | Latency within Section 23.2.6 budgets; every over-limit request returns 429 with correct headers; no over-limit request is served |
| S8 | Mixed peak | S1 + S3 + S5 + S6 concurrently at 60% of each individual peak | Every surface stays within its budget; this is the scenario that finds cross-component contention |
| S9 | Failure injection | Under S1 load, kill the database primary; separately, make Valkey unavailable; separately, make Mux return 503 | Degradation matches the table in Section 23.6 exactly. The widget path must not return a single 5xx in any of the three cases |
Pass/fail handling: a failed load test blocks the release. A scenario that passes but degrades more than 20% against the previous run's baseline opens a mandatory investigation, because a slow drift across releases is how a budget is lost without anyone deciding to lose it. Results are stored per run and charted, so the trend is visible rather than only the latest verdict.
23.10 Browser & Device Support, and Mobile-First Requirements #
23.10.1 Support matrix #
| Surface | Supported | Behaviour outside support |
|---|---|---|
| Widget | Last 2 versions of Chrome, Edge, Firefox, Safari (desktop); iOS Safari 16+; Chrome on Android last 2; Samsung Internet last 2 | ES2022 IIFE with no polyfills (Section 13.5). Older engines fail to parse the module and the <noscript> fallback or the oEmbed variant renders instead — the host page is never broken |
| Request page & recorder | Same list, with the recorder additionally requiring MediaRecorder and getUserMedia |
Feature detection at load; if either API is absent or permission is unavailable, the page presents the written path as the primary action rather than showing an error. The written path is always available regardless (Section 21.2.7) |
| Showcase page | Same list, plus graceful rendering in any browser that can parse HTML and CSS — the page is server-rendered and readable with JavaScript disabled | Progressive enhancement only |
| Dashboard | Last 2 versions of Chrome, Edge, Firefox, Safari (desktop). Tablet supported at ≥ 768 px | A supported-browser notice on older engines; the dashboard is not a public surface and does not carry a compatibility burden |
| Emails | Apple Mail, Gmail (web/iOS/Android), Outlook (web/Windows/Mac), Yahoo, ProtonMail | Table-based layout, inline CSS, a plain-text alternative always sent, no web fonts, no background images carrying meaning |
Codec support is negotiated at record time in this order (Section 9.2): video/mp4;codecs=avc1,mp4a, then video/webm;codecs=vp9,opus, then video/webm;codecs=vp8,opus, then video/webm. The chosen type is stored on the upload row so a playback issue is diagnosable. Playback is adaptive HLS from the video host, so the delivery codec is not tied to the capture codec.
Device classes used in testing: a mid-tier Android (4× CPU throttle, 4 GB RAM equivalent), a current iPhone, an older iPhone at the iOS 16 floor, a low-end Android at 6× CPU throttle for the degradation check, a desktop at cable speed, and a desktop on throttled 4G. Budgets in Section 23.2 that name "mid-tier mobile" refer to the first of these.
23.10.2 Mobile-first requirements #
The request page and the recorder are designed at 360 × 640 first and scaled up. Not "responsive down" — designed at that size first, because the majority of respondents arrive from a phone via an email link.
| Requirement | Detail | Verified by |
|---|---|---|
| Base design viewport | 360 × 640 CSS px is the primary design target; the desktop layout is the adaptation | Design review; Playwright viewport fixtures |
| Minimum supported viewport | No viewport narrower than 320 CSS px is ever required. Every surface is fully functional at 320 px | Playwright at 320 × 480; the 400% reflow pass in Section 21.13.3 |
| Portrait and landscape both supported | The recorder reflows in landscape to put controls beside the preview rather than below it; no orientation is blocked or discouraged; screen.orientation.lock is never called |
Playwright device rotation; manual on real devices |
| Safe-area insets respected | viewport-fit=cover with env(safe-area-inset-*) padding on every fixed or edge-adjacent element, so controls are never under a notch, a home indicator, or a rounded corner |
Manual on a notched device; a Playwright fixture with simulated insets |
| Camera switching is a first-class control | Front/rear switching is a labelled, always-visible 44 × 44 control in the recorder, not buried in a settings sheet. Switching mid-session is permitted before recording starts; during a take it is disabled with the explanation "You can switch cameras before you start recording" | Playwright with multiple fake devices; manual |
| Surviving an interruption | An incoming call, an app switch, a lock-screen, or a visibilitychange to hidden during recording finalizes the take rather than losing it: MediaRecorder.stop() is called on visibilitychange, the last chunk is flushed, the already-uploaded parts are preserved, and on return the respondent sees "We stopped your recording when you left the page — here's what we captured. Keep it, or record again." |
Playwright with a scripted visibility change mid-recording; manual on iOS and Android with a real incoming call |
| Network interruption | A dropped connection mid-upload resumes from the last acknowledged part (Section 9.6); the UI says "Connection lost. Your recording is safe and will finish uploading when you're back online" and retries automatically | Playwright with network offline/online cycling |
| Touch targets | ≥ 44 × 44 CSS px for every primary recorder control (Section 21.6) | Geometry assertion in CI |
| No hover-dependent affordance | Nothing is revealed only on hover on any surface, including the dashboard | Playwright with hover disabled; design review |
| Input ergonomics | Correct inputmode, enterkeyhint, and autocomplete on every field; the virtual keyboard never covers the focused input (scroll-into-view on focus, accounting for visualViewport) |
Manual on iOS and Android |
| Pinch-zoom never disabled | user-scalable=no and maximum-scale are forbidden by lint (Section 21.6) |
ESLint |
| Battery and thermal | Recording is the only sustained-CPU activity; the preview is rendered at capture resolution without an additional canvas pipeline, and no filters, effects, or background processing run during a take | Manual thermal check on a 5-minute take |
| Data cost honesty | The recorder shows the approximate upload size before submission on a metered connection where navigator.connection.saveData is true, and offers 720p capture as an option regardless of plan |
Manual |
23.11 Internationalization Posture #
The product UI is English-only at launch. This is a deliberate v1 decision, not an oversight. Shipping one well-written language beats shipping five machine-translated ones, and the respondent-facing surfaces are short enough that a customer can convey meaning through their own prompt text and their own appended consent clause.
But the content is multilingual from day one — testimonials arrive in whatever language the respondent speaks, transcription supports five languages at launch (Section 10.1), and imported reviews come in from global platforms. So the things that would make later localization a rewrite must be right now.
| Requirement | Status at v1 | Detail |
|---|---|---|
| No hard-coded user-visible strings | Required now | Every user-visible string lives in a message catalogue keyed by a stable id, in packages/ui/messages/en.json and apps/web/messages/en.json. Components import a t('key') accessor, never a literal. A lint rule flags string literals in JSX text positions and in aria-label, title, alt, and placeholder attributes. Adding a language later is adding a file, not editing components |
| No string concatenation for sentences | Required now | Messages use ICU MessageFormat with named placeholders — "{count, plural, one {# testimonial} other {# testimonials}}" — never count + " testimonials". Concatenated sentences are the single thing that most reliably makes localization a rewrite, because word order differs |
| Pluralization through ICU categories | Required now | Even though English has two forms, the catalogue uses ICU plural categories so a language with six does not require a code change |
lang attributes |
Required now | <html lang="en"> on every page; per-testimonial lang on every rendered testimonial whose language differs from the page (Section 21.11); srclang on every caption track; lang in the widget payload per item |
| Unicode handling end to end | Required now | UTF-8 everywhere: database encoding, column collation, HTTP charset, file encoding. Text is NFC-normalized on write (Section 22.13.1). String length limits are counted in Unicode code points, not UTF-16 units and not bytes, so an emoji or a CJK character is not miscounted; grapheme-cluster segmentation via Intl.Segmenter is used for any user-facing character counter and for truncation. Truncation never splits a grapheme cluster or a surrogate pair |
| RTL-safe rendering of testimonial text | Required now | Every element rendering respondent-authored text carries dir="auto", so an Arabic or Hebrew testimonial lays out correctly inside an LTR page. Testimonial cards use CSS logical properties (margin-inline-start, padding-inline, border-inline-start, text-align: start) rather than physical left/right, so a card containing RTL text mirrors correctly. Bidi-override characters are stripped on input (Section 22.13.1) so a testimonial cannot reorder surrounding page text. Quotation marks and attribution punctuation are placed with logical properties, not hard-coded sides |
| Full UI RTL | Deferred (Section 29.8) | Because the UI is English-only, the chrome is LTR. The logical-property discipline above means enabling RTL chrome later is a dir attribute and a CSS audit, not a redesign |
| Locale-aware dates and numbers | Required now | All formatting via Intl.DateTimeFormat and Intl.NumberFormat with an explicit locale parameter that currently resolves to en-US but is threaded through from a single getLocale() source. No date is ever formatted with string manipulation. Relative times use Intl.RelativeTimeFormat. Money is formatted with Intl.NumberFormat in currency style from the stored minor units and ISO-4217 code (Section 5) |
| Time zones | Required now | All timestamps stored timestamptz in UTC (Section 5). Display converts to the viewer's zone via Intl; the dashboard shows the zone abbreviation on any absolute timestamp so "3:00 PM" is never ambiguous. Analytics day boundaries use the workspace's configured reporting time zone, stored on the workspace, so a "daily" chart matches the customer's business day |
| Sorting and comparison | Required now | Intl.Collator for any user-visible sort of text (tag lists, member lists), never Array.prototype.sort on raw strings, which sorts by code point and gets accented characters wrong |
| Names | Required now | A single name field, not first/last, because name structure varies globally and splitting it is a common Western assumption baked into a schema. Display is the field as given |
| Addresses and tax identifiers | Handled by Stripe | Address and VAT/GST capture is Stripe's (Section 19.7.4), which already handles global formats |
| Phone numbers | Required now | Stored in E.164; input accepts local formats with a country selector; validation is library-based, not a regular expression |
| Email addresses | Required now | Validated permissively (presence of @, a domain with a dot, length limits) rather than with a strict regular expression, and internationalized domain names are accepted. Over-strict email validation rejects real addresses |
| Search | Required now | Postgres full-text search is configured with the simple dictionary plus unaccent for the general case, so it behaves reasonably across languages rather than being tuned to English stemming and failing on everything else. Per-language dictionaries are a post-v1 improvement (Section 29.8) |
| Transcription languages | 5 at launch | English, Spanish, French, German, Portuguese, with automatic detection (Section 10.1) |
| Email and SMS templates | English at launch, structured for translation | Templates use the same message-catalogue keys; the recipient's language is stored on the respondent row from the detected testimonial language, ready to select a template variant when translations exist |
| Currency | USD only at launch (Section 19.3) | Stored with an ISO-4217 code so multi-currency is additive |
The test that keeps this honest: a CI check runs the dashboard and the request page against a pseudo-localized message catalogue — every string wrapped in brackets and expanded by 40% with accented characters, plus one RTL fixture — and asserts no layout overflow, no clipped text, and no untranslated literal appearing on screen. A hard-coded string shows up immediately as the only un-bracketed text on the page, which is a far more reliable detector than a lint rule alone.
23.12 Non-Functional Requirements Summary #
The complete set of non-functional commitments, in one table, each pointing at the section that owns its detail. This is the list a reviewer checks a release against.
| # | Requirement | Target | Owner |
|---|---|---|---|
| N1 | Widget adds ≤ 27 KB gzip to a host page and 0 render-blocking resources | Hard CI gate | Section 13.3, summarized in 23.2.1 |
| N2 | Request page total JS ≤ 140 KB gzip; LCP ≤ 2.0 s on throttled 4G mid-tier mobile | Hard CI gate | Section 23.2.2 |
| N3 | Time to camera ready ≤ 800 ms p75 | Hard CI gate + field alert | Section 23.2.2 |
| N4 | Widget data API p95 ≤ 40 ms at edge, ≤ 120 ms at origin | Alert + load-test gate | Section 23.2.5 |
| N5 | Widget path availability 99.99%; showcase 99.95%; dashboard 99.5% | Error budget policy | Section 23.6 |
| N6 | Publish or withdrawal reflected on every public surface within 60 s | SLO with alert | Sections 23.2.5, 22.4.5 |
| N7 | No database write on the analytics beacon request path; beacon failure has zero effect on the host page | Hard CI gate | Section 23.2.7 |
| N8 | No query in a request path exceeds 500 ms without an alert carrying the parameterized SQL | Production alert | Section 23.4 |
| N9 | Zero axe violations at serious or critical on every respondent-facing and widget fixture |
Hard CI gate | Section 21.13.1 |
| N10 | WCAG 2.2 Level AA on request pages, recorder, showcase, and every widget; documented gaps only in the dashboard | Release gate | Sections 21.1, 21.12 |
| N11 | Captions on by default in every player surface | Hard CI gate | Section 21.3 |
| N12 | Every limit enforced server-side; reaching a limit never degrades anything already live | Integration tests | Section 19.4 |
| N13 | Erasure destroys the video asset, every version of every storage key, every derivative, every social card, and every CDN copy, verified by a query whose identifiers were captured before destruction | Nightly verification job | Section 22.6.2 |
| N14 | No secret and no personal data in any log, error report, or query log | CI test over the log pipeline | Section 22.18.4 |
| N15 | Cross-tenant isolation enforced by RLS, not by application WHERE clauses |
Quarterly isolation test | Sections 6.2, 22.12.2 |
| N16 | Every unsafe endpoint accepts Idempotency-Key; every webhook receipt is idempotent |
Contract tests | Sections 7.5, 19.7.3 |
| N17 | Full functionality at 320 CSS px, in portrait and landscape, with safe-area insets respected | Playwright fixtures | Section 23.10.2 |
| N18 | A mid-recording interruption finalizes the take rather than losing it | Playwright + manual device test | Section 23.10.2 |
| N19 | No user-visible string is hard-coded; all formatting is Intl-based |
Pseudo-localization CI check | Section 23.11 |
| N20 | Load-test scenarios S1–S9 pass before every release candidate | Release gate | Section 23.9 |
| N21 | Critical vulnerabilities patched within 24 hours; high within 7 days | Tracked SLA | Section 22.21.2 |
| N22 | Backups restorable, with a tested restore procedure in which erasure replay is a mandatory step before the restored instance takes traffic | Quarterly restore drill | Sections 22.6.2, 24 |
| N23 | The CDN serves stale rather than 5xx when the origin is unavailable, so widgets never break on a customer's page | Failure-injection scenario S9 | Section 23.6 |
24. Observability, Operations & Runbooks #
Vouch is an infrastructure product hiding inside a marketing product. A customer pastes one
<script> tag into their homepage and then never thinks about us again — until something
breaks on their site. That asymmetry sets the operational bar: we must detect a widget
regression before the customer's support inbox does, and we must be able to roll the widget
back in minutes without touching anything the customer pasted.
This section defines what we log, what we measure, what wakes a human, and exactly what that human does when woken.
24.1 Observability Principles #
- Every request is traceable end to end by one identifier. The
requestIddefined in Section 7.3 is generated at the edge ofapps/web, returned in theX-Request-Idresponse header, attached to every log line, attached to the trace, and copied into any job payload the request enqueues. Given arequestIdfrom a customer support ticket, an engineer can reconstruct the whole causal chain including asynchronous work. - Logs are for causality, metrics are for trends, traces are for latency. We do not attempt to compute business analytics from logs, and we do not attempt to debug a single failure from a dashboard.
- Telemetry never contains customer content. See the redaction rule in Section 24.2.4. This is not a style preference; testimonial bodies are personal data belonging to a data subject who consented to publication on a customer's website, not to appearing in our log store.
- An alert that nobody acts on is deleted. Every alert row in Section 24.7 names a first action. If an alert fires repeatedly and the first action is "acknowledge and ignore", the alert is either re-tuned or removed at the next post-incident review.
- The widget is observed from the outside as well as the inside. Synthetic checks run against a real fixture host page on a real CDN edge, because "our origin returned 200" is not the same statement as "the customer's visitor saw testimonials".
24.2 Structured Logging #
24.2.1 Format and transport #
All services log newline-delimited JSON to stdout using pino (version line in Section 3). The container runtime ships stdout to the log aggregator. No service writes log files to disk, rotates logs, or manages its own log shipping.
Pretty-printing is enabled only when NODE_ENV=development, via pino-pretty as a
dev-dependency piped in the local dev script — never in the application process itself in
any deployed environment.
24.2.2 The mandatory log line schema #
Every log line, at every level, in every service, carries these fields. A logger factory in
packages/core/observability/logger.ts is the only way to obtain a logger, and it enforces
the base fields; direct pino() construction outside that factory fails an ESLint rule
(no-restricted-imports on pino outside the factory module).
| Field | Type | Always present | Description |
|---|---|---|---|
time |
number (epoch ms) | Yes | pino default. |
level |
number | Yes | pino numeric level; rendered to a name by the aggregator. |
service |
"web" | "worker" | "embed-reporter" |
Yes | Emitting service. |
env |
"development" | "preview" | "staging" | "production" |
Yes | From APP_ENV (Section 29.2). |
release |
string | Yes | Git SHA of the deployed build, same value as the Sentry release. |
requestId |
string | Yes for request-scoped lines | req_ + Crockford base32 UUIDv7 (Section 7.3). |
traceId / spanId |
string | Yes when tracing is sampled | W3C trace context ids, for log↔trace correlation. |
workspaceId |
uuid | null |
Yes for tenant-scoped lines | The workspace the work belongs to. null only for pre-auth and platform-level lines. |
actorId |
uuid | string | null |
Yes for authenticated lines | User id, apiKey:<keyId>, or system:<jobName>. |
actorType |
"user" | "api_key" | "system" | "anonymous" |
Yes | Mirrors the audit-event actor taxonomy in Section 11.6. |
route |
string | Yes for HTTP lines | Route pattern, never the concrete path: /api/v1/testimonials/[id]. Concrete ids go in resourceId. |
method |
string | Yes for HTTP lines | HTTP method. |
status |
number | Yes for HTTP completion lines | HTTP status code. |
durationMs |
number | Yes for completion lines | Integer milliseconds, measured with performance.now(). |
outcome |
"ok" | "client_error" | "server_error" | "rejected" |
Yes for completion lines | rejected covers rate limiting and quota blocks, which are neither bugs nor client mistakes. |
errorCode |
string | null |
Yes when outcome != "ok" |
The catalogue code from Section 29.3. |
resourceType / resourceId |
string | uuid | When applicable | e.g. testimonial / the uuid. |
jobName / jobId / attempt |
string / string / number | Worker lines only | BullMQ job identity and attempt number. |
msg |
string | Yes | Short, lowercase, no interpolated user data. |
Example of a completed request line:
{
"time": 1771234567890,
"level": 30,
"service": "web",
"env": "production",
"release": "9f2c1ab",
"requestId": "req_01JQZ8T9M4KX7B3PYVN6D2WQ0S",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"spanId": "00f067aa0ba902b7",
"workspaceId": "0192f5c4-6b7e-7c31-9a4d-3f1c2e7b88aa",
"actorId": "0192f5c4-7a11-7d02-8f3e-11aa22bb33cc",
"actorType": "user",
"route": "/api/v1/testimonials/[id]/publish",
"method": "POST",
"status": 200,
"durationMs": 84,
"outcome": "ok",
"resourceType": "testimonial",
"resourceId": "0192f5c4-9c00-7a55-b0d1-abc123def456",
"msg": "testimonial published"
}24.2.3 Levels and when to use each #
| Level | pino value | Use for | Retention | Sampling |
|---|---|---|---|---|
trace |
10 | Local debugging only. Never enabled in staging or production. | Not shipped | n/a |
debug |
20 | Verbose diagnostics behind LOG_LEVEL=debug; enabled per-service for at most 60 minutes during an incident, then reverted. |
3 days | 100% while enabled |
info |
30 | Business-meaningful state transitions: request completed, testimonial published, job succeeded, domain became active. | 30 days | See 24.2.5 |
warn |
40 | Degraded but handled: provider retry, quota threshold crossed, near-duplicate import detected, transcript confidence below threshold. | 90 days | 100% |
error |
50 | A unit of work failed and a human may need to know: job exhausted retries, provider 5xx after retries, unhandled route error. Always paired with a Sentry event. | 90 days | 100% |
fatal |
60 | Process cannot continue: config validation failed at boot, database unreachable at startup, migration lock held by a dead process. Immediately followed by exit code 1. | 365 days | 100% |
Rules:
- A caught-and-handled expected condition is not an error. A 422 validation failure logs
at
infowithoutcome: "client_error". A quota block logs atinfowithoutcome: "rejected". Reservingerrorfor genuine defects is what keeps the error rate a usable alerting signal. - Every
errorandfatalline includeserrserialized by pino's standard error serializer (type, message, stack) — with the stack retained, because stacks contain code paths, not customer data. - No service logs the same failure twice at two levels. The layer that finally gives up logs;
layers that retry log at
debug.
24.2.4 The redaction rule — absolute #
The following are never written to a log line, at any level, in any environment, including local development:
| Never logged | Why | What to log instead |
|---|---|---|
Any secret, API key, token, password, session cookie, signing secret, ACME account key, or Authorization header value |
A log store is a lower-trust system than a secret store, and log stores get exported into support tickets | The key's display prefix only (vch_live_a1b2c3d4…, 12 chars, as defined in Section 7.6) or the key id |
| Full client IP addresses | Personal data under GDPR; we have no operational need for the full address | Truncated IP (/24 IPv4, /48 IPv6) or the derived country code, matching the analytics rule in Section 17 |
| Testimonial body text, transcript text, transcript segments, respondent free-text answers, custom-field answers | Customer content and the respondent's personal data | resourceId, contentLength, language, hasVideo |
| Respondent name, email, phone, company, job title, or avatar URL | Personal data | The submission_id / testimonial_id |
Respondent tokens (/p/{token}), invitation tokens, magic-link tokens, verification tokens |
Anyone with a log line could impersonate a respondent | The hashed token's first 8 hex characters, purely for correlation |
| Full request bodies or full response bodies | They contain all of the above | Field names that failed validation, never their values |
| Full host-page URLs from widget error reports | Reveals the customer's unreleased pages and can carry query-string PII | The origin only (https://example.com), never path or query |
| Stripe raw event payloads containing card metadata | PCI scope creep for no benefit | stripe_event_id, type, livemode |
Enforcement is mechanical, not cultural:
- The logger factory installs a pino
redactconfiguration with paths coveringreq.headers.authorization,req.headers.cookie,*.password,*.token,*.secret,*.apiKey,*.body,*.transcript,*.email,*.phone,*.ip, and*.consentText, censored as"[redacted]". - A custom ESLint rule
vouch/no-content-in-logsfails the build when a logger call's object literal contains a key matching/(body|text|transcript|email|phone|token|secret|password|ip)$/iwithout passing through thesafe()helper. - A CI test asserts the redactor against a fixture object containing one of every forbidden field and fails if any value survives serialization.
- A weekly automated scan greps a 1% sample of shipped log lines for high-entropy strings
matching known key formats (
vch_live_,sk_live_,whsec_) and opens a SEV-3 ticket on any hit.
24.2.5 Sampling #
warn,error,fatal: never sampled.infofor HTTP requests: 100% for all mutating methods, all non-2xx responses, and all routes under/api/v1/; 1% for successful reads of the widget data API (GET /api/v1/widgets/{id}/data), which is by far the highest-volume route and is already measured precisely by metrics. Sampling is deterministic on the hash ofrequestIdso a sampled request is either fully present or fully absent, never half-logged.- Analytics beacon ingestion (
POST /api/v1/e, Section 17): 0.1% sampled atinfo; every rejection reason is counted as a metric instead. - Sampling decisions are recorded in the line as
sampled: trueso absolute counts are never inferred incorrectly from a sampled stream.
24.2.6 Log retention and access #
Retention is per level as tabled in Section 24.2.3. Access to production logs requires membership in the on-call group, is itself audited, and is reviewed quarterly. Logs are stored in the same region as the primary database (Section 26.11) and are covered by the sub-processor list in Section 22.9.
24.3 Metrics #
Metrics are OpenTelemetry instruments exported over OTLP to the metrics backend. Names are
vouch_<subject>_<unit> in snake_case, with units in the suffix (_seconds, _bytes,
_total, _ratio). Every metric carries env, service, and release.
Cardinality rule: workspace_id is never a metric label. Per-workspace numbers come
from the analytics rollups in Section 17 and from the database, not from the metrics system.
Allowed high-value labels are route (pattern), status_class, job_name, provider,
widget_type, plan, and country — each of bounded cardinality.
24.3.1 RED metrics per service #
For apps/web, apps/worker, and the widget data API (which is measured separately because
it is CDN-fronted and has its own budget):
| Metric | Type | Labels | Meaning |
|---|---|---|---|
vouch_http_requests_total |
counter | service, route, method, status_class, outcome |
Rate. |
vouch_http_errors_total |
counter | service, route, error_code |
Errors — outcome=server_error only. |
vouch_http_request_duration_seconds |
histogram | service, route, method |
Duration; buckets 5ms→10s, explicit p50/p90/p95/p99 recording rules. |
vouch_job_runs_total |
counter | job_name, result (ok/failed/retried/dead) |
Worker rate and errors. |
vouch_job_duration_seconds |
histogram | job_name |
Worker duration. |
vouch_queue_depth |
gauge | queue |
Waiting + delayed jobs per queue, scraped every 15s. |
vouch_queue_oldest_waiting_seconds |
gauge | queue |
Age of the oldest waiting job — the honest backlog signal, since depth alone hides slow drains. |
vouch_db_pool_in_use / vouch_db_pool_size |
gauge | service |
Connection saturation (Section 26.3). |
vouch_db_query_duration_seconds |
histogram | operation |
Only for named query helpers, not ad-hoc SQL. |
24.3.2 Product metrics that actually matter #
These are the numbers that tell us whether the product is working, as opposed to whether the servers are up. Each has an owner audience and appears on the product dashboard (24.6).
| Metric | Type | Labels | Definition | Healthy range |
|---|---|---|---|---|
vouch_submissions_started_total |
counter | kind (text/video), device_class |
Respondent reached the recorder or the text form and interacted once | — |
vouch_submissions_completed_total |
counter | kind, device_class |
Submission accepted and persisted | Completion ratio ≥ 60% for text, ≥ 35% for video |
vouch_submission_completion_ratio |
derived | kind |
completed ÷ started, 1h window | Alert below 0.5× the trailing 7-day median |
vouch_upload_parts_total |
counter | result (ok/retried/failed) |
Multipart part PUTs (Section 9.6) | Failure ratio < 0.5% |
vouch_upload_resume_total |
counter | — | Sessions that resumed after a dropped connection | Informational; a spike means network or CDN trouble |
vouch_transcode_turnaround_seconds |
histogram | duration_bucket |
Submit → media_assets.state = ready |
p95 < 180s for takes ≤ 2 min |
vouch_transcription_jobs_total |
counter | result, provider, language |
Transcription outcomes (Section 10) | Failure ratio < 2% |
vouch_transcription_lag_seconds |
histogram | — | ready → transcript stored |
p95 < 300s |
vouch_ai_proposals_total |
counter | kind (quote/clip/tag/alt_text/headline), result (accepted_by_validator/rejected_by_validator/approved_by_human/discarded_by_human) |
AI assistant funnel (Section 12) | — |
vouch_ai_verbatim_rejection_ratio |
derived | — | validator rejections ÷ proposals | Alert above 2% — the boundary in Section 12.1 is enforced in code, and a rising rejection rate means the prompt or the model changed underneath us |
vouch_widget_data_requests_total |
counter | cache (hit/miss/revalidate), status_class |
Widget data API at the edge | Hit ratio ≥ 0.9 |
vouch_widget_data_cache_hit_ratio |
derived | — | hits ÷ total, 5m window | Alert below 0.85 for 15 min |
vouch_widget_render_errors_total |
counter | widget_type, error_kind |
Client-side widget errors reported by the loader (24.5.3) | Alert above 0.5% of impressions |
vouch_widget_impressions_total |
counter | widget_type |
Accepted impression events (Section 17) | — |
vouch_webhook_deliveries_total |
counter | result (ok/retry/exhausted), status_class |
Outbound webhook attempts (Section 18) | Success ratio ≥ 98% |
vouch_webhook_endpoints_auto_disabled_total |
counter | — | Endpoints disabled after 8 failed attempts | Alert on > 3 in 1h |
vouch_domain_verifications_total |
counter | result (active/failed/timeout) |
Custom-domain outcomes (Section 14) | Success ratio ≥ 90% |
vouch_domain_time_to_active_seconds |
histogram | record_type (cname/apex) |
Domain added → active |
p50 < 15 min once DNS is correct |
vouch_tls_certificate_days_remaining |
gauge | domain_class (platform/customer) |
Minimum days remaining across certificates | Alert at 14 and 7 days |
vouch_quota_blocks_total |
counter | limit (testimonials/seats/request_pages/storage/delivery/transcription), plan |
Server-side quota denials (Section 19) | Informational, but a step change means a pricing or accounting bug |
vouch_quota_counter_drift_total |
counter | limit |
Nightly reconciliation differences between the cached counter and the authoritative count | Must be 0; any non-zero pages |
vouch_media_purge_pending |
gauge | — | media_assets rows with deleted_at set and purged_at null older than 1 hour |
Alert above 0 for 6h; this is a legal commitment (Section 22) |
vouch_stripe_events_lag_seconds |
gauge | — | Now minus the oldest unprocessed row in stripe_events |
Alert above 300s |
vouch_import_rows_total |
counter | source, result (imported/duplicate/invalid) |
Review import (Section 16) | — |
vouch_email_sends_total / vouch_sms_sends_total |
counter | template, result |
Transactional messaging (Section 20) | Bounce ratio < 3% |
24.3.3 Recording rules and SLOs #
Three SLOs are formally tracked with error budgets over a rolling 30 days:
| SLO | Target | Error budget | Consequence of exhaustion |
|---|---|---|---|
Widget data API availability (status < 500 at the edge) |
99.95% | ~22 min/30d | Feature work on the widget stops; reliability work only, until the budget recovers |
| Widget data API latency (edge p95 ≤ 150 ms, origin p95 ≤ 400 ms) | 99% of 5-minute windows | 7.2h/30d | Same |
Dashboard availability (/app and /api/v1 5xx ratio < 0.1%) |
99.9% | ~43 min/30d | Release freeze except fixes |
Submission success (respondent reaches submit and the submission persists) is tracked as a product SLO at 99.5% but does not gate releases; it gates the milestone exit criteria in Section 27.
24.4 Distributed Tracing #
- SDK: OpenTelemetry (version line in Section 3), exported over OTLP to the tracing backend; Sentry performance is fed from the same spans so traces and errors share ids.
- Auto-instrumented: incoming HTTP (Next.js route handlers and server actions), outgoing
fetch,pgqueries via the Drizzle driver, ioredis commands, BullMQ producer and consumer, and S3 SDK calls. - Manually instrumented spans (with these exact names, because runbooks reference them):
submission.accept,upload.part.put,upload.complete,media.ingest,media.webhook.handle,transcription.request,transcription.store,vtt.generate,ai.suggest_highlights,ai.validate_verbatim,widget.data.build,widget.data.serve,cdn.purge,webhook.deliver,domain.verify.check,acme.issue,quota.check,quota.reconcile,media.purge,import.fetch,import.normalize,social_card.render. - Attributes on every span:
vouch.workspace_id,vouch.request_id,vouch.actor_type, plus span-specific ids. Span attributes obey the same redaction rule as logs (Section 24.2.4) — no bodies, no transcripts, no tokens. - Sampling policy: head-based parent sampling at 10% for authenticated dashboard
traffic, 1% for the widget data API, 100% for any request that ends in a 5xx (via a
tail-sampling collector rule), 100% for every background job that fails, and 100%
in staging. A
X-Vouch-Debug-Trace: 1header from an internal IP forces sampling for that request, for support debugging. - Trace ↔ log correlation: the logger factory pulls
traceIdandspanIdfrom the active context on every line, so a trace view links straight to its logs and vice versa. A log line without a trace id means the work happened outside a sampled trace, never that correlation is broken. - Cross-boundary propagation: W3C
traceparentis injected into BullMQ job payloads under_otel, into outbound webhook requests (so a customer debugging their endpoint can see our id), and into the CDN purge API call. The widget does not participate in tracing — it sends no trace headers, because doing so would add a header and a decision point to the highest-volume path for no operational benefit.
24.5 Error Tracking #
24.5.1 Grouping and release tagging #
Sentry (version line in Section 3) is the error tracker for apps/web, apps/worker, and
the widget reporter.
- Release tagging: every deploy sets
SENTRY_RELEASEto the git SHA and the release is created in CI before the deploy, so regressions are attributable to a commit within one click. The widget's release is its runtime chunk content hash, not the git SHA, because the widget ships on its own train (Section 26.9). - Grouping: default stack-based grouping, overridden by explicit fingerprints for classes
where the stack is uninformative:
["provider-error", provider, statusCode]for third-party failures,["job-failure", jobName, errorCode]for worker jobs,["app-error", errorCode]for any thrownAppError(Section 4), so the catalogue code from Section 29.3 is the grouping key rather than the throw site. - Expected conditions are not sent. Any
AppErrorwithhttpStatus < 500is logged and counted but never sent to Sentry. Validation failures, quota blocks, 404s, and rate limits are product behaviour. - Source maps for the dashboard are uploaded in CI and not served publicly; the
.mapfiles are deleted from the deployed static output after upload. The widget's source maps are uploaded to Sentry and also not published to the CDN. - PII scrubbing is configured
sendDefaultPii: false, with abeforeSendhook that applies the same deny-list as Section 24.2.4 toextra,tags,breadcrumbs, and the request context, and that strips any URL down to origin + route pattern. - Ownership: issues are auto-assigned by path ownership rules (
apps/embed/**→ widget owner,packages/db/**→ data owner, and so on) so no issue sits unowned.
24.5.2 Alert routing from errors #
A new issue at error level in production creates a ticket. A regression (an issue
previously marked resolved reappearing in a newer release) pages the primary on-call, because
it means a fix was not a fix. Issue volume spikes (>10× the trailing hour) page as well.
24.5.3 Does the widget report its own errors? Yes — carefully #
Decision: the widget reports errors, to a dedicated, low-volume, heavily-restricted endpoint, sampled, carrying the host page's origin and nothing more.
The reasoning matters, so it is stated rather than assumed. Without client reporting we would learn about widget breakage only when a customer emails us, which for an embed on someone else's homepage can be days. With naive client reporting we would build a system that collects arbitrary URLs, stack traces, and DOM context from third-party sites we do not own — an accidental surveillance system with real privacy and contractual exposure. The middle path is a deliberately impoverished report.
| Property | Decision |
|---|---|
| Endpoint | POST https://cdn.vouch.app/api/v1/widget-errors |
| Transport | navigator.sendBeacon, keepalive fetch fallback; never blocks, never retries |
| Sampling | 5% of error occurrences per page view, and a hard client-side cap of 3 reports per page view and 1 report per error fingerprint per page view |
| Rate limit | 60 requests/min per IP at the edge; excess is dropped with 204, never 429, so a broken host page cannot see an error from us |
| Payload | { widgetId, widgetType, runtimeHash, errorName, errorMessage, stackTop3, origin, viewportClass, browserFamily, browserMajor, ts } |
origin |
Origin only — https://example.com. Path, query, and hash are stripped in the widget before the payload is built, not server-side |
| Never included | Host-page DOM, host-page URL path or query, cookies, document.referrer, any testimonial content, any user identifier, any IP (the edge derives country only) |
errorMessage |
Truncated to 200 characters and matched against an allow-list of our own thrown messages; anything unrecognised is replaced by "unknown" so a host page's error text can never be exfiltrated through us |
| Storage | Forwarded to Sentry as a widget-project event, plus a counter vouch_widget_render_errors_total; raw rows retained 14 days |
| Opt-out | data-vouch-telemetry="off" on the placeholder element disables error reporting and analytics for that widget; documented publicly in Section 13 |
Why the restraint matters: our customers paste this script into pages that include checkout flows, staging URLs, and internal tools. A path in an error report can leak an unannounced product name; a query string can leak a customer email. Collecting origin only means the worst case of a total compromise of our error store is a list of domains that embed Vouch — which is already visible from the widget's own network requests. That is a bounded blast radius, and it is the reason this endpoint is separate from every other ingestion path.
24.6 Dashboards #
Three dashboards, each with a named audience and a fixed panel list. No dashboard is a dumping ground; a new panel replaces an old one or gets its own dashboard.
24.6.1 Executive dashboard (weekly review, non-engineers) #
| Panel | Source |
|---|---|
| Active workspaces (7d / 28d) | Database rollup |
| Testimonials collected this period, by kind (text/video) | vouch_submissions_completed_total |
| Submission completion ratio, text vs video, trend | Derived metric |
| Published testimonials and total widget impressions | Analytics rollups (Section 17) |
| MRR, new subscriptions, churn, plan mix | Stripe + subscriptions table |
| Plan-limit pressure: workspaces above 80% of any limit | Quota counters |
| Support: open tickets by category, median first response | Support tool |
| SLO status: three SLOs with remaining error budget | Recording rules (24.3.3) |
24.6.2 On-call dashboard (the one open during an incident) #
| Panel | Threshold marker drawn on the panel |
|---|---|
| Widget data API: RPS, cache hit ratio, edge p95, origin p95, 5xx ratio | Hit ratio 0.85; 5xx 0.1% |
| Origin error rate by route (top 10) | 0.5% |
| Queue depth and oldest-waiting age per queue | Depth 5,000; age 900s |
Job failure rate by job_name |
5% |
| Database: connections in use vs pool size, replication lag, slowest queries | 80% pool |
| Valkey: memory used, evicted keys, command latency, connected clients | 80% maxmemory |
| Upload part failure ratio and resume rate | 0.5% |
Transcode backlog (assets in processing older than 10 min) |
25 assets |
| Transcription backlog and failure ratio | 2% |
| Webhook success ratio and endpoints auto-disabled (1h) | 98% / 3 |
| Stripe event lag | 300s |
| Media purge pending count and oldest age | 0 older than 6h |
| TLS: minimum days remaining, certificates failing renewal | 14 days |
| Deploy markers and widget runtime-hash rollout percentage | — |
24.6.3 Product dashboard (feature health, product + engineering) #
| Panel |
|---|
| Submission funnel: page view → started → recorded → consent shown → submitted, by device class |
| Recorder drop-off by step, and retake count distribution |
| Time-to-first-published-testimonial for new workspaces (activation) |
| Moderation: pending queue age distribution, approve/reject ratio, median time to decision |
| AI assistant funnel: proposals → validator-accepted → human-approved, with the verbatim rejection ratio prominent |
| Widget mix by type, and impressions/interactions/CTR per type |
| Custom domain funnel: added → DNS detected → verified → TLS issued → active, with drop-off |
| Import volume by source, duplicate ratio, invalid ratio |
| Analytics ingestion: accepted vs bot-filtered events |
| Quota blocks by limit and plan |
24.7 Alerting #
Severity definitions are in Section 24.9.1. "Pages" means an out-of-hours phone alert; "tickets" means a work item in the queue for the next business day.
| # | Alert | Condition (production) | Severity | Routed to | First action |
|---|---|---|---|---|---|
| A1 | Widget data API 5xx rate | Edge 5xx ratio > 0.5% for 5 min | SEV-1 | Pages primary on-call | Check origin health; if origin is the cause, confirm CDN is still serving stale (stale-if-error) and follow Runbook R5 |
| A2 | Widget data API latency | Edge p95 > 400 ms or origin p95 > 800 ms for 10 min | SEV-2 | Pages primary | Check cache hit ratio panel; a hit-ratio collapse usually means an over-eager purge — Runbook R5 |
| A3 | Widget data cache hit ratio | < 0.85 for 15 min | SEV-3 | Tickets, notifies on-call channel | Inspect recent purge volume; verify no code path is purging per-request |
| A4 | CDN origin error spike | Origin 5xx count > 100 in 5 min, any route | SEV-2 | Pages primary | Identify route from the error-rate panel; if it is deploy-correlated, roll back (Section 26.7.4) |
| A5 | Upload part failure rate | vouch_upload_parts_total{result="failed"} ratio > 2% for 10 min |
SEV-2 | Pages primary | Runbook R2; check object-storage provider status and pre-signed URL expiry |
| A6 | Transcode backlog | ≥ 25 assets in processing older than 10 min, for 15 min |
SEV-2 | Pages primary | Runbook R3 (stuck transcode) / R1 (provider outage) |
| A7 | Transcription backlog | Queue transcription oldest-waiting > 900 s, or failure ratio > 10% for 15 min |
SEV-3 | Tickets + channel | Runbook R4; remember publishing is never blocked on transcription |
| A8 | Webhook auto-disable rate | > 3 endpoints auto-disabled in 1 h | SEV-3 | Tickets + channel | Runbook R9 — distinguish "many customers' endpoints failing" (our payload changed) from "one noisy customer" |
| A9 | Webhook delivery success | Success ratio < 90% over 15 min | SEV-2 | Pages primary | Check outbound egress and DNS; verify signature generation did not change |
| A10 | TLS expiry — warning | Any certificate < 14 days remaining | SEV-3 | Tickets | Runbook R8; check ACME account status and the renewal job's last success |
| A11 | TLS expiry — critical | Any certificate < 7 days remaining, or renewal failed 3× | SEV-1 | Pages primary | Runbook R8; manual issuance path documented there |
| A12 | Media purge failing | Any media.purge job failed 3 times, or vouch_media_purge_pending > 0 for 6 h |
SEV-2 | Pages primary + notifies the privacy owner | Runbook R11 — this is a legal commitment, not a cleanup task |
| A13 | Stripe webhook lag | Oldest unprocessed stripe_events row > 5 min |
SEV-2 | Pages primary | Runbook R10 |
| A14 | Stripe webhook failures | Any stripe_events row failed 5 processing attempts |
SEV-2 | Pages primary | Runbook R10, dead-letter section |
| A15 | Database connection saturation | in_use / size > 0.85 for 5 min |
SEV-2 | Pages primary | Check slow-query panel; identify the leaking route; consider raising pool only after finding the cause (Section 26.3) |
| A16 | Database replication lag | Replica lag > 30 s for 5 min | SEV-2 | Pages primary | Move reads to primary via the read-preference flag; investigate long transactions |
| A17 | Database disk | Free space < 20% or growth projects exhaustion within 7 days | SEV-2 | Pages primary | Check the analytics_events_raw retention sweep is running; expand volume |
| A18 | Queue depth | Any queue depth > 5,000, or oldest-waiting > 900 s, for 10 min | SEV-2 | Pages primary | Confirm worker replicas are healthy; scale workers; check for a poison job |
| A19 | Dead-letter arrivals | Any job lands in a dead-letter queue | SEV-3 | Tickets | Inspect the job payload (redacted), fix, and use the documented replay command (29.5) |
| A20 | Quota counter drift | vouch_quota_counter_drift_total > 0 after nightly reconciliation |
SEV-2 | Pages primary | Runbook R16; a drifted counter either over-charges or over-permits, and both are unacceptable |
| A21 | Domain verification success | Success ratio < 70% over 6 h with ≥ 10 attempts | SEV-3 | Tickets | Runbook R7; check the resolver and the challenge record generator |
| A22 | Auth failures | Failed sign-ins > 20× the trailing-hour baseline | SEV-2 | Pages primary + security owner | Possible credential stuffing; enable step-up challenge and check the IP distribution |
| A23 | Widget render error rate | vouch_widget_render_errors_total > 0.5% of impressions for 15 min |
SEV-1 if correlated with a widget release, else SEV-2 | Pages primary | Runbook R15 — roll back the widget runtime first, diagnose second |
| A24 | Synthetic widget check | Fixture host page fails to render 3 consecutive checks from 2+ regions | SEV-1 | Pages primary | Runbook R5 |
| A25 | Worker heartbeat | No completed job of any kind for 5 min while queue depth > 0 | SEV-1 | Pages primary | Worker deployment is wedged; check readiness probe and restart the deployment |
| A26 | Valkey unavailable | Connection failures > 50% for 2 min | SEV-2 | Pages primary | Runbook R12 — and reassure: published widgets keep serving |
| A27 | Email/SMS delivery | Provider error ratio > 10% for 15 min, or bounce ratio > 10% for 1 h | SEV-3 | Tickets | Check provider status and domain reputation (Section 20) |
| A28 | AI verbatim rejection ratio | > 2% of proposals over 6 h | SEV-3 | Tickets + product owner | Freeze the AI assistant behind its feature flag, inspect recent proposals, pin the prompt version (Section 12) |
| A29 | Storage growth anomaly | Object-storage bytes grow > 3× the trailing 7-day daily median | SEV-3 | Tickets | Check that the 30-day originals sweep and the abandoned-upload sweep are running (Section 9.10.1) |
| A30 | Certificate/secret expiry (non-TLS) | Any provider API key or OAuth refresh token within 14 days of expiry | SEV-3 | Tickets | Rotate per Section 26.10.3 |
24.8 Runbooks #
Each runbook assumes an engineer with production access, the on-call dashboard open, and no prior context on the affected subsystem. Commands below are illustrative of the shape of the fix rather than the exact invocation.
Write docs/RUNBOOKS.md in the repository as part of the milestone that first ships each
subsystem. For every runbook id in this section that file must carry: the id and title exactly
as written here, the symptom, the alert ids that route to it, the exact CLI invocations for this
deployment (real command names, real flag spellings, real queue and job names from Appendix
29.5), the expected output of each command, and the escalation contact. Add a CI check that
fails if a runbook id in this section has no counterpart in docs/RUNBOOKS.md, or vice versa —
that check is the only thing that keeps the two from drifting.
Universal first three steps for every incident:
- Post in the incident channel:
SEV-n <one-line symptom> — investigating, IC: <name>. - Check the deploy markers on the on-call dashboard. If a deploy landed in the last 60 minutes, roll back first and diagnose after (Section 26.7.4). Rollback is cheap.
- Check the provider status pages for the video host, transcription provider, object storage, CDN, and Stripe before assuming the fault is ours.
R1 — Video provider (Mux) outage #
Symptom: transcode backlog alert (A6), video.asset.* webhooks stop arriving, or the
provider status page reports degradation.
- Confirm scope: is it uploads (
media.ingestfailing), processing (assets stuck inprocessing), or delivery (playback failing on published testimonials)? - Delivery is the only customer-visible emergency. Published video is served through the provider's CDN; posters are served from our own CDN (Section 9.8), so a delivery outage degrades to poster + text, and widgets still render. Confirm this on the fixture host page before escalating.
- If uploads are failing: set the feature flag
flag_video_capture_pausedtotrue. Request pages then show the text-testimonial form with an inline notice ("Video recording is temporarily unavailable — you can still write a testimonial"). Submissions already uploaded to object storage are unaffected; they wait inuploadedstate. - If processing is stalled: do nothing destructive. The
media.ingestjob retries with backoff for 24 hours (Section 29.5). Assets inuploading/uploadedare safe in object storage; the original is retained 30 days regardless (Section 9.10.1). - Post status-page update using the "third-party degradation" template (24.9.5).
- On recovery: clear the flag, then run the reconciliation job
media.reconcile(29.5) which re-queries the provider for every asset not in a terminal state and repairsmedia_assets.state. - Aftercare: any submission that failed at the upload step emailed the respondent a resume
link valid 72 hours; check
vouch_upload_resume_totalfor the recovery spike.
R2 — Upload failures (not a provider outage) #
Symptom: A5 upload part failure rate.
- Determine whether failures cluster by region, browser, or file size, using the labels on
vouch_upload_parts_totaland the recorder's client-side error reports. - Check pre-signed URL expiry: parts are signed for 30 minutes; a clock skew or a long pause
in recording can expire them. The recorder re-signs automatically on 403 — verify the
re-sign endpoint is healthy (
/api/v1/uploads/{id}/parts:sign). - Check object-storage provider status and any bucket-level rate limiting.
- Check CORS configuration on the bucket. A CORS regression breaks all browser uploads at once and is the single most common cause of a total upload outage; the expected configuration is in Section 26.5.3, and a synthetic check exercises it every 5 minutes.
- If the fault is ours and not quickly fixable, set
flag_video_capture_pausedas in R1. - Never delete
upload_sessionsrows during an incident — they are how in-flight takes resume.
R3 — A stuck transcode #
Symptom: one or a few assets sit in processing far beyond p95, with no error webhook.
- Identify the assets:
SELECT id, workspace_id, mux_upload_id, mux_asset_id, state, created_at,
now() - created_at AS age
FROM media_assets
WHERE state = 'processing'
AND created_at < now() - interval '10 minutes'
ORDER BY created_at
LIMIT 50;- For each, query the provider API directly for the asset's real state. Three cases:
- Provider says ready: we missed a webhook. Enqueue
media.reconcilefor that asset; it applies the same idempotent handler the webhook would have. - Provider says errored: enqueue
media.mark_errored; the moderator is notified and the respondent receives the "we could not process your video" email with a re-record link (Section 20). - Provider has no such asset: the create call failed after we stored
mux_upload_id. Enqueuemedia.ingestagain with the original object-storage key; the job is idempotent onupload_session_id.
- Provider says ready: we missed a webhook. Enqueue
- If more than 25 assets are affected, treat it as R1 rather than fixing one by one.
- Record the missed-webhook count; if webhooks are being missed regularly, verify the webhook endpoint's response times — the provider retries only on non-2xx, and a timeout looks like success to nobody.
R4 — Transcription provider outage or backlog #
Symptom: A7.
- Reassure first, in the incident channel and the status page: transcription never blocks publishing (Section 10). Videos publish without captions and are re-transcribed later.
- Confirm whether the provider is down or we are rate-limited. Rate limiting appears as 429s
on
transcription.requestspans; the job already backs off. - If the outage will exceed one hour, set
flag_transcription_providerto the documented alternative provider (Section 3 names the alternative; theTranscriptionProviderinterface makes this a config change, not a deploy). Re-run the failed jobs; the transcript JSONB shape is provider-independent (Section 10.3). - Jobs exhaust after 6 attempts over ~6 hours and land in the dead-letter queue with the
testimonial flagged
transcription_failed. The moderator UI shows a "Retry transcription" action; a bulk replay command exists for operators (29.5). - On recovery, replay the dead-letter queue oldest-first, rate-limited to 20 jobs/minute so we do not immediately re-trigger provider throttling.
R5 — "My widget disappeared" #
Symptom: a customer reports their testimonials are gone from their site, or A24/A1 fires.
Work strictly outside-in; the order matters because each step eliminates a whole class of cause.
- Reproduce on our fixture host page first (
https://v.vouch.app/_fixtures/host-basicwith the customer's widget id, which is safe because widget data is public). If the fixture renders, the fault is on the customer's page, not in our pipeline. - Fetch the data payload directly:
curl -sS "https://cdn.vouch.app/api/v1/widgets/{widgetId}/data" -H "Origin: https://customer.example" -D -Check: HTTP status,access-control-allow-origin: *,cache-control,age, and whetherdata.itemsis empty. - Empty
itemsis a product state, not an outage. The common causes, in order of frequency: every testimonial was unpublished or archived (Section 11); the widget's tag or rating filter excludes everything; the testimonials were soft-deleted; the workspace was canceled — which must not empty the payload (Section 19 guarantees canceled workspaces keep serving), so if cancellation emptied it, that is a SEV-1 bug in our enforcement code, not a billing outcome. - Check the loader:
curl -sSI https://cdn.vouch.app/e.js— expect 200, correctcontent-type: application/javascript,cache-control: public, max-age=300, and a size at or below the 5 KB gzip budget (Section 13). - Check for host-page interference: ask for the page URL, load it, and look for a
Content Security Policy blocking
cdn.vouch.app(visible in the console as a CSP violation), an ad blocker or privacy extension (the loader path is deliberately generic to reduce list matching, but some lists are aggressive), or a tag manager that never injected the script. The documented CSP directives are in Section 13.9 and can be pasted straight into a support reply. - Check whether the placeholder div still exists. A customer CMS migration frequently
drops the
<div data-vouch-widget="…">while keeping the script. The loader logs a distinctiveno-placeholderwidget error (24.5.3), so search widget errors by widget id before asking. - If all of the above are clean and the payload is correct, escalate to SEV-2 and inspect
widget.data.buildtraces for that widget id.
R6 — "Your widget broke my site" #
Symptom: a customer reports layout damage, console errors, or a performance regression attributed to us. Treat as SEV-2 immediately regardless of whether we believe it.
- Get the URL. Load it with our widget blocked (via devtools request blocking) and with it allowed. Screenshot both. This determines causation in one minute.
- If layout: our styles are confined to an open shadow root with an adopted stylesheet and we
never write to
document.heador style nodes we do not own (Section 13). The two ways a regression can still escape are (a) the placeholder<div>inheriting a host layout rule and our content overflowing it, and (b) a host* { box-sizing }orimg { max-width }rule crossing into the shadow boundary via inherited properties. Our fix is always inside our own root — never a rule targeting the host page. - If console errors: pull the widget error events for that origin and widget id.
- If performance: run Lighthouse on the page with and without the widget and compare the deltas against the published budget (Section 13's budget table, verified in CI per Section 23). If we exceed budget in the wild, that is a release defect — go to R15.
- Offer the immediate mitigation before finishing the diagnosis: the customer can remove
the script tag, or we can set the widget to
pausedfrom the dashboard, which makes the data API return{"data":{"paused":true,"items":[]}}and the loader render nothing at all. Pausing takes effect within 60 seconds via CDN purge. - Post-incident, add the customer's page pattern to the hostile-fixture set (Section 25.4.3) so the same interaction is regression-tested forever.
R7 — A custom domain stuck in verifying #
Symptom: A21, or a customer report. States are defined in Section 14.
- Read the stored observation: the domain row records the records actually observed at the authoritative nameservers and the last check time. Compare with the required records shown in the UI.
- Resolve independently, bypassing caches:
dig +trace CNAME app.customer.comanddig @<authoritative-ns> TXT _vouch-challenge.customer.com. - Common causes and the exact reply: the customer added the record at the wrong level
(
app.customer.com.customer.com— a trailing-dot mistake); the registrar proxies the record (an orange-cloud proxy hides our CNAME target); the TTL has not expired; the apex is on a registrar without ALIAS support and needs the documented A records; or a CAA record forbids our ACME issuer (dig CAA customer.com— this one is invisible in DNS record listings and is the single most common silent failure). - If DNS is correct but our poller has not noticed, re-enqueue
domain.verifyfor that domain id; the repeatable job runs every 5 minutes for 72 hours (Section 14) and the manual enqueue is idempotent. - If the domain is claimed by another workspace, the UI shows
failedwith theDOMAIN_ALREADY_CLAIMEDcode (29.3). Resolution requires the other workspace to release it or a support-verified transfer — never a manual database edit that skips the challenge.
R8 — Certificate renewal failure #
Symptom: A10/A11.
- Identify the domains: query certificates with
days_remaining < 21and their last renewal attempt error. - Check the ACME account status and rate limits. Let's Encrypt's per-registered-domain and duplicate-certificate limits are the usual cause during bulk retries; the error text names which.
- Check the challenge path. HTTP-01 requires
http://{domain}/.well-known/acme-challenge/*to reach our edge — a customer adding a redirect-to-HTTPS or a WAF rule breaks it. DNS-01 requires our challenge record to still exist. - If Let's Encrypt is rate-limiting or degraded, switch the issuer to the documented fallback
CA via
ACME_DIRECTORY_URL(29.2) and re-runacme.issuefor the affected domains. - Manual last resort: issue with the fallback CA from the operator host and upload the certificate through the internal endpoint. Record it in the incident, because a manually issued certificate is not on the auto-renew path until reconciled.
- Never let a customer domain expire silently. At 3 days remaining, the workspace Owner
is emailed automatically with the exact DNS fix (Section 20), and the domain's public
status in the dashboard changes to
failedwith an explanatory banner.
R9 — A webhook endpoint flooding or failing #
Symptom: A8/A9, or a customer complaining about duplicate deliveries.
- Determine whether it is one endpoint or many. Many endpoints failing simultaneously means we changed something: check for a payload-shape change, a signature change, or an egress IP change.
- For one noisy endpoint: check its recent attempt history. The delivery ladder is fixed at one
send plus 8 retries over ~72 hours, on the schedule in Section 18.5; after the ninth attempt
the endpoint is auto-disabled with
reason: attempts_exhaustedand the Owner emailed. - If a customer endpoint is being flooded by us — for example a loop where their handler
calls our API and triggers another event — set the endpoint's
paused_untiland contact the customer. Our event emission is idempotent per(event_id, endpoint_id), so a customer seeing true duplicates is either retrying legitimately (they returned non-2xx) or not deduplicating onevent.id; the docs tell them to deduplicate onevent.id. - Never replay a webhook backlog at full speed after an outage. Use the replay command with
--rate 50/minand oldest-first.
R10 — Stripe webhook backlog #
Symptom: A13/A14.
- Check
stripe_eventsfor unprocessed rows and theirtypedistribution. A backlog of one type points at one handler. - Our handler is idempotent on
stripe_event_id; replay is always safe. Reprocess with the replay command. - If the backlog is caused by a poison event (a handler throwing on one payload shape), move
that row to
failed, capture the redacted payload in the incident, and fix forward. A subscription state error is customer-visible in the worst way — a workspace being wrongly markedpast_due— so verify the affected workspaces' states against Stripe after the fix using thebilling.reconcilejob (29.5). - Critical invariant to re-verify after any billing incident: no workspace lost widget serving. Cancellation and past-due never stop the widget data API or the showcase page (Section 19). Query for workspaces whose widgets stopped serving and confirm the count is zero.
R11 — A media purge that will not complete #
Symptom: A12. This is a compliance obligation with a 30-day maximum completion SLA (Section 22), so it escalates on a clock, not on convenience.
- List the stuck rows and their failure reasons:
SELECT ma.id, ma.workspace_id, ma.testimonial_id, ma.mux_asset_id,
ma.storage_key_original, ma.deleted_at, ma.purge_attempts, ma.last_purge_error,
now() - ma.deleted_at AS age
FROM media_assets ma
WHERE ma.deleted_at IS NOT NULL
AND ma.purged_at IS NULL
ORDER BY ma.deleted_at
LIMIT 100;- For each asset, verify externally rather than trusting our own flags — the whole point of
the guarantee is that a row is not deleted while its assets survive:
- Video provider:
GET /video/v1/assets/{muxAssetId}must return 404. - Object storage:
aws s3api head-objecton every derived key (original, poster, social cards, VTT) must return 404. - CDN: request each derived URL with a cache-busting query and confirm 404 from origin,
then confirm the purge-by-tag call for
testimonial:{id}returned success.
- Video provider:
- Fix the specific failure. The three real ones: the provider returns 409 while an asset is
still processing (wait and retry — the job's backoff already does this); an object key was
never recorded because an earlier failure interrupted the derivative pipeline (use the
prefix listing
s3://{bucket}/w/{workspaceId}/t/{testimonialId}/to find orphans, which is exactly why the key layout in Section 26.5.2 is prefix-per-testimonial); or credentials expired. - Re-enqueue
media.purgewithforce: true, which re-derives the key list from the prefix listing rather than from stored keys. - Escalation clock: unresolved at 7 days → SEV-2 and the privacy owner is informed; unresolved at 21 days → SEV-1, and the data subject or customer is contacted with a status update before the 30-day deadline, because a missed deadline that the subject learns about from us is a different event from one they discover themselves.
- Close by running the proof query in R14 and attaching its output to the incident.
R12 — Valkey (cache/queue backend) loss #
Symptom: A26.
Say this in the channel immediately, because it prevents a panicked over-reaction:
| Still works | Degraded | Stops |
|---|---|---|
| Published widgets and the widget data API (CDN-served; origin reads Postgres) | Dashboard latency (query cache cold) | Background jobs (BullMQ is Valkey-backed) |
| The showcase page | Rate limiting (fails open for reads, closed for submissions — see below) | New transcode/transcription/AI/import/webhook processing |
| Request pages and submission acceptance (writes go to Postgres + the outbox) | Idempotency replay (falls back to the idempotency_keys table) |
Repeatable schedulers until reconnect |
| Sign-in (sessions are database-backed, not Valkey-backed) |
- Fail-open/fail-closed policy is deliberate: when the limiter backend is unavailable, read endpoints are allowed (availability wins, and the CDN absorbs volume) while submission and invite endpoints are limited by a conservative in-process fallback limiter (per-pod token bucket at half the configured rate), because an unlimited submission endpoint is an abuse vector.
- Jobs are not lost: every job that matters is written first to the transactional outbox table in Postgres (Section 7 / Section 24.3) and a relay enqueues it. When Valkey returns, the relay drains the outbox in order. Confirm the outbox is growing rather than erroring.
- If Valkey data is lost entirely (a flushed or replaced instance), that is acceptable by
design: rate-limit buckets, caches, and idempotency snapshots are all reconstructible or
expendable, and queued jobs are re-derivable from the outbox. Run
outbox.replay --sincewith the outage start time. - After recovery, verify queue depths drain and run
quota.reconcilebecause quota counters are cached in Valkey with Postgres as the authority (Section 19).
R13 — Database failover #
Symptom: connection errors, A15/A16, or a managed-provider failover notification.
- Managed Postgres performs the failover; our job is to make it boring. The application uses
a connection pool with short
connect_timeout(5 s) andstatement_timeout(15 s for web, 120 s for worker), so a failover surfaces as a burst of 503s withDEPENDENCY_UNAVAILABLE(29.3), not as hung requests. - Confirm the new primary is accepting writes; confirm the pooler (Section 26.3) reconnected; confirm workers reconnected — BullMQ workers keep running but the outbox relay will error until the pool recovers.
- Check for lost writes: any request that got a 503 was not committed. Submissions in flight are retried by the respondent's browser (the submit endpoint is idempotent on the submission's client-generated idempotency key).
- Verify
SET LOCAL app.current_workspace_idstill applies — RLS depends on it, and a connection pooler in transaction mode is the only supported mode precisely soSET LOCALstays transaction-scoped (Section 26.3.2). If anyone has switched the pooler to session mode during an incident, revert it immediately: session mode plusSET LOCALis a cross-tenant data leak waiting to happen. - Post-incident: confirm replication is re-established and the backup schedule resumed.
R14 — A GDPR erasure that must be proven complete #
Symptom: a data subject or a customer asks for proof; or an erasure is being audited.
- Retrieve the erasure request row (
erasure_requests) and itscompleted_at. - Run the proof query, which checks every surface at once:
-- Proof of erasure for a single testimonial. Every count must be zero,
-- and every asset row must have purged_at set.
WITH t AS (SELECT :testimonial_id::uuid AS id)
SELECT
(SELECT count(*) FROM testimonials WHERE id = (SELECT id FROM t)) AS testimonial_rows,
(SELECT count(*) FROM submissions WHERE testimonial_id = (SELECT id FROM t)) AS submission_rows,
(SELECT count(*) FROM consents WHERE testimonial_id = (SELECT id FROM t)) AS consent_rows,
(SELECT count(*) FROM transcripts WHERE testimonial_id = (SELECT id FROM t)) AS transcript_rows,
(SELECT count(*) FROM ai_suggestions WHERE testimonial_id = (SELECT id FROM t)) AS ai_rows,
(SELECT count(*) FROM social_cards WHERE testimonial_id = (SELECT id FROM t)) AS card_rows,
(SELECT count(*) FROM media_assets
WHERE testimonial_id = (SELECT id FROM t) AND purged_at IS NULL) AS unpurged_assets,
(SELECT count(*) FROM audit_events
WHERE resource_id = (SELECT id FROM t) AND event_type = 'testimonial.erased') AS erasure_audit_rows;- Expected result: every count
0excepterasure_audit_rows = 1. The audit trail is append-only and retains the erasure event itself with the resource id and a hash of what was erased — it deliberately contains no personal data, which is what makes keeping it lawful and useful (Section 22.6). - Verify externally: the provider asset returns 404; each storage prefix lists zero objects;
each CDN URL returns 404; the respondent link
https://v.vouch.app/p/{token}returns 410 Gone withRESPONDENT_LINK_REVOKED. - Verify derived surfaces: the widget data payload for every widget that included the testimonial no longer contains it (fetch and grep by id), the showcase page returns without it, and any social card URL 404s.
- Produce the completion record: an operator-signed JSON artefact containing the request id, the subject identifier hash, timestamps, the six verification results, and the operator id. It is stored in the erasure request row and is what we send if asked.
R15 — A bad widget release, rolled back in minutes #
Symptom: A23/A24 shortly after a widget deploy, or a customer report.
The widget release train (Section 26.9) exists to make this a two-minute operation.
- Roll back before diagnosing. The loader
e.jsis a small mutable file whose only job is to load a content-hashed runtime chunk. Rollback = republish the previous loader build, which pins the previous runtime hash:pnpm widget:rollback --to <previous-runtime-hash>— this uploads the previous loader, purges the CDN tagwidget-loader, and takes effect within 60 seconds (the loader'smax-ageis 300 s withstale-while-revalidate, and the purge is immediate). - The runtime chunks are immutable and content-addressed, so the previous runtime is still on the CDN. Rollback never requires a rebuild.
- Confirm recovery on the synthetic fixture pages from at least two regions before declaring it done.
- Only then diagnose. Reproduce on the hostile fixture (Section 25.4.3) — most widget regressions are host-environment interactions our basic fixture does not exercise.
- Every widget rollback is followed by a mandatory canary re-run: the fixed build goes to 1% of loader responses for 30 minutes with the widget error rate watched, then 10%, then 100% (Section 26.9.3).
- If the bad release also changed the data payload shape, roll the payload builder back too —
an old runtime with a new payload is a second outage. The payload carries a
schemaVersionand the runtime tolerates one version in each direction, which is what bounds this risk.
R16 — Quota counter drift #
Symptom: A20.
- Drift means the Valkey-cached counter disagrees with the authoritative Postgres count. The
nightly
quota.reconcilejob (29.5) recomputes every counter and reports differences. - Postgres is always the authority. The reconcile job overwrites the cache; it never overwrites the database.
- Investigate the direction. Cache higher than reality means customers were wrongly
blocked — find them (
vouch_quota_blocks_totalaround the drift window), and email an apology plus a one-off allowance. Cache lower than reality means customers exceeded a cap — never claw back, never delete, never unpublish (Section 19 forbids it); simply let the reconciled counter take effect going forward. - Root cause is almost always a write path that inserted a testimonial without going through
packages/core/quota. Add the missing call and a test.
R17 — Suspected data breach #
Symptom: anomalous data access, a credible external report, a leaked key, or A22 escalating.
- Declare SEV-1 and name an Incident Commander (24.9.2). Start the incident log with timestamps; every subsequent step is logged.
- Contain before you investigate. Revoke the suspected credential (API key, session, OAuth token, provider key) immediately; revocation is reversible, exfiltration is not. Rotating a provider key is a one-command operation (Section 26.10.3).
- Preserve evidence: snapshot the relevant logs and the audit trail to the incident store
before any remediation that could alter them.
audit_eventsis append-only and cannot be altered by the application role, which is exactly why it is the primary evidence source. - Scope: which workspaces, which data classes, how many data subjects, and over what window. Use the audit trail and the access logs; the RLS design means cross-tenant reads require either a session-variable bug or the migrator role, so check for both explicitly.
- Notify: legal/privacy owner immediately; affected customers within 72 hours of becoming aware if personal data is involved, using the breach template (24.9.5); supervisory authority within 72 hours where required (Section 22.10).
- Remediate, then run a full credential rotation regardless of confirmed scope.
- Post-incident review is mandatory and is shared with affected customers on request.
R18 — A poison job wedging a queue #
Symptom: A18 with a flat drain rate and one job name dominating failures.
- Identify the job: highest
vouch_job_runs_total{result="failed"}byjob_name, then the specificjobIdfrom the dead-letter queue. - Remove it from the active set (
bullmqjob.moveToFailedvia the operator command), so the queue drains. - Reproduce locally with the redacted payload; fix; replay the dead-letter entry.
- If the job type can wedge repeatedly, add a payload-shape guard at enqueue time — the producer, not the consumer, is where a malformed payload should be rejected.
24.9 Incident Management #
24.9.1 Severity definitions #
| Severity | Definition | Response time | Update cadence | Status page |
|---|---|---|---|---|
| SEV-1 | Customer-visible loss of a core promise: published widgets not rendering for many customers, submissions failing broadly, data loss, suspected breach, or a compliance deadline at risk | Page immediately, 24/7; acknowledge within 5 minutes | Every 30 min | Yes, immediately |
| SEV-2 | Significant degradation with a workaround, or a single-subsystem outage: uploads failing, transcode backlog, billing state incorrect, dashboard unavailable | Page during extended hours (07:00–23:00 local on-call time); acknowledge within 15 min | Every 60 min | Yes if customer-visible for > 30 min |
| SEV-3 | Degraded quality or elevated risk without immediate customer impact: transcription backlog, webhook auto-disables, cert warning at 14 days | Next business day | Daily | No |
| SEV-4 | Cosmetic or internal-only | Backlog | — | No |
Anyone may declare an incident at any severity. Over-declaring is explicitly encouraged and is never criticised in review; the failure mode we care about is a SEV-1 that spent an hour as a Slack thread.
24.9.2 Incident command #
- Incident Commander (IC): owns the incident, not the fix. Decides severity, delegates, runs comms, and is the only person who declares resolution. The first responder is IC until they hand it over explicitly ("IC handoff to X, acknowledged?").
- Operations lead: performs changes. Only one person makes production changes at a time, and every change is announced in the channel before it happens.
- Communications lead: status page and customer email; on a SEV-2 the IC may hold this role.
- Scribe: timestamps in the incident channel. On a SEV-1 this is a distinct person.
- Rule: the IC does not type commands. If the IC is also fixing, the incident has no commander.
24.9.3 On-call rotation #
- One primary and one secondary, weekly rotation, handover at a fixed time with a written handover note covering open incidents, in-flight deploys, silenced alerts and why, and any planned provider maintenance.
- Minimum rotation size is 4 people; below that, the rotation is paused and alerts route to a shared channel with an explicit statement that out-of-hours coverage does not exist — because pretending otherwise is worse than admitting it.
- The secondary is paged if the primary does not acknowledge within 5 minutes; the engineering lead is paged if the secondary does not acknowledge within 10.
- Deploy freeze: no non-emergency deploys in the last 2 hours of an on-call shift, on Fridays after 14:00 local, or during an active SEV-1/SEV-2.
- Compensation, and the expectation that anyone paged overnight takes the following morning off, are policy, not favours.
24.9.4 The status page #
- Hosted externally, on infrastructure with no shared dependency on ours (a different provider and a different DNS zone), because a status page that shares our failure domain is decorative.
- Components listed: Dashboard, Request pages & submissions, Video processing, Transcription, Widgets & CDN delivery, Custom domains & TLS, Public API & webhooks, Email & SMS.
- "Widgets & CDN delivery" is listed first and is the component customers watch, because it is the one on their own site.
- Subscribers get email/RSS/webhook updates. Every SEV-1 gets a public post-incident summary within 5 business days.
24.9.5 Customer communication templates #
Write docs/COMMS-TEMPLATES.md in the repository during M11, seeding it with the five
templates reproduced below verbatim. That file is the working copy the incident commander
edits under pressure; this section is the specification it must satisfy. Every template in it
is short, dated, specific about impact, honest about cause, and free of blame directed at a
provider by name unless the provider has already published the incident. The required contents
are:
Status page — initial (SEV-1):
Investigating — {component}. Starting at {time UTC} we are seeing {plain-language symptom, e.g. "errors loading testimonial widgets on customer sites"}. Existing published testimonials {are/are not} affected. We are investigating and will update by {time+30m}.
Status page — identified:
Identified — {component}. The cause is {one sentence, no jargon}. {What we are doing}. Impact is limited to {precise scope}. Next update by {time}.
Status page — resolved:
Resolved — {component}. {Symptom} occurred between {start} and {end} UTC and affected {scope}. The cause was {one sentence}. It is fixed by {one sentence}. No {data was lost / testimonials were deleted / submissions were lost} — {or the precise statement of what was}. A full write-up will be published by {date}.
Email — third-party degradation:
Subject: Video processing is delayed Our video provider is currently experiencing degraded processing. Videos already published on your site continue to play normally, and new recordings are being saved safely — they will finish processing automatically once the provider recovers. No action is needed from you, and nothing has been lost. We will email again when processing has caught up.
Email — breach notification (only after legal review):
Subject: Important security notice about your Vouch workspace On {date} we identified {precise description}. Between {start} and {end}, {precise data classes} for {precise scope} {were / may have been} accessed. We have {containment steps}. {What you should do}. We are contacting {authorities/data subjects} as required. Our full report is available at {link}, and you can reach our privacy team at {address}.
Email — planned maintenance: sent 7 days and 24 hours ahead, naming the window in the customer's own timezone, the expected impact, and explicitly stating that published widgets continue serving throughout.
24.9.6 Blameless post-incident review #
Required for every SEV-1 and SEV-2, and for any SEV-3 that recurs three times. Held within 5
business days. Written in docs/incidents/{YYYY-MM-DD}-{slug}.md.
Fixed format:
- Summary — three sentences: what customers experienced, for how long, and the scope.
- Impact — measured, not estimated where measurement is possible: affected workspaces, failed requests, lost submissions (state zero explicitly if zero), error-budget consumed.
- Timeline — UTC timestamps from first symptom to resolution, including detection time and the gap between symptom and detection (that gap is usually the real finding).
- Contributing factors — plural, always. A single "root cause" is nearly always a story we tell ourselves.
- What went well — genuinely, including tooling that worked and decisions that were correct under uncertainty.
- What was difficult — including missing runbooks, confusing dashboards, and unclear ownership.
- Action items — each with an owner, a due date, and a priority. Every action is filed as a ticket before the review ends; a review with no filed tickets did not happen.
- Detection improvement — the specific alert or synthetic check that would have caught this sooner, and whether we are adding it.
Blameless means the review never names a person as a cause. "A deploy went out without a migration" is a finding; "X deployed without a migration" is a firing offence at the wrong company and a silence-generator at ours.
24.10 Backup & Disaster Recovery #
24.10.1 What is backed up, how often, and where #
| Data class | Mechanism | Frequency | Retention | Location | RPO | RTO |
|---|---|---|---|---|---|---|
| PostgreSQL — all application data | Managed automated snapshots + continuous WAL archiving (point-in-time recovery) | Snapshot daily; WAL continuous | 30 days PITR, plus monthly snapshot kept 12 months | Primary region + cross-region copy of daily snapshots | ≤ 5 min | ≤ 2 h |
| Object storage — originals, posters, VTT, social cards | Provider-native versioning + cross-region replication on the media bucket | Continuous | Versions 30 days; replicated copy indefinitely until lifecycle expiry | Primary + one secondary region | ≈ 0 (replication lag seconds) | ≤ 1 h to repoint |
| Video assets held by the video provider | Provider durability; our originals in object storage are the true backup for 30 days after ready (Section 9.10.1) |
Continuous | Per provider | Provider multi-region | n/a | Re-ingest from original: hours |
| Valkey (cache, queues, rate limits) | Not backed up, by design | — | — | — | n/a — reconstructible | ≤ 15 min to replace |
| Secrets | Secret manager with its own versioning and provider backup | On change | 90 days of versions | Secret manager region | n/a | ≤ 30 min |
| Logs / metrics / traces | Provider retention (24.2.6) | Continuous | Per level | Aggregator | n/a | n/a |
| Infrastructure definition | Git — the infrastructure code is the backup | Every commit | Forever | Git host + mirror | n/a | ≤ 4 h to rebuild an environment |
| Configuration (non-secret) | Git | Every commit | Forever | Git host | n/a | Minutes |
Backups are encrypted at rest with provider-managed keys, and the cross-region snapshot copy is encrypted with a separate key so a single key compromise does not reach both copies. Backup access is restricted to the platform owner role and is audited.
24.10.2 RPO and RTO by scenario #
| Scenario | RPO | RTO | Procedure |
|---|---|---|---|
| Single bad migration or accidental mass update | ≤ 5 min | ≤ 2 h | PITR restore to a new instance at the timestamp before the change, verify, repoint |
| Primary database instance failure | ≈ 0 | ≤ 15 min | Managed failover to standby (R13) |
| Full primary region loss | ≤ 24 h (cross-region snapshot) | ≤ 8 h | Rebuild from infrastructure code in the secondary region, restore latest cross-region snapshot, repoint DNS |
| Object-storage bucket deletion | ≈ 0 | ≤ 1 h | Restore from replica bucket; versioning protects against object-level deletion |
| Valkey total loss | n/a | ≤ 15 min | Provision new instance; replay outbox (R12) |
| CDN provider outage | n/a | ≤ 30 min | Failover cdn. to the secondary CDN configuration held in infrastructure code with pre-warmed origin rules |
| Compromised credential | n/a | ≤ 30 min | Rotate per Section 26.10.3 (R17) |
The widget promise under disaster: even during a full region loss, published widgets keep
serving from CDN cache for as long as the cached payload lives, and the CDN is configured with
stale-if-error=86400 on the widget data route specifically so that an origin outage of up to
24 hours does not blank a customer's homepage. This single header is the most important
availability decision in the product.
24.10.3 Restore drills — the part that is usually skipped #
A backup that has never been restored is a hypothesis. Therefore:
| Drill | Cadence | Success criteria | Evidence |
|---|---|---|---|
| Database PITR restore into an isolated environment | Monthly, first Tuesday | Restore completes within RTO; row counts within expected drift of production; pnpm db:verify schema check passes; a sample of 20 testimonials reads correctly through the application with RLS enforced |
Timed log + checksum report committed to docs/dr/ |
| Cross-region restore | Quarterly | Full environment rebuilt from infrastructure code + snapshot in the secondary region, serving the fixture host page end to end | Screenshot + timings in docs/dr/ |
| Object-storage replica failover | Quarterly | Media served from the replica bucket; posters and VTT resolve; widget renders video | Fixture page check |
| Valkey loss simulation | Quarterly | Outbox replay drains; no lost jobs; submissions continue throughout | Job count reconciliation |
| Widget rollback drill | Monthly | Rollback completes and is verified within 5 minutes wall clock (R15) | Timed record |
| Erasure proof drill | Quarterly | A seeded testimonial is erased and R14's proof query plus external checks all pass | Signed completion record |
| Alert drill | Quarterly | A synthetic condition fires one real alert path end to end; the paged person acknowledges | Page log |
Rules that make the drills real: a drill that fails its criteria creates a SEV-3 ticket with a
two-week deadline; a drill that is skipped twice in a row blocks the next feature release;
the drill is run by a different engineer each quarter, because a runbook only one person can
follow is not a runbook. The restore drill explicitly includes reading data through the
application (not just psql), because a restore that omits the RLS roles or the set_updated_at
trigger looks perfect at the database layer and fails completely at the application layer.
24.10.4 Durability posture for third parties #
| Provider | Durability posture | Our compensating control |
|---|---|---|
| Object storage | 11 nines annual object durability, versioning on, cross-region replication on the media bucket | Replica bucket + versioning; lifecycle rules never expire a version younger than 30 days |
| Video provider | Multi-region storage and delivery with its own redundancy | We keep the original upload in our object storage for 30 days after ready, and permanently when the workspace enables "keep originals" — so a total provider loss within the window is recoverable by re-ingest, and outside the window the loss is limited to renditions we can regenerate only if the original is retained. This trade-off is stated plainly to customers in Section 22.7's retention table |
| CDN | Anycast, multi-POP; stale-if-error shields the origin |
Secondary CDN configuration in infrastructure code; DNS TTL on cdn. kept at 300 s so failover is fast |
| Transcription provider | Stateless to us — transcripts are stored in our database | Alternative provider behind one interface (Section 3); no dependency on provider-side retention |
| Stripe | Source of truth for billing state, with its own durability | stripe_events mirror plus billing.reconcile job; we can rebuild subscription state from Stripe at any time |
| Email/SMS providers | Delivery only, no durable state | Message log in our database; re-send is always possible |
| Error/metrics/tracing backends | Observability only | Loss degrades observability, never the product; the product never depends on a telemetry write succeeding |
25. Testing Strategy & Quality Gates #
Vouch has three properties that make its testing strategy unusual, and the whole strategy follows from them:
- A bug ships to other people's websites. The widget runs on pages we have never seen, under CSPs we did not write, next to CSS we cannot control. Testing it against our own pretty fixture page proves almost nothing, so we test it against deliberately hostile ones.
- The tenancy boundary is a security boundary. A cross-tenant read is not a bug, it is an incident. Row-Level Security is therefore tested against a real PostgreSQL instance with explicit fail-closed assertions, not mocked.
- Several promises are legal, not merely functional — consent capture, media deletion that actually deletes, the AI select-never-rewrite boundary. Each has a test that would fail loudly if the promise were quietly broken.
25.1 The Testing Pyramid, and What Coverage Does Not Prove #
| Layer | Tooling | Share of suite | Where it runs | Wall clock budget |
|---|---|---|---|---|
| Unit | Vitest (version line in Section 3) | ~65% of test count | Every push, in parallel per package | ≤ 90 s |
| Integration (real Postgres, real Valkey, recorded providers) | Vitest + testcontainers | ~25% | Every push | ≤ 6 min |
| End-to-end (browser) | Playwright (Section 3) | ~8% | Every PR (core suite) + nightly (full matrix) | ≤ 12 min core |
| Non-functional gates (size, Lighthouse, axe, load, security) | size-limit, Lighthouse CI, axe-core, k6, scanners | ~2% | Every PR (fast gates) + nightly (slow gates) | ≤ 8 min PR gates |
Coverage thresholds, enforced in CI and failing the build when unmet:
| Scope | Line | Branch | Rationale |
|---|---|---|---|
packages/core |
90% | 85% | Business rules live here; this is where a gap is dangerous |
packages/core/quota, packages/core/authz, packages/core/ai/verbatim, packages/core/consent |
100% | 100% | These four modules encode promises we have made in writing |
packages/validation |
95% | 90% | Schemas are cheap to test and expensive to get wrong |
packages/db query helpers |
85% | 75% | Exercised heavily by integration tests too |
apps/web route handlers and server actions |
80% | 70% | Thin by design; logic belongs in core |
apps/embed |
90% | 85% | Small surface, huge blast radius |
apps/worker job handlers |
85% | 75% | Each handler has at least one success, one retry, and one poison test |
What coverage proves and does not prove — stated plainly so nobody games it. Line coverage proves that a line executed without throwing during a test run. It does not prove the line is correct, that its output was asserted, that the edge cases were chosen well, or that the feature works when composed with others. A module at 100% coverage whose tests assert nothing is worthless; a module at 70% coverage with sharp tests on its three dangerous branches is fine. We therefore treat coverage as a floor that detects untested code, never as a quality score, and we pair it with mutation testing on the four 100% modules (Stryker-style mutation run nightly, mutation score ≥ 80% required, failures filed as SEV-4 tickets). The reviewer's question is never "is coverage up?" — it is "what would break, and does a test fail when I break it?"
25.2 Unit Tests #
Unit tests are pure: no network, no database, no filesystem, no timers except fake ones
(vi.useFakeTimers()), no randomness except a seeded generator. A unit test that needs a
container is an integration test that is in the wrong folder.
The following are unit tested without exception. A PR touching any of them that adds no test is rejected by review, and each has a dedicated CI job so a failure names the promise it broke.
| Module | What must be covered |
|---|---|
Quota service (packages/core/quota) |
Every limit in the plan table of Section 19 at under-limit, exactly-at-limit, and over-limit; the 80% and 95% warning thresholds; the 90%/100% email triggers; downgrade producing over_limit without deleting or unpublishing anything; the soft-overage rule (over by >20% for 7 consecutive days before the next collection is blocked); the absolute rule that published content keeps serving on every plan state including canceled and past_due; unlimited values (null) never comparing as 0; and counter increments being idempotent under retry |
Permission function (can(actor, action, resource)) |
Every one of the four roles × every permission string in Appendix 29.6, asserted as a full truth table generated from the matrix so a new permission fails the test until it is classified; API-key scope checks; a user acting outside their workspace always denied; a soft-deleted member denied; the single-Owner invariant; the Free-tier seat block returning SEAT_LIMIT_REACHED rather than a generic 403 |
Verbatim validator (packages/core/ai/verbatim) |
Exact-substring acceptance after Unicode NFC normalization and whitespace collapsing; rejection of paraphrase, of case changes, of added punctuation, of "smoothed" filler removal; multi-part excerpts accepted only as an ordered list of exact substrings; out-of-order parts rejected; overlapping parts rejected; excerpts spanning segment boundaries in a transcript accepted when contiguous in the concatenated text; emoji, combining accents, curly-vs-straight quotes, and non-breaking spaces all handled; empty and whitespace-only proposals rejected; and a property-based test asserting that for any random substring of a random body the validator accepts, and for any single-character mutation it rejects |
| Cursor encode/decode | Round-trip for every documented sort key; opaque base64url output; tamper detection (a mutated cursor yields INVALID_CURSOR, never a 500); cursors from a different sort rejected; limit clamping to 1–100 with default 25; stability across ties via the id tiebreaker; and a fuzz test feeding 10,000 random strings that asserts no input produces an unhandled exception |
| VTT generator | Segment → cue conversion; timestamp formatting to HH:MM:SS.mmm; cue splitting at the configured max characters per line and max lines per cue; merged and split segments after moderator edits; overlapping timings corrected deterministically; zero-length segments dropped; escaping of &, <, >; RTL text preserved; and a golden-file comparison for each of the five launch languages |
| Webhook signer | Signature construction over "{t}.{rawBody}" exactly as specified in Section 7.18; verification accepting within the 5-minute tolerance and rejecting outside it; rejection of a modified body, a modified timestamp, and a truncated signature; constant-time comparison; multiple v1 signatures during secret rotation both accepted |
| Rating normalization | Every import source's native scale mapped to the canonical 1–5 integer (Section 16); half-star and 0–10 scales; source_rating_raw always retained verbatim; missing ratings mapped to null and never to 0; out-of-range inputs rejected with IMPORT_ROW_INVALID |
| Consent-version resolver | The correct consent version selected for a given request page and timestamp; a version change creating a new version rather than mutating the old one; historical submissions resolving to the text they were actually shown; consent_text_sha256 matching the stored text byte-for-byte; the required grant being mandatory and the two optional grants defaulting to false; and a test that asserts no code path can mark a consent granted without the stored text |
Additional unit-test targets with the same rigour but no separate CI job: slug generation and collision handling, the idempotency-key store, the rate-limiter token bucket (with fake timers), the transcript diff used by the caption editor, the widget payload builder's size guard (≤ 60 KB for 24 items, Section 13), the sanitizer allow-list, the near-duplicate hash, the plan-change proration mapper, and the error-envelope serializer (which is asserted to produce exactly the shape in Section 7.3 for every code in Appendix 29.3).
25.3 Integration Tests #
Integration tests run against real dependencies started by testcontainers: PostgreSQL (version line in Section 3), Valkey, and an S3-compatible object store. No mocked database. Migrations run from scratch at suite start, which also continuously tests the migration path.
25.3.1 Database and RLS — the fail-closed tests #
The tenancy model in Section 6 depends on two things: workspace_id on every domain table,
and PostgreSQL Row-Level Security policies enforced for a non-superuser application role.
These tests exist to make a regression in either impossible to merge.
// packages/db/test/rls.test.ts — abbreviated; the real suite iterates every table.
describe('RLS cross-tenant isolation', () => {
it('never returns rows from another workspace', async () => {
const { a, b } = await seedTwoWorkspaces() // each with testimonials, widgets, tags
await withWorkspace(a.id, async (tx) => {
const rows = await tx.select().from(testimonials)
expect(rows.every(r => r.workspaceId === a.id)).toBe(true)
expect(rows.some(r => r.workspaceId === b.id)).toBe(false)
})
})
it('fails CLOSED when the workspace variable is not set', async () => {
// No SET LOCAL app.current_workspace_id at all.
await expect(withoutWorkspace(tx => tx.select().from(testimonials)))
.rejects.toThrow() // must throw, must not return []
})
it('cannot write a row belonging to another workspace', async () => {
await withWorkspace(a.id, async (tx) => {
await expect(tx.insert(testimonials).values({ ...fixture, workspaceId: b.id }))
.rejects.toThrow(/row-level security/i)
})
})
it('cannot update or delete another workspace\'s row', async () => { /* … */ })
it('runs as a role that cannot bypass RLS', async () => {
const [{ rolbypassrls, rolsuper }] = await appPool.query(
`select rolbypassrls, rolsuper from pg_roles where rolname = current_user`)
expect(rolbypassrls).toBe(false)
expect(rolsuper).toBe(false)
})
})Three structural tests run over the entire schema, generated from the information schema rather than a hand-maintained list, so a new table is covered the moment it exists:
| Structural test | Assertion | Failure message |
|---|---|---|
| Workspace scoping | Every table outside the documented platform allow-list (users, sessions, stripe_events, feature_flags, job_runs, schema_migrations, plans) has a non-null workspace_id uuid with a foreign key to workspaces(id) |
"Table X is missing workspace_id — see Section 6.2" |
| RLS enabled | Every workspace-scoped table has relrowsecurity = true and relforcerowsecurity = true, and has at least one policy referencing app.current_workspace_id |
"RLS is not enabled on X — this is a cross-tenant leak" |
| Index leading column | Every multi-column index on a workspace-scoped table leads with workspace_id |
"Index I does not lead with workspace_id — see Section 5.13" |
Additional integration coverage:
- Soft delete: a test asserting that every read helper in
packages/dbcomposesnotDeleted(), verified by executing each helper against a fixture containing one soft-deleted row and asserting it is absent; plus a static check that no query file selects from a soft-deletable table without the helper. - Append-only audit:
UPDATEandDELETEonaudit_eventsas the application role must raise an error. Tested directly, because this is enforced by revoked privileges and a rule, and privileges are exactly the kind of thing a migration silently regrants. updated_attrigger: every table with the column gets it bumped on update and not on insert-only paths.- Transactional outbox: an event row and its domain write commit or roll back together; the relay delivers each row exactly once under concurrent relays (tested with two relay instances racing); a relay crash mid-batch redelivers without duplicating (consumer idempotency asserted).
- Job handlers: every handler in Appendix 29.5 has an integration test for success, for a retryable failure (asserting the retry policy and that no partial state persists), and for a poison payload (asserting dead-letter routing and the alert being emitted).
- Quota under concurrency: 50 concurrent submissions against a workspace with 1 slot
remaining must result in exactly 1 accepted and 49
TESTIMONIAL_LIMIT_REACHED— the counter is enforced with a database-level conditional write, and this test is what proves the cache is not the enforcement point. - Idempotency: the same
Idempotency-Keyreplayed concurrently produces one side effect and two identical responses, the second carryingIdempotency-Replayed: true.
25.3.2 External providers — recorded fixtures and contract tests #
No integration test calls a live third-party API on the PR path. Each provider has two layers:
| Provider | Recorded-fixture tests (every PR) | Contract test (nightly, live sandbox) |
|---|---|---|
| Video host | Direct-upload creation, asset ready/errored webhooks with real captured payloads, signature verification, idempotent replay of the same event id | Create a real asset from a 5-second fixture in the sandbox account, assert it reaches ready and the playback id resolves |
| Transcription | Nova-family response fixtures per language, low-confidence fixture, language-detection-below-0.6 fixture, provider 429 and 500 | Transcribe a 10-second fixture, assert segment/word shape matches the stored JSONB schema (Section 10.3) |
| Object storage | Multipart create/part/complete/abort against the local container | Same flow against a real bucket in staging, plus CORS preflight assertion |
| Stripe | Fixtures for checkout.session.completed, customer.subscription.updated/deleted, invoice.payment_failed ×3, invoice.paid; signature verification; out-of-order event handling |
Stripe test-mode flow: subscribe, upgrade, downgrade, cancel, dunning; assert workspace state after each |
| Email / SMS | Provider client mocked at the HTTP layer; every template rendered and snapshot-tested; bounce and complaint webhook fixtures | Send to provider sandbox addresses; assert delivery callbacks |
| AI provider | Recorded completions including a deliberately non-verbatim response that the validator must reject, a malformed JSON response, and a timeout | One live call per prompt version asserting the response parses and passes the validator |
| Google / G2 / Capterra / X imports | Recorded API responses per source including pagination, rate limiting, empty results, and the no-entitlement path for the syndication sources | Live sandbox/dev-account fetch where the source provides one; otherwise the recorded fixtures are authoritative and are refreshed quarterly with a dated note in docs/fixtures/README.md |
| ACME / DNS | A local ACME test server (pebble-style) plus a stub resolver returning scripted answers, including the CAA-forbids case | Issue a real staging certificate for a test subdomain weekly |
| CDN | Purge-by-tag API mocked with assertion on the tag set | Purge a real staging tag and assert the object is re-fetched |
Fixtures are captured with a recorder script and scrubbed by the same deny-list as logging (Section 24.2.4) before being committed. A CI check scans fixture files for anything resembling a live key and fails the build.
25.4 End-to-End Tests (Playwright) #
E2E tests run against a fully built application with real Postgres, Valkey, object storage, and stubbed external providers driven through a local provider-stub service (so webhooks can be triggered deterministically). Each test seeds its own workspace and tears it down; tests never share state and always run in parallel.
25.4.1 The respondent journey — the flagship test #
This single spec is the product. It runs on every PR, on Chromium desktop and on an emulated Pixel-class device, and it must never be quarantined.
// e2e/respondent-journey.spec.ts (abbreviated)
test.use({
launchOptions: { args: [
'--use-fake-ui-for-media-stream',
'--use-fake-device-for-media-stream',
'--use-file-for-fake-video-capture=e2e/fixtures/media/talking-head-20s.y4m',
'--use-file-for-fake-audio-capture=e2e/fixtures/media/speech-20s.wav',
] },
permissions: ['camera', 'microphone'],
})
test('respondent records, retakes, survives a dropped connection, and submits', async ({ page, context }) => {
await page.goto(requestPageUrl) // 1. public request page
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
await page.getByRole('button', { name: 'Record a video' }).click()
await page.getByRole('combobox', { name: 'Camera' }).selectOption({ index: 0 })
await page.getByRole('button', { name: 'Start recording' }).click()
await expect(page.getByRole('status')).toContainText('Recording') // aria-live announcement
await page.waitForTimeout(4000)
await page.getByRole('button', { name: 'Stop' }).click()
await page.getByRole('button', { name: 'Retake' }).click() // 2. retake is free
await expect(page.getByRole('status')).toContainText('Ready to record')
await expectAbortedMultipartUpload() // discarded take reclaimed
await page.getByRole('button', { name: 'Start recording' }).click()
await page.waitForTimeout(6000)
await context.setOffline(true) // 3. drop the connection
await page.waitForTimeout(4000)
await expect(page.getByRole('status')).toContainText('Reconnecting')
await context.setOffline(false)
await page.getByRole('button', { name: 'Stop' }).click()
await expect(page.getByText('Upload complete')).toBeVisible({ timeout: 30_000 })
await expectNoDuplicateParts() // resumed, did not re-send
await page.getByLabel('Your name').fill('Dana Reed') // 4. details + consent
await page.getByLabel('Company').fill('Northwind')
await expect(page.getByRole('checkbox', { name: /permission to publish/i })).not.toBeChecked()
await page.getByRole('checkbox', { name: /permission to publish/i }).check()
await page.getByRole('button', { name: 'Submit' }).click()
await expect(page.getByRole('heading', { name: /thank you/i })).toBeVisible()
})Assertions beyond the happy path that this spec (and its siblings) make:
| Scenario | Assertion |
|---|---|
| Consent is never pre-checked | The required checkbox starts unchecked; submitting without it shows an inline error and no row is written |
| Consent text is stored verbatim | After submit, the stored consent_text equals the rendered text and its SHA-256 matches |
| Hard stop at plan max length | On a Free workspace, recording stops automatically at 2:00 with warnings announced at 60/30/15 s remaining |
| Quota reached | With the lifetime cap hit, the request page renders the friendly collectionClosed state with HTTP 200, not an error page |
| Text-only submission | The full journey with no video, including custom fields of every type |
| Mid-recording app switch | A visibilitychange to hidden for 10 s finalizes the take rather than losing it |
| Resume link | A session abandoned mid-upload can be resumed from the emailed link within 72 hours |
| Mobile viewport | The entire flow completes at 320 px wide with no horizontal scroll |
25.4.2 Moderation, publishing, and the widget appearing #
A second flagship spec continues where the first ends, in the dashboard:
- Sign in as an Editor. The new submission is in the moderation inbox as
pending. - Play the video; assert captions are on by default and the caption track loads.
- Edit one transcript segment; assert
transcript_versionbumped, an audit event was written, and the regenerated VTT contains the edit. - Add two tags; approve; publish. Assert the status transitions match Section 11's state machine and that each transition wrote an audit event with before/after snapshots.
- Assert the widget data API response for the widget containing this testimonial now includes it, within the CDN's 60-second freshness window, and that the purge-by-tag call fired.
- Load the fixture host page; assert the testimonial's text is visible inside the open shadow root, that the video poster is present, and that no video request was made until the play affordance is clicked.
- Unpublish; assert the widget stops showing it within 60 seconds; assert
published_atis retained. - Delete; assert
media.purgeis enqueued and, after the stub provider confirms, thatpurged_atis set and the poster URL 404s.
25.4.3 The fixture host pages — and what each proves #
The widget's real environment is other people's websites, so we maintain four fixture host
pages under apps/embed/fixtures/, served on a separate origin from the CDN in tests so CORS
is genuinely exercised.
| Fixture | What it contains | What it proves |
|---|---|---|
host-basic.html |
Minimal HTML5 page, no CSS framework, one widget | Baseline render, the request-count assertion, and the Lighthouse control comparison |
host-hostile.html |
Aggressive global CSS: * { box-sizing: content-box !important }, img { max-width: 40px !important }, div { display: flex !important }, a global font-size: 9px, a z-index: 2147483647 sticky header, a CSS reset that unsets everything, plus a host page that defines window.Vouch before our script loads and a global error handler that rethrows |
That shadow-root isolation actually isolates; that inherited properties are explicitly reset inside our root; that our single global property does not collide destructively; that we never rely on host globals |
host-strict-csp.html |
Content-Security-Policy: default-src 'none'; script-src https://cdn.vouch.app; img-src https://cdn.vouch.app data:; connect-src https://cdn.vouch.app; style-src 'none' — deliberately without 'unsafe-inline' |
That the widget works with constructable/adopted stylesheets and injects no inline <style>; that the documented directives in Section 13.9 are sufficient and complete. A CSP violation reported on this page fails the build |
host-multi.html |
Six widgets of different types on one page, two of them below the fold, plus a second copy of the loader script tag | That multiple widgets share one loader instance and one batched data fetch (?ids=…), that below-the-fold widgets do not fetch video, that impressions fire once per widget per view, and that a duplicated script tag does not double-initialise |
Each fixture is also loaded in an iframe-based test with sandbox attributes to assert we
degrade correctly where APIs are restricted, and host-basic has a <noscript> variant that
asserts the fallback content and the oEmbed endpoint (Section 13) both render crawlable HTML.
25.4.4 Dashboard E2E coverage #
| Flow | Key assertions |
|---|---|
| Sign-up → email verification → first request page → invite | Free tier blocks the invite with a plan-upgrade error, not a generic failure |
| Sign-in with magic link, and with TOTP enabled | Session cookie attributes exactly as Section 6.5 specifies |
| Role enforcement | A Viewer cannot see publish controls and the underlying API returns 403 with the right code when called directly — the UI check alone is never the assertion |
| Ownership transfer | Requires confirmation, results in exactly one Owner |
| Widget editor | Accent-colour contrast checker warns and offers an accessible alternative; the generated snippet contains only the widget id |
| Custom domain | All six states render with text labels, not colour alone; the observed-vs-required record diff renders |
| Billing | Checkout → plan change → cancel; a canceled workspace is read-only and its widget still serves |
| Import | CSV mapping UI, duplicate detection, and the no-entitlement path for a syndication source |
| Export | Full workspace export downloads and contains the documented files |
25.5 Widget-Specific Testing #
The performance budget in Section 13 is a contract, so it is enforced by machines on every pull request. Every row below is a hard fail, not a warning.
| Gate | Tool | Threshold | Failure behaviour |
|---|---|---|---|
| Loader transfer size | size-limit | ≤ 5 KB gzip / ≤ 6 KB brotli | Build fails; the diff prints the byte delta and the top three contributors |
| Runtime chunk size | size-limit | ≤ 22 KB gzip | Build fails |
| Total JS added to host page | size-limit sum | ≤ 27 KB gzip | Build fails |
| No framework in the bundle graph | Custom esbuild metafile assertion | Zero modules matching react, react-dom, vue, svelte, preact, jquery, lodash, core-js, or any polyfill package |
Build fails, naming the import chain that pulled it in |
| Dependency allow-list | Metafile assertion | The widget bundle may contain only first-party modules; the allow-list is empty and adding to it requires an explicit product decision recorded in docs/DECISIONS.md |
Build fails |
No innerHTML / outerHTML / insertAdjacentHTML / document.write in apps/embed |
ESLint rule + a post-build string scan of the emitted bundle | Zero occurrences | Build fails — the scan catches minifier-introduced forms the linter cannot see |
No document.head or document.body mutation |
Post-build scan + runtime test asserting document.head.childElementCount is unchanged after render |
Zero | Build fails |
| Global footprint | Runtime test: snapshot Object.keys(window) before and after; the only new key is Vouch, and it is non-enumerable |
Exactly one, non-enumerable | Test fails |
| CLS contribution | Lighthouse CI on host-basic |
≤ 0.01 | Gate fails |
| INP contribution | Lighthouse CI + Playwright interaction timing | ≤ 50 ms | Gate fails |
| LCP delta vs. control page | Lighthouse CI comparison run (same page with and without the widget, 5 runs, median) | ≤ 25 ms | Gate fails |
| Long tasks caused by the widget | Playwright PerformanceObserver for longtask entries attributed to our script |
0 tasks > 50 ms | Gate fails |
| Network requests before first widget paint | Playwright request log filtered to our origin | Exactly 1 (the JSON payload) | Gate fails |
| Web fonts | Request log | 0 font requests | Gate fails |
| Time to first widget paint | Lighthouse CI throttled (4G, mid-tier mobile emulation) | ≤ 400 ms after loader execution | Gate fails |
| Render-blocking resources | Lighthouse audit + a lint asserting the documented snippet carries async |
0 | Gate fails |
| Payload size | Integration test building a 24-item wall | ≤ 60 KB JSON | Test fails |
Cross-browser matrix. The widget's baseline is the last two versions of Chrome, Edge, Firefox, and Safari, plus iOS Safari 16+ (Section 13).
| Browser | PR runs | Nightly | Notes |
|---|---|---|---|
| Chromium (latest, latest-1) | Yes | Yes | Full suite including Lighthouse |
| Firefox (latest, latest-1) | Rendering + interaction subset | Full | Adopted stylesheet behaviour differs subtly; explicitly asserted |
| WebKit (Playwright) | Rendering + interaction subset | Full | Stands in for desktop Safari |
| Real iOS Safari 16 and current, real Android Chrome | No | Yes, on a device cloud | Recorder and widget smoke suites; a real-device failure blocks release |
| Edge (Chromium) | No | Yes | Smoke only |
Nightly also runs a regression corpus: every host-page pattern that has ever caused a customer incident is added as a fixture permanently (R6 makes this mandatory), so the widget's fixture set grows monotonically and no incident recurs silently.
25.6 Accessibility Testing #
- Automated, every PR, hard gate: axe-core (via
@axe-core/playwright, Section 3) runs against every request-page state (empty, text form, recorder idle/recording/review, consent, thank-you,collectionClosed), every widget type in both light and dark themes onhost-basic, the showcase page, and the top 15 dashboard screens. Zero violations atseriousorcritical.moderateandminorviolations are reported and tracked but do not block, with a standing rule that the count must not increase between releases. - Shadow roots are traversed because the widget uses open mode specifically to permit
this (Section 21) — a change to closed mode would silently blind these tests and is
therefore also blocked by a lint rule asserting
attachShadow({ mode: 'open' }). - Targeted automated assertions that axe cannot express, written as Playwright tests:
keyboard operability of every recorder control in a documented tab order; visible focus
meeting the non-obscured and focus-appearance requirements;
aria-live="polite"recording announcements firing at 60/30/15 s and not on every tick; captions defaulting to on in every player surface; the carousel exposing a button equivalent for every swipe; target sizes ≥ 24 × 24 CSS px (≥ 44 × 44 for primary recorder controls on touch);prefers-reduced-motiondisabling auto-advance and autoplay; and a colour-independence check that re-renders status pills, domain states, transcript-confidence highlights, and analytics deltas in greyscale and asserts each still exposes a distinct accessible name or text label. - Contrast: an automated check of every theme preset, plus a unit test of the widget editor's contrast checker asserting it warns below 4.5:1 for body text and 3:1 for UI components and offers the nearest accessible colour.
- Manual matrix: the screen-reader and platform matrix is owned by Section 21. It is
executed before every milestone exit (Section 27), before any release that changes the
recorder, the consent screen, a widget type, or the showcase page, and on a quarterly
cadence regardless of changes. Results are recorded in
docs/a11y/manual-runs/{date}.mdwith the tester, the build SHA, and a pass/fail per row; a failed row blocks the release unless it is downgraded to a documented, dated, owned remediation item.
25.7 Performance and Load Testing #
Load tests run with k6 against a staging environment sized as a documented fraction of production (Section 26.1), and results are normalised before comparison.
| Scenario | Shape | Pass criteria |
|---|---|---|
| Widget data API — steady state | 2,000 rps for 10 min across 500 distinct widget ids, 90% expected cache hits | Edge p95 ≤ 150 ms, origin p95 ≤ 400 ms, 5xx ≤ 0.01%, origin CPU < 60% |
| Widget data API — cold cache stampede | Purge all tags, then 2,000 rps instantly | No origin 5xx; request coalescing collapses concurrent misses per key to one origin fetch; origin p95 ≤ 800 ms during the first 30 s |
| Traffic spike (customer homepage feature) | 0 → 10,000 rps over 60 s on a single widget id | Edge absorbs; origin sees ≤ 20 rps for that key |
| Submission burst | 200 concurrent text submissions, then 50 concurrent video submissions with 5-minute takes | All succeed or are rate-limited with a correct 429; zero 5xx; zero lost uploads; p95 submit ≤ 1.5 s |
| Multipart upload throughput | 50 concurrent 5-minute recordings uploading in 5 s chunks | Part failure ratio < 0.5%; no session exceeds the retry ladder |
| Dashboard read load | 100 concurrent moderators paging the inbox with filters | p95 ≤ 500 ms; no query plan regressions (asserted by comparing EXPLAIN fingerprints against a committed baseline) |
| Worker throughput | 5,000 queued mixed jobs | Drains within 10 min at the documented replica count; no queue's oldest-waiting exceeds 900 s |
| Analytics ingestion | 20,000 beacons/min for 10 min | Zero dropped batches at the edge; rollups complete within the hour; database write load < 30% |
| Soak | 10% of peak for 8 hours, nightly | No memory growth beyond 10% after warm-up in web or worker; no connection-pool growth; no file-descriptor growth |
| Request page on mobile | Lighthouse mobile on the request page | Performance ≥ 90; total request-page JS ≤ 140 KB gzip (Section 23) |
Database performance is additionally guarded by a query-plan regression test: the twenty
hottest queries have committed EXPLAIN (FORMAT JSON) baselines, and CI fails if a plan
switches to a sequential scan on a table above a seeded row threshold.
25.8 Security Testing #
| Layer | Tool / method | Cadence | Blocking |
|---|---|---|---|
| SAST | ESLint security rules + semgrep with a ruleset covering injection, SSRF, path traversal, unsafe deserialization, and React dangerouslySetInnerHTML |
Every PR | Yes, on high severity |
| Dependency scanning | pnpm audit + a continuous advisory scanner (Dependabot-style) |
Every PR + daily | Critical/high block the PR; a documented, time-boxed exception requires the security owner's approval recorded in the PR |
| Lockfile integrity | pnpm install --frozen-lockfile, plus a check that no dependency resolves to a git URL or a non-registry source |
Every PR | Yes |
| Secret scanning | gitleaks on the diff and on the full history nightly; plus the log-sample scan from Section 24.2.4 |
Every PR + nightly | Yes |
| Container scanning | Image vulnerability scan of the built web and worker images | Every build | High/critical block deploy |
| DAST | OWASP ZAP baseline + an authenticated active scan against staging, with the scanner given a Viewer-role account and a second workspace, so authorization findings are meaningful | Nightly against staging | High findings block the next production deploy |
| Authorization fuzzing | A generated suite that, for every route in the API surface, calls it as each of the four roles, as an API key with each scope, as an unauthenticated caller, and as a member of a different workspace, asserting the expected status from the permission matrix in Appendix 29.6 | Every PR | Yes — this is the single highest-value security test in the suite |
| Webhook signature tests | Replay, tamper, and expired-timestamp cases (also in 25.2) | Every PR | Yes |
| Sanitizer tests | A corpus of ~200 XSS payloads (including SVG, mutation-XSS, and CSS-based vectors) submitted as testimonial bodies, asserted to be neutralised at write time and again at serve time, and asserted never to reach the widget as markup | Every PR | Yes |
| Upload safety | Content-type sniffing, magic-byte validation, a polyglot file (valid GIF and valid JS), an oversized file, a zip-bomb-style media file, and a file with a malicious filename; assert rejection with the correct catalogue code and no storage write | Every PR | Yes |
| Rate-limit tests | Each documented limit exceeded and asserted to return 429 with Retry-After and the three X-RateLimit-* headers |
Every PR | Yes |
| SSRF | Every place we fetch a customer-supplied URL (import permalinks, webhook endpoints, avatar URLs) tested against localhost, link-local 169.254.169.254, private ranges, DNS-rebinding, and redirect-to-private; all must be refused by the shared safeFetch helper |
Every PR | Yes |
| Penetration test | External firm, full-scope including the multi-tenancy boundary, the embed, and the public API | Before public launch, then annually, plus after any change to the tenancy or authentication model | Launch-blocking; all high and critical findings fixed and retested before launch |
| Threat-model review | Written review of the trust boundaries (host page ↔ widget, respondent ↔ request page, tenant ↔ tenant, provider ↔ us) | At launch and annually, or when a boundary changes | Write docs/security/threat-model.md: copy Section 22.12 into it as the starting text, then add per boundary the assets, the assumed attacker, the existing controls, the residual risk and its owner, and the date and reviewer of each review |
25.9 Test Data #
- Factories, not fixtures-by-hand.
packages/db/testingexports typed factories (makeWorkspace,makeUser,makeMember,makeRequestPage,makeSubmission,makeTestimonial,makeMediaAsset,makeTranscript,makeWidget,makeCustomDomain,makeApiKey,makeImportRow,makeConsent). Every factory takes overrides, produces valid data by default, uses a seeded pseudo-random generator so runs are reproducible, and respects workspace scoping — a factory cannot produce a row without aworkspace_id. - The seeded demo workspace (
pnpm db:seed --demo) is a realistic, non-trivial dataset used for local development, the staging environment, screenshots, and the E2E dashboard suites: 1 Owner + 1 Admin + 1 Editor + 1 Viewer, 3 request pages with different custom-field sets, 42 testimonials spanning every status in Section 11 (including rejected and archived), 12 with video across all five languages, 4 with edited transcripts, 6 imported rows across all sources including one with a preserved permalink, 8 widgets covering all six types, one verified custom domain and one stuck inpending_dns, 90 days of synthetic analytics rollups, and a workspace on each of the four plans. The demo seed contains no real person's name, face, voice, or words — copy is written for the purpose and media is recorded by consenting team members with a release on file. - Media fixtures (
e2e/fixtures/media/, committed via Git LFS with total size capped at 50 MB):
| Fixture | Purpose |
|---|---|
talking-head-20s.y4m + speech-20s.wav |
The fake-device inputs for the recorder journey |
short-3s.mp4 |
Fastest possible transcode; used in provider contract tests |
silent-30s.mp4 |
Asserts transcription returns an empty transcript gracefully and the UI says "no speech detected" rather than failing |
long-11min.mp4 |
Exceeds every plan's max length; asserts server-side rejection with VIDEO_LENGTH_EXCEEDED even when the client guard is bypassed |
portrait-9x16.mp4 and landscape-16x9.mp4 |
Aspect-ratio handling in every widget type and the social card generator |
corrupt-truncated.mp4 |
Asserts the pipeline marks the asset errored, notifies the moderator, and offers a re-record link — never a stuck processing |
polyglot.gif.js |
Upload-safety test |
accented-multilingual.mp4 |
Five-language transcript and VTT correctness, including combining diacritics |
loud-clipping.mp4 |
Asserts audio normalisation and that low-confidence words are highlighted in the editor |
- The absolute rule: no production data is ever copied into any non-production environment. Not a database dump, not a "just this one workspace" export, not a customer's video for debugging. This is enforced technically (non-production environments have no network path to production credentials; production backup access is restricted to the platform owner role and is audited) and procedurally (any request to reproduce a customer issue is served by a synthetic reconstruction, or by a support session in production with the customer's written consent and an audit trail). If a bug can only be reproduced with real data, the correct response is to improve the synthetic fixtures until it can be — and every incident that forced this question adds a fixture.
25.10 CI Pipeline #
Runs on every push to a branch and on every pull request. Target wall clock for the PR pipeline: under 12 minutes at p95, because a slower pipeline is a pipeline people learn to bypass.
| # | Stage | Runs | Blocking | Typical time |
|---|---|---|---|---|
| 1 | Setup: checkout, pnpm install with frozen lockfile, Turborepo remote cache restore | Always | Yes | 40 s |
| 2 | Lint + format check (ESLint, Prettier, the custom vouch/* rules) |
Always, parallel | Yes | 60 s |
| 3 | Typecheck (tsc --noEmit, all packages) |
Always, parallel | Yes | 90 s |
| 4 | Unit tests + coverage thresholds | Always, parallel | Yes | 90 s |
| 5 | Secret scan (diff) + dependency audit + SAST | Always, parallel | Yes (high/critical) | 60 s |
| 6 | Build all packages and apps | Always | Yes | 2–3 min |
| 7 | Widget gates: size-limit, bundle-graph assertion, forbidden-API scan | Always, right after build | Yes | 20 s |
| 8 | Migration check: apply all migrations to an empty database, then assert the generated schema matches the checked-in snapshot, and assert every migration is reversible or explicitly marked irreversible | Always | Yes | 60 s |
| 9 | Integration tests (containers) incl. the RLS suite | Always | Yes | 4–6 min |
| 10 | E2E core suite (Chromium + mobile emulation): respondent journey, moderation→publish→widget, auth, billing smoke | Always | Yes | 6–8 min (parallel shards) |
| 11 | Accessibility gate (axe on request pages, widgets, showcase, top dashboard screens) | Always | Yes (serious/critical) | 90 s |
| 12 | Lighthouse CI (widget budgets, request-page budget) | Always | Yes | 3 min |
| 13 | Preview deploy + smoke test against the preview URL | PRs only | Yes | 2 min |
| 14 | Full cross-browser E2E, real-device suite, soak, DAST, mutation testing, full-history secret scan, license check, fixture-freshness check | Nightly on main |
Blocks the next release, not the PR | 45 min |
| 15 | Load tests | Weekly, and before any release touching the widget data API or the upload path | Blocks release on failure | 30 min |
Stages 2–5 run concurrently; 7–12 fan out after the build. Turborepo's task graph plus remote caching means an unchanged package is not rebuilt or retested, and the cache key includes the lockfile and the environment matrix so a stale cache cannot mask a dependency change.
Merge requirements: all blocking stages green, at least one approving review from a
code owner of every touched area, a linear history (rebase or squash — no merge commits), and
a Conventional Commit title. main is protected; nobody pushes to it directly, including
administrators.
Flaky-test policy — quarantine with an owner and a deadline, never a silent retry.
- Playwright is configured with
retries: 0on the PR path. A retry that hides a flake hides a real race, and races in this product mean lost uploads. - A test that fails and then passes on re-run is reported by the flake detector (which re-runs failed tests once, in a separate reporting job that cannot make the build green) and is filed automatically as a ticket.
- Quarantining requires: a
test.fixmeannotation carrying the ticket id, a named owner, and a deadline no more than 10 calendar days out, recorded indocs/quarantine.md. - A quarantined test past its deadline fails the build for everyone until it is fixed, deleted with a written justification, or re-scoped. There is no third state where a test quietly sleeps forever.
- More than 5 tests in quarantine at once triggers a stop-the-line: no new feature merges until the count is back under 5.
- Flake rate is tracked as a metric; a suite above 1% flake rate for two consecutive weeks gets a dedicated remediation ticket at the next planning session.
25.11 Definition of Done #
A feature is done when every box is checked. This list is the PR template, verbatim.
- The owning section of the specification was read, and any deviation from it is written
down in
docs/DECISIONS.mdwith a date and a rationale. - Migration written, expand/contract-safe, reversible (or explicitly marked irreversible with justification), applied to an empty database in CI, and the schema snapshot updated.
- Every new table carries
workspace_id, has RLS enabled and forced, has a policy, and leads its composite indexes withworkspace_id. - Zod schema added to
packages/validationand used by the client, the server, and the worker — one schema, three consumers, no duplicated shapes. - Business logic lives in
packages/corebehind a named service function; the route handler is thin. -
can(actor, action, resource)is called on every new route and server action, and the new permission string is added to Appendix 29.6's source of truth. - Quota is checked server-side via
packages/core/quotawherever a limit could apply. - Errors use
AppErrorwith a code from the catalogue; new codes are added to Appendix 29.3 with a message and remediation, and to the docs site. - Responses use the envelope and cursor pagination defined in Section 7 — no ad-hoc shapes,
no
page/offset. - Audit events written for every state transition, content edit, tag change, and consent change.
- Any state change that could alter public output enqueues the CDN purge with the correct tags.
- Any row deletion that owns media enqueues
media.purge. - Unit tests for the logic, integration tests for the data path (including a cross-tenant negative test), and an E2E test if the feature is user-visible.
- Accessibility: keyboard path verified, axe clean at serious/critical, no colour-only meaning, focus visible, labels associated.
- If the widget bundle changed: size gates pass, no new dependency, no forbidden API, Lighthouse gates pass.
- Observability: the new path emits the log fields from Section 24.2.2, increments the relevant metric, and has a span if it crosses a network boundary.
- Any new environment variable is added to the Zod boot schema, to
.env.example, and to Appendix 29.2. - Any new background job is added to Appendix 29.5 with its retry, timeout, dead-letter, and alert.
- Any new outbound event is added to Appendix 29.4 with its payload summary.
- Copy reviewed: plain language, no jargon, error messages actionable and safe to show an end user.
- Documentation updated: the customer-facing docs if behaviour changed,
docs/if a developer would be surprised. - Feature flag added if the change is risky, with the default and the removal plan noted.
- The PR description states what was tested manually and what was not.
25.12 Release-Readiness Checklist #
Run before every production deploy that is not a hotfix. Automated where the check is mechanical; the deploy command refuses to proceed if the automated subset fails.
| # | Check | Automated |
|---|---|---|
| 1 | All CI stages green on the exact commit being deployed | Yes |
| 2 | Nightly suite green on the previous run (cross-browser, real device, DAST, mutation) | Yes |
| 3 | No open SEV-1 or SEV-2 incident | Yes (incident tool query) |
| 4 | Migrations in this release are expand-only, or the contract step is a separate, later deploy | Yes (migration linter) |
| 5 | The previous release's migrations have completed everywhere | Yes |
| 6 | Rollback plan stated in the release notes, including whether a schema rollback is needed | Manual |
| 7 | Feature flags for this release default to off, with the rollout plan named | Manual |
| 8 | Widget release, if any, goes through the canary (Section 26.9.3) and not straight to 100% | Yes (deploy tool) |
| 9 | Error budget for all three SLOs has remaining headroom | Yes |
| 10 | No dependency added to the widget bundle | Yes |
| 11 | Performance budgets unchanged or improved versus the previous release | Yes (Lighthouse CI comparison) |
| 12 | Accessibility manual pass done if the recorder, consent screen, a widget type, or the showcase changed | Manual |
| 13 | Customer-facing copy and docs updated | Manual |
| 14 | On-call is staffed and aware; not within a deploy-freeze window | Manual |
| 15 | Post-deploy verification steps listed and assigned (synthetic checks, one real submission on the staging-of-production canary workspace, one widget render on the fixture page) | Manual |
26. Deployment, Environments & Configuration #
26.1 Environments #
Four environments, and only four. Every one is created from the same infrastructure code with a different variable set, so "it works in staging" means something.
| Local | Preview (per PR) | Staging | Production | |
|---|---|---|---|---|
| Purpose | Development | Review a change in a real deployment | Rehearse production; run nightly slow gates | Serve customers |
| Web app | pnpm dev on localhost:3000 |
One container per PR | 2 replicas | ≥ 3 replicas, autoscaled |
| Worker | pnpm dev:worker |
1 replica, low concurrency | 1 replica | ≥ 2 replicas, autoscaled |
| Postgres | Container | Shared preview cluster, one database per PR, created and dropped by CI | Dedicated managed instance, production-shaped, smaller | Dedicated managed instance + standby + read replica |
| Valkey | Container | Shared instance, key-prefixed per PR | Dedicated | Dedicated with replica |
| Object storage | MinIO-style container | Shared bucket, prefixed per PR, 7-day lifecycle | Dedicated bucket | Dedicated buckets (26.5) |
| Video provider | Provider stub service | Provider test environment | Provider test environment | Provider production |
| Transcription | Stub returning a canned transcript | Provider sandbox | Provider sandbox | Provider production |
| Stripe | Stripe CLI + test mode | Test mode | Test mode | Live mode |
| Email / SMS | Mailpit-style local catcher; SMS logged only | Catcher; sends only to @vouch.app addresses |
Provider sandbox; allow-list of internal recipients | Live |
| AI provider | Stub returning fixture proposals | Real provider, low rate limit, spend cap | Real provider, spend cap | Real provider |
| CDN | Bypassed (cdn. points at the local app) |
Real CDN on pr-{n}.preview.vouch.app |
Real CDN on staging-cdn.vouch.app |
cdn.vouch.app |
| Data | Seeded demo workspace | Seeded demo workspace | Seeded demo + synthetic load data | Real customer data |
| Access | The developer | Anyone with repo access | All engineers; read-only database access via a break-glass role | Deploy via pipeline only; database access restricted to on-call, audited, and time-boxed |
| Debug endpoints | On | On | On behind an internal-network guard | Off, absolutely |
| Robots | n/a | X-Robots-Tag: noindex on every response |
noindex + HTTP basic auth on the whole environment |
Indexable where intended |
What differs, stated as rules rather than a table: preview and staging never hold real customer data (Section 25.9); staging is the only non-production environment with real CDN, real object storage lifecycle rules, and real ACME issuance (against the staging CA), because those are the three things that cannot be meaningfully faked; production is the only environment with live Stripe and live email/SMS; and no environment other than production may hold production secrets — the secret manager enforces this with separate paths and separate IAM roles.
Preview environments are destroyed when the PR closes, and unconditionally after 7 days. Their databases are dropped, their storage prefixes are lifecycle-expired, and their DNS records are removed. A cost alarm fires if preview spend exceeds 10% of the total.
26.2 Infrastructure Topology #
The whole system is three long-lived compute shapes plus managed data services. It is deliberately unexciting; the interesting engineering is in the widget, not the topology.
┌──────────────────────────────────────────┐
Visitors on a │ CDN edge (cdn.vouch.app, v.vouch.app, │
customer's website ─▶│ customer custom domains) │
│ • /e.js loader (300 s, purge tag) │
│ • /api/v1/widgets/{id}/data (60 s SWR) │
│ • media derivatives, posters, VTT │
│ • showcase pages (60 s SWR) │
└───────────────┬──────────────────────────┘
│ misses / dynamic
Dashboard users ────────────────────▶│
(app.vouch.app) ▼
┌──────────────────────────────────────────┐
│ apps/web (Next.js, Node runtime) │
│ N replicas behind a load balancer │
│ • dashboard • request pages │
│ • widget data origin • public API │
│ • webhook receivers • analytics ingest │
└──┬──────────────┬─────────────┬──────────┘
│ │ │
┌─────────────▼───┐ ┌──────▼──────┐ ┌───▼─────────────┐
│ PostgreSQL │ │ Valkey │ │ Object storage │
│ primary+standby │ │ cache/queue│ │ (media buckets) │
│ + read replica │ │ + replica │ └───┬─────────────┘
└─────────────▲───┘ └──────▲──────┘ │
│ │ │
┌──┴──────────────┴─────────────▼──────────┐
│ apps/worker (BullMQ consumers) │
│ M replicas, queue-group aware │
└──┬──────────┬─────────┬─────────┬────────┘
│ │ │ │
video host transcription AI Stripe/Resend/
Twilio/CDN API26.2.1 Web tier #
- Container image built from the monorepo with a standalone Next.js output; runs on Node (version line in Section 3) as a non-root user, read-only root filesystem, no shell in the final image.
- Scaling rule: target 60% CPU and p95 request duration ≤ 300 ms; scale out at either threshold, scale in only after 10 minutes below 40% CPU (asymmetric, because scaling in during a traffic ramp is how you turn a spike into an outage). Minimum 3 replicas in production so a rolling deploy never drops below 2. Maximum 30.
- Additional scale trigger: widget data API origin request rate above 200 rps, which precedes CPU by enough to matter during a cache purge storm.
- Each replica caps its Postgres pool (26.3.1) so autoscaling cannot exhaust the database — the maximum replica count times the per-replica pool is held strictly below the pooler's capacity, and a CI check asserts that arithmetic.
26.2.2 Worker tier #
Workers subscribe to queue groups, so a slow queue cannot starve a fast one:
| Group | Queues | Concurrency per replica | Scaling signal | Min/Max replicas |
|---|---|---|---|---|
realtime |
webhook.deliver, cdn.purge, email.send, sms.send, analytics.ingest |
20 | Oldest-waiting > 30 s | 2 / 12 |
media |
media.ingest, media.webhook, media.poster, media.purge, media.reconcile |
8 | Oldest-waiting > 120 s | 2 / 10 |
heavy |
transcription.request, ai.suggest, social_card.render, import.fetch, import.normalize |
4 | Oldest-waiting > 300 s | 1 / 8 |
scheduled |
Repeatable jobs: rollups, sweeps, reconciliation, domain verification | 4 | Fixed | 1 / 2 |
The scheduled group holds a distributed lock per repeatable job so two replicas never run
the same sweep. Workers expose no HTTP surface except /healthz and /readyz on a private
port.
26.3 PostgreSQL Configuration #
Managed PostgreSQL (version line in Section 3): primary with a synchronous standby in a second availability zone, plus one asynchronous read replica.
26.3.1 Connection pooling and its sizing rationale #
Two layers, and the reason for each:
- In-process pool (
pgpool used by Drizzle):max= 8 per web replica, 6 per worker replica. Small on purpose — a Node process is single-threaded and cannot usefully drive more than a handful of concurrent queries; a large per-process pool just moves queueing from the application (where it is visible and boundable) into the database (where it is expensive). - External pooler in transaction mode between the application and Postgres: default pool size 40 to the primary, 20 to the read replica, with a client-connection limit well above the sum of application pools.
Sizing arithmetic, written out because it is the number people get wrong: managed Postgres of
the launch size supports ~200 connections after reserving superuser and maintenance slots. Web
maximum 30 replicas × 8 = 240, worker maximum 32 replicas × 6 = 192 — both above 200, which is
precisely why the transaction-mode pooler exists: it multiplexes those 432 potential client
connections onto ≤ 60 server connections. The pooler is therefore not optional, and a CI
assertion fails the infrastructure plan if maxWebReplicas × webPool + maxWorkerReplicas × workerPool exceeds the pooler's configured client limit.
26.3.2 Transaction mode is mandatory, and why #
The tenancy model issues SET LOCAL app.current_workspace_id inside every request's
transaction (Section 6). SET LOCAL is scoped to the transaction, so transaction-mode pooling
returns the connection to the pool with no residual tenant state. Session mode would leak
tenant context between requests and is forbidden in every environment, including local
development. This is asserted at boot: the application queries the pooler's mode and refuses
to start if it is not transaction mode.
Consequences accepted and designed around: no session-level prepared statements (Drizzle is
configured accordingly), no LISTEN/NOTIFY through the pooler (the outbox plus polling is
used instead), and no advisory locks held across transactions (the migration runner connects
directly to the primary, bypassing the pooler, for exactly this reason).
26.3.3 Server settings that are set deliberately #
| Setting | Value | Reason |
|---|---|---|
statement_timeout |
15 s (web role), 120 s (worker role), 0 (migrator) | A runaway dashboard query must not hold a connection; migrations need time |
idle_in_transaction_session_timeout |
30 s | Prevents a stuck request from pinning a connection and blocking vacuum |
lock_timeout |
5 s (web/worker), 10 s (migrator with retries) | A migration that cannot get its lock fails fast instead of queueing behind readers |
default_transaction_isolation |
read committed |
Sufficient given the explicit conditional writes used for quota and idempotency |
log_min_duration_statement |
500 ms | Slow-query visibility without log flooding |
work_mem |
32 MB | Analytics rollups sort; measured against the workload |
Autovacuum on analytics_events_raw, job_runs, audit_events |
More aggressive scale factors | High-churn or append-heavy tables |
| Extensions | pgcrypto (digests), pg_stat_statements, pg_trgm (near-duplicate import matching and moderation search) |
Named so a restore recreates them |
Read replica usage is explicit and narrow: analytics dashboard queries and the full export job read from the replica via a distinct connection; nothing that a user just wrote is read from the replica, to avoid read-your-writes surprises. A single flag can route all reads to the primary during a replication incident (R13).
26.4 Valkey #
- Managed Valkey (version line in Section 3) with a replica and automatic failover, TLS enforced, in-transit and at-rest encryption on, AUTH required.
- Two logical databases are not used; instead, key prefixes separate concerns so metrics
and eviction policy can be reasoned about:
q:(BullMQ),rl:(rate limits),cache:(query and payload caches),idem:(idempotency snapshots),lock:(distributed locks),quota:(counters). - Eviction policy
noeviction. Queue data must never be evicted; running out of memory must be an alarm, not silent job loss. Cache entries carry explicit TTLs and the cache sizing is monitored (A26 covers unavailability; a memory alarm at 80% covers growth). - Everything in Valkey is reconstructible (R12). Nothing is the sole record of anything.
26.5 Object Storage #
26.5.1 Buckets #
| Bucket | Contents | Public? | Versioning | Replication |
|---|---|---|---|---|
vouch-media-{env} |
Original uploads, posters, generated social cards, VTT files | No — read only through the CDN with a signed origin identity | On | Cross-region |
vouch-uploads-{env} |
In-flight multipart uploads before completion | No | Off | No |
vouch-exports-{env} |
Workspace data exports and respondent data downloads | No — served via short-lived signed URLs (15 min) | Off | No |
vouch-static-{env} |
Widget loader and runtime chunks, dashboard static assets | Yes, via CDN | On | Cross-region |
vouch-backups-{env} |
Application-level logical exports supplementing managed snapshots | No | On | Cross-region |
Every bucket blocks public ACLs at the account level; the only public path is through the CDN, which authenticates to the origin. Server-side encryption is on everywhere.
26.5.2 Key layout #
vouch-media-{env}/
w/{workspaceId}/
t/{testimonialId}/
original.{ext} # raw upload, retained per Section 9.10.1
poster/{variant}.jpg # 1x, 2x, and a 16:9 crop
vtt/{language}.vtt
card/{template}-{hash}.png # social cards, hash = content hash of inputs
tmp/{uploadSessionId}/… # multipart parts before completionThe prefix-per-testimonial layout exists so that deletion can be proven by listing the prefix, not by trusting a list of keys we stored (R11). Any object under a testimonial's prefix is that testimonial's, so purge can be exhaustive by construction.
26.5.3 Lifecycle rules and CORS #
| Rule | Scope | Action |
|---|---|---|
| Abandoned multipart uploads | All buckets | Abort after 24 hours (matches the Section 9.10.1 sweep, belt and braces) |
tmp/ prefix |
vouch-uploads |
Delete after 48 hours |
| Originals | vouch-media original.* |
Transition to infrequent-access at 7 days; delete at 30 days unless the object carries the tag keep-original=true, which the "keep originals" workspace setting applies |
| Noncurrent versions | All versioned buckets | Expire after 30 days |
| Exports | vouch-exports |
Delete after 7 days |
| Preview environments | {env}=preview buckets |
Delete all objects after 7 days |
CORS on vouch-uploads-{env} allows PUT and POST from the request-page origins only
(https://v.vouch.app, https://*.vouch.app, and verified customer custom domains, which the
domain-activation job adds to the CORS configuration), exposes ETag (multipart completion
requires reading it), and sets MaxAgeSeconds: 3000. A synthetic check exercises a real
preflight every 5 minutes, because a CORS regression is a total upload outage that looks
healthy from the server side (R2).
26.6 CDN Configuration #
| Route | Cache key | Cache-Control | Purge tags | Notes |
|---|---|---|---|---|
/e.js (loader) |
Path only — no query, no cookie, no Vary beyond Accept-Encoding |
public, max-age=300, stale-while-revalidate=86400 |
widget-loader |
Mutable by design; short TTL is what makes a rollback fast (Section 26.9) |
/r/{hash}.js (runtime chunk) |
Path only | public, max-age=31536000, immutable |
none needed | Content-addressed; never purged |
/api/v1/widgets/{id}/data |
Path + the ids query parameter, normalised by sorting the ids |
public, max-age=60, stale-while-revalidate=300, stale-if-error=86400 |
widget:{id}, workspace:{id} |
The stale-if-error window is the single most important availability decision in the product (Section 24.10.2) |
/api/v1/widgets/{id}/oembed |
Path + query | public, max-age=300, stale-while-revalidate=3600 |
widget:{id} |
SSR-friendly variant |
Showcase pages (/s/{slug} and custom domains) |
Host + path | public, max-age=60, stale-while-revalidate=600, stale-if-error=86400 |
workspace:{id}, showcase:{slug} |
Host is in the key because custom domains render the same content under different branding |
| Media derivatives (posters, cards, VTT) | Path | public, max-age=31536000, immutable |
testimonial:{id} |
Filenames carry content hashes |
/api/v1/e (analytics beacon) |
Not cached | no-store |
— | POST only |
/api/v1/widget-errors |
Not cached | no-store |
— | POST only |
Everything under app.vouch.app |
Not cached at the edge | private, no-store for authenticated responses |
— | Dashboard is dynamic |
Rules:
- Cookies are stripped at the edge on every cacheable route, in both directions. The widget and showcase paths must never become cookie-varying, and stripping is the only reliable way to guarantee that.
- Purge is by tag, never by URL, and purges are enqueued through the
cdn.purgejob so they are retried and observable. Publish, unpublish, edit, tag change, delete, widget configuration change, and plan changes that affect the badge all purgewidget:{id}for every widget containing the testimonial plusworkspace:{id}. - Request coalescing is enabled at the edge so a cold key under load produces one origin request, not thousands.
- Brotli and gzip both enabled; the widget budget is measured against brotli for the loader and gzip for the runtime, matching the numbers in Section 13.
- A secondary CDN configuration is maintained in infrastructure code with the same origin
rules; failover is a DNS change, and
cdn.runs a 300 s TTL specifically to keep that failover fast. - Origin shielding is on, with the shield in the same region as the web tier.
26.7 The Deployment Pipeline #
26.7.1 Stages #
main (protected)
└─▶ build docker image (web, worker) + widget bundle, tagged with the git SHA
└─▶ migrate run pending migrations against production (expand-only)
└─▶ deploy rolling update of web, then worker
└─▶ verify health, synthetic, smoke; auto-rollback on failureEvery stage is idempotent and every stage records its start, end, and outcome as a deployment event visible on the dashboards as a deploy marker.
26.7.2 Migration safety — expand/contract, always #
The rule: a deploy never depends on a migration that has not yet run, and a migration never depends on code that has not yet deployed. Since those two constraints cannot both hold for a single destructive change, every schema change is split into phases across releases:
| Phase | Release | Migration | Code |
|---|---|---|---|
| Expand | N | Add the new column/table/index. Nullable, or with a default that does not rewrite the table. Backfill in a batched background job, never in the migration | Code writes both old and new, reads old |
| Migrate reads | N+1 | None | Code reads new, still writes both |
| Contract | N+2 | Drop the old column/constraint after verifying the new path in production | Code writes and reads new only |
Mechanical rules enforced by the migration linter in CI:
- No
DROP COLUMN,DROP TABLE,ALTER COLUMN … TYPE, orRENAMEin the same release as the code change that requires it. The linter fails on a destructive statement unless the migration file carries a-- contract-of: <earlier-migration-id>header naming an already-deployed expand migration. CREATE INDEXmust beCONCURRENTLYon any table above a row threshold, and therefore outside a transaction — the runner supports this explicitly.NOT NULLis added in two steps: add aCHECK (col IS NOT NULL) NOT VALID, validate it separately, then setNOT NULLusing the validated constraint — never a blocking table scan.- New columns with a volatile default are added without the default, backfilled in batches, then given the default.
- Every migration must acquire its locks within
lock_timeoutor fail; a failed migration aborts the deploy and leaves the previous version running. - The migration runner is a one-shot job connecting directly to the primary (bypassing the pooler) as the migrator role, holding an advisory lock so concurrent deploys serialise.
- Migrations are forward-only in production. A "rollback migration" that drops a column would destroy data; the rollback path for schema is described in 26.7.4.
26.7.3 Deploy #
Rolling update with a maximum surge of 1 and maximum unavailable of 0. Web first, then worker, because the web tier enqueues jobs whose handlers must already understand them — and because job payloads are versioned, a new web with an old worker is safe for the length of one rolling update (the worker rejects unknown payload versions to the dead-letter queue rather than mangling them, and the deploy verification checks that dead-letter count is zero).
26.7.4 Rollback #
| What | Procedure | Time |
|---|---|---|
| Code | Redeploy the previous image tag. The pipeline keeps the last 10 tags. One command, no rebuild | ≤ 5 min |
| Widget | Republish the previous loader pinning the previous runtime hash, purge widget-loader (R15) |
≤ 2 min |
| Schema | Never rolled back by dropping. Because every migration is expand-only, the previous code version runs unchanged against the new schema — that property is the entire point of expand/contract. If an expand migration itself is faulty (a bad index, a bad constraint), it is fixed by a new forward migration | ≤ 15 min |
| Data | If a deploy corrupted data, PITR-restore to a side instance, extract the affected rows, and repair forward with a scripted, reviewed migration. Never restore the whole database over live production without an incident decision by the IC | Hours, per Section 24.10.2 |
| Config / flag | Flip the flag; takes effect within 30 s (flags are read server-side with a 30-second cache) | ≤ 1 min |
Automatic rollback: if post-deploy verification fails (health checks, the synthetic widget check, or a 5xx rate above 1% for 3 minutes), the pipeline redeploys the previous image without human intervention and pages on-call.
26.8 Zero-Downtime, Health Checks, and Graceful Shutdown #
| Probe | Path | What it checks | Failure action |
|---|---|---|---|
| Liveness | GET /healthz |
The process is responsive. No dependency checks at all — a liveness probe that fails when the database is slow turns a database blip into a restart storm | Restart the container after 3 consecutive failures, 10 s apart |
| Readiness | GET /readyz |
Can serve traffic: database SELECT 1 within 2 s, Valkey PING within 500 ms, required config present, migrations at the expected version |
Remove from the load balancer; do not restart |
| Startup | GET /readyz with a longer window |
Slow first boot does not trip liveness | Up to 60 s allowance |
| Worker liveness | Private /healthz |
Event loop responsive, last job poll within 60 s | Restart |
| Worker readiness | Private /readyz |
Connected to Valkey and Postgres | Stop receiving work |
| Deep health (operators only) | GET /internal/health/deep behind network policy |
Every dependency with latency, migration version, queue depths, feature-flag source | Never used by orchestration; diagnostics only |
Graceful shutdown, web: on SIGTERM — (1) immediately fail readiness so the load balancer
drains; (2) keep serving for a 10-second drain delay because load balancers notice readiness
changes asynchronously; (3) stop accepting new connections; (4) let in-flight requests finish,
up to 25 s; (5) flush telemetry; (6) close pools; (7) exit 0. Termination grace period is 45 s.
Graceful shutdown, worker: on SIGTERM — (1) stop pulling new jobs; (2) let running jobs
finish, up to 120 s (long enough for the slowest realtime/media job; heavy jobs are designed
to be resumable and are returned to the queue if they exceed it); (3) explicitly release any
distributed lock held; (4) flush telemetry; (5) exit 0. Termination grace period is 150 s.
No job is acknowledged before its work is committed, so a hard kill re-delivers rather than
loses, and every handler is idempotent (Appendix 29.5) to make re-delivery safe.
26.9 The Widget's Separate Release Train #
The widget does not ride the application deploy. It has its own pipeline, its own versioning, and its own rollback, because it is the only code we ship onto other people's pages.
26.9.1 Artefacts #
| Artefact | URL | Mutability | Cache |
|---|---|---|---|
| Loader | https://cdn.vouch.app/e.js |
Mutable — the snippet points here forever and must never need re-pasting (Section 13) | 300 s + SWR |
| Runtime chunk | https://cdn.vouch.app/r/{contentHash}.js |
Immutable, content-addressed | 1 year, immutable |
| Styles | Inlined into the runtime chunk as a constructable stylesheet | Immutable | — |
The loader's only responsibilities are: read the widget ids from the DOM, batch one data fetch, and dynamically import the runtime chunk whose hash it has baked in. That indirection is what makes rollback a 2 KB file replacement rather than a deploy.
26.9.2 Build and integrity #
- Built by esbuild (Section 3) to an ES2022 IIFE, minified, with the size gates from Section 25.5 as hard failures.
- The runtime chunk's content hash is computed post-minification and embedded in the loader, so the loader and runtime are always a matched pair.
- Source maps uploaded to the error tracker, never published.
- The loader cannot carry a Subresource Integrity hash because its URL is permanent and its
content changes (Section 13.10 documents this trade-off). The compensating controls are
named here so an operator knows what protects that path: the CDN origin is the private
static bucket with no public write path; publishing requires the deploy role; every publish
is logged with the publisher, the SHA, and the resulting hash; a nightly job fetches
https://cdn.vouch.app/e.jsfrom three regions and compares its hash to the expected value, paging on mismatch; and the runtime chunk is pinned by content hash, so a compromise of the loader alone cannot silently swap the runtime.
26.9.3 Canary and rollout #
| Step | Traffic | Duration | Automatic abort condition |
|---|---|---|---|
| 1. Internal | Fixture pages + our own marketing site only | 15 min | Any synthetic failure |
| 2. Canary 1% | 1% of loader responses serve the new loader (edge-side deterministic split on a hash of the widget id, so a given site is consistently in or out) | 30 min | Widget error rate > 0.2% for the canary cohort, or any Lighthouse budget breach on the synthetic run |
| 3. 10% | 10% | 60 min | Same |
| 4. 100% | All | — | Same, with a 24-hour watch |
Aborting at any step republishes the previous loader and purges the tag — the same operation as R15, exercised deliberately every month as a drill (Section 24.10.3). Because a customer's site either gets the old loader or the new one, and both loaders pin their own runtime, there is no mixed-version state within a page.
The data payload carries schemaVersion; the runtime accepts its own version and one version
either side, which is what allows the payload builder and the widget to deploy independently.
26.10 Configuration Strategy #
The complete environment-variable catalogue is in Appendix 29.2. This subsection defines how configuration behaves, not what each variable means.
26.10.1 Where configuration comes from #
Precedence, highest first:
- Process environment, injected by the platform at container start.
- Secret manager references resolved at container start into the process environment. A secret's value never appears in an image, a manifest, a log line, or a CI variable — only its reference path does.
- Committed non-secret defaults in
infra/config/{env}.envfor values that are neither secret nor environment-specific enough to warrant a manual step. - Compile-time public constants for the handful of values the browser needs
(
NEXT_PUBLIC_*), which are, by definition, public — a CI check fails if any variable matching/(SECRET|KEY|TOKEN|PASSWORD)/is also prefixedNEXT_PUBLIC_.
There is no runtime configuration service and no database-stored global config. Feature flags
are the only dynamic configuration, they live in the feature_flags table, are evaluated
server-side, and are cached for 30 seconds.
26.10.2 Fail-fast validation on boot #
Every service validates its entire configuration with a Zod schema (Section 3) before it
opens a port, connects to a database, or registers a queue consumer. A missing or malformed
required variable causes the process to log one fatal line naming every problem found
(not just the first) and exit with code 1.
// packages/config/src/env.ts (abbreviated — the full schema covers every row of Appendix 29.2)
import { z } from 'zod'
const Env = z.object({
APP_ENV: z.enum(['development', 'preview', 'staging', 'production']),
APP_URL: z.string().url(),
CDN_URL: z.string().url(),
DATABASE_URL: z.string().url().startsWith('postgres'),
DATABASE_POOL_MAX: z.coerce.number().int().min(1).max(20).default(8),
VALKEY_URL: z.string().url(),
S3_BUCKET_MEDIA: z.string().min(3),
MUX_TOKEN_ID: z.string().min(1),
MUX_TOKEN_SECRET: z.string().min(1),
MUX_WEBHOOK_SECRET: z.string().min(1),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),
SESSION_SECRET: z.string().min(32),
CURSOR_SECRET: z.string().min(32),
CURSOR_SECRET_PREVIOUS: z.string().min(32).optional(),
RATE_LIMIT_MULTIPLIER: z.coerce.number().min(1).default(1),
MUX_PLAYBACK_POLICY: z.enum(['public', 'signed']).default('signed'),
MUX_SIGNING_KEY_ID: z.string().min(1).optional(),
MUX_SIGNING_KEY_PRIVATE: z.string().min(1).optional(),
AI_PROVIDER: z.enum(['anthropic', 'openai']).default('anthropic'),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
}).superRefine((v, ctx) => {
if (v.APP_ENV === 'production') {
if (v.STRIPE_SECRET_KEY.startsWith('sk_test_'))
ctx.addIssue({ code: 'custom', path: ['STRIPE_SECRET_KEY'], message: 'test key in production' })
if (v.LOG_LEVEL === 'debug')
ctx.addIssue({ code: 'custom', path: ['LOG_LEVEL'], message: 'debug logging is not permitted in production' })
if (!v.APP_URL.startsWith('https://'))
ctx.addIssue({ code: 'custom', path: ['APP_URL'], message: 'must be https in production' })
if (v.RATE_LIMIT_MULTIPLIER !== 1)
ctx.addIssue({ code: 'custom', path: ['RATE_LIMIT_MULTIPLIER'], message: 'must be exactly 1 in production' })
}
if (v.MUX_PLAYBACK_POLICY === 'signed' &&
!(v.MUX_SIGNING_KEY_ID && v.MUX_SIGNING_KEY_PRIVATE))
ctx.addIssue({ code: 'custom', path: ['MUX_SIGNING_KEY_ID'],
message: 'signed playback requires a signing key pair' })
})
export const env = (() => {
const parsed = Env.safeParse(process.env)
if (!parsed.success) {
// Field names only. Values are never printed — a config error must not leak a secret.
const problems = parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`)
console.error(JSON.stringify({ level: 60, msg: 'configuration invalid', problems }))
process.exit(1)
}
return Object.freeze(parsed.data)
})()Rules that make this stick:
process.envis referenced in exactly one module. An ESLint rule forbidsprocess.envanywhere else, so an unvalidated variable cannot creep in.- The exported object is frozen; configuration cannot be mutated at runtime.
.env.exampleis generated from the schema bypnpm config:example, and CI fails if it is out of date — so the example file can never drift from reality.- A boot-time connectivity preflight (distinct from schema validation) checks that the database is reachable, migrations are at or ahead of the expected version, Valkey responds, and the media bucket is writable. Failure exits 1 in production and warns in development.
- Startup logs a configuration fingerprint: a sorted list of variable names present with a SHA-256 of each value truncated to 8 hex characters. Two replicas can be compared for config drift without any secret being readable.
26.10.3 Secret handling and rotation #
- Secrets live in the platform's secret manager, one path per environment, with IAM such that a staging role cannot read production paths.
- Rotation is supported for every secret without downtime, because every consumer accepts two
valid values during a rotation window: webhook signing publishes both
v1signatures; API-key verification checks the current and previous hash; provider credentials are rotated by adding the new one, deploying, then removing the old. - Rotation cadence: session secret and webhook secrets annually or on suspicion; provider keys
annually; database credentials quarterly; ACME account key on compromise only. Every rotation
is a checklist in
docs/RUNBOOKS.mdand is drilled once a year. - Compromise response is R17: revoke first, investigate second.
26.10.4 Per-environment differences #
| Variable class | Local | Preview | Staging | Production |
|---|---|---|---|---|
APP_ENV |
development |
preview |
staging |
production |
| Provider keys | Stubs or personal sandbox | Sandbox | Sandbox | Live |
LOG_LEVEL |
debug |
debug |
info |
info (schema forbids debug) |
| Rate limits | 10× relaxed | Relaxed | Production values | Production values |
| Email recipients | Local catcher | Allow-list | Allow-list | Unrestricted |
| Feature flags | All on | Default | Default | Explicit per flag |
AI_* spend caps |
Hard $5/day | $5/day | $25/day | Budget alarm, no hard cap (a hard cap would break a paid feature) |
| Debug endpoints | On | On | Network-guarded | Off |
26.11 Regions and Data Residency #
- Primary region at launch: US-East. All application compute, the primary database, the primary media bucket, and the queue live there. The CDN is global.
- The EU deployment path referenced by Section 22 is a full stack replica in an EU region (EU-West), not a partial one, because splitting a workspace's data across regions produces a system nobody can reason about. The path is:
| Step | What it entails | Why it is already possible |
|---|---|---|
| 1. Deploy the EU stack | Same infrastructure code, REGION=eu-west, its own database, buckets, Valkey, and workers |
Infrastructure is code and holds no region-specific assumptions; region is a variable |
| 2. Region-aware routing | app.vouch.app and v.vouch.app resolve to the stack that owns the workspace, via a small global lookup of workspace_slug → region at the edge |
Workspaces are already the tenancy boundary and already carry a slug (Section 6) |
| 3. Region pinning at signup | A workspace chooses its region at creation and never changes it silently | workspaces gains one region column; no relationship changes |
| 4. Provider configuration | Video, transcription, and object storage configured with EU regions/endpoints; the AI provider configured for EU processing or disabled for EU workspaces per contract | Every provider is behind an interface with per-environment configuration |
| 5. Sub-processor documentation | Section 22.9's list is published per region | Already per-service |
Migration of an existing workspace between regions is an offline, supported operation: export, import, re-point, verify, then erase the source — using the same export and erasure machinery that already exists for data-subject rights (Section 22).
Residency facts a customer can be told today: application data resides in the primary region; media is replicated to a second region within the same jurisdiction for durability; the CDN caches public, already-published content globally (which is the point of publishing); logs and telemetry reside in the primary region; and backups reside in the primary region with cross-region copies within the same jurisdiction.
26.12 Cost Model #
Order-of-magnitude estimates for planning, at two scales. "Launch" is 300 paying workspaces with ~25,000 published testimonials and ~40M widget impressions/month. "10×" is 3,000 paying workspaces, ~250,000 testimonials, ~400M impressions/month. Figures are monthly USD and deliberately rounded; they exist to show the shape of the bill and which levers matter.
| Line item | Launch | 10× | Driver |
|---|---|---|---|
| Video delivery + storage (video provider) | $900 | $7,500 | Delivery minutes, then stored minutes |
| CDN egress + requests | $450 | $3,900 | Widget payloads, posters, showcase HTML |
| Object storage (media + replication) | $180 | $1,500 | GB stored × 2 regions, plus request costs |
| Managed PostgreSQL (primary + standby + replica) | $420 | $1,600 | Instance size, then IOPS |
| Compute (web + worker) | $350 | $2,200 | Replica count |
| Transcription | $200 | $1,700 | Minutes transcribed |
| Managed Valkey | $90 | $320 | Memory |
| Observability (logs, metrics, traces, errors) | $250 | $1,400 | Log volume above all else |
| AI provider | $120 | $900 | Growth/Scale workspaces only |
| Email + SMS | $80 | $600 | Invitation and notification volume |
| Stripe fees | (revenue-linked, ~2.9% + 30¢) | — | Transaction count |
| Misc (DNS, status page, CI minutes, device cloud) | $180 | $450 | Mostly fixed |
| Total (excl. Stripe fees) | ≈ $3,220 | ≈ $22,000 |
The three largest line items are video delivery, CDN egress, and object storage — that is, the cost of moving and keeping media, not the cost of running the application. The levers that actually move them, in order of effect:
- Poster-first video (already a hard product rule, Section 13). Video bytes are never fetched until a visitor presses play. This is the single largest cost control in the product and it is also the performance rule — the incentives align perfectly, which is why it is a budget item in CI rather than a guideline.
- CDN cache hit ratio ≥ 90% on the widget data API. Every point of hit ratio is a point
off origin compute and off egress. The 60-second TTL with
stale-while-revalidateis tuned for this; over-eager purging is both a cost and a latency regression, which is why A3 alerts on hit-ratio collapse. - Original-media retention (30 days, Section 9.10.1). Keeping originals forever would roughly triple storage. The 30-day default with an opt-in "keep originals" on Growth+ puts the cost on the workspaces that choose it and counts it against their storage quota.
- Log volume: the sampling policy in Section 24.2.5 is worth more than any observability vendor negotiation.
- Transcode/transcription are per-minute and bounded by the plan's max video length — the 2-minute Free cap is a cost control as much as a packaging decision.
- Compute is the smallest meaningful lever; do not spend engineering time there first.
A monthly cost review compares actuals to this model, and any line item more than 40% above projection opens a ticket. Per-workspace unit economics (cost per published testimonial, cost per 1,000 impressions) are tracked so pricing decisions have a floor to reason against.
26.13 Platform DNS and the Edge Configuration for Customer Domains #
Section 14 owns the customer-facing custom-domain flow. This subsection owns the platform side.
| Record | Type | Target | TTL | Purpose |
|---|---|---|---|---|
vouch.app |
A/AAAA or ALIAS | Marketing site | 3600 | Marketing |
app.vouch.app |
CNAME | Load balancer | 300 | Dashboard |
v.vouch.app |
CNAME | CDN | 300 | Request pages, showcase, widget API |
cdn.vouch.app |
CNAME | CDN | 300 | Loader, runtime chunks, media derivatives. Short TTL exists to make CDN failover fast |
*.vouch.app |
CNAME | CDN | 300 | Free-tier workspace subdomains ({workspace-slug}.vouch.app) |
edge.vouch.app |
CNAME | CDN | 300 | The CNAME target customers point their domains at. A stable, permanent name, so changing our edge provider never asks a customer to change their DNS |
| Documented anycast A records | A | Edge anycast IPs | 3600 | For apex domains on registrars without ALIAS support |
_vouch-challenge.{customer domain} |
CNAME/TXT | Per-domain challenge value | customer-controlled | Ownership proof |
docs.vouch.app |
CNAME | Docs host | 3600 | Error docsUrl targets (Section 7.3) |
status.vouch.app |
CNAME | Status provider | 300 | Deliberately a different provider and zone (Section 24.9.4) |
MX, SPF, DKIM, DMARC on vouch.app |
— | Email provider | 3600 | DMARC p=reject after a 30-day monitoring ramp |
CAA on vouch.app |
CAA | Both ACME issuers | 3600 | Prevents issuance by anyone else while permitting our fallback |
Edge configuration that makes customer domains work:
- Wildcard TLS covers
*.vouch.appfor free-tier subdomains. Customer custom domains each get their own certificate, issued and renewed per Section 14. - The edge maps an incoming
Hostheader to a workspace through a lookup table populated when a domain reachesactive, cached at the edge with a 60-second TTL and purged on domain state change. An unknown host returns a branded 404 page, never a default backend. - Custom domains serve showcase pages and request pages — never the loader. The loader
stays on
cdn.vouch.apppermanently so the pasted snippet never goes stale (Section 13). - Domain activation also: adds the origin to the upload bucket's CORS allow-list (26.5.3),
registers the host in the widget data API's CORS allow-list (which is
*anyway, but the showcase and submission endpoints are origin-restricted), and enqueues a synthetic check that loads the customer's showcase page every 15 minutes. - Domain removal reverses all of the above, revokes the certificate, and leaves the workspace's
v.vouch.appURLs working — so removing a domain degrades to the platform URL rather than to a 404.
27. Milestones & Execution Plan #
27.1 How This Plan Is Structured #
Twelve milestones, M0 through M11. Each states a one-sentence goal, the sections it implements, its dependencies, its deliverables, its exit criteria, and a demo script.
Three rules govern the sequence:
- No milestone depends on anything a later milestone builds. Every dependency listed below points strictly backwards. This was verified by building the dependency graph from the lists in 27.2 and checking it is acyclic and monotone in milestone index; the verification and the two places where the naive ordering had to be adjusted are recorded in 27.4.
- Every milestone ends in something demonstrable. Exit criteria that can only be assessed by reading code are not exit criteria. Each milestone's demo script is the acceptance test a non-engineer can watch.
- Exit criteria are unambiguous. Each is either a command that exits zero, a query with a stated expected result, or an observable behaviour with a stated pass condition. "Feature is complete" is never an exit criterion.
Estimates are given in engineer-weeks for a single engineer of the profile this document is written for, to convey relative size. They are planning aids, not commitments.
27.2 The Milestones #
M0 — Foundations #
| Goal | A deployable, tested, multi-tenant skeleton where a user can sign up, land in a workspace, and every table is already RLS-protected. |
| Implements | Sections 3, 4, 5, 6, 7 (envelope, errors, pagination, request ids), 24.2 (logging), 25.10 (CI), 26.1–26.10 (environments, pipeline, config) |
| Depends on | Nothing |
| Size | ~3 weeks |
Deliverables
- Monorepo with the package layout of Section 3, pnpm workspaces, Turborepo pipeline, shared tsconfig/eslint/prettier/tailwind presets.
- CI pipeline with all stages of Section 25.10 wired, even where the suites are still small.
packages/db: Drizzle schema forworkspaces,users,workspace_members,sessions,invitations,feature_flags,audit_events,outbox,idempotency_keys,job_runs; UUIDv7newId(); theset_updated_at()trigger;notDeleted(); RLS policies; thevouch_appandvouch_migratorroles; migration runner.packages/core:AppError, the error catalogue scaffold,can(), the request-context helper that issuesSET LOCAL app.current_workspace_id.packages/validation: envelope, pagination, and cursor schemas.apps/web: auth (email+password with verification, magic link, optional TOTP), sign-up creating a workspace with the creator as Owner, invitations, member management, the app shell, and the/api/v1middleware chain (auth → tenancy → rate limit → idempotency → handler → envelope).apps/worker: BullMQ bootstrap, the outbox relay,email.send, health endpoints, graceful shutdown.- Structured logging, metrics, tracing, error tracking wired end to end.
- Staging and production environments deployed from infrastructure code; preview environments working on PRs.
Exit criteria
pnpm ciexits 0 locally and in CI; all blocking stages green.- The three structural RLS tests of Section 25.3.1 pass, and the cross-tenant negative tests fail closed (a missing session variable throws rather than returning rows).
SELECT rolbypassrls FROM pg_roles WHERE rolname = 'vouch_app'returnsfalse.- A migration applied to an empty database produces a schema byte-identical to the committed snapshot.
- Sign-up → email verification → sign-in → invite a second user → accept → second user sees the same workspace, all passing as an E2E test.
- A Viewer receives
403 FORBIDDENfrom a write endpoint called directly withcurl. - Every response carries
X-Request-Id, and that id appears in the log line and the trace. - A deliberately missing required environment variable causes the process to exit 1 with a
fatalline naming the variable and no value. - Staging deploy, rollback to the previous image, and re-deploy all complete from the pipeline.
Demo script — "Here is a brand-new workspace. I sign up, verify my email, invite a colleague as an Editor, and they join. Now watch: I copy the id of my colleague's workspace, call the API directly as my own user, and get a 403 with the right error code. Here is the request id from that response, and here is the log line and the trace for it."
M1 — Collection: request pages, text testimonials, consent, moderation, audit #
| Goal | A workspace can publish a request page, receive a written testimonial with recorded consent, and moderate it through the full lifecycle with a complete audit trail. |
| Implements | Sections 8, 11, 22 (consent capture, respondent link), parts of 20 (submission and moderation emails) |
| Depends on | M0 |
| Size | ~3 weeks |
Deliverables
request_pageswith slug, branding, prompts, and custom fields of every documented type; the request-page builder in the dashboard; public request page athttps://v.vouch.app/r/{slug}rendered mobile-first at 360×640.submissions,testimonials,consents,tags,testimonial_tags,audit_eventsin use.- Consent capture exactly as Section 22 specifies: three grants, never pre-checked, verbatim text stored with its SHA-256.
- Moderation inbox: filters, bulk actions, the full state machine of Section 11 with audit events on every transition.
- Respondent link
https://v.vouch.app/p/{token}with view, download, withdraw, and erase. - Email invitations to submit, with
contactsand per-contact tokens. - Rate limiting on submission endpoints (10/min per IP).
Exit criteria
- E2E: request page → text submission with consent → appears as
pending→ approve → publish; every transition wrote anaudit_eventsrow with before/after snapshots. UPDATE audit_eventsandDELETE FROM audit_eventsasvouch_appboth raise errors.- Submitting without the required consent returns 422 with
CONSENT_REQUIREDand writes no row (asserted by a count query). - The stored
consent_text_sha256equals the SHA-256 of the storedconsent_textfor 100% of rows (query returns 0 mismatches). - Changing the consent text creates a new version; the previous submission still resolves to the text it was shown.
- The respondent link renders the published content and a withdrawal action; withdrawal moves
the testimonial to
unpublishedwithin 60 seconds and notifies the workspace. - axe-core reports zero serious/critical violations on the request page in all its states.
- Keyboard-only pass on the request page, as an exit gate rather than a launch check. A tester who never touches a pointing device completes the whole page — every prompt, every custom field type in Section 8.5.2, the consent control, and submit — reaching a successful submission. Every interactive element is reachable in DOM order, every focus indicator is visible against its own background, no control traps focus, and no action is available only on hover or only by drag.
- Screen-reader pass on the request page, run on NVDA + Firefox and VoiceOver + Safari from the matrix in Section 21.13.2. Each field's label, required state, and error text are announced on focus and again on validation failure; the consent control's full text is announced before it can be ticked; and submission success is announced without the tester moving focus manually. Findings are fixed in M1, not deferred — an accessibility defect in the collection path is a rebuild of the path, not a patch on top of it.
- 11 concurrent submissions against a workspace with 10 remaining capacity yield exactly 10 accepted (the quota primitive exists here even though plans arrive in M7 — it is enforced against the Free defaults).
Demo script — "I build a request page with two custom fields, send myself an invitation, open it on a phone, write a testimonial, tick the consent box, and submit. In the dashboard it appears in the inbox; I tag it, approve it, publish it. Here is the audit trail with the exact before-and-after of every step, and here is the consent record with the exact words I agreed to. Now I open my respondent link and withdraw it — it is unpublished in under a minute."
M2 — Video: recorder, chunked upload, media pipeline, retention and deletion #
| Goal | A respondent can record a video on a phone, survive a dropped connection, retake freely, and submit — and deleting that testimonial provably destroys every byte of media. |
| Implements | Section 9, Section 22's erasure guarantee for media |
| Depends on | M1 (submissions and consent must exist to attach media to) |
| Size | ~4 weeks |
Deliverables
- Recorder: device picker, countdown, live timer, hard stop at max length with 60/30/15 s announcements, teleprompter (off by default), retake, review, keyboard-operable throughout.
- Codec negotiation in the documented order, recorded on the upload row.
- Chunked resumable multipart upload with 5-second timeslices, part-level retry with jitter,
resume from the last acknowledged part, and
AbortMultipartUploadon discard. upload_sessions,media_assets, video-provider integration, webhook handling with idempotency, state machineuploading → uploaded → processing → ready | errored.- Poster extraction cached to object storage and served from the CDN.
- Retention sweeps: originals at 30 days (unless "keep originals"), abandoned sessions at 24 hours.
media.purgewith the full external-asset destruction path and the proof query of R14.
Exit criteria
- E2E with a fake media device: record → retake → drop the connection mid-upload → reconnect → stop → submit, with an assertion that no part was uploaded twice and no part was lost.
- A discarded take's multipart upload is aborted within 5 seconds (asserted against the storage container's upload listing).
- Recording on a Free workspace stops automatically at 2:00; server-side rejection of an
11-minute file returns
VIDEO_LENGTH_EXCEEDEDeven when the client guard is bypassed. - A corrupt file marks the asset
errored, notifies the moderator, and offers a re-record link — it never sits inprocessing. - Deleting a testimonial results, within the job's completion, in: provider asset 404, zero
objects under the storage prefix, CDN URLs 404, and
purged_atset. The R14 proof query returns all zeros with one erasure audit row. - Keyboard-only pass on the recorder, as an exit gate rather than a launch check. A tester who never touches a pointing device completes permission grant, device selection, start, stop, retake, review, and submit. Focus is never lost across a state transition, the time limit is announced rather than merely displayed, and no control is reachable only by pointer. axe reports zero serious/critical violations in idle, recording, and review states.
- Screen-reader pass on the recorder, run against the full recorder script in Section 21.2.6 on NVDA + Firefox, VoiceOver + Safari on macOS, and VoiceOver + Safari on iOS in both portrait and landscape, per the matrix in Section 21.13.2. Every transient state — permission pending, permission denied, counting down, recording, time nearly up, processing — is announced when it occurs, and the tester can tell at any moment whether recording is in progress without watching the screen. This is an M2 gate and does not move. The recorder is the hardest accessibility surface in the product and the one place where a defect found at launch means rebuilding the capture flow rather than adjusting it.
- The recorder works at 320 px viewport width with no horizontal scroll, in portrait and landscape.
Demo script — "I record a 30-second video on a phone. Halfway through I put it in airplane mode; the UI says reconnecting; I turn it back on and it resumes without re-uploading anything. I retake once, then submit. It processes and appears in the inbox with a poster frame. Now I delete it — and here is the provider returning 404, the storage prefix listing empty, and the CDN URL gone."
M3 — Transcription, captions, and the transcript editor #
| Goal | Every video testimonial has an accurate, editable transcript that is the single source for captions, search, and later pull-quotes. |
| Implements | Section 10 |
| Depends on | M2 |
| Size | ~2.5 weeks |
Deliverables
- Transcription job with the documented model configuration, five launch languages, automatic detection with the 0.6 confidence fallback and review flag.
- The single transcript JSONB structure with segments and word-level confidence.
- VTT generation on save, written to object storage, purged on change.
- Segment editor: fix words, merge/split segments, nudge timings, with sub-0.75 confidence highlighted non-colour-only.
- Version bump, audit event, VTT regeneration, and CDN purge on every save.
- Failure path: publish proceeds without captions, moderator notified, retry action offered.
Exit criteria
- A 20-second fixture in each of the five languages produces a transcript whose shape validates against the documented schema and whose VTT matches the golden file.
- Editing a segment bumps
transcript_version, writes an audit event, regenerates the VTT (asserted by fetching it), and purges the CDN tag. - With transcription forced to fail through all retries, the testimonial still publishes, the moderator sees the failure state, and "Retry transcription" succeeds once the stub recovers.
- A silent video yields an empty transcript and a "no speech detected" state, not an error.
- Low-confidence words are marked with an accessible name and an underline, verified in greyscale (the colour-independence check).
- Captions render in the player and are on by default.
Demo script — "Here is a video with its transcript beside it. Word confidence below 75% is underlined — watch, it is still visible in greyscale. I fix a misheard word, merge two segments, and save. The caption file regenerates immediately and the player shows my correction. Now I break the transcription provider entirely — the video still publishes, just without captions, and one click retries it."
M4 — The embed: loader, runtime, three widget types, the data API, and the gates #
| Goal | A customer can paste one script tag onto any website and see their published testimonials render fast, isolated, and within every performance budget. |
| Implements | Section 13 (loader, runtime, Wall of Love, Carousel, Single Quote, the data API), the widget half of Section 23, Section 26.9 (release train) |
| Depends on | M1 (published testimonials), M2 (video posters), M3 (captions in the player) |
| Size | ~4 weeks — the highest-risk milestone in the plan |
Deliverables
apps/embed: loader ≤ 5 KB gzip, runtime ≤ 22 KB gzip, ES2022 IIFE, open shadow root, adopted stylesheet, one non-enumerable global, batched multi-widget fetch, poster-first video with the player chunk imported on first play.- Widget data API at the CDN edge with the documented cache headers and purge-by-tag.
- Three widget types with the shared option set (theme, font scale, radius, accent, max items, tag and rating filters, sort, badge state).
- Widget editor in the dashboard with live preview, the snippet, and the contrast checker.
<noscript>fallback and the oEmbed endpoint.- All four fixture host pages and every gate of Section 25.5 wired into CI.
- The widget release train with canary and the rollback command.
Exit criteria
- Every row of the widget gate table in Section 25.5 passes in CI as a hard gate.
- The widget renders correctly on all four fixture host pages, including the strict-CSP page
with no
'unsafe-inline'and the hostile-CSS page. - Exactly one network request occurs before first widget paint; zero web fonts are fetched; zero video bytes are fetched until the play affordance is clicked.
Object.keys(window)gains exactly one key, non-enumerable, after the widget loads.document.head.childElementCountis unchanged after render.- Six widgets on one page produce one loader instance and one batched data fetch.
- Publishing a testimonial makes it appear in the widget within 60 seconds; unpublishing removes it within 60 seconds.
pnpm widget:rollbackcompletes and is verified from two regions in under 5 minutes.- The rendered payload for a 24-item wall is ≤ 60 KB.
Demo script — "Here is a plain HTML page. I paste two lines. Refresh — testimonials. Now
here is the same page with a hostile stylesheet that sets !important on everything and a
Content-Security-Policy that bans inline styles; it still renders correctly. Here is the
network tab: one request, no fonts, no video until I click play. And here is CI failing when I
add a single dependency to the bundle."
M5 — Showcase pages and custom domains #
| Goal | Every workspace has a public, fast, crawlable testimonial page, optionally on the customer's own domain, with a verification flow that tells the truth at every step. |
| Implements | Section 14, Section 26.13 (platform DNS and the edge configuration for customer domains) |
| Depends on | M1, M4 (shared payload building and CDN configuration) |
| Size | ~2.5 weeks |
Deliverables
- Showcase page at
https://v.vouch.app/s/{slug}and on{workspace-slug}.vouch.app, server rendered, filterable, paginated by cursor, with structured data for review rich results. - Custom domain flow: add → exact DNS records → live polling → TLS issuance → active, with every state a customer action can reach, the observed-versus-required diff, the last check time, and the documented polling cadence.
- The platform DNS zones, the anycast edge, and the host-header routing specified in Section 26.13, including the record set customers are shown for apex domains.
- ACME issuance with the primary and fallback issuers, HTTP-01 with DNS-01 fallback, auto-renew at 30 days, alerts at 14 and 7.
- Edge host-to-workspace mapping and its cache invalidation.
- One-domain-one-workspace enforcement via the challenge record.
Exit criteria
- A domain added in a test DNS zone reaches
activeend to end against the ACME staging CA, withvouch_domain_time_to_active_secondsrecorded. - Every customer-reachable state in Section 14.3 is reachable in a test and renders a text
label plus an icon — never colour alone (verified by the greyscale check). The one state no
customer action reaches,
suspended, is entered only by the billing path and is asserted in M7 instead. - A CAA record forbidding our issuer produces a
failedstate whose message names the CAA record as the cause. - Claiming a domain already active on another workspace returns
DOMAIN_ALREADY_CLAIMED. - Adding, changing, and removing a custom domain leaves an already-pasted widget snippet working, asserted by loading the fixture host page after each operation.
- Removing a domain leaves the
v.vouch.appshowcase URL working. - Lighthouse on the showcase page: performance ≥ 90, accessibility 100, zero axe serious/critical.
Demo script — "I add love.example.com. The screen shows exactly which records to create
and what it currently sees — right now, nothing. I add the CNAME; within seconds the screen
says DNS detected, then verifying, then issuing a certificate, then active. Here is the page on
the customer's own domain. And here is the widget on the customer's site, still working — I
never touched the snippet."
M6 — Analytics #
| Goal | Customers can see which testimonials perform, measured without a single cookie or cross-site identifier. |
| Implements | Section 17 |
| Depends on | M4 (the widget emits the events), M5 (showcase pages emit them too) |
| Size | ~2 weeks |
Deliverables
- Client instrumentation: IntersectionObserver impressions (≥ 50% for ≥ 1 s, once per widget
per view), interactions, outbound clicks, video quartile events, batched via
sendBeaconwith the in-memory-onlyviewId. - Ingestion endpoint at the edge with bot filtering, impossible-timing rejection, and separate counting of filtered events.
- Raw retention 7 days, then hourly and daily rollups by workspace, widget, testimonial, event type, country, and device class; IP used to derive country and device class then discarded.
- Dashboard reports with per-plan retention windows.
Exit criteria
- A test asserts that after a full widget interaction session,
document.cookieis unchanged andlocalStorage/sessionStoragehave zero keys from our origin (asserted on the host page and inside the shadow root's script context). - No request from the widget carries a persistent identifier;
viewIddiffers across navigations and never appears in storage. analytics_events_rawcontains noipcolumn, asserted by a schema test.- Rollups for a seeded day match a direct count of raw events (exact equality).
- Retention sweeps delete raw events older than 7 days and rollups older than the plan window.
- Bot-filtered events appear in a separate counter, never silently dropped.
- Ingestion sustains 20,000 beacons/minute in the load test with zero drops.
Demo script — "Here is the analytics page: impressions, interaction rate, click-through, and the three testimonials that convert best. Now here is the browser's storage inspector on the customer's site while the widget runs — completely empty. No cookie banner is required, and here is the code path that makes that true."
M7 — Billing, plans, and quota enforcement #
| Goal | Money works, limits are enforced server-side, and nothing already live ever degrades. |
| Implements | Section 19, the billing parts of Section 20 |
| Depends on | M1 (things to count), M2 (storage/delivery/transcription metering), M4 (the badge is rendered into the payload), M6 (delivery metrics) |
| Size | ~3 weeks |
Deliverables
plans,subscriptions,stripe_events, quota counters with Postgres as the authority and Valkey as the cache, and the nightly reconciliation job.- Checkout, Billing Portal, webhook handling as the source of truth, proration, and tax.
- Enforcement of every limit in Section 19 at every write path, plus warnings at 80% and 95% in-product and emails at 90% and 100%.
over_limit,past_due, andcanceledworkspace states with their exact documented behaviour.- Server-side "Powered by Vouch" badge in the widget payload and the showcase HTML on Free.
- Custom-domain add-on billing.
- Dunning: 3 retries over 14 days.
Exit criteria
- 50 concurrent submissions against a workspace with 1 slot remaining yield exactly 1 success
and 49
TESTIMONIAL_LIMIT_REACHED. - Downgrading a workspace with 300 testimonials to a 100-cap plan deletes nothing, unpublishes
nothing, sets
over_limit, and blocks only new collection — asserted by counting published testimonials before and after and by fetching the widget payload before and after. - A
canceledworkspace's widget data API and showcase page still return the full published set; the dashboard is read-only. This is asserted as an explicit test namedcanceled-workspace-keeps-serving. - Removing the badge client-side does not remove it from the payload; the next data refresh re-asserts it.
- Stripe test-mode lifecycle (subscribe, upgrade, downgrade, fail payment ×3, cancel) leaves the workspace in the documented state after each step.
quota.reconcilereports zero drift on a seeded dataset.- Reaching the testimonial cap makes the request page return HTTP 200 with
collectionClosed, not an error status.
Demo script — "This workspace is at its limit. The request page politely says it is not accepting submissions — note the 200 status, not an error. I downgrade a workspace from Scale to Starter with 300 testimonials: nothing is deleted, nothing is unpublished, the widget is untouched, and only new collection is blocked. Then I cancel the subscription entirely — the dashboard goes read-only, and the widget on the customer's site keeps serving. Forever."
M8 — Remaining widget types, social cards, and the AI highlight assistant #
| Goal | The widget catalogue is complete, testimonials can be shared as images, and the AI assistant proposes highlights it can never rewrite. |
| Implements | Sections 12, 15, and the rest of Section 13's catalogue |
| Depends on | M3 (transcripts feed clip and quote proposals), M4 (widget framework), M7 (AI is a Growth+ entitlement) |
| Size | ~3.5 weeks |
Deliverables
- Video Reel, Badge/Rating Summary, and Popup/Slide-in widget presets with their specific rules (frequency cap, never in the first 5 seconds, dismissible, never blocking).
- Social image card generator: templates, rendering to PNG, storage, CDN delivery, Open Graph and Twitter card tags on showcase pages, AI-drafted alt text.
AiProviderinterface withsuggestHighlights,suggestTags,draftAltText; versioned prompts stored with every suggestion.- The verbatim validator in front of every proposal, with the rejection metric and its 2% alert.
- Human-approval-required UI on every AI proposal, on every plan.
Exit criteria
- The verbatim validator's full unit suite passes, including the property-based tests, at 100% coverage and ≥ 80% mutation score.
- A deliberately paraphrasing AI response is rejected before any human sees it, asserted by a test with a recorded non-verbatim fixture; the rejection increments the metric.
- No AI proposal can be published without an explicit approval action, asserted by attempting
the publish API directly with an unapproved suggestion id and receiving
AI_SUGGESTION_NOT_APPROVED. - Tag suggestions are drawn only from the workspace's existing vocabulary (a test with a suggestion outside the vocabulary is dropped).
- Every new widget type passes the same size, Lighthouse, axe, and long-task gates as M4.
- The popup respects
prefers-reduced-motion, its frequency cap, and the 5-second delay. - Social cards render for text and video testimonials in both aspect ratios, with alt text present on every card.
Demo script — "The assistant suggests five pull-quotes from this testimonial. Watch what happens when I make the model return a slightly reworded quote — it never reaches the screen, and the rejection is counted. I approve two real ones, generate a social card, and post it. The card's alt text was drafted by the assistant; the quote is the customer's exact words."
M9 — Review import #
| Goal | Existing reviews from Google, G2, Capterra, X, and CSV land in the same moderation inbox as collected testimonials. |
| Implements | Section 16 |
| Depends on | M1 (the moderation pipeline they land in), M7 (import is plan-gated) |
| Size | ~3 weeks |
Deliverables
- OAuth connections per workspace for Google and X; syndication-feed integration for G2 and Capterra where entitled; the customer-supplied export path as a first-class flow where not.
- CSV import with the documented column schema and a mapping UI, available on every plan.
- Normalisation: canonical 1–5 rating with
source_rating_rawretained, attribution and platform badge preserved, permalink stored and linked. - Deduplication on
(source, source_review_id)plus the fuzzy near-duplicate warning. - Scheduled daily sync for connected OAuth sources with per-source backoff.
- Immutability of imported text enforced at the service layer.
Exit criteria
- Each source imports its recorded fixture set: rows land as
pending, ratings normalise correctly, permalinks and attribution are stored. - Re-running the same import produces zero duplicates (unique constraint asserted) and reports the duplicate count.
- Attempting to edit an imported testimonial's text returns
IMPORTED_CONTENT_IMMUTABLE. - The no-entitlement path for a syndication source presents the export-upload flow rather than an error.
- A malformed CSV row is rejected with
IMPORT_ROW_INVALIDnaming the column, and the rest of the file still imports. - No code path fetches a review by scraping HTML — asserted by a lint forbidding HTML parsing libraries in the import package.
Demo script — "I connect Google Business Profile and import 40 reviews. They land in the same inbox as everything else, with the star ratings normalised and the original permalinks intact. I re-run it — zero duplicates. Then I upload a G2 export because this customer has no syndication entitlement, and it works exactly the same way. And I try to edit an imported review's words — the product will not let me, because they are not ours to change."
M10 — Integrations and the public API #
| Goal | Vouch is programmable: a documented REST API, signed webhooks, and the first-party integrations customers ask for on day one. |
| Implements | Section 18, the public half of Section 7, Sections 20.6–20.10 (SMS, the suppression list, the in-app notification centre, and deliverability operations) |
| Depends on | M1–M7 (there must be resources to expose and events to emit) |
| Size | ~3 weeks |
Deliverables
- Public REST API over
/api/v1with Bearer keys, scopes, per-workspace rate limits, idempotency, cursor pagination, and the documented error envelope. - API key management UI: create, name, scope, show once, rotate, revoke, last-used display.
- Webhooks: endpoint management, the documented signature scheme, the delivery ladder of one send plus eight retries, auto-disable with Owner notification, delivery log, and manual replay.
- SMS invitations behind their kill switch (Section 20.6), the cross-channel suppression list (Section 20.8), the in-app notification centre (Section 20.9), and the deliverability operations — SPF, DKIM, DMARC, reputation monitoring, and the bounce/complaint feedback loop (Section 20.10).
- Zapier integration (triggers: new testimonial, published, video ready; actions: create contact, send invitation).
- Slack: new-submission and published notifications to a chosen channel.
- HubSpot (Growth+): contact sync and testimonial-on-contact timeline events.
- Public API reference documentation generated from the same Zod schemas the API validates with, published at the documentation domain.
Exit criteria
- The authorization fuzzing suite passes for every public endpoint across all four roles, every scope, unauthenticated, and cross-workspace.
- A webhook delivered to a test endpoint verifies against the documented signature; a tampered body fails; a 6-minute-old timestamp fails.
- An endpoint that fails every one of its nine attempts — the first send plus eight retries —
is auto-disabled with
reason: attempts_exhaustedand the Owner is emailed; an endpoint answering410 Goneis disabled on the first response withreason: gone; replay after re-enabling succeeds. - Rate limits return 429 with
Retry-Afterand all threeX-RateLimit-*headers. - The same
Idempotency-Keyreplayed returns the stored response withIdempotency-Replayed: trueand produces exactly one side effect. - The generated API reference matches the implementation: a contract test calls every documented example request and asserts the documented response shape.
- Free-tier keys are read-only and limited to 30 requests/minute.
- An address on the suppression list receives no message from any send path, asserted by a
test that enumerates every sender in the codebase — transactional email, invitation email,
reminder, SMS, and digest — calls each one against a suppressed address and a suppressed
phone number, and asserts zero provider calls and a recorded
EMAIL_SUPPRESSEDoutcome. The test fails if a new sender is added without being enumerated.
Demo script — "Here is an API key with only testimonials:read. It lists testimonials and
is refused with a 403 when it tries to publish one. Here is a webhook arriving at my endpoint
with a valid signature — and here it is rejected when I change one character of the body. I
break my endpoint on purpose; after the first send and eight retries it is disabled and I get
an email explaining exactly why. And here is an address that bounced last week: every one of
our send paths silently declines to write to it."
M11 — Accessibility, performance, security hardening, and launch readiness #
| Goal | Every promise this product makes is verified by a machine or a named human, and the system is operable by an on-call engineer who did not build it. |
| Implements | Sections 21, 23, 24, 25 (completion of gates), 22 (compliance completion) |
| Depends on | M0–M10 |
| Size | ~3 weeks |
Deliverables
- Full manual accessibility pass across the matrix owned by Section 21, with every finding either fixed or documented with a dated, owned remediation item.
- All performance budgets enforced and green; load tests run at the documented scales.
- External penetration test completed; all high and critical findings fixed and retested.
- Every runbook in Section 24.8 written into
docs/RUNBOOKS.mdand rehearsed once. - Every alert in Section 24.7 configured and test-fired.
- Backup restore drill, cross-region drill, widget rollback drill, and erasure proof drill all executed with evidence committed.
- Data-subject request tooling (export and erasure) verified end to end.
- Status page live; on-call rotation staffed; incident process rehearsed with a game day.
- Customer-facing documentation, the error-code documentation pages, and the sub-processor list published.
Exit criteria
- Zero axe serious/critical violations across every audited surface; the manual matrix is fully executed with results committed.
- Every row of the widget performance budget passes, and the request page is ≤ 140 KB gzip of JavaScript.
- Penetration-test report shows zero open high or critical findings.
- Every alert has fired at least once in a test and reached a human.
- A database restore drill completed within RTO with data verified through the application, with RLS enforced.
- A widget rollback completed and verified in under 5 minutes.
- An erasure request completed end to end with the R14 proof output archived.
- A game-day incident was run end to end: declared, commanded, communicated, resolved, and reviewed.
- The release-readiness checklist of Section 25.12 passes in full.
Demo script — "Game day. I break the widget data API in staging. The alert fires, the pager goes off, the on-call engineer — who did not build this — opens the runbook, follows it, and resolves it. Meanwhile the fixture customer site keeps rendering testimonials from cache the entire time. Then we run the erasure drill and produce a signed proof that every byte is gone."
27.3 Dependency Graph #
M0 ──▶ M1 ──▶ M2 ──▶ M3 ──┐
│ │ │
│ └──────────┼──▶ M4 ──▶ M5 ──▶ M6 ──┐
│ │ │
└──────────────────┘ │
│
M1, M2, M4 ────────────────────────────────▶ M7 ──┬──▶ M8 ──┐
│ │
M1 ──────────────────────────────────────────────▶ M9 ──────┤
│ │
M1…M7 ───────────────────────────────────────────▶ M10 ────┼──▶ M11
│
(all) ─┘Two edges in that drawing are easy to misread and are therefore stated in words:
- M5 ──▶ M6 is a real edge, not a stylistic one. M6's dependency list in 27.2 names M5 because showcase pages emit the same analytics events the widget does, and an analytics milestone that shipped before the second emitter would have to be reopened to add it.
- M4 ──▶ M6 is transitive, through M5. M6 needs the widget, and the widget is finished in M4; the chain in the drawing carries that dependency without a second arrow.
- M8's and M9's plan gates ride on M7, which is why both sit to the right of it even though their content dependencies (M3 and M4 for M8, M1 for M9) are earlier.
27.4 Verification That No Milestone Depends on a Later One #
Every dependency listed in 27.2 was extracted and checked. All edges point from a lower index to a higher index; the graph is acyclic and monotone. Two places where the intuitive ordering would have violated the rule, and how they were resolved:
- Quota enforcement versus billing. M1 needs a testimonial cap (the Free tier's 10) but
plans and Stripe arrive in M7. Resolution: the quota primitive — the counter, the
conditional write, and the
packages/core/quotaservice — is built in M1 against hard-coded Free defaults, and M7 replaces the source of the limits with the plan table. Nothing in M1 waits for M7; M7 changes configuration, not architecture. Without this split, M1's exit criterion 8 would depend on M7. - Analytics versus the widget and the showcase page. M6's events are emitted by two
surfaces — the widget (M4) and the showcase page (M5) — so M6 must follow both. Neither M4's
nor M5's exit criteria mention analytics: both ship the same instrumentation seam, an
event-emitter interface that is a no-op until M6 fills it. That is why the graph in 27.3
draws
M4 ──▶ M5 ──▶ M6rather than treating M5 and M6 as siblings — building analytics before either emitter existed would have inverted the ordering, and building it after only one of them would have meant reopening M6 to add the second.
Two further orderings were checked and are deliberate rather than accidental: captions before the embed (M3 before M4), because the widget's player must ship with captions on by default and retrofitting a default is how defaults get lost; and the embed before showcase pages (M4 before M5), because both build the same public payload and doing it twice would guarantee divergence.
27.5 Critical Path #
The critical path is M0 → M1 → M2 → M3 → M4 → M7 → M11, approximately 22.5 engineer-weeks of the ~36-week single-engineer total. Everything else can, in principle, be moved around it.
Why each link is on the path:
- M0 → M1: nothing exists without tenancy and auth.
- M1 → M2: video attaches to submissions and consents.
- M2 → M3: there is nothing to transcribe until video is stored and processed.
- M3 → M4: the player ships with captions on by default, and a default that is retrofitted is a default that gets lost. M4 cannot close its accessibility exit criteria without a real caption track, so M3 is a hard dependency and therefore on the path by definition — not "critical-adjacent". A slip in M3 slips M4 one for one.
- M4 → M7: the badge and the "never degrade what is live" guarantee are properties of the widget payload; billing cannot be validated without them.
- M7 → M11: launch readiness includes billing correctness.
M6 sits just off the path: M7 needs its delivery metrics, but M6 is two weeks against M3–M4's seven, so it absorbs slippage rather than causing it. If it ever stops absorbing — if M6 starts late enough that M7 waits on it — the path becomes M0 → M1 → M2 → M3 → M4 → M5 → M6 → M7 → M11 at ~27 weeks, and that is the number to re-plan against.
The single riskiest item on the path is M4. The performance budget is unforgiving, and budgets are much harder to retrofit than to hold. Mitigation: the size gates and the fixture host pages are built in the first days of M4, before any widget feature, so every commit is measured against the budget from the start. A team that builds the widget first and measures it last will not make the budget.
27.6 Parallelization for a Team of Three #
Assume three engineers: A (platform/backend), B (frontend/widget), C (full-stack/integrations). Wall-clock estimate: ~16 weeks versus ~36 solo.
| Weeks | A (platform) | B (frontend/widget) | C (full-stack) |
|---|---|---|---|
| 1–2 | M0: repo, CI, database, RLS, migrations, config, deploy pipeline | M0: app shell, auth UI, design system in packages/ui |
M0: auth backend, invitations, member management, email plumbing |
| 3–5 | M1 backend: request pages, submissions, consent, lifecycle, audit | M1 frontend: request-page builder, public request page, moderation inbox | M1: contacts, invitations to submit, respondent link, transactional email |
| 6–9 | M2 backend: upload sessions, multipart, media pipeline, provider webhooks, purge | M2 frontend: the recorder (the largest single frontend item in the plan) | M3: transcription, VTT, transcript editor backend + editor UI |
| 10–13 | M4: widget data API, payload builder, CDN configuration, purge-by-tag; then M7 backend | M4: loader and runtime — B's sole focus, no context switching | M5: showcase pages and custom domains end to end |
| 14–16 | M7: billing, quotas, dunning; M11 hardening | M6 client instrumentation; M8 widget types; M11 accessibility | M6 ingestion/rollups; M9 imports; M10 API and integrations |
Rules that make this work rather than merely look parallel:
- One person owns the widget from M4 onward. Its budget is a property of consistent judgement about every byte; split ownership is how 5 KB becomes 9 KB.
- Contracts before implementations. The Zod schemas in
packages/validationand the service signatures inpackages/coreare written and merged before the two sides of a feature are built in parallel. A stub implementation that satisfies the schema unblocks the frontend immediately. - The database schema is owned by A, and migrations are reviewed by A even when written by others — the workspace-scoping and RLS invariants are too important to distribute.
- Nobody works in an integration branch for more than two days. Feature flags, not long branches.
- Highest-value work that can start early and is often forgotten: the fixture host pages (build them in week 1, they cost a day), the provider stubs (they unblock everyone), and the seeded demo workspace (it makes every subsequent demo instant).
27.7 Top Risks #
| # | Risk | Impact | Likelihood | Mitigation | Early-warning signal |
|---|---|---|---|---|---|
| 1 | The widget exceeds its size or performance budget and the budget is quietly relaxed | Severe and permanent: the budget is the product's differentiator, and once relaxed it never tightens | Medium-high | Gates built on day 1 of M4 as hard CI failures; a documented rule that raising a budget requires a written decision in docs/DECISIONS.md; no dependency may enter the bundle |
The gzip size in the CI output creeping upward across PRs; any PR proposing a size-limit config change |
| 2 | Cross-tenant data leak through a query missing its workspace scope | Existential | Low, if the design holds | RLS forced at the database as the backstop; the three structural schema tests; the authorization fuzzing suite; notDeleted() + workspace scoping in the query helpers rather than at call sites |
Any new table appearing without workspace_id in a PR; a structural test being skipped |
| 3 | Media deletion does not actually delete (an orphaned provider asset or storage key) | Legal and reputational; breaks an explicit promise | Medium | The prefix-per-testimonial key layout makes purge exhaustive by listing; media.purge retries until success and alerts after 3 failures; the erasure proof query and the quarterly drill |
vouch_media_purge_pending above zero; any purge job reaching attempt 3 |
| 4 | Chunked resumable upload is harder than estimated (it usually is) | Slips M2 and therefore M4 | Medium-high | Build the upload path as the first thing in M2, before any recorder UI; test resume with real network interruption in E2E from the beginning; keep the provider's direct-upload path as a documented fallback for the first release if resume proves unstable | The E2E resume test being marked flaky; part-failure ratio above 1% in staging |
| 5 | Video provider or transcription provider pricing or terms change | Margin compression on the largest cost line | Medium | Provider abstraction behind an interface (transcription has a documented alternative); originals retained so re-ingest elsewhere is possible; unit-economics tracking per workspace | Provider pricing announcements; cost per published testimonial drifting above model |
| 6 | Review-syndication entitlements (G2, Capterra) are unavailable to most customers | A headline import feature works for few customers | High | The customer-supplied export path is a first-class flow, not an error path, and is built first; marketing copy describes both paths honestly | Low connection rate in the import funnel during M9 |
| 7 | Browser MediaRecorder inconsistency, especially on iOS Safari |
Respondents cannot record; the core loop breaks on the most common device | Medium-high | Codec negotiation in a documented order with the chosen mime type recorded; real-device testing in the nightly suite from M2 onward; the text-testimonial path always available as a fallback | Real-device suite failures; a spike in submissions started but not completed on iOS |
| 8 | Accessibility treated as an M11 activity | Retrofitting WCAG into a recorder is far more expensive than building it in | Medium | axe in CI from M1; keyboard operability as an exit criterion for M1, M2, M3, M4, and M5 individually — not deferred to M11; the greyscale colour-independence check in every milestone | Any milestone shipping with an accessibility exit criterion waived |
| 9 | CDN cache invalidation is wrong — either stale (customers see deleted testimonials) or over-purged (cost and latency) | Trust and cost | Medium | Purge-by-tag through a retried job; an E2E assertion that publish and unpublish take effect within 60 seconds; hit-ratio alerting | Cache hit ratio below 0.85; a support ticket about a testimonial still showing after deletion |
| 10 | Quota enforcement drifts from the plan table as features are added | Revenue leakage or wrongly blocked customers | Medium | One enforcement module with 100% coverage; nightly reconciliation with a zero-drift alert; a test that fails when a new limit is added to the plan table without a corresponding enforcement point | Any non-zero drift; quota blocks changing shape without a pricing change |
| 11 | Scope creep into a general-purpose review platform | Nothing ships | High | Section 29.8 is the written list of what is deferred and why; anything not in this document requires an explicit decision recorded in docs/DECISIONS.md |
Milestone exit criteria growing after the milestone starts |
| 12 | Single-engineer knowledge concentration (especially on the widget) | Bus factor of one on the riskiest component | Medium | Every runbook rehearsed by someone who did not write it; the widget rollback drill run monthly by a different person; architecture decisions written in docs/DECISIONS.md at the time they are made |
A drill that only one person can complete |
28. Executor Instructions #
This section is addressed to the AI agent or engineering team that will build Vouch from this document with no product manager to ask. Everything you need is here; where it is not, this section tells you how to decide and how to record what you decided.
28.1 How to Read This Document #
- Read Sections 1 through 7 in full before writing any code. They define the product, the stack, the conventions, the data primitives, the tenancy model, and the HTTP contract. Everything after Section 7 assumes them.
- Each concern has exactly one owning section. When two sections mention the same thing, the owner defines it and the other references it. The owners are:
| Concern | Owner |
|---|---|
| Customization decisions (naming, domains) | Section 1 |
| Product scope and success criteria | Section 2 |
| Dependency versions, repository layout, architecture | Section 3 |
| Naming, code standards, project structure | Section 4 |
| Identifiers, timestamps, money, deletion policy, enums, schema | Section 5 |
| Tenancy, RLS, auth, roles, permissions | Section 6 |
| HTTP envelope, errors, pagination, idempotency, versioning, API auth, rate limits, webhook signing | Section 7 |
| Request pages, custom fields, invitations | Section 8 |
| Video capture, chunked upload, media pipeline, media retention | Section 9 |
| Transcription, captions, transcript editing | Section 10 |
| Testimonial lifecycle, moderation, tagging, audit trail | Section 11 |
| AI assistant and its boundary | Section 12 |
| Widgets, the embed SDK, the performance budget | Section 13 |
| Showcase pages, custom domains, TLS | Section 14 |
| Social image cards | Section 15 |
| Review import | Section 16 |
| Analytics | Section 17 |
| Webhooks, Zapier, Slack, HubSpot | Section 18 |
| Plans, billing, limit enforcement | Section 19 |
| Notifications and transactional messaging | Section 20 |
| Accessibility | Section 21 |
| Security, privacy, consent, compliance | Section 22 |
| Performance budgets and non-functional requirements | Section 23 |
| Observability, alerting, runbooks, backup and DR | Section 24 |
| Testing and quality gates | Section 25 |
| Deployment, environments, configuration, cost | Section 26 |
| Milestones and sequencing | Section 27 |
| Catalogues: env vars, error codes, events, jobs, permissions, services | Section 29 |
- Never redefine something an owning section defines. If you find yourself writing a second error envelope, a second pagination scheme, or a second definition of a role, stop — you have lost the thread. Reference the owner.
- The appendices are the source of truth for catalogues. A new error code, environment variable, event, job, or permission string is not "added to the code" — it is added to its appendix and the code, in the same pull request.
28.2 Build Order #
Follow Section 27's milestones in order. Within a milestone, build in this order:
- The migration and the schema.
- The Zod schemas in
packages/validation. - The service in
packages/core, with its unit tests. - The route handler or server action in
apps/web, thin, callingcan()and the service. - The job handler in
apps/worker, if the feature has asynchronous work. - The UI.
- The tests that were not written alongside the above (integration, E2E).
- The gates: run them and fix what they find before opening the pull request.
When reality contradicts the plan. This document was written before the code existed, so some of it will be wrong. When you hit a contradiction:
- If it is a detail (a column name reads badly, an index is unnecessary, a library API
differs from what is described): decide it yourself, implement the better option, and record
it in
docs/DECISIONS.mdwith the date, what the document said, what you did, and why. One paragraph. Do not seek permission. - If it is a structural constraint (the widget cannot make the size budget; a provider does not offer a capability the document assumes; a security requirement conflicts with a functional one): stop, write the problem down with the options and their consequences, and escalate it as a product decision. Do not silently relax a constraint listed in 28.3.
- If a dependency's current major line differs from what Section 3 states: use the current stable major line, verify the APIs used still exist, and record the change. Section 3's lines are a known-good floor, not a lockfile.
- Never leave a contradiction unrecorded. The next person to read the code will otherwise reconstruct the wrong intent.
28.3 The Non-Negotiables #
These are the promises the product is built on. Every one of them is cheap to keep from the start and expensive or impossible to retrofit. Under time pressure, cut features, cut polish, cut milestones — never cut these.
| # | Non-negotiable | Owner | Why it cannot be traded |
|---|---|---|---|
| 1 | Every domain table carries workspace_id, and every workspace-scoped table has RLS enabled and forced. No implicit "current org" anywhere |
Section 6 | The database is the last line of defence against a cross-tenant leak. Adding RLS later means auditing every query ever written |
| 2 | The widget performance budget holds: ≤ 5 KB gzip loader, ≤ 22 KB gzip runtime, ≤ 27 KB total, zero framework bytes, zero web fonts, one request before first paint, CLS ≤ 0.01 | Section 13, gates in Section 23 and 25.5 | It runs on other people's homepages. A budget relaxed once is never restored |
| 3 | Every limit is enforced server-side in the quota service. Client checks are advisory only | Section 19 | A limit enforced only in the UI is not a limit |
| 4 | Already-live widgets never break. Reaching a cap, downgrading, going past due, or cancelling never unpublishes, never degrades quality, never reduces volume, and never stops the widget data API or the showcase page | Section 19 | This is the product's core trust promise. A customer's homepage must never punish them for a billing event |
| 5 | The audit trail is append-only. No UPDATE, no DELETE, enforced by revoked privileges and a database rule | Section 11 | An audit trail that can be edited is not evidence |
| 6 | Consent is captured at submission, on the same screen as submit, never pre-checked, with the exact rendered text stored verbatim and hashed | Section 22 | Consent that cannot be reproduced as it was shown is not consent |
| 7 | Media deletion actually deletes: provider asset, every storage key, every derivative, CDN entries — and only then is the row considered deleted | Section 9, Section 22 | A row marked deleted whose video still streams is a data-protection failure, not a bug |
| 8 | The AI assistant selects, never rewrites. Every proposed pull-quote is validated server-side as an exact substring of the person's own words, and any failure is discarded before a human sees it | Section 12 | Publishing words a customer did not say, under their name and face, is the one failure this product cannot survive |
| 9 | Captions are on by default in every player surface, including inside widgets | Section 10, Section 21 | A default that must be discovered is a default nobody gets |
| 10 | WCAG 2.2 AA on every respondent-facing surface — request page, recorder, showcase, and every widget — including a fully keyboard-operable recorder and no colour-only meaning | Section 21 | These surfaces are used by the customer's customers, who did not choose us |
Add to these two operational invariants that follow from the same reasoning: no secret, token, full IP, or testimonial body ever reaches a log line (Section 24.2.4), and no production data is ever copied into a non-production environment (Section 25.9).
28.4 Decisions You May Make, and Decisions You May Not #
Free to decide, without asking, recording only if a future reader would be surprised:
- Internal file organisation within a package; helper names; the internal shape of a component tree.
- Which Radix primitive to use for a given control, and the dashboard's visual details within the design system.
- Index choices beyond the mandated
workspace_id-leading rule; query shapes and optimisation strategy. - Caching decisions that are invisible in behaviour (adding a memoisation, choosing a TTL for an internal cache).
- Test structure, factory design, fixture naming.
- Copy wording, as long as it stays plain, actionable, and consistent with the product voice.
- The order of work within a milestone.
- Choosing the current stable major line of a dependency where it has moved on from Section 3.
Free to decide, but must be recorded in docs/DECISIONS.md:
- Any deviation from a documented column name, endpoint path, status code, or payload shape.
- Adding a new background job, error code, event, environment variable, or permission string (which also means updating the relevant appendix).
- Choosing the documented alternative for a third-party service (Section 29.7).
- Any change to a cache TTL, retry ladder, or rate limit that is documented here.
- Any performance budget headroom decision (for example, deciding the runtime chunk will hold at 18 KB to leave room for a future widget type).
Must NOT change without an explicit product decision from a human:
- Anything in the non-negotiables table (28.3).
- The plan structure, prices, or limits in Section 19.
- The four roles and their capabilities in Section 6.
- The testimonial lifecycle states in Section 11.
- The consent model: which grants exist, which are required, and how they are stored.
- The pasted embed snippet's shape — it must carry only the widget id, forever.
- The pagination model (cursor only), the error envelope, or the API versioning scheme.
- The analytics model's cookie-free, identifier-free stance.
- The "no scraping" rule for review import.
- The decision that the widget ships zero framework bytes.
28.5 The Per-Feature Workflow #
For every feature, in this order. Deviating from the order is the most common cause of rework.
- Read the owning section end to end. Not the summary — the section. Note every field, default, edge case, and error condition it names.
- Write the migration. Expand-only.
workspace_idnon-null with its foreign key. RLS enabled and forced with a policy. Indexes leading withworkspace_id. Soft-delete column if the entity is on the soft-delete list; hard delete otherwise. Run it against an empty database and update the schema snapshot. - Write the Zod schema in
packages/validation— one schema, used by the client form, the route handler, and the worker. If you find yourself writing the shape twice, you have made a mistake that will diverge within a month. - Write the service in
packages/core. Pure-ish: it takes a context (actor, workspace, transaction) and data, and returns data or throws anAppErrorwith a catalogued code. It callscan(). It calls the quota service where a limit could apply. It writes audit events. It enqueues jobs through the outbox, never directly. Unit-test it now, not later. - Write the route handler or server action. Thin: parse with the schema, open the transaction with the tenancy context, call the service, return the envelope. No business logic. No direct database access.
- Write the job handler if the feature has asynchronous work. Idempotent. Payload versioned. Registered in the job catalogue with its retry, timeout, dead-letter behaviour, and alert.
- Write the UI. Server components by default; client components only where interaction demands. Form state through React Hook Form with the shared Zod resolver. Server state through TanStack Query. Accessible from the first commit: label every control, verify the keyboard path, never encode meaning in colour alone.
- Write the tests you have not yet written: an integration test for the data path with a cross-tenant negative case, and an E2E test if it is user-visible.
- Run the gates: lint, typecheck, unit, integration, E2E, axe, widget size, Lighthouse. Fix what they find.
- Walk the Definition of Done checklist (Section 25.11) before opening the pull request.
28.6 Common Pitfalls, and the Correct Approach #
Each of these has happened to someone building a product like this. They are listed with the symptom, the reason it is dangerous here specifically, and what to do instead.
| # | Pitfall | Why it bites in this product | Correct approach |
|---|---|---|---|
| 1 | Forgetting the workspace scope on a new query | RLS will save you in production, but a query without the scope silently returns nothing (or throws) in ways that look like a product bug, and any code path that runs as the migrator role has no backstop at all | Use the typed query helpers in packages/db, which take the workspace context as a required argument and compose notDeleted(). Never hand-write a db.select() in a route handler. The structural test will fail a new table without workspace_id; add the query helper in the same PR |
| 2 | Adding a dependency to the widget bundle | A 30 KB date library is one import away, and it doubles the budget. The bundle-graph assertion will fail the build, but only after you have built a feature around it |
Before importing anything into apps/embed, ask whether 20 lines of first-party code would do. Formatting dates, cloning objects, and event emitting all fit in a few lines. The allow-list is empty and stays empty |
| 3 | Using innerHTML in the widget |
Testimonial text is user-submitted and rendered on other people's sites. One innerHTML is a stored XSS on every customer's homepage |
Use textContent for all text. Rich-text bodies are sanitized server-side at write time and re-sanitized at serve time; the widget still renders them through a constrained node builder, never by assigning markup. ESLint plus a post-build string scan will fail the build, but do not rely on the scan — rely on the habit |
| 4 | Enforcing a limit only in the UI | Hiding the "invite" button is not enforcement; the API is public and documented | Every limit check lives in packages/core/quota and is called by the service before the write. The UI check is a courtesy that reads the same numbers. A test that calls the API directly with the UI bypassed is part of the Definition of Done |
| 5 | Deleting a row without purging its media | The row disappears from the dashboard and the video keeps streaming from the provider's CDN forever, which is exactly the failure the erasure guarantee exists to prevent | Deletion of anything owning media goes through the service that enqueues media.purge. Never DELETE a testimonial in a script or a migration. If you must clean up data manually, use the same service |
| 6 | Letting the AI produce text that is not verbatim | A model asked to "suggest a great pull quote" will happily tidy grammar. Published under a real person's name and face, that is defamatory in the worst case and dishonest in every case | Every proposal passes the verbatim validator before it is stored, before it is shown, and again before it is published. Never add a code path that displays a raw model output. When adding a new AI capability, the first question is: does it emit the respondent's words? If yes, it goes through the validator; if it emits prose about the testimonial (alt text, a platform-authored headline), it is labelled as platform-authored |
| 7 | Shipping a status indicator that is colour-only | Domain states, moderation states, transcript confidence, and analytics deltas are all naturally colour-coded, and colour alone fails WCAG 1.4.1 and is invisible to a large minority of users | Every status carries text or an icon plus text. The greyscale check in CI catches this; run it locally before you commit a new status UI |
| 8 | Hard-coding a host into the embed snippet | The snippet is pasted into a CMS and never touched again. If it carries a workspace host or a custom domain, then adding, changing, or removing a domain breaks every existing embed — a self-inflicted outage on customers' homepages | The snippet carries only the widget id, and the loader always lives on the platform CDN host. Everything else is resolved at runtime from the widget id. Adding a "convenience" host parameter is forbidden |
| 9 | Reading a just-written row from the read replica | Replication lag turns "publish" into "nothing happened" in the UI | The replica serves analytics and exports only. Everything else reads the primary |
| 10 | Enqueuing a job directly instead of through the outbox | The transaction rolls back, the job still runs, and the job acts on a row that does not exist | Write to the outbox in the same transaction; the relay enqueues after commit |
| 11 | Logging a request body while debugging | It works, it ships, and now testimonial text and respondent emails are in the log store | Log ids and field names. If you need the body locally, log it behind APP_ENV === 'development' and delete it before the PR |
| 12 | Purging the whole CDN because tag purging is fiddly | Cache hit ratio collapses, origin load spikes, and the widget gets slower for everyone | Purge by tag through the cdn.purge job. If you do not know which tags, the payload builder does — ask it |
| 13 | Treating an imported review as editable content | It is someone else's platform's record; editing it is misrepresentation | Imported text is immutable at the service layer. Tag, feature, or hide — never edit |
| 14 | Adding a "quick" page/offset endpoint |
Two pagination models means two client implementations, and offset pagination breaks under concurrent writes exactly where moderation happens | Cursor pagination only, using the shared encoder |
28.7 What "Done" Means #
The build is done when all of the following are true. Not "mostly true" — true, with the evidence attached.
Functional completeness
- Every milestone in Section 27 has met every one of its exit criteria, with the evidence recorded.
- Every section of this document has an implementation, or a written, dated decision in
docs/DECISIONS.mdexplaining what was done instead and why. - Every catalogue in Section 29 matches the code: every environment variable in the boot schema, every error code thrown, every event emitted, every job registered, every permission checked.
Quality gates
-
pnpm ciexits 0 onmain, including every blocking stage. - Coverage thresholds met, with 100% line and branch on the quota service, the permission function, the verbatim validator, and the consent resolver, and ≥ 80% mutation score on those four.
- The nightly suite (cross-browser, real device, DAST, soak, mutation) is green.
- Every widget performance budget row passes; the request page is within its JavaScript budget.
- Zero axe violations at serious or critical across every audited surface, and the manual accessibility matrix executed with results committed.
- Load tests pass at the documented scales.
- Penetration test complete with zero open high or critical findings.
Operational readiness
- Every alert in Section 24.7 is configured and has fired at least once in a test.
- Every runbook in Section 24.8 exists in
docs/RUNBOOKS.mdand has been rehearsed by someone who did not write it. - Backup restore, cross-region restore, widget rollback, Valkey loss, and erasure proof
drills all executed with evidence committed under
docs/dr/. - The status page is live and on independent infrastructure; on-call is staffed; a game-day incident has been run end to end.
- Rollback verified: code in ≤ 5 minutes, widget in ≤ 2 minutes.
Compliance and trust
- Consent capture verified against Section 22, including verbatim text storage and hashing.
- Data export and erasure verified end to end, with a proof artefact produced.
- Sub-processor list published; retention schedule implemented and verified by querying for rows past their retention window (expected: zero).
- No secret, token, full IP, or testimonial body appears in a sampled scan of production logs.
- The canceled-workspace-keeps-serving test passes, and a manual check confirms a canceled workspace's widget still renders.
Documentation
-
README.mdgets a new engineer from clone to running application in under 15 minutes, and that has been verified by someone who has never run it. -
docs/DECISIONS.mdrecords every deviation from this document. - Customer-facing documentation covers embedding, the CSP directives a strict host needs, custom domains, imports, the public API, and every error code's remediation page.
28.8 How to Verify the Build Against This Document #
Before declaring completion, run this audit. It is designed to be executed by an agent mechanically, in order, and it takes a few hours — which is a rounding error against the cost of discovering a gap after launch.
- Section sweep. For each section 1–26, list its normative statements (every "must",
"always", "never", every table row, every default). For each, record: implemented / decided
otherwise (with the
docs/DECISIONS.mdlink) / missing. Anymissingblocks completion. - Catalogue reconciliation. Four automated diffs, each of which must produce an empty set:
- Error codes thrown in the codebase versus Appendix 29.3, both directions.
- Environment variables read by the boot schema versus Appendix 29.2 versus
.env.example. - Jobs registered with BullMQ versus Appendix 29.5.
- Permission strings passed to
can()versus Appendix 29.6. A fifth, manual: events emitted versus Appendix 29.4.
- Structural invariants. Run the three schema tests (workspace scoping, RLS enabled and
forced, indexes leading with
workspace_id) and confirm the platform allow-list has not grown without a recorded decision. - Non-negotiables audit. For each of the ten rows in 28.3, name the test that would fail if it were broken, and break it deliberately in a scratch branch to confirm the test fails. A promise with no failing test is not protected. This step is the single highest-value part of the audit.
- Budget audit. Print the current widget loader and runtime sizes, the total, and the Lighthouse results, next to the budget. Confirm no budget value in the configuration has been edited since M4 (check the file's git history).
- Promise audit — the customer-facing four. Physically verify, in a browser, not in a test: (a) a canceled workspace's widget still renders; (b) a testimonial deleted five minutes ago 404s at every media URL; (c) captions are on by default in a widget video without touching any control; (d) an AI-proposed quote is character-for-character present in the transcript.
- Operational audit. Confirm every alert exists, every runbook exists and has a rehearsal date, and every drill has evidence with a date inside the required cadence.
- Fresh-eyes test. Have someone who did not build it follow
README.mdfrom a clean machine, then complete one full loop: create a workspace, build a request page, submit a video testimonial from a phone, moderate it, publish it, embed it on a plain HTML page, and delete it. Every friction point they hit is a bug, including documentation friction. - Write the completion report into
docs/BUILD-REPORT.md: what was built, what was decided differently and why, what is deferred (cross-referenced to Appendix 29.8), the evidence for each item above, and the known gaps with owners and dates. A build declared complete without this report is a build whose gaps will be discovered by customers.
29. Appendices #
These appendices are the single source of truth for the catalogues the rest of the document refers to. Adding an error code, environment variable, event, job, or permission string means editing the appendix and the code in the same pull request; Section 28.8 step 2 reconciles them mechanically.
29.1 Glossary #
| Term | Definition |
|---|---|
| Workspace | The tenancy boundary. Every domain row belongs to exactly one workspace. A workspace owns its request pages, testimonials, widgets, domains, billing, and members (Section 6). |
| Workspace slug | Lowercase [a-z0-9-]{3,48} identifier, unique platform-wide, used in {slug}.vouch.app and public URLs. |
| Member | A user's membership of a workspace, carrying exactly one role. |
| Role | One of Owner, Admin, Editor, Viewer (Section 6). Exactly one Owner per workspace. |
| Actor | Whoever caused an action: a user, an API key, or the system. Recorded on every audit event. |
| Request page | A public page at v.vouch.app/r/{slug} (or a custom domain) where a respondent submits a testimonial (Section 8). |
| Prompt | A question shown on a request page to guide what the respondent says or writes. |
| Custom field | An extra structured question on a request page. Exactly seven types exist — short_text, long_text, select, star_rating, url, file, boolean — defined once in Section 8.5.2. |
| Respondent | The person giving a testimonial. Never has an account. |
| Contact | A person stored in a workspace for the purpose of being invited to give a testimonial. |
| Invitation (to submit) | An email or SMS to a contact containing a tokenised request-page link. Distinct from a team invitation. |
| Team invitation | An email invitation for a person to join a workspace as a member. |
| Submission | The raw act of a respondent completing a request page: answers, consent, and any media. Becomes a testimonial. |
| Testimonial | The moderatable, publishable unit. Text or video, collected or imported (Section 11). |
| Testimonial status | One of pending, approved, published, unpublished, rejected, archived (Section 11), plus the derived deleted value the public API projects when deleted_at IS NOT NULL (Section 5.2). deleted is never a stored status. |
| Moderator | Any member with permission to review, approve, reject, tag, and publish — that is, Owner, Admin, or Editor. |
| Moderation inbox | The dashboard queue of testimonials awaiting a decision. |
| Tag | A workspace-defined label on a testimonial. Tags are rows, not enum values, because customers create them. |
| Media asset | A stored video and its derivatives (original, poster, captions, social cards), with a state machine (Section 9). |
| Upload session | The server-side record of an in-progress chunked multipart upload, enabling resume (Section 9). |
| Transcript | The single JSONB structure of segments and word-level timings and confidences from which captions, quotes, and search all derive (Section 10). |
| Segment | One timed chunk of a transcript, editable by a moderator. |
| Caption track | A WebVTT file generated from the transcript, never hand-maintained separately. |
| Highlight | An AI-proposed or human-chosen emphasis on part of a testimonial: a pull-quote or a clip. |
| Pull-quote | A verbatim excerpt of the respondent's own words, featured visually. Never rewritten (Section 12). |
| Clip | An in/out point pair on a video, used to feature a shorter excerpt. |
| Verbatim validator | The server-side check that every AI-proposed quote is an exact substring of the stored text after Unicode NFC normalization and whitespace collapsing (Section 12). A proposal that fails it is refused with HIGHLIGHT_NOT_VERBATIM and never stored. |
| Suggestion state | ai_suggestions.state, one of six values (Section 5.2): proposed — awaiting a human decision; accepted — a human took it; dismissed — a human rejected it; discarded — the validator refused it before any human saw it; expired — it aged out unreviewed; stale — the underlying text changed after it was proposed. expired and stale are not the same thing and are never merged: one is time passing, the other is the content moving underneath the suggestion, and Section 12.4 counts them separately because they mean different things about the assistant's usefulness. |
| Widget | A configured, embeddable presentation of a workspace's published testimonials, identified by wgt_… (Section 13). |
| Widget type | One of Wall of Love, Carousel, Single Quote, Video Reel, Badge/Rating Summary, Popup/Slide-in. |
| Wall of Love | The masonry/grid/list widget showing many testimonials at once. |
| Embed loader | The permanent, mutable e.js file the customer's snippet points at. Its only job is to read widget ids, fetch data once, and import the runtime chunk it pins (Section 13, Section 26.9). |
| Runtime chunk | The immutable, content-hashed widget code the loader imports. Rolling back the loader rolls back the runtime. |
| Snippet | The two lines a customer pastes. It carries only the widget id and must never need re-pasting. |
| Widget data API | The public, unauthenticated, CDN-cached JSON endpoint that feeds widgets (Section 13). |
| Showcase page | The hosted public page of a workspace's testimonials, at v.vouch.app/s/{slug}, a free-tier subdomain, or a custom domain (Section 14). |
| Custom domain | A customer-owned hostname pointed at our edge, serving their showcase and request pages (Section 14). |
| Challenge record | The _vouch-challenge DNS record proving domain ownership. |
| Social card | A generated PNG image of a testimonial for sharing, with platform-authored alt text (Section 15). |
| Import source | Google, G2, Capterra, X, or CSV (Section 16). |
| Source review id | The origin platform's identifier for an imported review; unique per (source, workspace). |
| Impression | A widget entering the viewport at ≥ 50% for ≥ 1 s while the tab is visible (document.visibilityState === 'visible'), counted once per widget per page view (Section 17.3). The visibility condition is part of the definition, not an optimisation. |
| Interaction | A play, next/prev, filter, or expand action inside a widget. |
| View id | A random, in-memory-only value used to deduplicate events within one page view. Never persisted, never a cross-site identifier. |
| Rollup | An hourly or daily aggregate of analytics events, replacing raw events after 7 days. |
| Bot filtering | Rejection of events by user-agent list and impossible timings, counted separately rather than dropped silently. |
| Outbox | A table written in the same transaction as a domain change, from which a relay enqueues jobs after commit — so a rolled-back transaction never produces a job. |
| Quota counter | The cached per-workspace count of a metered resource, with PostgreSQL as the authority and a nightly reconciliation (Section 19). |
| Over-limit | The workspace state after a downgrade leaves it above a cap: nothing is deleted or unpublished, only new collection is blocked. |
| Billing state | workspaces.billing_state — one of trialing, active, past_due, canceled. Answers "is this workspace paid up?" and nothing else (Section 19.8). |
| Limit state | workspaces.limit_state — ok or over_limit. Answers "is this workspace within its caps?" It is deliberately orthogonal to billing state: a fully paid workspace can be over its limit, and a workspace in dunning can be well inside every cap (Section 19.8). |
| Seat | One non-deleted workspace_members row, plus one for each pending, unexpired team invitation — so holding invitations open cannot exceed the plan's seat count (Section 6.7.1, Section 19.5.1). |
| Soft overage | The tolerance band above a metered cap in which a workspace is warned but not yet refused, recorded by workspaces.soft_overage_since. It exists so a customer who crosses a line mid-task finishes the task (Section 19). |
| Dunning | The retry-and-notify sequence after a failed payment: 3 retries over 14 days. The workspace enters past_due at the first failure, not at the end, and canceled only when the last retry fails (Section 19.4 E7). |
| Consent version | A numbered revision of the consent text. Historical submissions always resolve to the text they were actually shown. |
| Consent grant | One of the three permissions captured at submission: publish (required), advertising (optional), follow-up contact (optional). |
| Respondent token | The 32-byte value (stored hashed) behind v.vouch.app/p/{token}, letting a respondent view, download, withdraw, or erase without an account. |
| Withdrawal | A respondent revoking publication permission. Unpublishes within 60 seconds. |
| Erasure | A hard delete of a data subject's data plus destruction of every external asset, with a 30-day maximum completion SLA (Section 22). |
| Sub-processor | A third party that processes customer or respondent data on our behalf, listed with purpose and DPA reference (Section 22). |
| Idempotency key | A client-supplied header making an unsafe request safely repeatable; the stored response is replayed for 24 hours. |
| Cursor | The opaque, base64url-encoded position marker that is the only pagination mechanism in the API (Section 7). |
| Audit event | An append-only record of a state transition, content edit, tag change, or consent change, with actor, before/after snapshots, IP, and user agent. |
| Feature flag | A server-evaluated toggle stored in feature_flags, prefixed flag_. |
| Error envelope | The single non-2xx response shape used by every endpoint (Section 7). |
| Error code | The stable SCREAMING_SNAKE identifier in that envelope, catalogued in 29.3. |
| Performance budget | The enforced-in-CI limits on widget size and page impact (Section 13, Section 23). |
| Fixture host page | A test page — basic, hostile, strict-CSP, or multi-widget — used to prove the widget behaves on sites we do not control (Section 25). |
| Canary | A percentage rollout of a new widget loader, aborted automatically on error-rate or budget regression (Section 26.9). |
| Expand/contract | The migration discipline that lets code and schema deploy independently, making rollback safe (Section 26.7). |
| RED metrics | Rate, Errors, Duration — the per-service baseline metrics (Section 24.3). |
| SLO / error budget | A reliability target and the permitted shortfall over 30 days; exhausting it stops feature work on that surface. |
29.2 Environment Variable Reference #
This is the canonical catalogue. Every variable is validated by the Zod boot schema described
in Section 26.10.2; a missing or malformed required variable prevents startup. "Read by" is
web, worker, or build. Example values are illustrative and are never real secrets. A
variable that does not appear here does not exist: adding one means adding a row here and a
field to the boot schema in the same pull request, and pnpm config:example regenerates
.env.example from the schema so the three cannot drift.
Two names are settled here because the rest of the document has used both: the migration
connection string is DATABASE_MIGRATION_URL (never DATABASE_URL_MIGRATOR), and the
upload ceiling is VIDEO_MAX_UPLOAD_BYTES (never MAX_UPLOAD_BYTES).
Core application
| Name | Purpose | Req. | Default | Example | Read by | Described in |
|---|---|---|---|---|---|---|
APP_ENV |
Environment identity; gates production-only rules | Yes | — | production |
web, worker, build | 26.1 |
NODE_ENV |
Framework build mode | Yes | production |
production |
web, worker, build | 26.10 |
APP_URL |
Canonical dashboard origin | Yes | — | https://app.vouch.app |
web, worker | 26.13 |
PUBLIC_APP_URL |
Public request/showcase origin | Yes | — | https://v.vouch.app |
web, worker | 26.13 |
CDN_URL |
Origin for loader, runtime chunks, media derivatives | Yes | — | https://cdn.vouch.app |
web, worker, build | 26.6 |
EDGE_CNAME_TARGET |
The permanent CNAME target customers point custom domains at | Yes | — | edge.vouch.app |
web | 14, 26.13 |
DOCS_URL |
Base for the docsUrl field in error envelopes |
Yes | — | https://docs.vouch.app |
web, worker | 7.3 |
PORT |
HTTP listen port | No | 3000 |
3000 |
web | 26.8 |
LOG_LEVEL |
Minimum log level; debug forbidden in production |
No | info |
info |
web, worker | 24.2.3 |
LOG_SAMPLE_WIDGET_DATA |
Sample rate for successful widget data reads | No | 0.01 |
0.01 |
web | 24.2.5 |
SESSION_SECRET |
Signing key for session and CSRF tokens; ≥ 32 chars | Yes | — | <32+ random chars> |
web | 6.5.2 |
CURSOR_SECRET |
HMAC key that signs every pagination cursor; ≥ 32 chars. Pagination does not function without it — an unsigned cursor is refused with INVALID_CURSOR, so every list endpoint returns only its first page |
Yes | — | <32+ random chars> |
web | 7.4.2 |
CURSOR_SECRET_PREVIOUS |
The immediately previous CURSOR_SECRET, accepted for verification but never used for signing. Set during a rotation window so cursors already in customers' hands keep resolving; unset it once the window closes |
No | — | <32+ random chars> |
web | 7.4.2, 26.10.3 |
SESSION_ROLLING_DAYS |
Idle lifetime of a dashboard session before re-authentication | No | 30 |
30 |
web | 6.5, 7.7.1 |
SESSION_ABSOLUTE_MAX_DAYS |
Absolute lifetime of a session regardless of activity; must be ≥ SESSION_ROLLING_DAYS |
No | 90 |
90 |
web | 6.5, 7.7.1 |
ENCRYPTION_KEY |
AES-256 key (base64) for encrypting stored provider OAuth tokens | Yes | — | <base64 32 bytes> |
web, worker | 16, 22 |
FEATURE_FLAG_CACHE_TTL_SECONDS |
Server-side flag cache lifetime | No | 30 |
30 |
web, worker | 26.10.1 |
RATE_LIMIT_MULTIPLIER |
Multiplies all documented limits. The boot schema rejects any value other than 1 when APP_ENV = production — a relaxed limit in production is a fatal configuration error, not a warning |
No | 1 |
10 |
web | 7.10, 26.10.2 |
SUPPORT_EMAIL |
Address printed in email footers, error pages, and the API error envelope's support line | Yes | — | support@vouch.app |
web, worker | 1.2.18, 20.4 |
SECURITY_EMAIL |
Address published in security.txt and in the breach-notification template |
Yes | — | security@vouch.app |
web, worker | 22.21, 24.9.5 |
PRIVACY_EMAIL |
Data-protection contact shown on consent screens, the respondent portal, and erasure correspondence | Yes | — | privacy@vouch.app |
web, worker | 22 |
STATUS_URL |
Status-page URL linked from error pages, incident emails, and the 503 body | Yes | — | https://status.vouch.app |
web, worker | 24.9.4 |
MAINTENANCE_MODE |
Read-only dashboard banner and write rejection; never affects widget serving | No | false |
false |
web | 24.8 |
Database
| Name | Purpose | Req. | Default | Example | Read by | Described in |
|---|---|---|---|---|---|---|
DATABASE_URL |
Primary connection string via the transaction-mode pooler, as the vouch_app role |
Yes | — | postgres://vouch_app:***@pooler:6432/vouch |
web, worker | 26.3 |
DATABASE_REPLICA_URL |
Read replica for analytics and exports | No | falls back to DATABASE_URL |
postgres://vouch_app:***@replica:6432/vouch |
web, worker | 26.3.3 |
DATABASE_MIGRATION_URL |
Direct primary connection as vouch_migrator, bypassing the pooler. This is the only name for it |
Yes (migrations) | — | postgres://vouch_migrator:***@primary:5432/vouch |
build | 26.7.2 |
DATABASE_POOL_MAX |
In-process pool size | No | 8 web / 6 worker |
8 |
web, worker | 26.3.1 |
DATABASE_STATEMENT_TIMEOUT_MS |
Per-role statement timeout, applied at the role level | No | 5000 web / 30000 worker |
5000 |
web, worker | 26.3.3 |
DATABASE_SSL_MODE |
TLS mode for the connection | No | require |
require |
web, worker | 26.3 |
READS_FROM_PRIMARY |
Incident switch routing all reads to the primary | No | false |
false |
web, worker | 24.8 R13 |
Cache and queue
| Name | Purpose | Req. | Default | Example | Read by | Described in |
|---|---|---|---|---|---|---|
VALKEY_URL |
Cache, rate limits, locks, quota counters | Yes | — | rediss://:***@valkey:6379 |
web, worker | 26.4 |
VALKEY_QUEUE_URL |
Queue connection, separable from cache | No | falls back to VALKEY_URL |
rediss://:***@valkey:6379 |
web, worker | 26.4 |
QUEUE_PREFIX |
Key namespace, isolating preview environments | No | q |
q:pr-1284 |
web, worker | 26.4 |
WORKER_GROUPS |
Comma-separated queue groups this replica consumes | No | realtime,media,heavy,scheduled |
media |
worker | 26.2.2 |
WORKER_CONCURRENCY_OVERRIDE |
Overrides per-group concurrency | No | per 26.2.2 | 8 |
worker | 26.2.2 |
Object storage
| Name | Purpose | Req. | Default | Example | Read by | Described in |
|---|---|---|---|---|---|---|
S3_ENDPOINT |
S3-compatible endpoint; omit for AWS S3 | No | provider default | https://s3.us-east-1.amazonaws.com |
web, worker | 26.5 |
S3_REGION |
Bucket region | Yes | — | us-east-1 |
web, worker | 26.5 |
S3_ACCESS_KEY_ID |
Credential | Yes | — | AKIA… |
web, worker | 26.5 |
S3_SECRET_ACCESS_KEY |
Credential | Yes | — | <secret> |
web, worker | 26.5 |
S3_BUCKET_MEDIA |
Originals, posters, VTT, social cards | Yes | — | vouch-media-production |
web, worker | 26.5.1 |
S3_BUCKET_UPLOADS |
In-flight multipart parts | Yes | — | vouch-uploads-production |
web, worker | 26.5.1 |
S3_BUCKET_EXPORTS |
Data exports and respondent downloads | Yes | — | vouch-exports-production |
web, worker | 26.5.1 |
S3_BUCKET_STATIC |
Widget loader and runtime chunks | Yes | — | vouch-static-production |
build, worker | 26.9 |
S3_FORCE_PATH_STYLE |
Required by some S3-compatible providers | No | false |
true |
web, worker | 26.5 |
PRESIGN_TTL_SECONDS |
Lifetime of every pre-signed upload part URL. There is one such lifetime in the product, not two — long enough for a stalled part on a poor mobile connection, short enough to bound the value of a leaked URL (Section 9.6.2) | No | 900 |
900 |
web | 9.6.2, 22 |
SESSION_IDLE_TIMEOUT_HOURS |
Upload-session idle expiry: a session with no part activity for this long is swept and returns UPLOAD_SESSION_EXPIRED. Unrelated to dashboard sessions, which use SESSION_ROLLING_DAYS |
No | 24 |
24 |
web, worker | 9.6.2 |
EXPORT_URL_TTL_SECONDS |
Signed export download lifetime | No | 900 |
900 |
web | 26.5.1 |
Video provider
| Name | Purpose | Req. | Default | Example | Read by | Described in |
|---|---|---|---|---|---|---|
MUX_TOKEN_ID |
API credential id | Yes | — | abcd1234-… |
web, worker | 9 |
MUX_TOKEN_SECRET |
API credential secret | Yes | — | <secret> |
web, worker | 9 |
MUX_WEBHOOK_SECRET |
Verifies inbound asset webhooks | Yes | — | <secret> |
web | 9.7 |
MUX_PLAYBACK_POLICY |
signed or public. signed is the policy for every non-public asset and is the default |
No | signed |
signed |
web, worker | 9.8.1 |
MUX_SIGNING_KEY_ID / MUX_SIGNING_KEY_PRIVATE |
Signed playback tokens for non-public assets | Yes, whenever MUX_PLAYBACK_POLICY = signed; the boot schema fails startup if the policy is signed and either is absent |
— | <key id> / <base64 key> |
web, worker | 9.8.1, 26.10.2 |
MUX_ENVIRONMENT |
production or test |
Yes | — | production |
web, worker | 26.1 |
VIDEO_MAX_UPLOAD_BYTES |
Absolute server-side backstop on a single upload, in bytes, above which no upload is accepted on any plan. The per-plan ceilings in Section 9.3.2 are lower and bind first; this variable exists so a misconfigured plan row can never authorise an unbounded upload. The 10 000-part protocol maximum is not a third ceiling | No | 2147483648 |
2147483648 |
web | 9.3.2, 9.6.2 |
Transcription provider
| Name | Purpose | Req. | Default | Example | Read by | Described in |
|---|---|---|---|---|---|---|
TRANSCRIPTION_PROVIDER |
Active provider; switchable during an outage | No | deepgram |
deepgram |
worker | 10, 24.8 R4 |
DEEPGRAM_API_KEY |
Credential for the default provider | Yes (if active) | — | <secret> |
worker | 10 |
DEEPGRAM_MODEL |
Model family | No | nova-3 |
nova-3 |
worker | 10 |
ASSEMBLYAI_API_KEY |
Credential for the documented alternative | No | — | <secret> |
worker | 10, 29.7 |
TRANSCRIPTION_DEFAULT_LANGUAGE |
Fallback when detection confidence < 0.6 | No | en |
en |
worker | 10 |
TRANSCRIPTION_MAX_ATTEMPTS |
Retry ceiling before dead-letter | No | 6 |
6 |
worker | 29.5 |
AI provider
| Name | Purpose | Req. | Default | Example | Read by | Described in |
|---|---|---|---|---|---|---|
AI_PROVIDER |
anthropic or openai |
No | anthropic |
anthropic |
worker | 12 |
ANTHROPIC_API_KEY |
Credential for the default provider | Yes (if active) | — | <secret> |
worker | 12 |
ANTHROPIC_MODEL |
Model identifier | No | current default model | <model id> |
worker | 12 |
OPENAI_API_KEY |
Credential for the documented alternative | No | — | <secret> |
worker | 12, 29.7 |
AI_PROMPT_VERSION |
Pinned prompt version recorded on every suggestion | Yes | — | 2 |
worker | 12 |
AI_MAX_TOKENS |
Response ceiling per suggestion call | No | 2048 |
2048 |
worker | 12 |
AI_DAILY_SPEND_CAP_USD |
Hard cap in non-production; alarm-only in production | No | 25 |
25 |
worker | 26.10.4 |
AI_ENABLED |
Kill switch for the whole assistant | No | true |
true |
web, worker | 12 |
Billing
| Name | Purpose | Req. | Default | Example | Read by | Described in |
|---|---|---|---|---|---|---|
STRIPE_SECRET_KEY |
API credential; must not be a test key in production | Yes | — | sk_live_… |
web, worker | 19 |
STRIPE_WEBHOOK_SECRET |
Verifies inbound Stripe events | Yes | — | whsec_… |
web | 19 |
STRIPE_PRICE_STARTER_MONTHLY / _ANNUAL |
Price ids | Yes | — | price_… |
web | 19 |
STRIPE_PRICE_GROWTH_MONTHLY / _ANNUAL |
Price ids | Yes | — | price_… |
web | 19 |
STRIPE_PRICE_SCALE_MONTHLY / _ANNUAL |
Price ids | Yes | — | price_… |
web | 19 |
STRIPE_PRICE_DOMAIN_ADDON |
Per-domain add-on price id | Yes | — | price_… |
web | 19 |
STRIPE_PORTAL_CONFIGURATION_ID |
Billing Portal configuration | Yes | — | bpc_… |
web | 19 |
STRIPE_TAX_ENABLED |
Enables Stripe Tax | No | true |
true |
web | 19 |
BILLING_TRIAL_DAYS |
Length of the cardless Growth trial described in Section 19.7.4. This configures our trialing billing state and nothing else: no Stripe subscription exists during the trial, so there is no provider-side trial period for it to disagree with. One clock, ours — which is why a trial can never expire in Stripe while still reading as active here, or the reverse |
No | 14 |
14 |
web | 19.7.4 |
Email and SMS
| Name | Purpose | Req. | Default | Example | Read by | Described in |
|---|---|---|---|---|---|---|
RESEND_API_KEY |
Transactional email credential | Yes | — | <secret> |
web, worker | 20 |
EMAIL_FROM |
Default sender | Yes | — | Vouch <hello@vouch.app> |
worker | 20 |
EMAIL_REPLY_TO |
Reply-to address | No | support@vouch.app |
support@vouch.app |
worker | 20 |
EMAIL_ALLOWLIST |
Comma-separated recipients permitted in non-production | No | — | @vouch.app |
worker | 26.1 |
EMAIL_BOUNCE_WEBHOOK_SECRET |
Verifies bounce/complaint callbacks | Yes | — | <secret> |
web | 20 |
TWILIO_ACCOUNT_SID |
SMS credential | No | — | AC… |
worker | 20 |
TWILIO_AUTH_TOKEN |
SMS credential | No | — | <secret> |
worker | 20 |
TWILIO_MESSAGING_SERVICE_SID |
Sender pool | No | — | MG… |
worker | 20 |
SMS_ENABLED |
Kill switch for SMS invitations | No | false |
true |
web, worker | 20 |
Integrations
| Name | Purpose | Req. | Default | Example | Read by | Described in |
|---|---|---|---|---|---|---|
SLACK_CLIENT_ID / SLACK_CLIENT_SECRET |
Slack app OAuth | No | — | <id> / <secret> |
web | 18 |
SLACK_SIGNING_SECRET |
Verifies Slack callbacks | No | — | <secret> |
web | 18 |
HUBSPOT_CLIENT_ID / HUBSPOT_CLIENT_SECRET |
HubSpot OAuth | No | — | <id> / <secret> |
web, worker | 18 |
HUBSPOT_REDIRECT_URI |
OAuth callback | No | ${APP_URL}/integrations/hubspot/callback |
— | web | 18 |
ZAPIER_APP_SECRET |
Shared secret for the Zapier integration's auth check | No | — | <secret> |
web | 18 |
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET |
Google Business Profile OAuth | No | — | <id> / <secret> |
web, worker | 16 |
GOOGLE_REDIRECT_URI |
OAuth callback | No | ${APP_URL}/imports/google/callback |
— | web | 16 |
X_CLIENT_ID / X_CLIENT_SECRET |
X API OAuth 2.0 | No | — | <id> / <secret> |
web, worker | 16 |
X_BEARER_TOKEN |
App-only reads where OAuth is not required | No | — | <secret> |
worker | 16 |
G2_API_TOKEN |
Syndication API credential where entitled | No | — | <secret> |
worker | 16 |
CAPTERRA_FEED_URL / CAPTERRA_FEED_TOKEN |
Gartner Digital Markets syndication feed | No | — | <url> / <secret> |
worker | 16 |
IMPORT_MAX_ROWS_PER_FILE |
CSV upload ceiling | No | 5000 |
5000 |
web | 16 |
Domains, TLS, and CDN
| Name | Purpose | Req. | Default | Example | Read by | Described in |
|---|---|---|---|---|---|---|
ACME_DIRECTORY_URL |
Certificate authority directory; switchable to the fallback CA | Yes | Let's Encrypt production | https://acme-v02.api.letsencrypt.org/directory |
worker | 14, 24.8 R8 |
ACME_FALLBACK_DIRECTORY_URL |
Fallback CA | No | — | <url> |
worker | 14 |
ACME_ACCOUNT_EMAIL |
Registration contact | Yes | — | ops@vouch.app |
worker | 14 |
ACME_ACCOUNT_KEY |
Account private key (PEM) | Yes | — | <pem> |
worker | 14 |
DNS_RESOLVER |
Authoritative-resolution resolver for verification | No | system | 1.1.1.1 |
worker | 14 |
DOMAIN_APEX_A_RECORDS |
The anycast IPv4 addresses of the edge, shown to customers who point an apex domain at us. These are the anycast addresses Section 14.2.2 refers to; there is no separate EDGE_ANYCAST_* pair |
Yes | — | 203.0.113.10,203.0.113.11 |
web | 14.2.2, 26.13 |
DOMAIN_APEX_AAAA_RECORDS |
The anycast IPv6 addresses of the edge, shown alongside the A records. Required because an apex domain with only A records is not dual-stack | Yes | — | 2001:db8::10,2001:db8::11 |
web | 14.2.2, 26.13 |
CDN_PROVIDER |
Active CDN, for purge API selection | Yes | — | primary |
worker | 26.6 |
CDN_PURGE_API_TOKEN |
Purge credential | Yes | — | <secret> |
worker | 26.6 |
CDN_ZONE_ID |
Zone or distribution identifier | Yes | — | <id> |
worker | 26.6 |
WIDGET_DATA_MAX_AGE_SECONDS |
Widget payload edge TTL | No | 60 |
60 |
web | 26.6 |
WIDGET_STALE_IF_ERROR_SECONDS |
How long the edge serves stale on origin failure | No | 86400 |
86400 |
web | 24.10.2 |
WIDGET_LOADER_MAX_AGE_SECONDS |
Loader TTL; short by design for fast rollback | No | 300 |
300 |
build | 26.9 |
WIDGET_RUNTIME_HASH |
Runtime chunk hash the loader pins. Normally computed from the build metafile and not set by hand; set it explicitly only to pin the loader to an older runtime during a rollback | No | computed from the build metafile | 9f2c1ab4d… |
build | 26.9 |
WIDGET_CANARY_PERCENT |
Canary share of loader responses | No | 0 |
10 |
build | 26.9.3 |
Observability
| Name | Purpose | Req. | Default | Example | Read by | Described in |
|---|---|---|---|---|---|---|
SENTRY_DSN |
Server error reporting | No | — | https://…ingest.sentry.io/… |
web, worker | 24.5 |
SENTRY_DSN_WIDGET |
Separate, low-volume widget project | No | — | https://…ingest.sentry.io/… |
build | 24.5.3 |
SENTRY_RELEASE |
Release tag; the git SHA | Yes (deploy) | — | 9f2c1ab |
web, worker, build | 24.5.1 |
SENTRY_AUTH_TOKEN |
Source-map upload | Yes (build) | — | <secret> |
build | 24.5.1 |
SENTRY_TRACES_SAMPLE_RATE |
Performance sampling | No | 0.1 |
0.1 |
web, worker | 24.4 |
OTEL_EXPORTER_OTLP_ENDPOINT |
Trace and metric export target | No | — | https://otlp.example.com |
web, worker | 24.4 |
OTEL_EXPORTER_OTLP_HEADERS |
Export credentials | No | — | authorization=Bearer <secret> |
web, worker | 24.4 |
OTEL_SERVICE_NAME |
Service identity in traces | Yes | — | vouch-web |
web, worker | 24.4 |
WIDGET_ERROR_SAMPLE_RATE |
Client error-report sampling | No | 0.05 |
0.05 |
build | 24.5.3 |
Build-time public variables (compiled into browser bundles; never secret)
| Name | Purpose | Req. | Default | Example | Read by | Described in |
|---|---|---|---|---|---|---|
NEXT_PUBLIC_APP_URL |
Client-side absolute links | Yes | — | https://app.vouch.app |
build | 26.10.1 |
NEXT_PUBLIC_CDN_URL |
Loader and media base | Yes | — | https://cdn.vouch.app |
build | 26.10.1 |
NEXT_PUBLIC_SENTRY_DSN |
Browser error reporting for the dashboard | No | — | https://… |
build | 24.5 |
NEXT_PUBLIC_APP_ENV |
Environment badge in non-production UIs | Yes | — | production |
build | 26.1 |
29.3 Error Code Catalogue #
Every code appears in the envelope defined in Section 7.3 with a docsUrl of
https://docs.vouch.app/errors/{CODE}. Messages are safe to display to an end user verbatim.
This is the whole catalogue: a code not listed here does not exist, and adding one means adding
a row here in the same pull request that adds the throw site. Section 28.8 step 2 reconciles the
two mechanically.
What "the whole catalogue" means, precisely — there are three tiers of uppercase identifier in
this document and only the first is an error code. Without this distinction a reader finds
BODY_EMPTY or ROLE_LACKS_PERMISSION in the body, fails to find it below, and reasonably
concludes the catalogue is incomplete.
| Tier | What it is | Where it lives | Catalogued here? |
|---|---|---|---|
| Top-level error code | The value of error.code in the envelope (Section 7.3). One per failed request. TESTIMONIAL_LIMIT_REACHED, STEP_UP_REQUIRED, HIGHLIGHT_NOT_VERBATIM |
The response body, the docsUrl, this table |
Yes — exhaustively. This is the closed set |
| Field-level reason | The value of details[].issue on a 422 VALIDATION_FAILED (and on the single documented exception, 409 INVALID_STATUS_TRANSITION). Names why one field failed: BODY_EMPTY, RATING_OUT_OF_RANGE, PARTS_OVERLAP, NOT_A_SUBSTRING, BOUNDARY_NOT_WORD_ALIGNED |
Inside details[], alongside the field path |
No, deliberately. These are an open set that grows with every validated field. They are never the value of error.code, never appear alone, and never carry their own docsUrl. The owning section defines each one where it defines the field |
| Internal reason string | The reason returned by the authorization function (Section 6.6.3) or recorded in a log or audit row: ROLE_LACKS_PERMISSION, SCOPE_MISSING, PERMISSION_NOT_AVAILABLE_TO_API_KEYS |
Server logs and audit_events only |
No. These are never sent to a client at all — surfacing one would leak the authorization model. Section 6.6.3 states the two named exceptions that are promoted to real codes |
A reader auditing this catalogue for completeness should therefore compare it against the set of
values that can appear in error.code, not against every uppercase token in the document.
Configuration constants (Appendix 29.2), feature-flag names, and enum values share the same
visual convention and are also not error codes.
Four conventions govern the HTTP column, and they are the reason several codes here carry a status a reader might not expect:
409is every quota and plan refusal. The request was authenticated, authorized, and well-formed; it conflicts with the current state of the workspace. Section 19.5.6 owns this reasoning and Section 19 owns the codes.402is never used. There is noPAYMENT_REQUIRED. The condition it would describe isPAYMENT_METHOD_REQUIRED(409).403is reserved for refusals of the actor: the role lacks the permission, the API key lacks the scope, the workspace is read-only, or the caller has not met an identity gate that applies before any resource is considered (2FA, re-authentication freshness, a verified email, an IP allowlist, an allowed origin). It is never used for a plan, an entitlement, or the state of the thing being acted on — those are all409.422is a validation failure on a well-formed request — a property of what was sent, never a property of the plan.
One consequence is worth stating because it is the single exception to a rule elsewhere: Section
7.3.3 confines the details array to 422 responses, and INVALID_STATUS_TRANSITION is the one
409 that also carries it. A client refused a transition needs to be told the current status and
the transitions available from it, and putting that in prose would force it to parse English.
A handful of codes carry — in the HTTP column. Those never travel in an HTTP response, and they
are catalogued here anyway because they are the strings the UI switches on, the analytics count,
and the audit trail records. Two kinds qualify: client-side conditions raised inside the recorder
or the embed, and audited outcome codes written to audit_events when a rule permits a
deliberate, recorded exception.
Authentication and session
| Code | HTTP | Meaning | User-facing message | Remediation |
|---|---|---|---|---|
UNAUTHENTICATED |
401 | No valid session or key | "You need to sign in to continue." | Sign in again |
SESSION_EXPIRED |
401 | Session past its rolling or absolute limit | "Your session has expired. Please sign in again." | Sign in |
INVALID_CREDENTIALS |
401 | Wrong email or password | "That email or password is incorrect." | Retry or reset password |
EMAIL_NOT_VERIFIED |
403 | Account exists but is unverified | "Please verify your email address to continue." | Resend verification |
ACCOUNT_LOCKED |
423 | Too many failed attempts | "Your account is temporarily locked. Try again in 15 minutes." | Wait or reset password |
PASSWORD_TOO_WEAK |
422 | Below 12 characters or found in a breach list | "Choose a longer password that hasn't appeared in a data breach." | Choose another password |
MAGIC_LINK_INVALID |
400 | Token unknown, used, or expired | "This sign-in link is no longer valid." | Request a new link |
TOTP_REQUIRED |
401 | 2FA challenge outstanding | "Enter your authentication code." | Enter the code |
TOTP_INVALID |
401 | Wrong or reused code | "That code isn't right. Try the next one." | Retry |
TWO_FACTOR_REQUIRED_BY_WORKSPACE |
403 | Workspace enforces 2FA and the user has none | "This workspace requires two-factor authentication." | Enrol in 2FA |
TWO_FACTOR_REQUIRED |
403 | This specific action is in the sensitive set and the session has not satisfied 2FA, whether or not the workspace enforces it generally | "Confirm your identity with two-factor authentication to do that." | Complete the 2FA challenge |
REAUTHENTICATION_REQUIRED |
403 | The action requires a fresh authentication (password change, ownership transfer, workspace deletion) and the session is older than the freshness window | "Please confirm your password to continue." | Re-enter the password |
STEP_UP_REQUIRED |
401 | A read-only respondent-portal session attempted a state-changing operation (withdraw, export, correct, erase). Distinct from REAUTHENTICATION_REQUIRED: that one re-confirms an existing account password, this one verifies control of the respondent's email address, and the respondent has no account at all |
"For your security, we've emailed you a code. Enter it to continue." | Call POST /public/portal/verify-code with the emailed code, then retry (Sections 7.7.3, 22.4.2) |
AMBIGUOUS_CREDENTIALS |
400 | Both a session cookie and an Authorization header were presented; we refuse to guess which actor is making the request |
"We couldn't tell which credentials to use for that request." | Send exactly one credential |
SESSION_NOT_PERMITTED |
401 | The route accepts API keys only; a browser session was presented | "This endpoint can't be used from a signed-in session." | Call it with an API key |
CSRF_TOKEN_INVALID |
403 | Missing or mismatched CSRF token | "Your request could not be verified. Refresh and try again." | Refresh |
Authorization, tenancy, and keys
| Code | HTTP | Meaning | User-facing message | Remediation |
|---|---|---|---|---|
FORBIDDEN |
403 | Role lacks the permission | "You don't have permission to do that." | Ask an Owner or Admin |
WORKSPACE_ACCESS_DENIED |
403 | Actor is not a member of the workspace | "You don't have access to this workspace." | Request an invitation |
OWNER_REQUIRED |
403 | Owner-only action | "Only the workspace owner can do that." | Ask the Owner |
LAST_OWNER_CANNOT_LEAVE |
409 | Would leave the workspace ownerless | "Transfer ownership before leaving this workspace." | Transfer ownership |
WORKSPACE_READ_ONLY |
403 | The workspace is read-only (canceled subscription, or an active data export freeze); every write is refused while serving continues | "This workspace is read-only right now." | Reactivate the subscription |
INVALID_API_KEY |
401 | Unknown key, or a key whose prefix does not match any stored hash | "That API key isn't valid." | Create a new key |
API_KEY_EXPIRED |
401 | Key past its expires_at |
"That API key has expired." | Create a new key |
API_KEY_REVOKED |
401 | Key explicitly revoked by a member | "That API key was revoked." | Create a new key |
API_KEY_NOT_PERMITTED |
401 | The route accepts browser sessions only; an API key was presented | "This endpoint can't be used with an API key." | Call it from a signed-in session |
INSUFFICIENT_SCOPE |
403 | The key is valid but lacks the scope the route requires | "This API key doesn't have permission for that action." | Create a key with the needed scope |
PLAN_DOWNGRADED_SCOPE_REVOKED |
403 | An existing write-scoped key is presented by a workspace that has since downgraded to a read-only-API plan. The key is refused, never silently deleted | "Your plan no longer includes write access for API keys." | Upgrade, or use a read-only key |
IP_NOT_ALLOWED |
403 | The key carries an IP allowlist and the caller is not on it | "This API key can't be used from this address." | Add the address to the key's allowlist |
API_KEY_LIMIT_REACHED |
409 | Maximum keys per workspace | "You've reached the maximum number of API keys." | Revoke an unused key |
PERMISSION_UNKNOWN |
500 | can() was called with a permission string that is not in Section 6.6.2. A programming error, never a user error — it is a 500 precisely so it cannot be mistaken for a denial |
"Something went wrong on our side. We've been notified." | Report the request id |
MULTI_WORKSPACE_NOT_AVAILABLE |
409 | An attempt to create or switch to a second workspace on a plan that pins one active workspace | "Managing more than one workspace isn't available on your plan." | Upgrade |
Validation and request shape
| Code | HTTP | Meaning | User-facing message | Remediation |
|---|---|---|---|---|
VALIDATION_FAILED |
422 | One or more fields invalid; details populated |
"Some information needs fixing." | Correct the listed fields |
MALFORMED_JSON |
400 | The body is not parseable JSON at all. Distinct from INVALID_REQUEST_BODY: nothing could be read, so nothing can be reported per field |
"We couldn't read that request." | Send valid JSON |
INVALID_REQUEST_BODY |
422 | The body parsed but is the wrong shape — an array where an object was expected, a missing required object, a scalar where a nested object belongs | "That request wasn't in the expected format." | Check the documented request shape |
UNKNOWN_FIELD |
422 | An unrecognised key in a request body. Bodies are strict: an unknown key is far more often a typo than an extension | "We don't recognise one of the fields you sent." | Remove or correct the field |
UNKNOWN_QUERY_PARAM |
422 | An unrecognised query-string parameter, for the same reason | "We don't recognise one of the query parameters." | Remove or correct the parameter |
UNSUPPORTED_MEDIA_TYPE |
415 | Wrong Content-Type |
"That file type isn't supported." | Send a supported type |
PAYLOAD_TOO_LARGE |
413 | Body above the route's ceiling | "That's larger than we can accept." | Send a smaller payload |
URI_TOO_LONG |
400 | Request line above the edge's ceiling. Returned as 400 rather than 414 because the edge rejects it before routing | "That request address is too long." | Shorten the query string |
HEADERS_TOO_LARGE |
400 | Request headers above the edge's ceiling, usually an oversized cookie | "That request carried too much header data." | Clear cookies and retry |
IMAGE_TOO_LARGE |
422 | An uploaded image (avatar, logo, brand mark) above its route's byte ceiling | "That image is larger than we can accept." | Upload a smaller image |
EVENT_BATCH_TOO_LARGE |
413 | An analytics beacon above the 32 KB payload cap owned by Section 17.4, or above the per-beacon event count. The single code for both, because the client's remedy is identical either way | "That batch of events was too large." | Send fewer events per beacon |
INVALID_CURSOR |
400 | Cursor unreadable, tampered with, signed by a retired CURSOR_SECRET, or carried over from a different sort |
"This page link is no longer valid." | Start from the first page |
SORT_NOT_SUPPORTED |
422 | Sort key not offered on this collection | "That sort option isn't available here." | Use a documented sort |
LIMIT_OUT_OF_RANGE |
400 | limit outside 1–100 |
"Choose a page size between 1 and 100." | Adjust limit |
IDEMPOTENCY_KEY_REQUIRED |
422 | An unsafe endpoint that mandates Idempotency-Key was called without one |
"This request needs an idempotency key." | Send a unique Idempotency-Key header |
IDEMPOTENCY_KEY_REUSED |
409 | Same key, different payload | "This request was already made with different details." | Use a new idempotency key |
IDEMPOTENCY_IN_PROGRESS |
409 | The original request carrying this key is still running | "We're still processing your previous request." | Retry shortly |
UNSUPPORTED_API_VERSION |
400 | Unknown version prefix | "That API version isn't supported." | Use /api/v1/ |
API_VERSION_SUNSET |
410 | A version that existed and has passed its published sunset date | "That API version has been retired." | Migrate to the current version |
CORS_ORIGIN_NOT_ALLOWED |
403 | A browser origin not on the route's allowlist | "That origin isn't allowed to call this endpoint." | Call from an allowed origin, or server-side |
RATE_LIMITED |
429 | Bucket exhausted | "You're making requests too quickly. Please slow down." | Honour Retry-After |
Generic resource errors
| Code | HTTP | Meaning | User-facing message | Remediation |
|---|---|---|---|---|
NOT_FOUND |
404 | Resource absent or not visible to this workspace | "We couldn't find that." | Check the identifier |
GONE |
410 | Resource permanently removed | "That's no longer available." | None |
RESOURCE_CONFLICT |
409 | Concurrent modification with no more specific code available | "Someone else changed this first. Refresh and try again." | Refresh and retry |
SLUG_TAKEN |
409 | Slug already used in scope | "That address is already in use." | Choose another |
SLUG_INVALID |
422 | Fails the slug pattern for its resource | "Use 3–48 lowercase letters, numbers, or hyphens." | Choose another |
SLUG_RESERVED |
422 | Matches a platform-reserved word (api, app, admin, www, and the rest of the reserved list) |
"That address is reserved." | Choose another |
SLUG_TOO_SIMILAR |
422 | Differs from an existing slug only by characters that are easily confused, which would let one workspace impersonate another | "That address is too similar to one already in use." | Choose a more distinct address |
PRECONDITION_REQUIRED |
428 | The endpoint requires If-Match or an explicit expectedVersion and neither was supplied. Refusing beats guessing on an endpoint whose whole purpose is to prevent a lost update |
"This request needs to say which version it's updating." | Send the current version |
INTERNAL_ERROR |
500 | Unexpected failure | "Something went wrong on our side. We've been notified." | Retry; contact support with the request id |
DEPENDENCY_UNAVAILABLE |
503 | A required upstream is down | "We're having trouble reaching a service. Please try again shortly." | Retry |
STORAGE_UNAVAILABLE |
503 | Object storage is unreachable. Distinct from DEPENDENCY_UNAVAILABLE because the recorder's retry behaviour differs: parts are held client-side and re-sent rather than the take being lost |
"We can't save your upload right now. Please try again shortly." | Retry; the recorder retries automatically |
FEATURE_DISABLED |
409 | Feature flag off for this workspace. A property of the workspace's configuration, not of the caller | "That feature isn't available right now." | Contact support |
Workspace, members, and invitations
| Code | HTTP | Meaning | User-facing message | Remediation |
|---|---|---|---|---|
WORKSPACE_SLUG_INVALID |
422 | Fails the workspace slug pattern | "Use 3–48 lowercase letters, numbers, or hyphens." | Choose another |
WORKSPACE_CREATION_LIMIT_REACHED |
409 | This user already owns the maximum number of Free workspaces. An anti-abuse ceiling on account creation, deliberately named so it cannot be confused with WORKSPACE_OVER_LIMIT, which is a capacity state on an existing workspace |
"You've created the maximum number of free workspaces." | Upgrade one, or delete one you no longer use |
SEAT_LIMIT_REACHED |
409 | Plan seat cap reached, counting members plus pending unexpired invitations | "You've used all the seats on your plan." | Upgrade or remove a member |
INVITATION_NOT_FOUND |
404 | Token unknown | "This invitation is no longer valid." | Ask for a new invitation |
INVITATION_EXPIRED |
410 | Past 7 days | "This invitation has expired." | Ask for a new invitation |
INVITATION_ALREADY_USED |
409 | Single-use token reused. The row is retained for the audit trail, which is what makes this answerable rather than a 404 | "This invitation has already been used." | Sign in instead |
INVITATION_REVOKED |
410 | Withdrawn by an admin | "This invitation was withdrawn." | Ask for a new invitation |
INVITATION_ALREADY_PENDING |
409 | An unexpired invitation to this address already exists | "There's already an open invitation for that address." | Resend or revoke the existing one |
INVITATION_NOT_PENDING |
409 | Resend or revoke attempted on an invitation in a terminal status | "That invitation can no longer be changed." | Send a new invitation |
INVITATION_RESEND_LIMIT |
429 | Resend attempted more often than the per-invitation ceiling allows | "You've resent this invitation too many times." | Wait before resending |
ALREADY_MEMBER |
409 | User is already in the workspace | "You're already a member of this workspace." | Sign in |
MEMBER_NOT_FOUND |
404 | No such membership | "We couldn't find that member." | Refresh the list |
CANNOT_MODIFY_OWN_ROLE |
403 | Self role change | "You can't change your own role." | Ask another admin |
CANNOT_INVITE_OWNER |
422 | An invitation was addressed with the Owner role. Ownership moves by transfer, never by invitation | "Invite them as an Admin, then transfer ownership." | Invite as Admin |
CANNOT_DEMOTE_OWNER |
403 | Role change attempted on the Owner | "The workspace owner's role can't be changed." | Transfer ownership first |
CANNOT_REMOVE_OWNER |
403 | Removal attempted on the Owner | "The workspace owner can't be removed." | Transfer ownership first |
ALREADY_OWNER |
409 | Ownership transfer targeting the current Owner | "They already own this workspace." | None |
TRANSFER_TARGET_MISSING_2FA |
409 | The workspace enforces 2FA and the proposed new Owner has not enrolled | "The new owner needs two-factor authentication enabled first." | Ask them to enrol, then retry |
OWNERSHIP_TRANSFER_UNCONFIRMED |
400 | Confirmation missing | "Confirm the transfer to continue." | Confirm |
EMAIL_SUPPRESSED |
409 | The address is on the cross-channel suppression list after a hard bounce or complaint. No send path may write to it | "We can't email that address — it previously bounced or unsubscribed." | Use a different address |
Request pages, submissions, and consent
| Code | HTTP | Meaning | User-facing message | Remediation |
|---|---|---|---|---|
REQUEST_PAGE_NOT_FOUND |
404 | Unknown or unpublished slug | "This page isn't available." | Check the link |
REQUEST_PAGE_GONE |
410 | The page existed and was deleted or permanently closed. Distinct from a 404, because a respondent holding an old link deserves to be told the difference between a typo and a closed page | "This page is no longer collecting testimonials." | Ask the company for a current link |
REQUEST_PAGE_DISABLED |
409 | Deliberately paused by the workspace. The page's state, not the respondent's standing | "This page isn't accepting submissions right now." | Contact the workspace |
REQUEST_PAGE_LIMIT_REACHED |
409 | Plan cap on request pages | "You've reached the number of request pages on your plan." | Upgrade or delete one |
CONSENT_REQUIRED |
422 | Required grant not given | "Please give permission to publish before submitting." | Tick the box |
CONSENT_VERSION_STALE |
409 | Text changed while the form was open | "Our permission text was updated — please review it." | Reload and review |
CONSENT_MISSING |
409 | An action requiring publication consent was attempted on a testimonial that carries none — publishing it, generating a card from it, or featuring it. Distinct from CONSENT_REQUIRED, which is a respondent failing to tick the box on a form; this one is a workspace acting on something it was never given permission to show |
"We don't have permission to publish this testimonial." | Ask the respondent for permission |
CONSENT_NOT_CONFIRMED |
422 | The consent box was submitted without the explicit confirmation step | "Please confirm the permission text before submitting." | Confirm |
CONSENT_TEXT_MISMATCH |
409 | The submitted consent hash does not match the version served, meaning the form was tampered with or is very stale | "Our permission text changed — please review it again." | Reload and review |
ADVERTISING_CONSENT_MISSING |
409 | A paid-advertising use was attempted on a testimonial whose respondent did not grant it. There is no override: the grant is the respondent's to give | "This person didn't give permission for use in paid advertising." | Use a testimonial with the advertising grant |
FOLLOW_UP_CONSENT_MISSING |
409 | Follow-up contact was attempted without that optional grant | "This respondent didn't agree to follow-up contact." | None |
THREAD_MESSAGE_LIMIT |
409 | More than 20 messages in one respondent reply thread. A ceiling on how much mail one testimonial can generate towards one person | "This conversation has reached its message limit." | Continue the conversation outside Vouch |
CUSTOM_FIELD_INVALID |
422 | Answer fails the field's rule | "Check the highlighted answer." | Correct the field |
CUSTOM_FIELD_REQUIRED |
422 | Required field empty | "This answer is required." | Complete the field |
CUSTOM_FIELD_LIMIT_REACHED |
422 | The 21st custom field on a request page | "A request page can have up to 20 custom fields." | Remove a field |
PROMPT_LIMIT_REACHED |
422 | Prompt ceiling on a request page | "You've reached the prompt limit for this page." | Remove a prompt |
CUSTOM_FIELD_KEY_LOCKED |
409 | A field's stable key was edited after answers existed, which would orphan every stored answer | "This field's key can't be changed once it has answers." | Add a new field instead |
CUSTOM_FIELD_TYPE_IMMUTABLE |
409 | A field's type was changed after answers existed | "This field's type can't be changed once it has answers." | Add a new field instead |
REQUEST_FIELD_HAS_RESPONSES |
409 | Deletion attempted on a field that holds answers | "This field has answers and can't be deleted." | Hide it instead |
OPTIONS_NOT_ALLOWED |
422 | options supplied for a field type that has none |
"Only select fields can have options." | Remove the options |
SUBMISSION_NOT_FOUND |
404 | Unknown submission | "We couldn't find that submission." | Refresh |
SUBMISSION_ALREADY_COMPLETED |
409 | Duplicate submit | "You've already submitted this." | None |
SUBMISSION_TOO_FAST |
422 | The form was completed faster than a human plausibly could — a bot signal, not a judgement about the person | "That was submitted a little too quickly. Please try again." | Resubmit |
CAPTCHA_FAILED |
422 | The challenge was not satisfied | "We couldn't confirm you're not a robot." | Try the challenge again |
IN_APP_BROWSER_BLOCKED |
409 | The recorder cannot run in this embedded browser (an in-app webview without camera permission) | "Please open this page in your normal browser to record a video." | Open in Safari or Chrome |
INSECURE_CONTEXT |
409 | The page is not on HTTPS, so the browser will not grant camera or microphone access | "Recording needs a secure connection." | Open the page over HTTPS |
DRAFT_NOT_FOUND |
404 | Unknown or already-submitted draft | "We couldn't find your saved answers." | Start again |
DRAFT_EXPIRED |
410 | Past the draft retention window | "Your saved answers have expired." | Start again |
DRAFT_REVISION_CONFLICT |
409 | The draft was saved from another device since this one loaded it | "You edited this on another device. Reload to get the latest version." | Reload |
COLLECTION_CLOSED |
200 | Not an error status — the request page returns a collectionClosed payload with a machine-readable reason |
"This page isn't accepting new testimonials right now." | Workspace upgrades or reopens the page |
CONTACT_LIMIT_REACHED |
409 | Contact ceiling for the workspace | "You've reached the contact limit." | Remove contacts |
INVITE_LIMIT_REACHED |
409 | Outstanding-invite ceiling for the workspace | "You have too many invitations outstanding." | Wait for some to be answered, or revoke a few |
INVITE_SEND_BLOCKED |
409 | Cap reached, so invitations are blocked | "You can't send invitations while at your testimonial limit." | Upgrade |
Testimonials, moderation, and tags
| Code | HTTP | Meaning | User-facing message | Remediation |
|---|---|---|---|---|
TESTIMONIAL_NOT_FOUND |
404 | Unknown or soft-deleted | "We couldn't find that testimonial." | Refresh |
TESTIMONIAL_LIMIT_REACHED |
409 | Lifetime collection cap for the plan | "You've reached the testimonial limit on your plan." | Upgrade |
INVALID_STATUS_TRANSITION |
409 | Not permitted by the state machine. The one 409 that carries a details array (Section 7.3.3), holding the current status and the transitions legal from it |
"That change isn't allowed from the current status." | Refresh, then use a transition listed in details |
ALREADY_PUBLISHED |
409 | Publish on a published item | "This is already published." | None |
NOT_PUBLISHED |
409 | Unpublish on a non-published item | "This isn't published." | None |
IMPORTED_CONTENT_IMMUTABLE |
409 | Edit attempted on imported text. No role can do it — the words belong to the source platform's author, so this is a property of the testimonial rather than a permission the caller lacks | "Imported reviews can't be edited." | Tag or hide instead |
TESTIMONIAL_VERSION_CONFLICT |
409 | expectedVersion does not match the stored version — someone else saved first |
"Someone else edited this testimonial. Refresh to see their changes." | Refresh and reapply |
EDIT_REASON_REQUIRED |
422 | A content edit was submitted without the reason the audit trail requires | "Say briefly why you're editing this." | Add a reason |
RESTORE_WINDOW_EXPIRED |
409 | Restore attempted after the 30-day bin window, by which point the media is destroyed | "This testimonial is past the 30-day restore window." | None |
MEDIA_ALREADY_PURGED |
409 | Restore attempted on a testimonial whose media has already been destroyed; the text can be restored but the video cannot | "The video for this testimonial has already been deleted." | Restore the text only |
TESTIMONIAL_FLAGGED |
409 | Publish attempted on a testimonial carrying an uncleared flag | "Clear the flag on this testimonial before publishing it." | Review and clear the flag |
UNKNOWN_TAG |
422 | A tag id or name in the request does not exist in this workspace | "One of those tags doesn't exist." | Create the tag first |
TAG_LIMIT_REACHED |
409 | The workspace ceiling of 200 tags | "You've reached the tag limit for this workspace." | Remove a tag |
TESTIMONIAL_TAG_LIMIT_REACHED |
422 | The per-testimonial ceiling of 25 tags. A separate code from TAG_LIMIT_REACHED because the two have different remedies and a client cannot otherwise tell which limit it hit |
"A testimonial can carry up to 25 tags." | Remove a tag from this testimonial |
TAG_NAME_TAKEN |
409 | Duplicate tag name | "You already have a tag with that name." | Choose another |
CANNOT_MERGE_INTO_SELF |
422 | A merge whose source and target are the same tag | "Choose two different tags to merge." | Pick a different target |
BULK_SELECTION_TOO_LARGE |
422 | A bulk action addressed more items than one request may carry | "That's more items than we can update at once." | Select fewer items |
BULK_ACTION_PARTIAL_FAILURE |
207 | Some items in a bulk action failed; per-item results returned | "Some items couldn't be updated." | Review the results |
Media, upload, and transcription
| Code | HTTP | Meaning | User-facing message | Remediation |
|---|---|---|---|---|
NO_MEDIA_DEVICES |
— | Client-side: the browser reports no camera or microphone | "We can't find a camera on this device." | Connect a device, or write a testimonial instead |
NO_MEDIA_RECORDER |
— | Client-side: the browser has no usable MediaRecorder |
"This browser can't record video." | Use a current browser, or write a testimonial |
NO_SUPPORTED_CODEC |
— | Client-side: no codec in the negotiated list is supported | "This browser can't record in a format we support." | Use a current browser |
UPLOAD_SESSION_NOT_FOUND |
404 | Unknown or swept session | "This upload has expired. Please record again." | Re-record |
UPLOAD_SESSION_EXPIRED |
410 | Idle beyond SESSION_IDLE_TIMEOUT_HOURS |
"This upload expired." | Re-record |
UPLOAD_TOO_LARGE |
413 | Declared or observed size above VIDEO_MAX_UPLOAD_BYTES |
"That video is larger than we can accept." | Record a shorter video |
PART_SIZE_INVALID |
422 | A part is below the minimum or above the maximum part size | "Part of your video didn't upload correctly." | The recorder retries automatically |
PART_SEQUENCE_INVALID |
422 | Part numbers are non-contiguous, out of range, or repeated with different content | "Part of your video didn't upload correctly." | The recorder retries automatically |
PART_ALREADY_COMPLETED |
409 | A part was re-uploaded after the session completed | "This upload is already finished." | None |
UPLOAD_INCOMPLETE |
409 | Complete called while parts are missing | "Your video hasn't finished uploading." | Wait for the upload to finish |
UPLOAD_ALREADY_COMPLETED |
409 | Complete called twice | "This upload is already finished." | None |
UPLOAD_SIZE_MISMATCH |
422 | The assembled object's byte count does not match the declared total | "Your video didn't upload completely." | Re-record |
UPLOAD_VERIFICATION_FAILED |
422 | The assembled object's checksum does not match the client's | "Your video didn't upload correctly." | Re-record |
UPLOAD_TAKE_LIMIT_REACHED |
409 | The respondent has used every permitted re-record on this request | "You've used all your recording attempts for this request." | Submit the take you have |
VIDEO_LENGTH_EXCEEDED |
422 | The submitted file is longer than the maximum length. A property of the file, not a quota, which is why it is 422 and not 409 | "Your video is longer than we can accept." | Record a shorter video |
VIDEO_RESOLUTION_TOO_HIGH |
422 | Above the accepted resolution | "Your video resolution is higher than we can accept." | Record at a lower resolution |
UNSUPPORTED_VIDEO_FORMAT |
415 | Codec or container not accepted | "We can't process that video format." | Record in the app |
MEDIA_NOT_OWNED |
422 | An attach referenced a media asset belonging to another workspace or another testimonial | "That video can't be attached here." | Attach a video from this submission |
MEDIA_ALREADY_ATTACHED |
409 | The media asset is already attached to a testimonial | "That video is already attached to a testimonial." | Detach it first |
MEDIA_NOT_READY |
409 | Action requires a ready asset |
"Your video is still processing." | Wait a moment |
MEDIA_PROCESSING_FAILED |
422 | Provider reported an error | "We couldn't process this video." | Re-record |
AUDIO_DESCRIPTION_UNDETERMINED |
422 | Approval was attempted on an upload or imported video whose audio-description question — does it show anything not said out loud — has not been answered. Recorder-captured video never raises this: its medium is constrained to a single speaker with no visual-only information |
"Tell us whether this video shows anything that isn't said out loud." | Answer the question; answering yes then requires a text description before publish |
MEDIA_PIPELINE_UNAVAILABLE |
503 | The media provider is unreachable; uploads are paused behind the kill switch (R1) | "Video processing is paused right now. Your recording is saved." | Retry later; nothing is lost |
PROVIDER_UPLOAD_FAILED |
502 | The handoff of a completed upload to the provider failed after retries | "We couldn't hand your video to our video service." | The job retries automatically |
PROVIDER_REJECTED_INPUT |
422 | The provider refused the file as unprocessable | "Our video service couldn't read that file." | Re-record |
PROVIDER_ASSET_DISAPPEARED |
500 | An asset we hold an id for no longer exists at the provider — a reconciliation failure, never a customer error | "Something went wrong with this video on our side. We've been notified." | Report the request id |
PROCESSING_TIMEOUT |
504 | Processing exceeded its budget without a terminal result | "This video is taking longer than expected." | Retry processing |
STORAGE_LIMIT_EXCEEDED |
409 | Plan storage cap exceeded beyond the soft-overage tolerance | "You've reached your storage limit." | Upgrade or delete media |
DELIVERY_MINUTES_EXCEEDED |
409 | Plan video-delivery minutes exhausted for the period | "You've used your video delivery minutes for this period." | Upgrade or wait for the reset |
TRANSCRIPTION_MINUTES_EXCEEDED |
409 | Plan transcription minutes exhausted for the period | "You've used your transcription minutes this period." | Upgrade or wait for the reset |
TRANSCRIPTION_IN_PROGRESS |
409 | A retry was requested while a run is already active | "This video is already being transcribed." | Wait for it to finish |
TRANSCRIPTION_SOURCE_MISSING |
409 | Transcription requested with no audio source available | "There's no audio to transcribe for this testimonial." | None |
TRANSCRIPTION_UNSUPPORTED_MEDIA |
422 | The provider does not accept this media's audio characteristics | "We can't transcribe this recording's audio." | Re-record |
TRANSCRIPTION_PROVIDER_UNAVAILABLE |
503 | The provider is unreachable. Publishing is never blocked by this | "Captions are delayed. Your testimonial can still be published." | None; it retries |
TRANSCRIPTION_FAILED |
422 | All retries exhausted | "We couldn't transcribe this video." | Retry transcription |
TRANSCRIPT_NOT_FOUND |
404 | No transcript for this testimonial | "There's no transcript for this video." | Retry transcription |
TRANSCRIPT_SHAPE_INVALID |
422 | A submitted transcript does not match the segment structure | "That transcript isn't in a format we can store." | Re-edit from the current transcript |
TRANSCRIPT_SEGMENT_OVERLAP |
422 | Two segments overlap in time | "Two captions overlap. Adjust the timings." | Fix timings |
TRANSCRIPT_TOO_LARGE |
413 | A transcript above the stored-size ceiling | "That transcript is larger than we can store." | Shorten the recording |
TRANSCRIPT_VERSION_CONFLICT |
409 | Concurrent edit | "Someone else edited this transcript. Refresh to see their changes." | Refresh |
SEGMENT_TIMING_INVALID |
422 | Negative, inverted, or out-of-range timings | "Check the start and end times." | Fix timings |
VTT_GENERATION_FAILED |
500 | The caption file could not be written from a stored transcript | "We couldn't build the caption file." | Retry; the job also retries |
AI assistant
| Code | HTTP | Meaning | User-facing message | Remediation |
|---|---|---|---|---|
AI_SUGGESTION_NOT_FOUND |
404 | Unknown suggestion | "We couldn't find that suggestion." | Refresh |
AI_SUGGESTION_NOT_APPROVED |
409 | Publish attempted without human approval | "Approve this highlight before publishing it." | Approve it |
AI_SUGGESTION_STALE |
409 | The underlying text changed after the suggestion was made, so the excerpt may no longer be verbatim | "The testimonial changed since this suggestion was made." | Regenerate suggestions |
HIGHLIGHT_NOT_VERBATIM |
422 | The proposal is not an exact substring of the stored text after NFC normalization and whitespace collapsing. The single code for every verbatim failure, whether the proposal is a pull-quote or a clip label | "That highlight doesn't match the original words." | Choose a different highlight |
AI_UNAVAILABLE |
503 | Provider error after retries, or the global kill switch is off | "The assistant is unavailable right now." | Try again later; manual curation is unaffected |
AI_DISABLED_FOR_WORKSPACE |
409 | The workspace has turned the assistant off in its own settings | "The highlight assistant is turned off for this workspace." | An Admin can re-enable it |
AI_COST_CAP_REACHED |
409 | The daily spend cap for the assistant has been reached | "The assistant has reached today's usage limit." | Try again tomorrow |
AI_RUN_LIMIT_REACHED |
409 | Per-testimonial ceiling on suggestion runs. A 409 rather than a 429 because waiting does not help: the ceiling is per testimonial for its lifetime, not per unit of time |
"You've asked for suggestions on this testimonial too many times." | Use the suggestions you have |
AI_RATE_LIMITED |
429 | Per-workspace assistant throttle | "You've used the assistant a lot just now. Try again shortly." | Wait |
AI_RESPONSE_UNPARSEABLE |
502 | Malformed model output after retries | "The assistant returned something we couldn't use." | Try again |
Widgets and the embed
| Code | HTTP | Meaning | User-facing message | Remediation |
|---|---|---|---|---|
WIDGET_NOT_FOUND |
404 | Unknown widget id — almost always a typo in the snippet | "We couldn't find that widget." | Check the widget id in the snippet |
WIDGET_DELETED |
410 | The widget existed and was deleted. Deliberately distinct from WIDGET_NOT_FOUND, because the loader renders a different state for "you deleted this" than for "this never existed" |
"This widget was deleted." | Create a new widget and update the snippet |
WIDGET_PAUSED |
200 | Paused; payload returns paused: true with no items |
— (renders nothing) | Unpause in the dashboard |
WIDGET_CONFIG_INVALID |
422 | Bad option combination | "Check the widget settings." | Fix the highlighted setting |
WIDGET_ACCENT_CONTRAST_INSUFFICIENT |
422 | The chosen colours fail the contrast thresholds owned by Section 21.5 and the customer chose to enforce them | "This colour combination isn't readable enough." | Use the suggested accessible colour |
WIDGET_DECORATIVE_CONTRAST_OVERRIDE |
— | Not a refusal. The audited outcome code written to audit_events when a decorative-only element is exempted from the contrast rule — the single permitted deviation from the block in Section 21.5.3. It never suppresses a text or focus-indicator failure, and there is no plan, toggle or per-widget setting that produces it |
— (no user-facing message; it is a record, not a response) | None — it is evidence, not an error |
WIDGET_THEME_INCOMPLETE |
422 | A custom theme omits a token the runtime requires | "This theme is missing a required colour." | Complete the theme |
WIDGET_SORT_REQUIRES_CURATION |
422 | A manual sort order was set on a widget that is not in curated mode | "Switch this widget to a curated list to order it by hand." | Enable curation |
LAYOUT_NOT_VALID_FOR_TYPE |
422 | A layout that this widget type does not offer | "That layout isn't available for this widget type." | Choose a listed layout |
WIDGET_ATTRIBUTION_REQUIRED |
422 | The badge was disabled on a plan that requires it | "The Vouch badge is required on your plan." | Upgrade to remove it |
WIDGET_ORIGIN_NOT_ALLOWED |
403 | The widget is restricted to named origins and the requesting page is not one | "This widget isn't allowed to load on this site." | Add the site to the widget's allowed origins |
WIDGET_BATCH_TOO_LARGE |
400 | More than 10 ids in one data request | "Too many widgets requested at once." | Split the request |
WIDGET_BATCH_INVALID |
400 | The batch parameter is malformed — duplicate ids, an empty list, or an unparseable id | "That widget request wasn't valid." | Correct the widget ids |
OEMBED_URL_INVALID |
400 | Unrecognised URL for the oEmbed endpoint | "That URL isn't a Vouch widget." | Use a widget URL |
Domains and TLS
| Code | HTTP | Meaning | User-facing message | Remediation |
|---|---|---|---|---|
DOMAIN_INVALID |
422 | Not a valid hostname | "That doesn't look like a valid domain." | Check the spelling |
DOMAIN_RESERVED |
422 | A hostname on our own reserved list, or one of our own domains | "That domain can't be connected." | Use your own domain |
DOMAIN_IS_PUBLIC_SUFFIX |
422 | The hostname is a public suffix (co.uk, github.io), which no single customer may claim |
"That's a shared domain suffix and can't be connected." | Use a hostname you control |
DOMAIN_ALREADY_CLAIMED |
409 | Active on another workspace | "This domain is already connected to another Vouch workspace." | Remove it there, or contact support |
DOMAIN_NOT_FOUND |
404 | Unknown domain | "We couldn't find that domain." | Refresh |
CUSTOM_DOMAIN_LIMIT_REACHED |
409 | Plan domain allowance used | "You've used the custom domains included in your plan." | Add a domain add-on or upgrade |
DOMAIN_ADDON_REQUIRED |
409 | An additional domain beyond the plan's allowance, purchasable as an add-on | "Adding another domain needs the custom-domain add-on." | Add the add-on |
DOMAIN_NOT_ALLOWED_ON_PLAN |
409 | Custom domains are not included on this plan at all | "Custom domains are available from Starter upward." | Upgrade |
DOMAIN_VERIFICATION_PENDING |
409 | Action requires an active domain | "This domain isn't verified yet." | Complete the DNS records |
DNS_NOT_FOUND |
422 | The hostname does not resolve at its authoritative nameservers at all | "That domain isn't resolving yet." | Check the domain exists and is delegated |
DNS_RECORD_MISSING |
422 | Required record absent at the authoritative nameservers | "We can't see the required DNS record yet." | Add the record shown |
DNS_RECORD_MISMATCH |
422 | Record present but pointing somewhere else | "The DNS record doesn't match what's required." | Correct the record |
CNAME_AT_APEX |
422 | A CNAME was created at the zone apex, which the DNS specification forbids and most providers silently break | "An apex domain needs A and AAAA records, not a CNAME." | Use the A and AAAA records shown |
CONFLICTING_RECORD |
422 | Another record of an incompatible type exists at the same name | "Another DNS record is conflicting with the one we need." | Remove the conflicting record |
PROXIED_BY_ANOTHER_CDN |
422 | The record resolves to a third-party proxy, so our edge never sees the request and cannot complete the challenge | "This domain is proxied by another CDN, so we can't verify it." | Turn off proxying for this hostname |
NAMESERVERS_NOT_PUBLISHING |
422 | The authoritative nameservers answer but do not publish the zone | "Your nameservers aren't publishing this domain yet." | Check the delegation |
DNSSEC_VALIDATION_FAILED |
422 | DNSSEC is enabled and the chain does not validate | "This domain's DNSSEC configuration isn't valid." | Fix or disable DNSSEC, then retry |
PROPAGATION_IN_FLIGHT |
409 | Records observed at some resolvers but not all; verification continues automatically | "Your DNS change is still spreading. We're still checking." | Wait |
SETUP_TIMED_OUT |
409 | 72 hours elapsed without the records appearing | "We stopped checking this domain after three days." | Add the records and start again |
CAA_FORBIDS_ISSUANCE |
422 | A CAA record blocks our certificate authority | "A CAA record on your domain prevents us issuing a certificate." | Add our issuer to your CAA record |
ACME_RATE_LIMITED |
429 | The certificate authority is rate-limiting issuance for this registered domain | "The certificate authority is limiting new certificates for this domain." | Wait; issuance resumes automatically |
TLS_RATE_LIMITED |
429 | Our own issuance throttle for this workspace | "Too many certificate requests just now." | Wait |
TLS_ISSUANCE_FAILED |
503 | ACME failure after retries | "We couldn't issue a certificate. We're retrying." | Wait; contact support if it persists |
Social cards
| Code | HTTP | Meaning | User-facing message | Remediation |
|---|---|---|---|---|
SOCIAL_CARD_SOURCE_NOT_ELIGIBLE |
409 | A card was requested for a testimonial that is not published, or whose consent does not cover it | "This testimonial can't be turned into a shareable card." | Publish it first, or check its permissions |
SOCIAL_CARD_CONTRAST_FAILED |
422 | The chosen template and theme produce text below the contrast threshold | "This card's colours aren't readable enough." | Choose another theme |
SOCIAL_CARD_FONT_COVERAGE |
422 | The quote uses characters the template's font cannot render, which would produce blank boxes in the image | "This template's font can't display some of these characters." | Choose a template with fuller language coverage |
SOCIAL_CARD_TOO_LARGE |
422 | The quote does not fit the template at the minimum permitted type size | "This quote is too long for that card layout." | Shorten the excerpt or pick a taller template |
TEMPLATE_REQUIREMENTS_UNMET |
422 | The template requires a field the testimonial lacks (an avatar, a company, a rating) | "This template needs information this testimonial doesn't have." | Choose another template |
ALT_TEXT_REQUIRED |
422 | An image was published without alt text, which is never optional here | "Add alt text describing this image." | Write 10–420 characters of alt text |
Import
| Code | HTTP | Meaning | User-facing message | Remediation |
|---|---|---|---|---|
IMPORT_SOURCE_NOT_CONNECTED |
409 | OAuth connection missing | "Connect this source before importing." | Connect the account |
IMPORT_SOURCE_UNAUTHORIZED |
409 | Token revoked or expired at the source. The refusal is a property of the stored connection, not of the caller's role, so it is a state conflict rather than a 403 |
"Your connection to this source has expired." | Reconnect |
IMPORT_SOURCE_NOT_OWNED |
409 | The connected account does not administer the business profile being imported | "That account doesn't manage this business listing." | Connect an account that does |
IMPORT_SOURCE_NOT_ENTITLED |
409 | The customer's contract with the review platform does not include syndication. Their entitlement, not our plan — and not a refusal of the caller, which is why it is not a 403 |
"Your account with this review site isn't entitled to a feed." | Upload an export instead |
IMPORT_NOT_AVAILABLE_ON_PLAN |
409 | Automatic imports are not included on this plan; Free is CSV only | "Automatic imports are available from Starter upward." | Upgrade or use CSV |
IMPORT_SOURCE_LIMIT_REACHED |
409 | The workspace has connected the maximum number of sources | "You've connected the maximum number of review sources." | Disconnect one |
IMPORT_RUN_IN_PROGRESS |
409 | A run for this source is already active | "An import from this source is already running." | Wait for it to finish |
IMPORT_FILE_TOO_LARGE |
413 | CSV above 10 MB | "That file is larger than 10 MB." | Split the file |
IMPORT_ROW_LIMIT |
422 | CSV above IMPORT_MAX_ROWS_PER_FILE data rows |
"That file has more rows than we can import at once." | Split the file |
IMPORT_FILE_INVALID |
422 | Unparseable file | "We couldn't read that file." | Check the format |
CSV_ENCODING_UNSUPPORTED |
422 | The file is not UTF-8 and its encoding could not be detected confidently | "We couldn't read that file's text encoding." | Save it as UTF-8 and retry |
CSV_PARSE_FAILED |
422 | Ragged rows, an unterminated quote, or an unresolvable delimiter | "That CSV file is malformed." | Check the file and retry |
CSV_ALREADY_COMMITTED |
409 | Commit called twice on the same staged upload | "That file has already been imported." | None |
IMPORT_ROW_INVALID |
422 | A row fails validation; the rest still import | "Some rows couldn't be imported." | Fix the listed rows |
IMPORT_MAPPING_INCOMPLETE |
422 | Required columns unmapped | "Map the required columns to continue." | Complete the mapping |
IMPORT_CONSENT_NOT_CONFIRMED |
422 | The importer did not confirm they have the right to republish these reviews | "Confirm you have permission to publish these reviews." | Tick the confirmation |
IMPORT_DATE_FORMAT_AMBIGUOUS |
422 | A date column could be day-first or month-first and the file gives no way to tell | "We can't tell whether these dates are day-first or month-first." | Choose the format explicitly |
DATE_UNPARSEABLE |
422 | A specific date value cannot be read in the chosen format | "One of the dates in that file couldn't be read." | Fix the listed rows |
DUPLICATE_IN_FILE |
422 | The same source review appears twice within one file | "That file contains the same review more than once." | Remove the duplicate rows |
IMPORT_DUPLICATE |
409 | Same (source, source_review_id) as an already-imported review |
"This review was already imported." | None |
IMPORTED_REVIEW_ADS_BLOCKED |
409 | An advertising use was attempted on a review whose source terms forbid it | "This review's source doesn't allow use in advertising." | Use a first-party testimonial |
IMPORT_RATE_LIMITED_BY_SOURCE |
429 | Upstream throttle | "The review source is limiting us. We'll continue shortly." | Wait |
Analytics
| Code | HTTP | Meaning | User-facing message | Remediation |
|---|---|---|---|---|
ANALYTICS_RANGE_INVALID |
422 | Bad or inverted date range | "Check the date range." | Adjust the range |
ANALYTICS_RETENTION_EXCEEDED |
422 | Range predates the plan's retention | "Your plan keeps analytics for a shorter period." | Upgrade or choose a later range |
GRANULARITY_TOO_FINE |
422 | Hourly granularity requested over a range longer than hourly rollups are kept for | "Hourly detail is only available for the last 7 days." | Choose daily granularity, or a shorter range |
ANALYTICS_EVENT_REJECTED |
202 | Beacon accepted then discarded (bot or impossible timing); never surfaced to a visitor | — | None |
Integrations and webhooks
| Code | HTTP | Meaning | User-facing message | Remediation |
|---|---|---|---|---|
WEBHOOK_ENDPOINT_NOT_FOUND |
404 | Unknown endpoint | "We couldn't find that webhook endpoint." | Refresh |
WEBHOOK_ENDPOINT_LIMIT_REACHED |
409 | Endpoint cap | "You've reached the webhook endpoint limit." | Remove one |
WEBHOOK_URL_INVALID |
422 | Malformed URL, or one our resolver refuses | "Webhook URLs must be public HTTPS addresses." | Use a public HTTPS URL |
INSECURE_WEBHOOK_URL |
422 | The URL is http://. We do not deliver signed payloads over plaintext even if the customer asks |
"Webhook URLs must use HTTPS." | Use an HTTPS URL |
WEBHOOK_ENDPOINT_DISABLED |
409 | The endpoint is disabled. The details object carries reason: attempts_exhausted (all nine attempts failed), gone (the endpoint answered 410 Gone), or failure_ratio (over 90% of deliveries failed across a rolling 200-delivery window) |
"This endpoint was disabled after repeated delivery failures." | Fix the endpoint and re-enable it |
WEBHOOK_SIGNATURE_INVALID |
401 | Inbound verification failed (Stripe, provider, Slack) | "We couldn't verify that request." | Check the signing secret |
WEBHOOK_TIMESTAMP_OUT_OF_TOLERANCE |
401 | Outside the 5-minute window | "That request is too old to process." | Retry with a current timestamp |
INTEGRATION_NOT_CONNECTED |
409 | Slack/HubSpot not linked | "Connect this integration first." | Connect it |
CONNECTION_REAUTH_REQUIRED |
409 | The stored OAuth grant was revoked or has expired at the provider | "This connection needs to be re-authorised." | Reconnect the integration |
OAUTH_STATE_INVALID |
400 | The OAuth callback's state is missing, expired, or does not match — the CSRF guard on the connect flow |
"That connection attempt couldn't be verified." | Start the connection again |
INTEGRATION_NOT_ALLOWED_ON_PLAN |
409 | Plan gate (for example, HubSpot below Growth) | "This integration is available on a higher plan." | Upgrade |
SSRF_BLOCKED |
422 | A customer-supplied URL resolved to a private or link-local address | "That address isn't allowed." | Use a public address |
Billing
| Code | HTTP | Meaning | User-facing message | Remediation |
|---|---|---|---|---|
BILLING_NOT_CONFIGURED |
409 | No customer record yet | "Set up billing to continue." | Start checkout |
PLAN_NOT_FOUND |
404 | Unknown plan id | "That plan isn't available." | Choose a listed plan |
PLAN_CHANGE_NOT_ALLOWED |
409 | Illegal transition (for example, a downgrade during dunning) | "You can't change plans right now." | Resolve the outstanding payment |
PLAN_FEATURE_UNAVAILABLE |
409 | The action needs a capability this plan does not include. The single code for every plan-feature gate — the AI assistant, enforced 2FA, write API scopes, and the rest — with the feature named in details.feature |
"That feature isn't included in your plan." | Upgrade |
PAYMENT_METHOD_REQUIRED |
409 | The action needs a payment method on file and there is none, or the one on file is unusable. This is the code for the condition a 402 would otherwise describe — the request is authorized and well-formed, and the workspace's state refuses it |
"Add a payment method to continue." | Add or update the card |
SUBSCRIPTION_CANCELED |
409 | Write attempted on a canceled workspace (widgets keep serving) | "This workspace is canceled and is read-only." | Reactivate |
WORKSPACE_OVER_LIMIT |
409 | The workspace's limit_state is over_limit after a downgrade: nothing is deleted or unpublished, new collection is paused |
"You're above your plan's limit, so new collection is paused." | Upgrade or reduce |
CHECKOUT_SESSION_INVALID |
400 | Expired or mismatched session | "That checkout session is no longer valid." | Start again |
STRIPE_ERROR |
502 | Upstream billing failure | "We couldn't reach our payment provider." | Retry shortly |
Respondent self-service, privacy, and export
| Code | HTTP | Meaning | User-facing message | Remediation |
|---|---|---|---|---|
RESPONDENT_LINK_INVALID |
404 | Unknown token | "This link isn't valid." | Ask the company for a new link |
RESPONDENT_LINK_REVOKED |
410 | Erased or withdrawn | "This link is no longer available." | None |
WITHDRAWAL_ALREADY_REQUESTED |
409 | Duplicate withdrawal | "You've already withdrawn this testimonial." | None |
ERASURE_ALREADY_REQUESTED |
409 | Duplicate erasure request | "We're already processing your erasure request." | None |
EXPORT_IN_PROGRESS |
409 | An export is already running for this scope. This is the only code for that condition, whether the requester is a workspace member or a respondent | "Your export is already being prepared." | Wait for the email |
EXPORT_NOT_READY |
409 | Download requested too early | "Your export isn't ready yet." | Wait for the email |
EXPORT_EXPIRED |
410 | Past the 7-day retention | "This export has expired." | Request a new export |
29.4 Event & Webhook Catalogue #
Section 18.2 owns event names; this table is a mechanical regeneration of it with one added
column. A name that appears here and not in 18.2 does not exist, and the reverse is a defect —
Section 28.8 step 2 diffs the two. Two naming decisions are worth repeating because both had a
plausible-looking alternative in earlier drafts: media events are media.* and never video.*,
and the four events about a request for a testimonial sent to a respondent are invite.*,
while invitation.* refers only to a workspace seat invitation and is used for nothing else.
Events are written to the outbox in the same transaction as the change that caused them, so a rolled-back transaction never produces one. The relay picks them up after commit and, for each subscribed endpoint, enqueues a delivery. Deliveries are signed as described in Section 7.18 and retried on the ladder in Section 18.5 — one send plus eight retries, nine attempts in all.
Every delivery body carries the eight-key envelope defined in Section 18.3:
{
"id": "evt_01JBQ8Q2CD2N9EJ6DF0LCD2RUV",
"type": "testimonial.published",
"apiVersion": "v1",
"createdAt": "2025-11-04T10:01:41.907Z",
"workspaceId": "wsp_01JBQ7S1C3A7X9QY3E8G1K5U7W",
"livemode": true,
"actor": { "type": "user", "id": "usr_01JBQ7W9F6D0A2TB6H1K4N8X0Z" },
"data": { }
}workspaceId is the prefixed public identifier, never a bare uuid; livemode and actor are
required on every event without exception; and consumers deduplicate on id, which is stable
across retries and manual replays.
| Event | Trigger | Delivery | data payload |
|---|---|---|---|
testimonial.submitted |
A respondent completes a submission, or a manual or imported testimonial is created. Always the first event for a testimonial | Webhook | testimonial |
testimonial.approved |
pending → approved, by a moderator or an API call |
Webhook | testimonial |
testimonial.published |
approved or unpublished → published; the testimonial becomes publicly visible |
Webhook | testimonial |
testimonial.unpublished |
A published testimonial is deliberately pulled | Webhook | testimonial |
testimonial.rejected |
pending → rejected |
Webhook | testimonial, reason |
testimonial.updated |
Any content, author, rating, tag, or feature-flag change on an existing testimonial. Never fired for status changes, which have their own events | Webhook | testimonial, changedFields (names only, never values) |
testimonial.deleted |
A testimonial is soft-deleted. Fires once; the later hard purge does not fire a second event | Webhook | testimonial (minimal), deletedAt |
testimonial.archived |
rejected, unpublished, or approved → archived |
Webhook | testimonial, previousStatus |
testimonial.purged |
A soft-deleted testimonial passes its restore window and is hard-purged: media destroyed, rows removed, CDN entries gone. The last event a testimonial ever emits | Webhook | testimonialId, purgedAt |
testimonial.erased |
A data-subject erasure destroys a testimonial's content. Ids and timestamps only — carrying the content would defeat the erasure | Webhook | testimonialId, erasedAt, dsrReference |
media.ready |
A media asset reaches ready: transcode complete, playback id available, poster written |
Webhook | media, testimonialId |
media.failed |
A media asset reaches errored after an upload or transcode failure |
Webhook | media, testimonialId, failure |
transcript.ready |
A transcript completes and its WebVTT file is written | Webhook | transcript (metadata only), testimonialId |
transcript.edited |
A human saves a transcript edit, bumping transcriptVersion |
Webhook | transcript, testimonialId, editedByUserId |
invite.sent |
An invite is handed to the email or SMS provider | Webhook | invite |
invite.opened |
The recipient loads the request page from an invite link. A page load, not a tracking pixel | Webhook | invite |
invite.submitted |
An invite results in a completed submission | Webhook | invite, testimonialId |
invite.bounced |
A hard bounce or undeliverable number is reported; the address joins the suppression list | Webhook | invite, bounce |
import.completed |
An import run finishes in succeeded or partial |
Webhook | importRun |
import.failed |
An import run finishes in failed, or is abandoned after exhausting retries |
Webhook | importRun, failure |
widget.published |
A widget is created, or its configuration changes in a way that alters the served payload | Webhook | widget |
domain.verified |
A custom domain reaches active with a valid certificate |
Webhook | domain |
domain.failed |
A custom domain reaches failed, or a certificate renewal fails |
Webhook | domain, failure |
consent.withdrawn |
A respondent withdraws consent, or a workspace records a withdrawal on their behalf | Webhook | testimonialIds, withdrawnAt, scope |
quota.threshold_reached |
A metered limit crosses 80 %, 90 %, 95 %, or 100 %. Once per threshold per metric per period; crossing back down resets it | Webhook | metric, threshold, used, limit |
quota.exceeded |
A metered limit reaches 100 % and the associated action is now refused. Fires in addition to the 100 % threshold event | Webhook | metric, used, limit, blockedAction |
workspace.over_limit |
limit_state becomes over_limit: new collection pauses, everything published keeps serving |
Webhook | limitState, metric, overLimitSince |
member.role_changed |
A member's role changes, including through an ownership transfer | Webhook | member, previousRole, newRole |
webhook.endpoint_disabled |
An endpoint is auto-disabled by Section 18.5, after nine failed attempts, on a 410 Gone, or on a failure ratio above 90 % across a rolling 200-delivery window |
Webhook, to the surviving endpoints | endpoint, reason (attempts_exhausted | gone | failure_ratio), lastStatus, disabledAt |
export.ready |
An export completes and its signed download URL becomes available. The URL is deliberately not in the payload — a webhook body is not a place to put a credential-bearing link | Webhook | exportId, dataset, rowCount, expiresAt |
erasure.completed |
A data-subject erasure finishes across every store, including the media provider and the CDN | Webhook | dsrReference, subjectEmailHash, completedAt, deletedItems |
subscription.updated |
Plan, interval, seat count, add-ons, or billing state changes, including dunning transitions | Webhook | subscription, previous |
All thirty-two are workspace-scoped; there are no account-level, user-level, or global events.
Why the delivery column reads the same on every row. Section 18.2 admits a name only when a customer could reasonably subscribe to it, so every catalogued event is deliverable. The outbox carries a second class of row — internal job triggers, such as the payload rebuild that follows a publish or the CDN purge that follows it — which are not events: they have no names in this catalogue, no envelope, and no subscriber, and they appear only in the Trigger column of Appendix 29.5. If a name is not in the table above, it is one of those, or it does not exist.
29.5 Job Catalogue #
All jobs run on BullMQ in apps/worker, are enqueued through the outbox, and are idempotent.
"Retries" gives attempt count and backoff. Dead-lettered jobs are retained 180 days, matching
the failed-job retention in Section 22.7, and can be replayed with the operator command written
into docs/RUNBOOKS.md.
Job names are domain.snake_case_verb. A hyphen never appears in a job name, in a queue
name, or in a job id. This matters more than it looks: job ids are constructed by concatenating
the name with the idempotency key, and a codebase that spells the same job two ways enqueues it
twice.
Three conventions the columns assume:
- Idempotency key is the value appended to the job name to form the BullMQ job id, so an enqueue that repeats — because the outbox relay retried, or because two requests raced — collapses to one job. Where the key is a time bucket, re-enqueuing inside that bucket is a no-op by construction.
- Group is the queue the job runs on, and therefore its concurrency and its isolation:
realtime(customer-visible latency),media(long, provider-bound),heavy(CPU or token cost),scheduled(cron-driven). A poison job wedges only its own group (Runbook R18). - Dead-letter says what happens after the last retry. Two jobs never dead-letter silently,
because both are promises to a person rather than to a system:
media.purgeanderasure.executeescalate to a human instead.
| Job | Group | Trigger | Payload | Idempotency key | Retries | Timeout | Dead-letter | Alert |
|---|---|---|---|---|---|---|---|---|
outbox.relay |
scheduled | Every 1 s poll | Batch of outbox rows | Outbox row id | Infinite, 1 s fixed | 30 s | Never (rows persist) | A25 if no progress |
media.finalize |
media | Upload session completed by the client | Upload session id | Upload session id | 5, exp 10 s→5 m | 120 s | Yes → session failed, respondent offered a re-record |
A5 |
media.abort_upload |
media | Client abandons, or a session is swept | Upload session id | Upload session id | 3, exp 30 s | 60 s | Yes | Ticket |
media.ingest |
media | media.finalize succeeded |
Upload session id, storage key, workspace id | Upload session id | 6, exp 30 s→30 m | 120 s | Yes → asset errored, moderator notified |
A6 |
media.retry_ingest |
media | Operator action in Runbook R3, or media.reconcile finding a stalled asset |
Asset id | Asset id + attempt number | 3, exp 2 m | 120 s | Yes | A6 |
media.mark_errored |
media | Ingest or transcode exhausted its retries | Asset id, failure class | Asset id | 3, exp 30 s | 30 s | Yes | A6 |
media.webhook |
media | Provider webhook received | Provider event id, asset id | Provider event id | 5, exp 10 s→5 m | 30 s | Yes | A6 |
media.reconcile |
media | Hourly, and manually during R3 | Asset id or all |
Asset id + hour bucket | 3, exp 1 m | 300 s | Yes | A6 |
media.verify_integrity |
media | After ingest, and nightly for a sample of assets | Asset id | Asset id + date | 3, exp 1 m | 120 s | Yes | A6 |
media.poster |
media | Asset ready |
Asset id, timestamp | Asset id + timestamp | 5, exp 15 s→5 m | 120 s | Yes → widget falls back to a generated placeholder | A6 |
media.waveform |
media | Asset ready on an audio-bearing asset |
Asset id | Asset id | 4, exp 15 s→2 m | 60 s | Yes → the player renders without a waveform | Ticket |
media.process_image |
media | An avatar, logo, or imported image is uploaded | Image id, target derivatives | Image id + derivative set | 4, exp 15 s→2 m | 60 s | Yes | Ticket |
media.repolicy |
media | Playback policy changes, or a testimonial is published or unpublished | Asset id, target policy | Asset id + policy | 5, exp 30 s→10 m | 60 s | Yes | A6 |
media.purge |
media | Testimonial soft-deleted (enqueued with a 30-day delay, matching the restore window in Section 11.2.3), or erased (enqueued with zero delay) | Testimonial id, asset ids, storage prefix | Testimonial id — so the job id is deterministically media.purge:{testimonialId} and a restore inside the window can cancel it by id |
10, exp 1 m→6 h, then daily for 30 days | 300 s | Never silently — escalates instead | A12 after 3 failures |
media.sweep_abandoned_uploads |
scheduled | Hourly | — | Hour | 3, exp 5 m | 300 s | Yes | A29 |
media.sweep_orphans |
scheduled | Daily 03:15 UTC | — | Date | 3, exp 5 m | 900 s | Yes | A29 |
transcription.request |
heavy | Asset ready |
Testimonial id, asset id, language hint | Asset id + transcript version | 6, exp 30 s→60 m | 600 s | Yes → transcription_failed, retry action offered |
A7 |
transcription.store |
heavy | Provider callback or poll | Testimonial id, provider job id | Provider job id | 5, exp 15 s→5 m | 120 s | Yes | A7 |
vtt.generate |
media | Transcript stored or edited | Testimonial id, transcript version | Testimonial id + version | 5, exp 10 s→2 m | 60 s | Yes | A7 |
ai.suggest |
heavy | Moderator opens the assistant, or on publish-ready for Growth+ | Testimonial id, kinds, prompt version | Testimonial id + kinds + prompt version | 3, exp 30 s→5 m | 120 s | Yes → assistant shows unavailable | A28 |
ai.validate_verbatim |
heavy | Inline in ai.suggest, before any proposal is stored |
Suggestion ids | Suggestion id | 0 (pure, in-process) | 5 s | n/a | A28 on ratio |
social_card.render |
heavy | Card requested, or testimonial published with cards enabled | Testimonial id, template, theme | Testimonial id + template + content hash | 4, exp 20 s→5 m | 120 s | Yes | Ticket only |
social_card.bulk |
heavy | A bulk card request; fans out one social_card.render per card at a per-run concurrency of 4 |
Run id, testimonial ids, template, theme | Run id | 3, exp 1 m | 600 s | Yes → per-card results reported, the run marked partial | Ticket only |
widget.payload_build |
realtime | Publish, unpublish, edit, tag change, widget config change | Widget ids | Widget id + testimonial version | 5, exp 5 s→2 m | 60 s | Yes | A1/A3 |
cdn.purge |
realtime | Any payload-affecting change | Tag list | Tag set hash + minute bucket | 8, exp 5 s→10 m | 30 s | Yes | A3 |
webhook.deliver |
realtime | Outbox event with subscribers | Endpoint id, event id, body | Endpoint id + event id | 8 retries after the first send — nine attempts in all — on the ladder in Section 18.5 (1 m, 5 m, 30 m, 2 h, 5 h, 10 h, 24 h, 24 h) | 15 s | Yes → endpoint auto-disabled with reason: attempts_exhausted, Owner emailed |
A8/A9 |
email.send |
realtime | Any transactional email | Template, recipient id, variables | Template + recipient + dedupe key | 5, exp 30 s→30 m | 30 s | Yes | A27 |
sms.send |
realtime | SMS invitation | Template, contact id, variables | Template + contact + dedupe key | 4, exp 60 s→30 m | 30 s | Yes | A27 |
analytics.ingest |
realtime | Beacon batch received | Event batch | Batch hash | 3, exp 5 s | 30 s | Yes (batch dropped, counted) | A18 |
analytics.funnel_event |
realtime | A request-page funnel step is reached (request_started, request_captured, request_submitted) |
Event, request page id, view id | View id + event | 3, exp 5 s | 30 s | Yes (dropped, counted) | A18 |
analytics.rollup_hourly |
scheduled | Every hour at :05 | Hour window | Hour window | 3, exp 2 m | 600 s | Yes | Ticket |
analytics.rollup_daily |
scheduled | Daily 01:00 UTC | Date | Date | 3, exp 5 m | 1800 s | Yes | Ticket |
analytics.export |
heavy | An analytics export is requested from the dashboard or the API | Export id, range, granularity, dimensions | Export id | 3, exp 2 m | 900 s | Yes → requester emailed a failure notice | Ticket |
domain.verify |
scheduled | Repeatable every 5 min for 72 h per pending domain; manual re-enqueue | Domain id | Domain id + 5-minute bucket | 3, exp 1 m | 60 s | Yes → domain failed |
A21 |
domain.health |
scheduled | Every 15 min for every active domain |
Domain id | Domain id + 15-minute bucket | 2, exp 1 m | 30 s | Yes | A21 |
acme.issue |
scheduled | Domain verified | Domain id | Domain id + challenge id | 5, exp 2 m→2 h | 300 s | Yes → domain failed, Owner emailed |
A11 |
acme.renew |
scheduled | Daily 04:00 UTC for certificates < 30 days remaining | Domain id | Domain id + date | 5, exp 10 m→6 h | 300 s | Yes | A10/A11 |
import.sync |
heavy | Daily, for every connected source with sync enabled | Source id | Source id + date | 3, exp 5 m | 120 s | Yes | Ticket |
import.fetch |
heavy | Manual import, or import.sync |
Import id, source, cursor | Import id + cursor | 5, exp 1 m→30 m | 600 s | Yes | Ticket |
import.batch |
heavy | After each fetch page, one job per batch of rows | Import id, batch index, row ids | Import id + batch index | 3, exp 30 s | 300 s | Yes | Ticket |
import.normalize |
heavy | Inside each batch | Import id, raw row batch | Import id + row hash | 3, exp 30 s | 300 s | Yes | Ticket |
billing.sync_stripe_event |
realtime | Stripe webhook stored | Stripe event id | Stripe event id | 5, exp 30 s→30 m | 60 s | Yes | A13/A14 |
billing.reconcile |
scheduled | Daily 05:00 UTC, and after any billing incident; repairs drift between our subscription rows and Stripe's | — | Date | 3, exp 5 m | 900 s | Yes | A13 |
billing.audit_projection |
scheduled | Daily 05:30 UTC; compares every active subscription against Stripe and reports discrepancies rather than repairing them. Deliberately separate from billing.reconcile: a job that silently fixes billing can hide a systematic error indefinitely, so one repairs and one accuses |
— | Date | 3, exp 5 m | 900 s | Yes | A13 on any discrepancy |
billing.dunning_step |
scheduled | Daily 06:00 UTC | Subscription id, step | Subscription id + step | 3, exp 1 h | 120 s | Yes | Ticket |
quota.recount |
realtime | Any metered write | Workspace id, metric | Workspace id + metric + minute bucket | 3, exp 5 s | 30 s | Yes | A20 |
quota.reconcile |
scheduled | Nightly 00:30 UTC | — | Date | 3, exp 5 m | 1800 s | Yes | A20 on any drift |
quota.notify_threshold |
realtime | A counter crosses 80 %, 90 %, 95 %, or 100 % | Workspace id, metric, threshold | Workspace + metric + threshold + period | 3, exp 1 m | 30 s | Yes | Ticket |
quota.roll_periods |
scheduled | Hourly; rolls each workspace's period at its own usage_anchor_day |
— | Hour bucket | 3, exp 5 m | 600 s | Yes | A20 |
quota.evaluate_overage |
scheduled | Daily 00:45 UTC, after quota.reconcile |
— | Date | 3, exp 5 m | 600 s | Yes | A20 |
consent.withdrawal_apply |
realtime | Respondent withdraws | Testimonial id | Testimonial id + withdrawal id | 8, exp 5 s→10 m | 60 s | Escalates rather than dead-letters — a withdrawal must complete within 60 s | SEV-2 ticket if > 5 min |
consent.reproject |
realtime | A consent row is withdrawn, corrected, or restored | Consent id | Consent id + reprojected_at target |
5, exp 5 s→2 m | 30 s | Yes | Ticket |
erasure.execute |
media | Erasure request accepted | Erasure request id | Erasure request id | 10, exp 1 m→6 h, then daily for 30 days | 900 s | Escalates per R11 | A12 |
erasure.replay |
media | Every restore, without exception, including the monthly drill: replays each erasure_log entry completed after the snapshot before the restored instance accepts traffic |
Erasure log entry id, snapshot timestamp | Entry id + snapshot timestamp | 10, exp 1 m→1 h | 900 s | Never silently — a restore with an incomplete replay is not a completed restore, and R13 blocks on it | A12, and the restore is held |
export.build |
heavy | Workspace or respondent export requested. The payload's format selects CSV or JSON; there is no separate per-format job |
Export id, scope, format | Export id | 3, exp 2 m | 1800 s | Yes → requester emailed a failure notice | Ticket |
export.sweep |
scheduled | Daily 03:30 UTC | — | Date | 3, exp 5 m | 300 s | Yes | Ticket |
session.sweep |
scheduled | Daily 03:45 UTC | — | Date | 3, exp 5 m | 300 s | Yes | Ticket |
invitation.expire |
scheduled | Hourly | — | Hour | 3, exp 5 m | 120 s | Yes | Ticket |
drafts.sweep |
scheduled | Hourly; expires drafts past their retention window | — | Hour | 3, exp 5 m | 300 s | Yes | Ticket |
drafts.purge_submitted |
scheduled | Daily 02:15 UTC; hard-deletes drafts whose submission completed | — | Date | 3, exp 5 m | 300 s | Yes | Ticket |
retention.sweep_originals |
scheduled | Daily 03:00 UTC | — | Date | 3, exp 5 m | 900 s | Yes | A29 |
retention.sweep_uploads |
scheduled | Daily 03:05 UTC; removes objects left in the uploads bucket past the lifecycle window, which media.sweep_abandoned_uploads has already abandoned at the session level |
— | Date | 3, exp 5 m | 900 s | Yes | A29 |
retention.sweep_analytics_raw |
scheduled | Daily 02:00 UTC; deletes analytics_events_raw rows past their retention window. This is the sweep A17 tells the on-call engineer to check when database disk runs short, because raw events are the fastest-growing table in the system |
— | Date | 3, exp 5 m | 900 s | Yes | A17 |
retention.sweep_analytics_rollups |
scheduled | Daily 02:30 UTC; deletes analytics_rollup_hourly and analytics_rollup_daily rows past their respective windows. Runs after the raw sweep so a rollup is never deleted while the events it summarises are still being aggregated |
— | Date | 3, exp 5 m | 900 s | Yes | Ticket |
retention.purge_soft_deleted |
scheduled | Daily 04:00 UTC; hard-deletes testimonials past the 30-day restore window | — | Date | 3, exp 5 m | 900 s | Yes | Ticket |
retention.purge_ai_suggestions |
scheduled | Daily 04:15 UTC; removes suggestions 180 days after creation | — | Date | 3, exp 5 m | 600 s | Yes | Ticket |
retention.purge_consent_tombstones |
scheduled | Monthly, 1st at 04:20 UTC | — | Month | 3, exp 5 m | 600 s | Yes | Ticket |
retention.purge_job_runs |
scheduled | Daily 04:30 UTC; 30 days for succeeded runs, 180 for failed | — | Date | 3, exp 5 m | 300 s | Yes | Ticket |
retention.purge_workspaces |
scheduled | Daily 04:45 UTC; hard-deletes workspaces whose deletion grace period has elapsed, and is the only path by which a workspace's rows leave the database | — | Date | 3, exp 10 m | 1800 s | Yes | Ticket, and a SEV-3 if it fails twice |
retention.prune_transcript_revisions |
scheduled | Weekly, Sunday 04:50 UTC | — | Week | 3, exp 5 m | 600 s | Yes | Ticket |
retention.prune_caption_versions |
scheduled | Weekly, Sunday 04:55 UTC | — | Week | 3, exp 5 m | 600 s | Yes | Ticket |
synthetic.widget_check |
scheduled | Every 5 min from 2+ regions | Fixture URL, widget id | Region + 5-minute bucket | 2, no backoff | 30 s | No | A24 |
synthetic.upload_cors_check |
scheduled | Every 5 min | — | 5-minute bucket | 2, no backoff | 30 s | No | A5 |
widget.loader_integrity_check |
scheduled | Nightly 01:30 UTC | Expected hash | Date | 2, exp 1 m | 60 s | No | SEV-1 on mismatch |
Three things this table is deliberately arranged to make obvious:
- Every retention sweep is in the
retention.*family. There is nomedia.retention, noanalytics.retention, and no per-domain sweep hiding in another group. Sections 5.20 and 22.7 give the periods and the trigger columns; this table gives the schedule and the operational behaviour, and the job name is the join between them. A retention rule with no job here, or a job here with no rule there, is a defect. - Two jobs escalate instead of dead-lettering, and both are promises to a person.
media.purgeis a deletion a customer was told had happened;erasure.executeis a legal obligation with a 30-day ceiling. A dead-letter queue is where work goes to be forgotten, and neither of those may be forgotten, so both retry for 30 days and raise A12 long before then. media.purgeis cancellable by id. Its job id ismedia.purge:{testimonialId}, which is what makes the 30-day restore bin work: restoring a testimonial cancels the pending purge rather than racing it.
29.6 Permission Matrix Reference #
Section 6.6.2 owns the permission strings; this table is a mechanical regeneration of it with one added column. A string that appears here and not in 6.6.2 does not exist, and the reverse is a defect — the CI check described in Section 6.6.3 asserts the two tables are identical row for row, which is the only thing that keeps a hand-maintained copy honest.
Permission strings are resource:action and are the only argument to can(actor, action, resource) (Section 6.6.3). ✅ = allowed, ❌ = denied. The API scope column names the scope a
public API key needs; — means no key can perform the action at any scope, because it is above
the fixed ceiling in Section 6.6.4.
| Permission | Owner | Admin | Editor | Viewer | API scope |
|---|---|---|---|---|---|
workspace:read |
✅ | ✅ | ✅ | ✅ | testimonials:read |
workspace:update |
✅ | ✅ | ❌ | ❌ | — |
workspace:delete |
✅ | ❌ | ❌ | ❌ | — |
workspace:transfer_ownership |
✅ | ❌ | ❌ | ❌ | — |
workspace:enforce_2fa |
✅ | ❌ | ❌ | ❌ | — |
workspace:export_data |
✅ | ✅ | ❌ | ❌ | — |
settings:read |
✅ | ✅ | ✅ | ✅ | — |
settings:update |
✅ | ✅ | ❌ | ❌ | — |
brand:update |
✅ | ✅ | ❌ | ❌ | — |
member:read |
✅ | ✅ | ✅ | ✅ | — |
member:invite |
✅ | ✅ | ❌ | ❌ | — |
member:update_role |
✅ | ✅ | ❌ | ❌ | — |
member:remove |
✅ | ✅ | ❌ | ❌ | — |
invitation:read |
✅ | ✅ | ❌ | ❌ | — |
invitation:create |
✅ | ✅ | ❌ | ❌ | — |
invitation:resend |
✅ | ✅ | ❌ | ❌ | — |
invitation:revoke |
✅ | ✅ | ❌ | ❌ | — |
billing:read |
✅ | ❌ | ❌ | ❌ | — |
billing:manage |
✅ | ❌ | ❌ | ❌ | — |
plan:change |
✅ | ❌ | ❌ | ❌ | — |
request_page:read |
✅ | ✅ | ✅ | ✅ | testimonials:read |
request_page:create |
✅ | ✅ | ✅ | ❌ | testimonials:write |
request_page:update |
✅ | ✅ | ✅ | ❌ | testimonials:write |
request_page:delete |
✅ | ✅ | ✅ | ❌ | testimonials:write |
contact:read |
✅ | ✅ | ✅ | ❌ | testimonials:read |
contact:create |
✅ | ✅ | ✅ | ❌ | testimonials:write |
contact:update |
✅ | ✅ | ✅ | ❌ | testimonials:write |
contact:delete |
✅ | ✅ | ✅ | ❌ | testimonials:write |
contact:import |
✅ | ✅ | ✅ | ❌ | testimonials:write |
invite:read |
✅ | ✅ | ✅ | ✅ | testimonials:read |
invite:send |
✅ | ✅ | ✅ | ❌ | testimonials:write |
invite:revoke |
✅ | ✅ | ✅ | ❌ | testimonials:write |
submission:read |
✅ | ✅ | ✅ | ✅ | testimonials:read |
testimonial:read |
✅ | ✅ | ✅ | ✅ | testimonials:read |
testimonial:read_pii |
✅ | ✅ | ✅ | ❌ | testimonials:read |
testimonial:create |
✅ | ✅ | ✅ | ❌ | testimonials:write |
testimonial:update |
✅ | ✅ | ✅ | ❌ | testimonials:write |
testimonial:approve |
✅ | ✅ | ✅ | ❌ | testimonials:write |
testimonial:reject |
✅ | ✅ | ✅ | ❌ | testimonials:write |
testimonial:publish |
✅ | ✅ | ✅ | ❌ | testimonials:write |
testimonial:unpublish |
✅ | ✅ | ✅ | ❌ | testimonials:write |
testimonial:archive |
✅ | ✅ | ✅ | ❌ | testimonials:write |
testimonial:delete |
✅ | ✅ | ❌ | ❌ | testimonials:write |
testimonial:restore |
✅ | ✅ | ❌ | ❌ | testimonials:write |
testimonial:flag |
✅ | ✅ | ✅ | ❌ | testimonials:write |
testimonial:flag_clear |
✅ | ✅ | ❌ | ❌ | — |
testimonial:reply |
✅ | ✅ | ✅ | ❌ | — |
testimonial:feature |
✅ | ✅ | ✅ | ❌ | testimonials:write |
testimonial:tag |
✅ | ✅ | ✅ | ❌ | testimonials:write |
testimonial:export |
✅ | ✅ | ✅ | ❌ | testimonials:write |
tag:create |
✅ | ✅ | ✅ | ❌ | testimonials:write |
tag:update |
✅ | ✅ | ✅ | ❌ | testimonials:write |
tag:delete |
✅ | ✅ | ✅ | ❌ | testimonials:write |
transcript:read |
✅ | ✅ | ✅ | ✅ | testimonials:read |
transcript:edit |
✅ | ✅ | ✅ | ❌ | testimonials:write |
transcript:retry |
✅ | ✅ | ✅ | ❌ | testimonials:write |
highlight:create |
✅ | ✅ | ✅ | ❌ | testimonials:write |
highlight:update |
✅ | ✅ | ✅ | ❌ | testimonials:write |
highlight:delete |
✅ | ✅ | ✅ | ❌ | testimonials:write |
ai:suggest |
✅ | ✅ | ✅ | ❌ | testimonials:write |
ai:accept |
✅ | ✅ | ✅ | ❌ | testimonials:write |
media:read |
✅ | ✅ | ✅ | ✅ | testimonials:read |
media:purge |
✅ | ✅ | ❌ | ❌ | testimonials:write |
social_card:read |
✅ | ✅ | ✅ | ✅ | testimonials:read |
social_card:generate |
✅ | ✅ | ✅ | ❌ | testimonials:write |
social_card:delete |
✅ | ✅ | ✅ | ❌ | testimonials:write |
widget:read |
✅ | ✅ | ✅ | ✅ | widgets:read |
widget:create |
✅ | ✅ | ✅ | ❌ | widgets:write |
widget:update |
✅ | ✅ | ✅ | ❌ | widgets:write |
widget:delete |
✅ | ✅ | ✅ | ❌ | widgets:write |
showcase:read |
✅ | ✅ | ✅ | ✅ | widgets:read |
showcase:update |
✅ | ✅ | ✅ | ❌ | widgets:write |
showcase:publish |
✅ | ✅ | ✅ | ❌ | widgets:write |
domain:read |
✅ | ✅ | ✅ | ✅ | — |
domain:create |
✅ | ✅ | ❌ | ❌ | — |
domain:verify |
✅ | ✅ | ❌ | ❌ | — |
domain:set_primary |
✅ | ✅ | ❌ | ❌ | — |
domain:delete |
✅ | ✅ | ❌ | ❌ | — |
import:read |
✅ | ✅ | ✅ | ✅ | testimonials:read |
import:connect |
✅ | ✅ | ❌ | ❌ | — |
import:disconnect |
✅ | ✅ | ❌ | ❌ | — |
import:run |
✅ | ✅ | ✅ | ❌ | testimonials:write |
import:upload_csv |
✅ | ✅ | ✅ | ❌ | testimonials:write |
analytics:read |
✅ | ✅ | ✅ | ✅ | analytics:read |
analytics:export |
✅ | ✅ | ✅ | ❌ | analytics:read |
webhook:read |
✅ | ✅ | ❌ | ❌ | webhooks:manage |
webhook:create |
✅ | ✅ | ❌ | ❌ | webhooks:manage |
webhook:update |
✅ | ✅ | ❌ | ❌ | webhooks:manage |
webhook:delete |
✅ | ✅ | ❌ | ❌ | webhooks:manage |
webhook:replay |
✅ | ✅ | ❌ | ❌ | webhooks:manage |
integration:read |
✅ | ✅ | ✅ | ✅ | — |
integration:connect |
✅ | ✅ | ❌ | ❌ | — |
integration:disconnect |
✅ | ✅ | ❌ | ❌ | — |
api_key:read |
✅ | ✅ | ❌ | ❌ | — |
api_key:create |
✅ | ✅ | ❌ | ❌ | — |
api_key:revoke |
✅ | ✅ | ❌ | ❌ | — |
audit:read |
✅ | ✅ | ❌ | ❌ | — |
consent:read |
✅ | ✅ | ✅ | ❌ | testimonials:read |
dsr:read |
✅ | ✅ | ❌ | ❌ | — |
dsr:create |
✅ | ✅ | ❌ | ❌ | — |
dsr:fulfil |
✅ | ✅ | ❌ | ❌ | — |
notification:read |
✅ | ✅ | ✅ | ✅ | — |
Notes that the table cannot express:
- An API key never exceeds its scopes and never exceeds what an Admin could do. A key is not a superuser and is never tied to the role of the user who created it.
- No key holds any
domain:permission — not evendomain:read— and none holdsintegration:connectorintegration:disconnect. That ceiling is absolute, and it is why those rows read—rather than naming a scope: a leaked key must not be able to repoint a customer's hostname or attach a third-party account. Section 6.6.4 states the full exclusion list once, and this column is derived from it rather than restating it. - A key may raise a flag but not clear one, and may not reply at all.
testimonial:flagis intestimonials:writebecause an automated safety check is exactly the caller that should be able to pull content off the air without waking anyone. Clearing a flag is a judgement call, and replying sends mail to a real respondent — both are excluded. - On the Free plan, all keys are read-only and limited to 30 requests/minute regardless of the scopes selected (Section 19).
- The Editor boundary is "may shape collection and curation, may not destroy or expose."
That principle decides the grants a reader is most likely to query. An Editor may delete a
tag and delete a contact — both are curation of the workspace's own organising metadata, and
both are recoverable from the audit trail. An Editor may not delete a testimonial: that
destroys someone else's words, starts a 30-day clock on their video, and consumes a lifetime
quota slot that deletion never returns.
testimonial:restorefollowstestimonial:deletefor the same reason — an Editor who cannot put a testimonial in the bin has no occasion to take one out. - Viewers can read everything readable and can do nothing else — not even export analytics. Viewer is a genuine read-only role, not a limited editor.
29.7 Third-Party Service Reference #
| Service | Used for | Assumed tier | Failure mode when unavailable | Documented alternative |
|---|---|---|---|---|
| Managed PostgreSQL | All application data | Production tier with standby and one read replica, PITR enabled | Hard dependency. Writes fail with DEPENDENCY_UNAVAILABLE; the CDN keeps serving widgets from cache (24.10.2) |
Any managed PostgreSQL of the same major line; the schema uses no proprietary features beyond standard extensions |
| Managed Valkey | Cache, queues, rate limits, quota counters, locks | Production tier with replica | Degraded: jobs pause, dashboard slows, submissions still accepted, widgets unaffected (R12) | Any Redis-protocol-compatible managed service |
| Object storage (S3-compatible) | Originals, posters, captions, cards, exports, widget bundles | Standard tier, versioning and cross-region replication on the media bucket | Uploads fail; already-published media still served from the CDN | Either of the two S3-compatible providers named in Section 3; the code uses only the S3 API |
| Video host and transcoder | Ingest, transcode, adaptive delivery, thumbnails | Pay-as-you-go with signed playback available | Uploads pause behind a feature flag; published video degrades to poster + text; processing resumes on recovery (R1) | Self-hosted transcode plus a CDN is possible because originals are retained, but it is a significant build — the abstraction boundary is MediaProvider |
| Transcription provider | Transcripts, captions, quote and clip source | Pay-as-you-go, nova-family model | Captions delayed; publishing is never blocked (R4) | The alternative provider named in Section 3, behind the same TranscriptionProvider interface and switchable by configuration |
| AI provider | Highlight, tag, alt-text, and headline suggestions | Standard API tier with a spend cap | Assistant shows unavailable; manual curation, which is always the default, is unaffected | The alternative provider named in Section 3, behind the AiProvider interface |
| CDN | Widget delivery, media derivatives, showcase caching, edge routing for custom domains | Global network with purge-by-tag, request coalescing, stale-if-error |
stale-if-error covers origin outages up to 24 h; a CDN outage itself is mitigated by a secondary configuration and a 300 s DNS TTL |
Secondary CDN configuration maintained in infrastructure code |
| Stripe | Subscriptions, checkout, portal, tax, invoicing | Standard, with Stripe Tax and Billing Portal | Plan changes and checkout unavailable; all existing service continues, including widgets | None assumed — billing is deliberately single-provider, and the isolation is that no serving path depends on it |
| Transactional email | Verification, invitations, notifications, alerts | Production sending domain with SPF, DKIM, and DMARC | Messages queue and retry for hours; nothing is lost | Any SMTP or API email provider behind the EmailProvider interface |
| SMS | Optional SMS invitations | Pay-as-you-go with a messaging service | SMS invitations pause; email invitations unaffected | Any SMS API behind the SmsProvider interface; SMS is optional throughout |
| Google Business Profile API | Google review import | Standard API access, per-workspace OAuth | Import unavailable; existing imported reviews unaffected | CSV export upload |
| G2 syndication | G2 review import where entitled | Contract-gated | Import unavailable | Customer-supplied export upload, which is a first-class flow |
| Gartner Digital Markets feed | Capterra review import where entitled | Contract-gated | Import unavailable | Customer-supplied export upload |
| X API v2 | Import of tweets as testimonials | Paid tier sufficient for tweet lookup | Import unavailable | Manual paste of tweet content with attribution, subject to the customer's rights |
| ACME certificate authority | TLS for customer custom domains | Free tier, production directory | Existing certificates keep working; new issuance and renewal pause (R8) | The fallback certificate authority configured alongside the primary |
| DNS provider (platform zones) | Platform hostnames and the challenge infrastructure | Managed authoritative DNS with API access | Existing resolution continues; changes pause | Any managed DNS with an API |
| Error tracking, metrics, tracing, logging | Observability | Standard tiers with the documented retentions | Observability degrades, the product does not. No request path blocks on a telemetry write | Self-hosted OpenTelemetry collector plus any compatible backend |
| Status page provider | Customer communication during incidents | Any tier, on independent infrastructure | If it is down during our incident, communication falls back to email and social channels | A static page on the secondary CDN, pre-built and ready to publish |
29.8 Deferred Capabilities (post-v1) #
Everything here is deliberately out of scope for v1. Each entry states why it is deferred and what already exists that makes it additive rather than a rewrite. Nothing in this list requires a change to the tenancy model, the lifecycle, or the embed contract.
| # | Capability | Why deferred | What already makes it additive |
|---|---|---|---|
| 1 | Agency / white-label multi-workspace management — one operator managing many client workspaces under their own brand, with cross-workspace dashboards and consolidated billing | v1's job is to make a single workspace excellent. Agency features multiply every surface (permissions, billing, navigation, branding) before that is proven | The largest structural piece is already done: workspace_members is a genuine many-to-many join, every table carries workspace_id, RLS is per-workspace, and authorization is a single can() function taking the actor and the resource. Enabling it needs a workspace switcher, a parent-account row that groups workspaces, a billing-ownership rule, and white-label branding on the dashboard shell — no schema migration of existing relationships, and no change to the widget, the payload, or the embed contract |
| 2 | One user in many workspaces, surfaced in the UI | v1 pins one active workspace to keep navigation and mental models simple | The join table already models it; the session already resolves a workspace; only a switcher and a "last active workspace" preference are missing |
| 3 | SSO / SAML / SCIM provisioning | Enterprise demand is unproven at launch and each protocol is a multi-week commitment | Auth is behind one library with a pluggable provider model; roles are already a fixed, small set that maps cleanly onto directory groups |
| 4 | Custom roles and granular permissions | Four roles cover the observed shapes of small marketing teams; custom roles need a permission-editing UI and a migration story | Permissions are already individual resource:action strings evaluated by one function; a role becomes a set of strings rather than an enum, and the matrix in 29.6 is already the data model |
| 5 | Multi-speaker (diarized) transcripts | v1 assumes one speaker per testimonial, which is true of nearly every testimonial | The transcript structure has a segment layer; adding a speaker id per segment is additive, and the caption generator already iterates segments |
| 6 | Additional transcription languages beyond the launch five | Quality assurance per language is real work, and the editor must be verified per script direction | Language is a field on the transcript, the provider supports more, and the VTT generator is language-agnostic; adding a language is configuration plus a test pass |
| 7 | AI-assisted translation of testimonials | It collides with the select-never-rewrite boundary and needs a distinct consent conversation with the respondent | The transcript is the single source of truth and can carry alternate translated tracks as clearly-labelled derived content, which is a different data class from the verbatim original |
| 8 | Case-study builder — long-form pages composed from several testimonials | It is a content-management product hiding behind a testimonial product | Showcase pages, the social card renderer, and the tag model already provide composition primitives |
| 9 | A/B testing of widget variants | Requires a statistics layer and an experiment UI that only matter at higher volume | The analytics rollups already key on widget id, and widget configuration is already a row — a variant is another row plus an assignment rule at the edge |
| 10 | Conversion attribution beyond outbound clicks | Cross-domain attribution requires identifiers we have deliberately refused to store | The click event already exists; anything beyond it must be reconciled with the cookie-free stance in Section 17, which is a product decision and not a technical gap |
| 11 | Native mobile SDKs (iOS, Android) | The web recorder covers mobile browsers, which is where respondents actually are | The submission API, upload session model, and consent capture are all HTTP and already used by a non-React client (the widget), so a native client is a new consumer of existing contracts |
| 12 | In-app widget SDKs for React, Vue, and Svelte | The script tag works everywhere and adding framework packages multiplies the release surface | The oEmbed endpoint and the documented data API mean a framework wrapper is a thin published package with no server changes |
| 13 | Self-hosted / on-premise deployment | Support burden and the CDN dependency make it a different product | Everything is containerised and configured by environment variables, with no proprietary managed-service APIs beyond the documented providers |
| 14 | EU-region production deployment | Launch demand is US-first; running two regions doubles operational surface | The full path is specified in Section 26.11 and requires one region column on workspaces and a global slug-to-region lookup — infrastructure is already code, and no cross-region joins exist by design |
| 15 | Additional import sources (Trustpilot, Yelp, App Store, Amazon, LinkedIn recommendations) | Each is a separate legal and technical integration, several with terms that forbid what customers assume | The import pipeline is source-pluggable: normalisation, dedupe, rating mapping, attribution, and the moderation landing are all shared, so a new source is an adapter |
| 16 | Scheduled publishing and expiry of testimonials | Adds a time dimension to the lifecycle that complicates the state machine and the payload cache | The lifecycle already has explicit timestamps and the payload is already purged by tag; scheduling is a job plus two nullable columns |
| 17 | Approval workflows with multiple reviewers | Small teams do not need it and it complicates the audit story | The audit trail already records every actor and transition, so a second approval step is an additional status guard rather than new infrastructure |
| 18 | Respondent accounts (a person managing all their testimonials across companies) | It inverts the product's trust model and creates a second, cross-tenant identity graph | The respondent token already provides per-testimonial self-service (view, download, withdraw, erase) without an account, which covers the actual rights involved |
| 19 | Incentive and reward management (gift cards for testimonials) | Regulatory disclosure requirements vary by jurisdiction and belong to the customer, not to us | Custom fields and the consent model can already record that an incentive was disclosed |
| 20 | Advanced widget theming via customer CSS | Arbitrary CSS injection into the shadow root would put the performance and isolation guarantees at the customer's mercy | The documented option set covers theme, scale, radius, accent, and layout; a future safe mechanism would be a constrained token set, not raw CSS |
| 21 | Public plan-level API rate-limit customisation | Needs a commercial framework before a technical one | Limits are already per-workspace token buckets keyed by plan, so raising one is a configuration row |
| 22 | Data warehouse export / reverse ETL | Few customers at launch scale have a warehouse to export into | The rollups and the public API already expose everything a scheduled export would move, and webhooks already stream the events |
29.9 Alert and Runbook Index #
Alert ids (A#) and runbook ids (R#) are referenced from the job catalogue in 29.5, the
third-party reference in 29.7, the environment reference in 29.2, and from Sections 22 and 23.
This index exists so that a reader who has Appendix 29 open can resolve any of them without
searching the body. Alerts are defined in Section 24.7 and runbooks in Section 24.8; those are
the owners, and the one-line titles below are pointers, not second definitions.
Alerts. Thirty, A1–A30. "First responder" repeats 24.7's routing so the index is usable on its own.
| Id | Title | Severity | First responder |
|---|---|---|---|
| A1 | Widget data API 5xx rate | SEV-1 | Pages primary on-call |
| A2 | Widget data API latency | SEV-2 | Pages primary |
| A3 | Widget data cache hit ratio | SEV-3 | Tickets, notifies the on-call channel |
| A4 | CDN origin error spike | SEV-2 | Pages primary |
| A5 | Upload part failure rate | SEV-2 | Pages primary |
| A6 | Transcode backlog | SEV-2 | Pages primary |
| A7 | Transcription backlog | SEV-3 | Tickets + channel |
| A8 | Webhook auto-disable rate | SEV-3 | Tickets + channel |
| A9 | Webhook delivery success | SEV-2 | Pages primary |
| A10 | TLS expiry — warning (< 14 days) | SEV-3 | Tickets |
| A11 | TLS expiry — critical (< 7 days, or 3 failed renewals) | SEV-1 | Pages primary |
| A12 | Media purge failing | SEV-2 | Pages primary + the privacy owner |
| A13 | Stripe webhook lag | SEV-2 | Pages primary |
| A14 | Stripe webhook failures | SEV-2 | Pages primary |
| A15 | Database connection saturation | SEV-2 | Pages primary |
| A16 | Database replication lag | SEV-2 | Pages primary |
| A17 | Database disk | SEV-2 | Pages primary |
| A18 | Queue depth | SEV-2 | Pages primary |
| A19 | Dead-letter arrivals | SEV-3 | Tickets |
| A20 | Quota counter drift | SEV-2 | Pages primary |
| A21 | Domain verification success rate | SEV-3 | Tickets |
| A22 | Auth failures | SEV-2 | Pages primary + the security owner |
| A23 | Widget render error rate | SEV-1 if release-correlated, else SEV-2 | Pages primary |
| A24 | Synthetic widget check | SEV-1 | Pages primary |
| A25 | Worker heartbeat | SEV-1 | Pages primary |
| A26 | Valkey unavailable | SEV-2 | Pages primary |
| A27 | Email/SMS delivery | SEV-3 | Tickets |
| A28 | AI verbatim rejection ratio | SEV-3 | Tickets + the product owner |
| A29 | Storage growth anomaly | SEV-3 | Tickets |
| A30 | Certificate/secret expiry (non-TLS) | SEV-3 | Tickets |
Runbooks. Eighteen, R1–R18. "Reached from" names the alerts that route to each one; a runbook with no alert is entered from a support conversation or a human report, which is worth knowing before an incident rather than during one.
| Id | Title | Reached from | Subsystem owner |
|---|---|---|---|
| R1 | Video provider outage | A6 | Section 9 |
| R2 | Upload failures that are not a provider outage | A5 | Section 9.6 |
| R3 | A stuck transcode | A6 | Section 9.7 |
| R4 | Transcription provider outage or backlog | A7 | Section 10 |
| R5 | "My widget disappeared" | A1, A2, A24 | Section 13 |
| R6 | "Your widget broke my site" | Support report | Section 13.4 |
| R7 | A custom domain stuck in verifying |
A21 | Section 14.3 |
| R8 | Certificate renewal failure | A10, A11 | Section 14.5 |
| R9 | A webhook endpoint flooding or failing | A8, A9 | Section 18.5 |
| R10 | Stripe webhook backlog | A13, A14 | Section 19.7 |
| R11 | A media purge that will not complete | A12 | Section 22.6 |
| R12 | Valkey loss | A26 | Section 26.4 |
| R13 | Database failover | A15, A16 | Section 26.3 |
| R14 | A GDPR erasure that must be proven complete | A12, or a data-subject deadline | Section 22.6 |
| R15 | A bad widget release, rolled back in minutes | A23 | Section 26.9 |
| R16 | Quota counter drift | A20 | Section 19.5 |
| R17 | Suspected data breach | A22, or any credible report | Section 22.20 |
| R18 | A poison job wedging a queue | A18, A19, A25 | Section 26.2.2 |
Two properties of this index are worth stating because they are enforced rather than hoped for:
every alert in Section 24.7 whose first action names a runbook appears in the "Reached from"
column of that runbook, and every runbook id here has a counterpart in docs/RUNBOOKS.md. The
CI check described in Section 24.8 fails the build if either stops being true.
Generated at GenerateSpecs.com — one idea in, one buildable spec out.
Licensed under CC BY 4.0. Use it for anything — just credit GenerateSpecs.com.