LinkHub — Bio Pages, Branded Links & Dynamic QR Codes
One workspace combining link-in-bio pages, branded short links, and dynamic QR codes with unified analytics.
28,109 lines391,301 words33 sectionsgenerated in 2h 4mAug 19, 2026
LinkHub — Bio Pages, Branded Links & Dynamic QR Codes #
Product Specification — Final
Overview #
LinkHub is a single workspace that unifies three things creators and marketing teams currently buy separately: a link-in-bio page, branded short links on their own domain, and dynamic QR codes whose destination can be changed after the code has been printed. The product's thesis is that these are not three products — they are three delivery surfaces for the same underlying object (a destination, owned by a workspace, measured by one analytics pipeline). Every tool that treats them separately fragments the numbers, the branding and the team permissions. LinkHub does not.
The audience is four overlapping groups. Independent creators need one page that holds their whole presence and analytics they can actually read. Marketing teams need branded links, UTM discipline, scheduling and A/B testing without a growth engineer. Agencies need many client brands in one login, with per-client workspaces, per-resource access grants and clean separation of analytics. Small businesses need a QR code they can print on a menu, a box or a shopfront window and repoint next season without reprinting. The plan tiers in Section 22 are drawn along those lines: Free proves the product, Pro serves the individual professional, Business serves the team and the agency.
Three surfaces are unified into one workspace. Bio pages are server-rendered public pages built from a block catalog (Section 10) in a drag-free-if-you-need-it editor (Section 9). Branded short links (Section 12) resolve on a LinkHub-owned default host or on the customer's own verified domain (Section 13). Dynamic QR codes (Section 14) are short links with a rendered, print-grade symbol and a permanence guarantee attached. All three write into the same analytics ingestion pipeline (Section 17) and are read back through the same dashboards, reports and exports (Section 18). One audience definition, one attribution model, one export.
The architectural spine is deliberately small and deliberately boring. Four deployables: a Next.js application serving the marketing site, the authenticated dashboard and the server-rendered public bio pages; a Hono redirect resolver that carries the highest traffic and the tightest latency budget; a separate Hono service for the public REST API so that API traffic can be scaled and rate-limited independently of the human dashboard; and a BullMQ worker fleet for everything asynchronous — analytics ingest, rollups, webhook delivery, ESP sync, domain verification, TLS renewal, QR rendering, exports and retention purges. PostgreSQL is the system of record for every durable fact in the product. Redis is a cache and a buffer — resolved-redirect cache, negative cache, rate-limit counters, session cache, and the click event stream — and is never authoritative. Section 4 defines the stack and its versions; Section 6 defines the schema; Sections 11 and 17 define the two hot paths.
The public delivery path is optimised to a degree that will look excessive until you read the budgets in Section 11. A bio page must reach first contentful paint in under 0.8 s and largest contentful paint in under 1.2 s on a mid-range Android handset over a throttled 4G connection, in under 40 KB of gzipped HTML, with zero bytes of render-blocking JavaScript. A redirect must complete server-side processing in under 50 ms at the 95th percentile. These are not aspirations printed in a design doc; they are enforced in continuous integration by a Lighthouse budget gate and a bundle-size gate that fail the pull request, and by load tests that fail the build if the redirect path misses its target under sustained load.
Three product promises are non-negotiable and shape decisions throughout the document. First: a printed QR code never stops resolving. Not on downgrade, not on non-payment, not on workspace deletion, not on account deletion. The slug reservation is permanent, resolution never returns 404 or 410, and the fallback chain in Section 14 always terminates in something a human can read. Physical media cannot be recalled, so the software must behave as though it never can be. Second: the public page is fast on a mid-range phone over 4G, and it works with JavaScript disabled. Every link on a bio page is a server-rendered anchor element. JavaScript is progressive enhancement — the analytics beacon, media embeds, the share sheet — and its absence degrades nothing that matters. Third: analytics are cookie-free by default. Visitor identity is a salted, daily-rotating hash; the raw IP address is never written to any durable store; geographic resolution stops at country and region. No consent banner is required for first-party measurement, and the reasoning for that position is documented rather than assumed (Section 23).
Read the document in order the first time. Sections 1 through 5 establish the decisions, the stack and the conventions that everything else assumes. Section 6 is the schema and is the reference you will return to most. Sections 7 through 22 are the product itself, roughly in build order. Sections 23 through 27 are the cross-cutting concerns — security and privacy, accessibility, operations, testing, deployment — that must be built into features as they are written rather than retrofitted. Section 28 is the execution plan; Section 29 is written directly to the engineer or agent doing the building; Section 30 is the reference material you will look things up in. Each concern has exactly one canonical home. Where a topic appears in more than one section, one section owns it and the others cite it by number; Section 29.4 is the lookup table from concern to owning section.
Every decision required to begin building has been made. Plan limits, role permissions, error codes, retention windows, cache keys, redirect status codes, hashing parameters, rate limits, performance budgets, environment variables and rollout order are all specified with concrete values. Where a genuine choice existed and either option would have worked, one option has been picked and the rationale recorded in the decision log in Section 30.10. There are no open questions, no placeholders and nothing deferred to a later conversation. If you find yourself needing to ask, Section 29.5 tells you exactly how to resolve it and where to record what you decided.
Table of Contents #
- 1. Before You Start — Customization Decisions
- 2. Project Overview & Vision
- 3. Personas, Roles & Permission Model
- 4. Technology Stack & Architecture
- 5. Conventions & Standards
- 6. Data Model & Database Schema
- 7. Authentication, Sessions & Account Management
- 8. Workspaces, Members, Invitations & Audit Log
- 9. Bio Page Builder — Editor Experience
- 10. Bio Page Block Catalog
- 11. Public Delivery Path — Rendering & Performance
- 12. Branded Short Links
- 13. Custom Domains & TLS Provisioning
- 14. Dynamic QR Codes
- 15. UTM Builder, Scheduling, Expiry & Targeting Rules
- 16. A/B Testing
- 17. Analytics Ingestion Pipeline
- 18. Analytics Dashboards, Reporting & Export
- 19. Integrations & Pixels
- 20. Email Capture & Lead Management
- 21. Public REST API v1
- 22. Billing, Plans & Entitlements
- 23. Security, Privacy & Compliance
- 24. Accessibility
- 25. Observability, Operations & Runbooks
- 26. Testing Strategy & Quality Gates
- 27. Deployment, Environments & Configuration
- 28. Milestones & Execution Plan
- 29. Executor Instructions
- 30. Appendices
1. Before You Start — Customization Decisions #
Every decision in this section already has a working default, and those defaults are internally consistent with one another and with the rest of this document. A team can clone the repository, follow Section 27, and build the entire product without changing a single value below. This section exists because some of these values are commercial or organisational choices that a specific team may want to make differently, and it is cheaper to change them deliberately on day one than to discover them scattered across the codebase in month three. Treat the table as a pre-build checklist: read it, change nothing or change a few rows on purpose, record what you changed in DECISIONS.md at the repository root, and start building.
1.1 The decision table #
Blast radius is rated as Low (config or token change, no code edits), Medium (config plus localised code changes in one or two areas), or High (schema, contract, or architectural consequences that ripple across sections).
Every "Lives in" cell names the section that owns the value. If two sections mention a value, the one named here is the one to edit; the other refers to it.
| # | Decision | LinkHub default | Lives in | Blast radius if changed |
|---|---|---|---|---|
| 1 | Product / brand name | LinkHub |
Section 5.2 naming, Section 27.4 environment configuration | Medium. The name appears in the LH_ env-var prefix, the lh_session / lh_consent / lh_ab cookie names, the X-LinkHub-Signature webhook header, the DNS challenge record _linkhub-challenge, transactional email copy, and the free-plan public badge. All are constants in packages/core; changing them after launch invalidates live cookies, live webhook signatures and in-flight domain verifications. |
| 2 | Primary application domain | linkhub.app, with app.linkhub.app (dashboard), api.linkhub.app (public API) |
Section 27.4.10 | Low. Single env var per deployable plus DNS records and TLS certificates. Change before the first production deploy and it costs nothing. |
| 3 | Default redirect host | go.linkhub.app |
Sections 12.2.2, 27.4.10 | Medium. Every short link created without a custom domain is stored against this host. Changing it after links exist requires a data migration and permanent redirects from the old host, which conflicts with the 302-only rule in Section 12.1.5 — plan a host alias instead of a rename. |
| 4 | Shared vanity short domain | lnkhb.co (offered as an alternative to the default redirect host) |
Section 27.4.10 | Low before launch. Registering a different short domain is a DNS and certificate task; the resolver treats all hosts uniformly (Section 13.1.3). |
| 5 | Customer CNAME target | cname.linkhub.app |
Section 13.2.3 | High after launch. Every customer custom domain points at this name. Changing it means asking every customer to edit DNS. Choose it once and never move it; use a CNAME chain internally if you need to re-home the edge. |
| 6 | Theme defaults (colour, radius, font stack) | Accent #3B5BFF, surface #FFFFFF, text #101322, radius 12px, system font stack, light theme default with automatic prefers-color-scheme dark variant |
Sections 9.6, 5.11 | Low. Design tokens in packages/ui. The contrast gate in Section 24.5 applies to any replacement palette: the default accent on white passes 4.5:1 and any substitute must too. |
| 7 | Plan prices | Free $0; Pro $12/mo or $108/yr; Business $39/mo or $348/yr, USD |
Section 22.1.1 | Low before launch, Medium after. Prices live in Stripe as the source of truth for amounts and in a local plan catalogue for display. Existing subscriptions must be grandfathered or migrated with notice; the entitlement engine does not care about price. |
| 8 | Plan limits (pages, links, QR codes, seats, domains) | Per the entitlement table in Section 22.1.2 | Section 22.1.2, enforced via Section 22.2 | Medium. Limits are data in one entitlement map, not scattered conditionals, so raising a limit is a one-line change. Lowering a limit for existing customers triggers the guided downgrade flow in Section 22.5 and must be handled as a commercial event, not a config edit. |
| 9 | Free-plan branding on public surfaces | Badge shown on bio pages and on branded fallback landing pages; removed on Pro and Business | Sections 22.1.2 (the branding_removed entitlement), 22.13.6 |
Low. A single entitlement flag consumed by the public renderer and the redirect payload. |
| 10 | Supported OAuth providers | Google only at launch | Section 7.7 | Low to add a provider (the auth layer is provider-agnostic), Medium to remove Google after launch because existing accounts need a password-set migration path. Apple and Microsoft are named roadmap in Section 2.7. Enterprise SSO/SAML is explicitly out of scope. |
| 11 | Second factor policy | TOTP optional for every user; an Owner on Business may enforce it workspace-wide; 10 single-use recovery codes | Section 7.9 | Low. Enforcement is a workspace setting. Making 2FA mandatory for all plans is a one-flag change but will suppress signup conversion; it is a product decision, not a technical one. |
| 12 | Geo database provider | Self-hosted MaxMind GeoLite2 Country + City database, used for country and region only, refreshed weekly by a scheduled job, bundled into the edge image with a runtime override path | Sections 17.4, 4.9.4 | Medium. The lookup is behind a GeoResolver interface with one method (resolve(ip) -> { country_code, region_code }), so swapping to IP2Location, DB-IP or a CDN-provided geo header is an adapter change. The privacy commitment in Section 23.12.1 — country and region only, never city, never coordinates — is not customisable and constrains any replacement. |
| 13 | Transactional email provider | Resend, accessed through a provider-agnostic EmailSender interface, with an SMTP adapter as the drop-in fallback and a local file-writing adapter for development |
Sections 7.12, 27.4.8 | Low. One adapter plus API-key config. Templates are provider-neutral MJML-compiled HTML with a plain-text alternative, stored in the repository. |
| 14 | Object storage | S3-compatible; AWS S3 in the reference deployment, MinIO for local, any S3-compatible service in production |
Sections 4.9.5, 27.4.6 | Low. Accessed through the S3 API only; no vendor-specific features are used. Bucket layout and lifecycle rules are in Section 27.4.6. |
| 15 | Payment processor | Stripe (Checkout for acquisition, Billing Portal for self-service, webhooks for state) | Sections 22.3, 22.10 | High. Billing state transitions, proration, tax handling, dunning and the past-due behaviour in Section 22.7 are written against Stripe's object model. Replacing it is a rewrite of Section 22's integration layer, though the entitlement engine it feeds is processor-agnostic by design. |
| 16 | Default locale and timezone | UI locale en-US; all storage and computation in UTC; analytics dashboards render in the workspace's configured display timezone, which defaults to UTC and is user-selectable |
Sections 5.12, 18.3.2 | Low. All user-facing strings are externalised from day one (Section 5.12), so adding a locale is a translation task, not a refactor. Changing the default storage timezone away from UTC is not supported and would break the daily partitioning scheme in Section 17.8.4. |
| 17 | Marketing site in scope | Yes — a minimal marketing surface (home, pricing, legal, changelog, one docs index) ships inside apps/web under statically generated routes |
Sections 4.2, 4.3 | Low. It is four static routes plus the pricing table fed from the same plan catalogue as billing. Dropping it means pointing the apex at an external site and deleting a route group; no other deployable is affected. |
| 18 | Application log retention | 30 days of full structured logs in the hot log store; 13 months of derived metrics and audit-relevant counters; audit log retention follows the plan table in Section 22.1.2, not this row, and QR destination-change entries are exempt from purge entirely (Sections 6, 8.9) | Section 25.2.5 | Low. Retention is a log-platform setting plus one nightly purge job. Increasing it raises cost linearly and increases the surface of the redaction rules in Section 5.6.3 — those rules are what make long retention safe. |
| 19 | Analytics data retention | Raw events: 30 days Free / 90 days Pro / 24 months Business. Rollups: 30 days / 365 days / indefinite | Section 17.10 | Medium. Retention drives partition count, storage cost and the capacity arithmetic in Section 4.10. The purge worker drops whole partitions, so extending Free retention beyond 30 days is safe but changes the storage model's inputs. |
| 20 | Reference deployment platform | Vendor-neutral: OCI containers on a container platform of the team's choice, managed PostgreSQL, managed Redis, S3-compatible storage, a CDN in front of the public path | Section 27.2 | Low. Nothing in the code targets a specific cloud. The only platform-shaped requirements are: blue/green deploys for the redirect resolver, a way to run scheduled jobs, and the ability to terminate TLS for customer-supplied domains (Section 13.7). |
| 21 | Redirect status code | 302 Found with Cache-Control: private, no-store |
Section 12.1.5 | Locked — do not change. Destinations are editable at any time; a cached permanent redirect is a product defect that cannot be undone in a visitor's browser. 301 and 308 are never issued for short links or QR codes. The single 301 anywhere in the product is the HTTP→HTTPS scheme upgrade to the identical URL: that is a transport upgrade, not a destination redirect, and the never-301 rule in Section 12 governs destinations, whose targets are editable. |
| 22 | Malicious-destination screening | Google Safe Browsing Update API on create and on a weekly recheck, plus the SSRF and scheme allow-list checks in Section 23.6 | Section 23.6 | Low to substitute a different reputation feed (one adapter, Section 23.6.5). Do not remove screening: the interstitial in Section 23.6.6 and the abuse-report path in Section 23.6.8 are what keep the redirect host off blocklists. |
| 23 | Public API rate limits | Pro: 120 req/min per key. Business: 600 req/min per key, each with the burst bucket defined in Section 21.7.2 | Section 21.7 | Low. Values are entitlement data. Section 21.7 is the authoritative table and every other section reproduces or references it. Raising the limits changes the capacity arithmetic in Section 4.10 for apps/api only; the redirect path has its own independent per-IP limit. |
| 24 | Session lifetime | lh_session cookie, 30-day rolling window, 90-day absolute cap, opaque 256-bit token stored hashed |
Section 7.8 | Low. Two constants. Shortening it increases login friction; lengthening it beyond the absolute cap requires a reconsideration of the step-up re-authentication rules that govern billing and destructive actions (Section 7.8, applied by condition C5 in Section 3.3.1). |
| 25 | QR error-correction floor | Level M, automatically upgraded to H whenever a logo overlay, gradient or custom module shape is applied; never below M |
Section 14.5.2 | Locked — do not lower. The scannability validation suite in Section 14.5.5 assumes this floor. Raising the floor to Q or H universally is safe but increases symbol density and minimum print size. |
| 26 | Free trial on paid plans | 14 days, on Pro or Business, once per billing account, with a payment method collected up front and not charged during the trial | Section 22.3.3 | Medium. Trial length is one constant plus the Stripe subscription parameter. Removing the card requirement is the change with real consequences: it is the single most effective filter against automated trial farming, and dropping it moves the abuse burden onto the controls in Section 22.13. Lengthening the trial delays the first charge and therefore the first dunning cycle in Section 22.7. |
| 27 | QR public URL shape | Bare https://{host}/{slug} — no /q/ prefix, and QR slugs share one namespace with short-link slugs on the same host |
Sections 14.2.4, 27.4.10 | Locked after the first print run — effectively High. A printed symbol encodes fewer characters, so it carries fewer modules and stays scannable at a smaller physical size; and one shared namespace is exactly what lets the permanent reservation table protect a QR slug and the identical short-link slug together (Section 14). Changing the shape after codes are printed cannot be undone, because the artefacts cannot be recalled. |
1.2 How to change a default safely #
- Change the value in its single canonical location. Every row above names a section; each of those sections names one file or one configuration key. There are no duplicated constants — if you find yourself editing the same number twice, the second one is a bug.
- Record the change in
DECISIONS.mdat the repository root using the ADR format in Section 5.10.2: what you changed, why, and which row number in this table it corresponds to. - Re-run the quality gates in Section 26.13. Contrast changes are caught by the accessibility gate (Section 26.6), budget changes by the performance gate (Section 26.7), entitlement changes by the entitlement test suite (Section 26.5.1), and API-shape changes by the contract tests (Section 26.10).
- If the change touches a row marked High, write an ADR before the code change, not after.
1.3 Decisions that are not customizable #
The following are invariants, not defaults. They are load-bearing for correctness, legal posture or the product promise, and other sections are written assuming they hold. Changing one is a fork of the product, not a configuration change.
| Invariant | Where it is specified | Why it is locked |
|---|---|---|
| PostgreSQL is the system of record; Redis is cache and buffer only | Section 4.5 | Every recovery procedure assumes a full rebuild of Redis from PostgreSQL is possible and non-destructive. |
Short links and QR codes redirect with 302, never 301/308 |
Section 12.1.5 | Editable destinations plus a cached permanent redirect equals a broken link that no server-side fix can repair. The HTTP→HTTPS scheme upgrade is the one permitted 301; it targets the identical URL and is a transport upgrade, not a destination redirect. |
| A QR slug reservation is permanent, never recycled, never purged | Section 14.8.1 | Printed material outlives accounts. This is the core product promise and it survives downgrade, non-payment, workspace deletion and account deletion. |
| QR resolution never returns 404, 410 or 5xx | Section 14.8.3 | The four-rung fallback chain always terminates in a resolvable page. |
| Raw visitor IP addresses are never written to any durable store | Section 23.11.2 | It is the foundation of the cookie-free, consent-free legitimate-interest position for first-party analytics. |
| The raw user-agent string is never persisted | Section 23.12 | Only a parsed family label and a daily-salted hash are stored; the raw string exists in worker memory for parsing and is then discarded. |
| Geo resolution is country and region only | Section 23.12.1 | Same as above; city-level geo changes the privacy analysis. |
| Public bio pages work with JavaScript disabled for all navigation | Section 11.4 | The public path is the product's shop window; it must degrade to plain HTML. |
| WCAG 2.2 Level AA on all public surfaces and the dashboard | Section 24.1 | It is a shipped commitment with CI enforcement, not an aspiration. |
| Roles are per workspace, never global | Section 3.2 | A user in two workspaces must be able to be an Owner in one and a Viewer in the other. |
| The audit log is append-only | Section 8.9.2 | An editable audit log has no evidentiary value. |
2. Project Overview & Vision #
2.1 What LinkHub is #
LinkHub is a hosted platform where one workspace owns three linked surfaces: a bio page (a fast, public, mobile-first landing page addressed by handle or custom domain), branded short links (arbitrary destinations behind a short slug on a LinkHub host or the customer's own domain), and dynamic QR codes (printable symbols whose destination is editable after printing and whose resolution is permanent). All three emit into one analytics pipeline, so a workspace sees a single unified view of clicks, scans and page views across every surface, sliced by the same dimensions, filtered by the same date range, exported in the same format, and governed by the same per-workspace roles, per-resource grants and plan entitlements.
2.2 The problem #
The person who needs these three capabilities almost always needs all three, and today they buy them separately.
A typical creator or small marketing team runs a link-in-bio tool for the profile page, a URL shortener for campaign links, and a QR generator for print and packaging. The consequences are concrete:
- Three analytics silos. The bio page tool knows page views. The shortener knows clicks. The QR tool knows scans. Nobody can answer "which channel drove traffic to the product page last month" without exporting three CSVs and joining them by hand — and the joins are unreliable because each tool defines a unique visitor differently.
- Three brand surfaces to keep consistent. A custom domain configured in one tool does not help the other two. Colours, logo and typography are re-entered three times and drift immediately.
- Three permission models, or none. Shorteners and QR generators typically ship a single login. An agency running eleven client brands ends up sharing one password, and there is no record of who changed a destination.
- A printed QR code that dies when billing lapses. This is the failure that removes trust from the entire category. A small business prints ten thousand boxes with a dynamic QR code on them, a card expires, the account downgrades or is cancelled, and every box becomes a 404. The physical artefact outlives the subscription by years, but the redirect does not.
LinkHub removes all four. The fourth one is removed absolutely: a QR slug is reserved permanently and resolution never fails, regardless of plan, payment state, workspace deletion or account deletion. The complete fallback chain is specified in Section 14.8.2 and its legal carve-out in Section 23.15.
2.3 Product principles #
Each principle below has a direct, testable consequence for how the system is built. They are ordered by how often they will be invoked when resolving a design argument.
2.3.1 A printed code is a promise #
Consequence for the build. QR slug reservations live in a table with no soft-delete column and no purge path. Resolution has a four-rung fallback chain that cannot terminate in an error. The QR fallback chain carries a 95% line-coverage gate (Section 26.12). Billing state may change branding and may disable editing; it may never stop resolution.
2.3.2 The public path is the product's shop window #
Consequence for the build. The redirect resolver is its own deployable (apps/edge) with its own latency budget of p95 < 50 ms server-side, its own scaling policy, its own blue/green deployment strategy, and no dependency on the dashboard being up. Public bio pages have a hard performance budget enforced in CI: ≤ 40 KB gzipped HTML, ≤ 14 KB critical inline CSS, zero blocking JavaScript, LCP < 1.2 s on a mid-range Android device over 4G. Section 11.2 owns these budgets and Section 26.7 owns their enforcement.
2.3.3 Analytics must not be able to break delivery #
Consequence for the build. Event capture is fire-and-forget into a Redis Stream. The redirect response is never blocked on analytics. If the stream write fails, the redirect still succeeds and a counter increments for alerting. There is no code path in which an analytics failure produces a user-visible error. Section 17.2 specifies the capture contract.
2.3.4 Privacy is a design constraint, not a settings page #
Consequence for the build. There is no visitor cookie by default. Visitor identity is a salted rotating hash whose salt changes daily and is never logged. Raw IP addresses exist only in process memory long enough to derive that hash and a country/region lookup, and are then discarded — they are never written to a log line, a database column, or an object in storage. The raw user-agent string is treated the same way: parsed in memory, reduced to a family label and a daily-salted hash, then discarded. The consent banner only appears when the workspace has enabled a third-party pixel or the visitor is in a gated region. Section 23.12 owns the model and its DPIA summary.
2.3.5 Every capability is governed by one permission model #
Consequence for the build. There is exactly one permission matrix (Section 3.3) and exactly one evaluation algorithm (Section 3.5). No feature ships with a bespoke access check. Adding an action to the product means adding a row to the matrix, and the matrix is executable: the entitlement and permission tests enumerate it directly, and carry a 95% line-coverage gate (Section 26.12).
2.3.6 Defaults are decisions #
Consequence for the build. No configuration flag exists without a shipped default that produces a working, safe, accessible result. A workspace that changes nothing gets a page that passes contrast, a QR code that scans, links that resolve, and analytics that are lawful in the EEA. Where a user can make a choice that breaks something — a low-contrast theme, a logo overlay that defeats the decoder — the system blocks it and offers a one-click correction rather than silently shipping a broken artefact.
2.3.7 The dashboard is for editing, not for delivering #
Consequence for the build. Nothing on the public path reads from the dashboard's session store, its query layer, or its rendering pipeline. A total outage of the authenticated dashboard leaves every bio page, short link and QR code resolving normally. The degradation ladder in Section 4.9 is written against this separation.
2.4 Target personas at a glance #
Full detail, including failure modes and device context, is in Section 3.1.
| Persona | Primary surface | Plan fit | Defining need | The thing that loses them |
|---|---|---|---|---|
| Independent creator | Bio page | Free → Pro | One page that looks like their brand and loads instantly on a phone | Any setup step that takes longer than five minutes, or a page that looks generic |
| In-house marketer | Branded short links + QR | Pro → Business | Campaign links with consistent UTMs on the company domain, and per-campaign attribution | Having to reconcile three analytics exports to write one report |
| Agency operator | All three, multiplied | Business | Isolated client workspaces, scoped team access, per-client domains and reporting | Any model where a junior contractor can see or edit another client's assets |
| Small business owner | Dynamic QR | Free → Pro | A code on packaging or signage that they can re-point later, forever | Discovering that a printed code stopped working |
2.5 Primary user journeys #
Each journey is written as the surfaces touched in order. "Surface" means the deployable and route group that serves the step.
2.5.1 Creator publishes a first bio page #
| # | Step | Surface |
|---|---|---|
| 1 | Lands on the pricing or home page from a social post | apps/web, marketing routes |
| 2 | Signs up with Google OAuth, or email plus password | apps/web, auth routes (Section 7.2) |
| 3 | Receives the verification email; verification is required before anything public can be published (Section 7.4) | Email, then apps/web auth routes |
| 4 | A personal workspace is created automatically on first sign-in, with the user as Owner and a workspace slug derived from their email local part (Section 8.2.1) | apps/web onboarding |
| 5 | Chooses a handle; availability is checked live against the reserved-word and confusable blocklists in Section 9.2.3 | apps/web onboarding |
| 6 | Picks a starter template; theme tokens are pre-filled and already pass the 4.5:1 contrast gate | apps/web builder (Section 9.10.3) |
| 7 | Adds a profile block, three link blocks and a social-icons block by drag or by keyboard; the keyboard alternative to dragging is mandatory (Section 9.3.3) | apps/web builder (Section 9.3.4) |
| 8 | Uses live preview at a phone viewport; preview renders through the same component tree as production | apps/web builder (Section 9.8) |
| 9 | Publishes. The page becomes available at handle.linkhub.app-style routing per Section 11.3, the render cache is warmed, and a bio_page.published audit entry is written |
apps/web builder → apps/worker |
| 10 | Shares the URL; first visitors are captured as page views by the ingestion pipeline (Section 17.2) | Public renderer → apps/worker |
| 11 | Returns the next morning to a dashboard showing views, clicks per link, top countries and referrers for the last 7 days | apps/web analytics (Section 18.1.2) |
2.5.2 Marketer runs a branded campaign with UTMs #
| # | Step | Surface |
|---|---|---|
| 1 | Signs in and switches to the company workspace via the workspace switcher | apps/web dashboard |
| 2 | Adds the custom domain go.acme.com, copies the CNAME and the _linkhub-challenge TXT record shown with a live "what we currently see" diagnostic |
apps/web domains (Section 13.2.3) |
| 3 | Domain moves pending_dns → verifying → provisioning_tls → active while the verification worker polls; the UI shows each step live |
apps/worker domain-verify, tls-renew |
| 4 | Creates a link on go.acme.com with slug spring-sale, destination the campaign landing page |
apps/web links (Section 12.2) |
| 5 | Opens the UTM builder, sets utm_source, utm_medium, utm_campaign from workspace presets; the composed destination is previewed in full before saving |
apps/web (Section 15.1.3) |
| 6 | Sets a schedule: live from campaign launch, expiring at campaign end with a defined post-expiry destination rather than a 404 | apps/web (Section 15.3) |
| 7 | Adds a device targeting rule sending iOS traffic to the App Store and everything else to the web landing page | apps/web (Section 15.5) |
| 8 | Duplicates the link four times for four channels, each with a different utm_source, using bulk create |
apps/web links, bulk operations (Section 12.5) |
| 9 | Campaign runs. Every click is resolved by the edge resolver within the p95 50 ms budget and pushed fire-and-forget to the ingest stream | apps/edge → Redis Stream → apps/worker |
| 10 | Reviews the campaign dashboard grouped by utm_source, compares device split, and exports a CSV for the monthly report |
apps/web analytics (Section 18.4.6), apps/worker export-generate |
| 11 | After the campaign, changes the destination on the printed collateral's link without reprinting anything; the change is written to the audit log with before and after values | apps/web → apps/worker cache refresh, Section 8.9 |
2.5.3 Agency onboards a multi-brand client roster #
| # | Step | Surface |
|---|---|---|
| 1 | Owner upgrades the agency account to Business, unlocking 10 workspaces, 25 seats, per-resource grants and 5 custom domains (Section 22.1.2) | apps/web billing (Section 22.4) |
| 2 | Creates one workspace per client brand, each with its own slug, brand tokens and domain | apps/web workspaces (Section 8.2.1) |
| 3 | Invites the account manager as Admin in three client workspaces and as Viewer in a fourth; roles are per workspace, so one user carries four different roles | apps/web members (Section 8.5) |
| 4 | Invites a freelance designer as Editor in one workspace, then scopes them with a per-resource grant to exactly two bio pages; everything else in that workspace is invisible to them | apps/web members (Sections 3.4, 8.6) |
| 5 | Enforces TOTP two-factor for the workspace, which is available because the workspace is on Business | apps/web security (Section 7.9) |
| 6 | Connects each client's own custom domain; each domain is verified and provisioned independently | apps/worker domain-verify |
| 7 | Sets up per-client scheduled CSV exports and a Slack milestone alert on the agency's own channel | apps/web (Sections 18.10.5, 19.9) |
| 8 | Reviews the audit log per client workspace to answer "who changed this destination and when" | apps/web audit (Section 8.9) |
| 9 | Off-boards the freelancer: revoking the membership removes access to the granted resources immediately, and every resource the freelancer created stays with the workspace (Section 3.4.4) | apps/web members |
2.5.4 Small business prints a QR code on packaging and re-points it later #
| # | Step | Surface |
|---|---|---|
| 1 | Creates a dynamic QR code pointing at the current product page. Its public URL is the bare https://{host}/{slug} form (Section 14.2.4) |
apps/web QR (Section 14.2) |
| 2 | Applies brand colours and a logo overlay. Error correction auto-upgrades from M to H because a logo is present; the overlay is capped at 22% of symbol width and height |
apps/web QR editor (Sections 14.5.2, 14.5.3) |
| 3 | Render runs the three-condition scannability validation — clean, downscaled-and-upscaled, and degraded with contrast loss, noise and rotation. All three must decode | apps/worker qr-render (Section 14.5.5) |
| 4 | A colour choice fails the 4.5:1 contrast floor; the render is rejected with a specific message naming the styling choice that caused it and a one-click correction | apps/web QR editor |
| 5 | Downloads the corrected symbol as SVG for the printer, plus PNG at 300 and 600 DPI and a print-ready PDF, with the physical-size guidance table shown alongside | apps/web → object storage (Section 14.6) |
| 6 | Ten thousand boxes are printed. The physical artefact now has a service-life measured in years | — |
| 7 | Six months later the product page URL changes. The owner edits the destination; the symbol is untouched and the change is audited | apps/web → cache write-through |
| 8 | A year later the card on file expires and the account goes past due. Scans continue to resolve; the page may gain LinkHub branding, and editing is disabled once the grace period reaches read-only | apps/edge (Sections 14.8.8, 22.7) |
| 9 | Two years later the business closes the account. Scans still resolve. Until erasure completes, the code falls to the workspace's branded unavailable page, which may carry the last known workspace display name; after erasure it falls to the neutral platform page, which carries no workspace identity at all. The slug is never recycled | apps/edge (Sections 14.8.5, 23.15) |
2.6 Competitive positioning #
The claim is not that LinkHub has more features than any single category tool. It is that three specific capabilities only exist when the three surfaces share one data model.
| Capability | Link-in-bio tool alone | Shortener alone | QR tool alone | LinkHub |
|---|---|---|---|---|
| One visitor identity across page views, clicks and scans, joinable server-side | No — page only | No — clicks only | No — scans only | Yes; the same visitor_hash is carried by the page render and by every click originating from it (Section 17.3) |
| One custom domain serving both the bio page and the short links | Rare, page only | Links only | Usually none | Yes; a verified domain serves both surfaces (Section 13.1.3) |
| A/B test where the page variant and the destination variant are the same experiment | No | Sometimes, links only | No | Yes; cross-surface sticky assignment with no variant identity leaked into outbound URLs (Section 16.3) |
| A printed code that survives downgrade, non-payment and account deletion | n/a | n/a | Almost never | Yes, unconditionally (Section 14.8) |
| One permission model with per-resource grants across all three surfaces | Rare | Rare | Almost never | Yes (Section 3.3) |
| One analytics export containing every surface, with identical dimensions | No | No | No | Yes (Section 18.10) |
| One consent state respected by the page, the pixels and server-side forwarding | Partial | Rare | Rare | Yes (Sections 19.6, 23.13) |
The single-sentence positioning: LinkHub is the only one of the three where changing a destination is a first-class, audited, permanent-by-design operation across every surface at once.
2.7 Non-goals #
The following are explicitly out of scope for this build. Each is listed with the reason and its roadmap status. "Not roadmap" means the decision is that LinkHub does not do this, not that it has been deferred.
| Out of scope | Why | Roadmap status |
|---|---|---|
| Native iOS and Android applications | Every primary journey is either authoring (better on a large screen) or public consumption (a web page that must load in under 1.2 s on a mid-range phone). A native shell would add store-review latency to the release cycle and would not improve either journey. | Roadmap. A native capture app for QR analytics is plausible once volume justifies it. |
| Full CRM integrations | A real CRM integration means field mapping, deduplication, bidirectional sync and per-object permissions. That is a product in itself and would dominate the build. Lead capture instead delivers to Mailchimp, ConvertKit or a generic webhook (Section 20.5). | Roadmap, named only. |
| Ad-platform conversion APIs (server-side CAPI) | Conversion APIs require event deduplication with the client pixel, per-platform hashing rules for user data, and a consent model that differs by platform. Client-side pixels plus server-side GA4 forwarding cover the common case (Section 19.2). | Roadmap, named only. |
| Built-in cart, checkout or e-commerce | Payments to a creator's customers means merchant onboarding, KYC, payouts, tax and disputes. LinkHub links to a storefront; it is not the storefront. | Not roadmap. |
| Webhook subscription management UI | Exactly one webhook URL per workspace, configured in settings, delivering click and lead events (Section 19.7). A subscription manager implies per-event-type routing, multiple endpoints and a delivery-topology UI that ninety-nine percent of workspaces will never open. | Not roadmap in this form; a second endpoint may be added without a management UI. |
| Enterprise SSO and SAML | SAML brings IdP-initiated flows, just-in-time provisioning, SCIM directory sync and per-tenant certificate rotation. It only pays for itself against an enterprise sales motion that this product does not yet have. Google OAuth plus enforceable TOTP covers the target segment (Sections 7.7, 7.9). | Roadmap, gated on an enterprise tier. |
| White-label reseller program | Reseller means sub-tenancy, per-reseller billing, per-reseller branding on transactional email and the redirect host, and a support hierarchy. It is a different business model layered on the same product. The agency persona is served by multi-workspace plus per-resource grants instead. | Roadmap, gated on demand. |
2.8 Success metrics #
Every metric below has a defined measurement source so it can be instrumented in the same release as the feature it measures. Instrumentation is specified in Section 25.3.
| # | Metric | Definition | Target | Source |
|---|---|---|---|---|
| 1 | Activation rate | Share of verified signups that publish at least one bio page, short link or QR code within 7 days of signup | ≥ 55% | Product analytics on bio_page.published, link.created, qr.created (Section 8.9.1) joined to users.created_at |
| 2 | Time to first published page | Median elapsed time from first authenticated dashboard load to the first bio_page.published event |
≤ 8 minutes (p50); ≤ 25 minutes (p90) | Same events, per-user first occurrence |
| 3 | Redirect availability | Share of redirect requests answered with a non-5xx response, measured at the edge, monthly | ≥ 99.99% | Edge metrics (Section 25.3.1) |
| 4 | Redirect processing latency | Server-side processing time in apps/edge, excluding network transit |
p50 < 20 ms, p95 < 50 ms, p99 < 120 ms | Edge histogram, per Section 11.8.2 |
| 5 | Scan-to-destination success rate | Share of QR scans that terminate in a 200 from the final destination or a LinkHub-served fallback page, with zero 404/410 responses from LinkHub |
100% non-error resolution from LinkHub; ≥ 98.5% reaching a live customer destination | Edge resolution outcome counter, bucketed by fallback_stage and rung (Section 14.8.2) |
| 6 | QR render first-pass rate | Share of QR renders that pass all three scannability conditions without an error-correction escalation | ≥ 90% | qr-render worker result records (Section 14.5.7) |
| 7 | Bio page LCP in the field | 75th percentile Largest Contentful Paint across real public page loads | < 1.2 s | Cookie-free real-user monitoring (Section 11.13.1), reported in Section 25.3 |
| 8 | Analytics freshness | Elapsed time from event capture to visibility in the hourly rollup | p95 < 90 seconds | Ingest worker lag metric (Section 17.13) |
| 9 | Free-to-paid conversion | Share of activated free workspaces that start a paid subscription within 60 days | ≥ 6% | Billing state transitions (Section 22.3) |
| 10 | Custom-domain success rate | Share of domains added that reach active without a support intervention, within 24 hours |
≥ 85% | Domain state machine transitions (Section 13.4) |
| 11 | Support contact rate | Support conversations per 100 active workspaces per month | ≤ 4 | Support tooling, correlated with audit log events |
| 12 | Accessibility regression count | Number of axe-core violations of impact serious or critical merged to the default branch |
0 | CI gate (Section 26.6) |
3. Personas, Roles & Permission Model #
3.1 The four personas in depth #
3.1.1 Maya — independent creator #
| Attribute | Detail |
|---|---|
| Role | Full-time content creator; photography, short video, a small print shop |
| Goals | One link in her social bios that leads everywhere; know which of her links people actually tap; look like her own brand, not like a template |
| Context of use | Between shoots, on a phone, in short bursts. Sets the page up once in a sitting of 10–20 minutes, then edits it a few times a month, usually to swap the top link |
| Device | iPhone for authoring roughly 70% of the time; a laptop for the initial build. Her audience is 90%+ mobile |
| Technical skill | Comfortable with social platforms and consumer design tools. Has never edited a DNS record and will not start. Does not know what UTM means |
| Plan | Starts Free, upgrades to Pro when she wants the LinkHub badge removed and a custom domain |
| What success looks like | A published page in under ten minutes, with her colours, that loads instantly for her followers |
| What failure looks like | The builder requires a desktop; the theme editor produces something that looks nothing like her brand; the page takes three seconds to load on 4G and her followers bounce; she cannot tell which link performed better |
| Design consequences | Full authoring parity on a touch viewport (Section 9.1.2). Templates that are attractive out of the box and already contrast-safe (Section 9.10.3). No DNS-shaped concept anywhere in the default path. Analytics that answer "which link, how many, from where" without a query builder |
3.1.2 Daniel — in-house marketer #
| Attribute | Detail |
|---|---|
| Role | Growth marketer at a 60-person company; owns paid social, email and the campaign calendar |
| Goals | Every campaign link on the company's own domain, with correct and consistent UTM parameters; a per-campaign view he can paste into a monthly report; the ability to re-point a link after a landing page moves |
| Context of use | Desktop, working hours, in a browser with fifteen tabs. Creates links in batches at campaign kick-off, then checks performance daily for two weeks |
| Device | Laptop, 1440px-wide browser window, external monitor |
| Technical skill | High for a non-engineer. Understands UTMs, attribution windows, pixels and cookie consent. Can file a ticket to get a DNS record added but cannot add one himself |
| Plan | Pro, moving to Business when the team grows past one seat or a second brand appears |
| What success looks like | Nine campaign links created in five minutes with identical UTM discipline, live on go.acme.com, reporting grouped by utm_source without an export |
| What failure looks like | UTM parameters typed by hand and inconsistent across channels, so the report is wrong; a landing page moves and every printed and scheduled asset breaks; the domain setup stalls with an opaque "verification failed" and no diagnostic |
| Design consequences | UTM presets at workspace level with a live composed-URL preview (Section 15.1.3). Bulk create and bulk edit (Section 12.5). A domain verification UI that shows what LinkHub currently resolves versus what it expects, with a copy button per record (Section 13.6.2). Grouping by UTM dimension in the dashboard, not only in the export (Section 18.4.6) |
3.1.3 Priya — agency operator #
| Attribute | Detail |
|---|---|
| Role | Operations lead at an eleven-client digital agency; four full-time staff, a rotating pool of freelancers |
| Goals | Hard separation between client brands; give a freelancer access to exactly the two pages they were hired to build and nothing else; answer a client's "who changed this and when" within a minute; bill her time, not her tool administration |
| Context of use | Desktop, all day, switching between client contexts dozens of times. Onboards a new client roughly monthly and off-boards a freelancer roughly weekly |
| Device | Laptop, often two browser windows side by side for two clients |
| Technical skill | High. Manages DNS for some clients, requests it from others. Understands roles, least privilege and audit requirements because her contracts require them |
| Plan | Business, at or near the 10-workspace and 25-seat limits |
| What success looks like | Every client in an isolated workspace with its own domain and brand; a scoped Editor who literally cannot see other clients' assets; an audit log that satisfies a client review; an off-boarding that takes one click |
| What failure looks like | A shared login; a freelancer seeing a competitor client's campaign; a role model where "read-only" still exposes every resource name; no record of a destination change during a client dispute; work created by a departed freelancer disappearing with their account |
| Design consequences | Per-workspace roles with a per-workspace switcher (Section 8.3). Per-resource grants where a non-granted resource is invisible, not merely non-editable — a scoped member's list endpoints return only granted resources and a direct fetch returns 404, never 403 (Section 3.4). Append-only audit log with actor, before and after values (Section 8.9). Resources created by a scoped member belong to the workspace, never to the member (Section 3.4.4) |
3.1.4 Tom — small business owner #
| Attribute | Detail |
|---|---|
| Role | Owner of a specialty food producer; twelve staff; sells through a website, farmers' markets and two retail chains |
| Goals | A QR code on the back of every package that takes a customer to the current product story, recipes and a review prompt; the ability to change where that goes without a print run; proof that scanning is happening |
| Context of use | Rarely. Sets a code up once, downloads assets for the printer, and may not sign in again for months. Comes back when something needs to change or when a retailer asks for scan numbers |
| Device | Laptop for setup; phone to test the printed code in the warehouse |
| Technical skill | Low to moderate. Knows what DPI means because his printer told him. Does not distinguish between a static and a dynamic QR code until it is explained once |
| Plan | Free initially (3 QR codes is enough), Pro once he wants his own domain and 90-day analytics |
| What success looks like | A print-ready file the printer accepts first time, a code that scans reliably off a curved package under warehouse lighting, and a destination he can change two years later |
| What failure looks like | The code scans in the design proof but not on the finished matte-laminate box; the download is a low-resolution PNG the printer rejects; his card expires and ten thousand boxes point at a 404 |
| Design consequences | The three-condition scannability validation and the automatic error-correction escalation in Section 14.5.5, which exist precisely to catch the "scanned in the proof, failed on the box" case. Vector-first output with print formats and a physical-size guidance table (Section 14.6). The permanence guarantee in Section 14.8, which is unconditional and applies after cancellation, after downgrade, and after account deletion |
3.2 The role model #
Roles are per workspace, never global. A user account has no product-wide role. Membership is a row linking a user to a workspace with exactly one role, and a user may hold a different role in every workspace they belong to. There is no "super admin" role in the product; internal staff access is a separate, audited operator path described in Section 25.12.3 and is not modelled as a workspace role.
| Role | Scope of authority | Explicitly cannot |
|---|---|---|
| Owner | Everything in the workspace, including billing, plan changes, workspace deletion, ownership transfer and enforcing two-factor authentication | Nothing within the workspace. Cannot act in a workspace they do not belong to |
| Admin | Members and invitations, per-resource grants, custom domains, brand and workspace settings, all content, all analytics, integrations, API keys, audit log | Access billing in any form (not even read), delete the workspace, transfer ownership, remove or demote the Owner, enforce two-factor authentication |
| Editor | Create and edit bio pages, blocks, short links, QR codes and experiments; publish and unpublish content; view all analytics | Manage members, invitations, grants, domains, brand settings, integrations, API keys, billing or the audit log |
| Viewer | Read-only across content and analytics, including exports where the plan allows | Any mutation of any kind, including publishing, duplicating, or changing a destination |
Rules that hold without exception:
- Exactly one Owner exists per workspace at all times. See Section 3.6.
- Role is assigned at invitation time and changed only by an Owner, or by an Admin for the Editor and Viewer roles (an Admin cannot create another Admin — see footnote
C3in Section 3.3.1). - A user's role in workspace A has no bearing on their capabilities in workspace B.
- Removing a membership revokes access immediately: the next request re-evaluates and fails at the membership check in Section 3.5. Sessions are not workspace-scoped, so no session invalidation is required, but any cached entitlement and capability entry for that user and workspace is deleted on the same transaction.
- Content is owned by the workspace, never by the member who created it. Removing a member never deletes, hides or reassigns content.
3.3 The permission matrix #
This is the canonical permission matrix. Every other section in this document refers to it rather than restating access rules. The matrix is also the specification for the test suite in Section 26.5.2: each row is enumerated as a test case for all four roles.
3.3.1 Legend and conditional footnotes #
| Symbol | Meaning |
|---|---|
Y |
Allowed |
N |
Denied — the evaluation algorithm in Section 3.5 returns the error indicated there |
Cn |
Allowed only when condition n holds |
| Condition | Rule |
|---|---|
C1 |
Plan entitlement required. The workspace plan must include the feature (Section 22.1.2). Otherwise 403 plan_feature_unavailable for a binary feature gate, or 403 plan_limit_reached when a numeric or period quota rather than a feature is the blocker. Both carry the details payload defined in Section 21.3.2. |
C2 |
Grant scoping applies. If the member holds per-resource grants, the action is permitted only on granted resources. Non-granted resources are invisible: list endpoints omit them and direct fetches return 404 not_found. See Section 3.4. |
C3 |
Admin may not create or modify Admins or the Owner. An Admin can invite, change and remove Editors and Viewers only. Assigning the Admin role, or changing any Admin's or the Owner's membership, requires the Owner. |
C4 |
Verified email required. The acting user's email address must be verified before anything becomes publicly reachable (Section 7.4). Otherwise 403 email_verification_required. |
C5 |
Step-up authentication required. The session must have completed a password or second-factor challenge within the last 5 minutes (Section 7.8). Otherwise 403 reauthentication_required. |
C6 |
Typed confirmation required. The client must send an exact confirmation string (the workspace slug, resource slug or the literal word specified by the endpoint). Otherwise 422 confirmation_required. |
C7 |
Own record only. The actor may perform this action on their own membership, session or grant list, not on another member's. |
C8 |
Resource must be mutable. Soft-deleted, archived and downgrade-locked resources reject mutations with 410 resource_gone, 409 resource_archived and 409 resource_locked respectively (Section 3.5, step 8). |
C9 |
Not the last Owner. Blocked when it would leave the workspace without an Owner (Section 3.6). |
C10 |
Billing enforcement mode permits writes. Section 22.7.5 owns the schedule: writes are fully permitted on days 0–7 of past_due, and blocked from day 8 with 403 billing_write_blocked. QR destination resolution is never affected at any point (Sections 14.8.8, 22.6). |
3.3.2 Workspace and brand #
| # | Action | Owner | Admin | Editor | Viewer |
|---|---|---|---|---|---|
| 1 | workspace.view — open the workspace and read its settings |
Y | Y | Y | Y |
| 2 | workspace.create — create a new workspace |
C1 | C1 | C1 | C1 |
| 3 | workspace.update_profile — name, slug, display name |
Y | Y | N | N |
| 4 | workspace.update_brand — logo, favicon, brand colours, default theme tokens |
Y | Y | N | N |
| 5 | workspace.update_settings — default timezone, analytics defaults, consent geo-gating |
Y | Y | N | N |
| 6 | workspace.update_public_defaults — default social preview image, default fallback page copy |
Y | Y | N | N |
| 7 | workspace.transfer_ownership — initiate transfer to another member |
C5 C6 | N | N | N |
| 8 | workspace.accept_ownership — accept an inbound transfer |
Y | Y | N | N |
| 9 | workspace.delete — soft-delete the workspace (30-day restore window) |
C5 C6 | N | N | N |
| 10 | workspace.restore — restore within the window |
Y | N | N | N |
| 11 | workspace.leave — leave the workspace |
C9 | Y | Y | Y |
workspace.create is account-scoped: there is no workspace context to evaluate it in, so it is decided by the account-scoped branch of Section 3.5 against the plan's workspace cap (1 on Free and Pro, 10 on Business, Section 22.1.2), evaluated against the plan of the billing account that will own the new workspace, not the workspace the user happens to be viewing.
3.3.3 Members, invitations and grants #
| # | Action | Owner | Admin | Editor | Viewer |
|---|---|---|---|---|---|
| 12 | member.list — list members and their roles |
Y | Y | Y | Y |
| 13 | member.invite — send an invitation |
C1 | C1 C3 | N | N |
| 14 | member.invite_resend — resend a pending invitation |
Y | C3 | N | N |
| 15 | member.invite_revoke — revoke a pending invitation |
Y | C3 | N | N |
| 16 | member.role_change — change a member's role |
C9 | C3 | N | N |
| 17 | member.remove — remove a member from the workspace |
C9 | C3 | N | N |
| 18 | member.grant_create — scope a member to specific resources |
C1 | C1 C3 | N | N |
| 19 | member.grant_revoke — remove a scoping grant |
C1 | C1 C3 | N | N |
| 20 | member.grant_list_own — see which resources one is scoped to |
C7 | C7 | C7 | C7 |
| 21 | member.grant_list_any — see any member's grants |
Y | Y | N | N |
| 22 | security.enforce_two_factor — require TOTP for the whole workspace |
C1 C5 | N | N | N |
| 23 | security.view_member_two_factor_status — see who has 2FA enabled |
Y | Y | N | N |
| 24 | security.list_own_sessions / security.revoke_own_session |
C7 | C7 | C7 | C7 |
3.3.4 Billing and plan #
Billing is an Owner-only domain. Admin has no read access, by design: an agency Admin manages content and people, and the commercial relationship is not theirs. Billing is also dashboard-session-only: no API key of any scope can reach any billing action, so a leaked key can never touch payment state (Sections 3.5 step 2, 21.2.5).
| # | Action | Owner | Admin | Editor | Viewer |
|---|---|---|---|---|---|
| 25 | billing.view_plan — see current plan, cycle and renewal date |
Y | N | N | N |
| 26 | billing.view_usage — see quota consumption against limits |
Y | N | N | N |
| 27 | billing.view_invoices — list and download invoices |
Y | N | N | N |
| 28 | billing.update_payment_method |
C5 | N | N | N |
| 29 | billing.update_details — company name, address, tax identifier |
Y | N | N | N |
| 30 | billing.upgrade — move to a higher plan or annual cycle |
Y | N | N | N |
| 31 | billing.downgrade — move to a lower plan (runs the guided flow in Section 22.5) |
C6 | N | N | N |
| 32 | billing.cancel — cancel the subscription |
C5 C6 | N | N | N |
| 33 | billing.apply_coupon — redeem a promotion code |
Y | N | N | N |
| 34 | billing.open_portal — open the hosted billing portal session |
C5 | N | N | N |
A read-only surface showing quota consumption without prices is available to Admin, Editor and Viewer so they understand why a create action was blocked; it is a different action (workspace.view_quota, allowed to all four roles) and exposes no commercial data.
3.3.5 Custom domains #
| # | Action | Owner | Admin | Editor | Viewer |
|---|---|---|---|---|---|
| 35 | domain.list — list domains and their states |
Y | Y | Y | Y |
| 36 | domain.add — add a domain and receive DNS instructions |
C1 | C1 | N | N |
| 37 | domain.verify_retry — re-run verification now |
Y | Y | N | N |
| 38 | domain.view_diagnostics — see resolved versus expected DNS values |
Y | Y | Y | N |
| 39 | domain.set_default — set the workspace default host for new links |
Y | Y | N | N |
| 40 | domain.attach_to_resource — use a domain for a specific page or link |
Y | Y | C2 C8 | N |
| 41 | domain.force_tls_renew — trigger certificate renewal |
Y | Y | N | N |
| 42 | domain.remove — remove a domain from the workspace |
C6 | C6 | N | N |
Removing a domain does not delete the links bound to it. Section 13.9 specifies the required re-homing step and the branded landing page served in the interim.
3.3.6 Bio pages and blocks #
| # | Action | Owner | Admin | Editor | Viewer |
|---|---|---|---|---|---|
| 43 | bio_page.list |
Y | Y | C2 | C2 |
| 44 | bio_page.view — open in the builder (read) |
Y | Y | C2 | C2 |
| 45 | bio_page.create |
C1 | C1 | C1 | N |
| 46 | bio_page.update_content — title, bio, avatar, block content |
C8 C10 | C8 C10 | C2 C8 C10 | N |
| 47 | bio_page.update_theme — colours, fonts, layout, background |
C8 C10 | C8 C10 | C2 C8 C10 | N |
| 48 | bio_page.update_seo — meta title, description, social preview image |
C8 C10 | C8 C10 | C2 C8 C10 | N |
| 49 | bio_page.update_handle — change the public handle |
C8 | C8 | C2 C8 | N |
| 50 | bio_page.publish |
C4 C8 C10 | C4 C8 C10 | C2 C4 C8 C10 | N |
| 51 | bio_page.unpublish |
Y | Y | C2 | N |
| 52 | bio_page.duplicate |
C1 | C1 | C1 C2 | N |
| 53 | bio_page.archive / bio_page.unarchive |
Y | Y | C2 | N |
| 54 | bio_page.delete — soft delete |
C6 | C6 | C2 C6 | N |
| 55 | bio_page.restore — restore within the 30-day window |
Y | Y | N | N |
| 56 | block.add |
C8 C10 | C8 C10 | C2 C8 C10 | N |
| 57 | block.update |
C8 C10 | C8 C10 | C2 C8 C10 | N |
| 58 | block.reorder |
C8 C10 | C8 C10 | C2 C8 C10 | N |
| 59 | block.delete |
C8 | C8 | C2 C8 | N |
| 60 | block.set_schedule — show/hide a block on a schedule |
C1 C8 | C1 C8 | C1 C2 C8 | N |
| 61 | bio_page.preview_unpublished — open a signed preview link |
Y | Y | C2 | C2 |
3.3.7 Branded short links #
| # | Action | Owner | Admin | Editor | Viewer |
|---|---|---|---|---|---|
| 62 | link.list |
Y | Y | C2 | C2 |
| 63 | link.view |
Y | Y | C2 | C2 |
| 64 | link.create |
C1 | C1 | C1 | N |
| 65 | link.update_destination — the audited destination change |
C8 C10 | C8 C10 | C2 C8 C10 | N |
| 66 | link.update_slug |
C8 | C8 | C2 C8 | N |
| 67 | link.update_metadata — title, notes, tags |
C8 | C8 | C2 C8 | N |
| 68 | link.update_utm — UTM parameters via the builder |
C1 C8 | C1 C8 | C1 C2 C8 | N |
| 69 | link.set_schedule_expiry — activation window and post-expiry destination |
C1 C8 | C1 C8 | C1 C2 C8 | N |
| 70 | link.set_targeting_rules — device, language, country rules |
C1 C8 | C1 C8 | C1 C2 C8 | N |
| 71 | link.set_password / link.set_interstitial |
C1 C8 | C1 C8 | C1 C2 C8 | N |
| 72 | link.bulk_import — CSV import |
C1 | C1 | C1 | N |
| 73 | link.bulk_edit — apply a change to a selection |
C8 | C8 | C2 C8 | N |
| 74 | link.archive / link.unarchive |
Y | Y | C2 | N |
| 75 | link.delete — soft delete |
C6 | C6 | C2 C6 | N |
| 76 | link.restore |
Y | Y | N | N |
A link pinned to a QR code is never archived and never soft-deleted by any of rows 74–75, and it does not count toward the link cap. The QR permanence rule outranks archival; Sections 12.9, 22.2 and 22.5 specify the behaviour and Section 3.5 step 8 refuses the mutation with 409 resource_locked.
3.3.8 Dynamic QR codes #
| # | Action | Owner | Admin | Editor | Viewer |
|---|---|---|---|---|---|
| 77 | qr.list |
Y | Y | C2 | C2 |
| 78 | qr.view |
Y | Y | C2 | C2 |
| 79 | qr.create |
C1 | C1 | C1 | N |
| 80 | qr.update_destination — audited; never affects resolution availability |
C8 C10 | C8 C10 | C2 C8 C10 | N |
| 81 | qr.update_styling — colours, module shape, logo overlay, error correction |
C8 C10 | C8 C10 | C2 C8 C10 | N |
| 82 | qr.set_paused_fallback — the URL used when the code is paused (rung 2) |
C8 | C8 | C2 C8 | N |
| 83 | qr.pause / qr.resume |
Y | Y | C2 | N |
| 84 | qr.render — request a new render of the current version |
Y | Y | C2 | N |
| 85 | qr.download_asset — SVG, PNG 300/600 DPI, PDF, EPS |
Y | Y | C2 | C2 |
| 86 | qr.view_scannability_report — the three-condition validation result |
Y | Y | C2 | C2 |
| 87 | qr.archive — remove from active lists; slug remains reserved forever |
C6 | C6 | C2 C6 | N |
| 88 | qr.delete — soft delete the record; the slug reservation is never deleted |
C6 | C6 | N | N |
There is no action anywhere in this product that releases, recycles or reassigns a QR slug. Section 14.8 owns this rule.
3.3.9 Experiments (A/B testing) #
| # | Action | Owner | Admin | Editor | Viewer |
|---|---|---|---|---|---|
| 89 | experiment.list |
C1 | C1 | C1 C2 | C1 C2 |
| 90 | experiment.create |
C1 | C1 | C1 C2 | N |
| 91 | experiment.update_variants — while in draft |
C1 C8 | C1 C8 | C1 C2 C8 | N |
| 92 | experiment.start |
C1 | C1 | C1 C2 | N |
| 93 | experiment.pause / experiment.resume |
C1 | C1 | C1 C2 | N |
| 94 | experiment.promote_winner — only after the minimum-sample guard passes |
C1 | C1 | C1 C2 | N |
| 95 | experiment.force_promote — before the guard passes; audited |
C1 C6 | C1 C6 | N | N |
| 96 | experiment.view_results |
C1 | C1 | C1 C2 | C1 C2 |
| 97 | experiment.delete |
C1 C6 | C1 C6 | C1 C2 C6 | N |
The experiment states these rows refer to are draft, running, paused, concluded, promoted and archived; Section 16.9 owns the lifecycle.
3.3.10 Analytics and reporting #
| # | Action | Owner | Admin | Editor | Viewer |
|---|---|---|---|---|---|
| 98 | analytics.view_workspace_summary |
Y | Y | C2 | C2 |
| 99 | analytics.view_resource — per page, link or QR code |
Y | Y | C2 | C2 |
| 100 | analytics.view_realtime — last 30 minutes |
Y | Y | C2 | C2 |
| 101 | analytics.view_raw_drilldown — event-level detail within the plan's raw retention |
Y | Y | C2 | C2 |
| 102 | analytics.toggle_bot_filter — include or exclude classified bot traffic |
Y | Y | Y | Y |
| 103 | analytics.change_display_timezone — session-level display preference |
Y | Y | Y | Y |
| 104 | analytics.export_csv |
C1 | C1 | C1 C2 | C1 C2 |
| 105 | analytics.schedule_report — recurring emailed report |
C1 | C1 | C1 | N |
| 106 | analytics.manage_saved_view — create, update, delete a saved filter set |
Y | Y | Y | C7 |
3.3.11 Leads and email capture #
| # | Action | Owner | Admin | Editor | Viewer |
|---|---|---|---|---|---|
| 107 | lead.list — captured email addresses |
Y | Y | C2 | N |
| 108 | lead.view_detail |
Y | Y | C2 | N |
| 109 | lead.export |
C1 C5 | C1 C5 | N | N |
| 110 | lead.delete — erase an individual lead on request |
Y | Y | N | N |
| 111 | lead.configure_destination — Mailchimp, ConvertKit or generic webhook |
Y | Y | N | N |
| 112 | lead.view_sync_status — delivery state and failures |
Y | Y | C2 | N |
Leads are personal data. Viewer is denied entirely; export requires step-up authentication and is written to the audit log with the row count. Leads are dashboard-only. There is no lead scope in the API key catalogue (Section 21.2.4) and no lead endpoint on the public API, because an API key cannot satisfy the step-up requirement that row 109 imposes, and weakening row 109 to make an API surface possible would be a privacy regression.
3.3.12 Integrations, pixels and webhooks #
| # | Action | Owner | Admin | Editor | Viewer |
|---|---|---|---|---|---|
| 113 | integration.list |
Y | Y | Y | Y |
| 114 | integration.connect_pixel — GA4, Meta, TikTok |
Y | Y | N | N |
| 115 | integration.disconnect |
Y | Y | N | N |
| 116 | integration.set_webhook_url — the single workspace webhook endpoint |
Y | Y | N | N |
| 117 | integration.rotate_webhook_secret |
C5 | C5 | N | N |
| 118 | integration.view_webhook_deliveries — delivery log and dead-letter queue |
Y | Y | Y | N |
| 119 | integration.replay_webhook_delivery |
Y | Y | N | N |
| 120 | integration.connect_slack / integration.connect_zapier |
Y | Y | N | N |
Webhook endpoints are HTTPS-only, with no plaintext-HTTP exception; Section 19.7 owns the transport and SSRF rules that row 116 validates against.
3.3.13 API keys #
| # | Action | Owner | Admin | Editor | Viewer |
|---|---|---|---|---|---|
| 121 | api_key.list — prefix, scopes, created, last used; never the secret |
C1 | C1 | N | N |
| 122 | api_key.create — secret shown exactly once |
C1 C5 | C1 C5 | N | N |
| 123 | api_key.update_scopes — not offered; scopes are immutable after creation (Section 21.2.4), so this action is never granted to any role. Rotation means creating a new key and revoking the old one |
N | N | N | N |
| 124 | api_key.revoke |
C1 | C1 | N | N |
| 125 | api_key.view_usage — request counts and rate-limit rejections |
C1 | C1 | N | N |
An API key never carries more authority than the role of the member who created it, and never carries Owner-only capabilities: there are no billing scopes, no member-management scopes, no audit scope, no lead scope and no workspace-deletion scopes. Section 21.2.4 is the single catalogue of API key scopes; this section does not restate it and does not state its size, because a count restated in two places is a count that will disagree with itself. A key is additionally bound to exactly one workspace at creation and can never address another (Section 3.5, step 2).
3.3.14 Audit log, data export and account data #
| # | Action | Owner | Admin | Editor | Viewer |
|---|---|---|---|---|---|
| 126 | audit.view — the workspace audit log |
Y | Y | N | N |
| 127 | audit.filter — by actor, resource, action, date |
Y | Y | N | N |
| 128 | audit.export — CSV of the visible range |
C1 | C1 | N | N |
| 129 | gdpr.request_workspace_export — full JSON + CSV bundle |
C5 | N | N | N |
| 130 | gdpr.request_workspace_deletion — 30-day grace then irreversible purge |
C5 C6 | N | N | N |
| 131 | gdpr.download_export — signed 24-hour link |
C7 | N | N | N |
| 132 | gdpr.request_own_account_deletion — the user's own account |
C7 | C7 | C7 | C7 |
Rows 126–128 are dashboard-only for the same reason as leads: the audit log is not reachable by any API key scope (Section 21.2.4).
Deleting a workspace or an account never releases a QR slug reservation and never stops QR resolution (Sections 14.8, 23.15).
3.4 Per-resource grants (Business only) #
3.4.1 The scoping model #
A grant is a row associating a membership with a single resource. Grantable resource types are bio_page, link and qr_code. A membership with zero grants is unscoped and operates at full workspace scope for its role. A membership with one or more grants is scoped, and its authority is the intersection of its role capabilities and its granted resource set.
| Property | Rule |
|---|---|
| Availability | Business plan only. On Free and Pro, grant endpoints return 403 plan_feature_unavailable |
| Granularity | One row per (membership, resource type, resource id) |
| Applicable roles | Editor and Viewer. Granting to an Owner or Admin is rejected with 422 role_not_scopable — those roles are workspace-wide by definition |
| Default | No grants. A new member is unscoped |
| Effect on downgrade | If a workspace downgrades from Business, existing grants are retained in the database but not enforced; scoped members revert to full workspace scope for their role, and the members list shows a persistent notice naming every affected member. Grants re-activate automatically on re-upgrade. Retaining rather than deleting is deliberate: silently discarding an access-control configuration during a billing event is a security regression |
| Audit | Grant creation and revocation are audited events (Section 8.9) |
3.4.2 Intersection with role #
| Role | Unscoped | Scoped |
|---|---|---|
| Editor | Create, edit, publish and delete any page, link, QR code or experiment in the workspace | Create new resources (which are then auto-granted, see 3.4.4); edit, publish and delete only granted resources; experiments only on granted resources |
| Viewer | Read every resource and all workspace analytics | Read only granted resources and analytics filtered to those resources only |
| Admin / Owner | Full workspace scope | Not applicable — cannot be scoped |
3.4.3 What a scoped member sees #
Invisibility, not merely denial, is the rule. A scoped member must not be able to enumerate resources they were not granted, and must not be able to confirm a resource exists by probing an identifier.
| Surface | Behaviour for a scoped member |
|---|---|
List endpoints (GET /v1/links, pages, QR codes) |
Return only granted resources. meta.has_more and meta.next_cursor reflect the filtered set |
| Direct fetch of a non-granted resource | 404 with code not_found — never 403. A 403 would confirm existence |
| Search and filters | Operate over the granted set only |
| Dashboard counts and quota indicators | Show workspace-level quota (so the member understands a create failure) but resource counts scoped to grants. What does and does not consume a cap is defined once, in Section 22.2.5 — in particular, archived resources do not count toward any cap, so a scoped member's quota indicator and an Owner's billing page always agree |
| Workspace analytics summary | Aggregates computed over granted resources only. Where a metric is inherently workspace-wide (for example total workspace views), it is omitted rather than shown as a partial number that would leak the size of the full set |
| Audit log | Denied to Editor and Viewer regardless of scoping (rows 126–128) |
| Bulk operations | The selection set is the granted set; a bulk request naming a non-granted identifier fails atomically with 404 not_found and no partial application |
| Export | Contains granted resources only; the export manifest states the scope that produced it |
3.4.4 Resources created by a scoped member #
This rule is exact and has no exceptions:
- A scoped Editor may create new bio pages, links, QR codes and experiments, subject to plan quota. What counts toward that quota is Section 22.2.5's list, not this section's — archived resources are excluded, and a link pinned to a QR code is excluded.
- On successful creation, the system automatically creates a grant for that resource on the creating membership, in the same database transaction as the resource insert. Without this, the creator would immediately lose access to their own new resource.
- The resource belongs to the workspace, not to the creator.
created_byis recorded for audit purposes only and confers no ongoing authority. - If the creating membership is later removed, the auto-created grant is deleted with it. The resource is not deleted, not archived, not hidden and not reassigned. It remains a normal workspace resource, fully visible to Owners, Admins and unscoped Editors and Viewers.
- If the creating membership is later un-scoped (all grants revoked), the member gains full workspace scope; the auto-created grant becomes irrelevant and is removed as part of the same operation.
- Auto-created grants are recorded in the audit log with
source = auto_on_createto distinguish them from grants an Admin issued deliberately.
3.5 Permission evaluation algorithm #
Every authorised operation in every deployable runs this function. There is no second implementation and no bypass. The order of checks is fixed, because it determines which error a caller sees and therefore what a caller can infer.
authorize(actor, workspaceId, action, resourceRef?) -> Allow | Deny(status, code, details?)
# 1. Authentication
if actor is null:
return Deny(401, "unauthenticated")
if actor.kind == "user" and actor.user.status == "suspended":
return Deny(403, "account_suspended")
# 2. API-key actors: workspace binding first, then the scope gate
if actor.kind == "api_key":
if actor.api_key.revoked_at is not null:
return Deny(401, "api_key_revoked")
# A key is bound to exactly ONE workspace at creation and can never
# address another. This check runs BEFORE any capability evaluation, so
# a key cannot even probe for the existence of another tenant's data.
if workspaceId is null or actor.api_key.workspace_id != workspaceId:
return Deny(404, "not_found")
if action.required_scope not in actor.api_key.scopes:
return Deny(403, "insufficient_scope",
details = [{ field: "scope", issue: "insufficient_scope",
required_scope: action.required_scope }])
if action.owner_only
or action.domain in { billing, members, audit, gdpr, leads }:
return Deny(403, "action_not_available_to_api_key")
# The key inherits the role of the membership it was created under.
membership = membershipOf(actor.api_key.created_by_membership_id)
else:
# 2a. Account-scoped actions are decided before any workspace exists.
if action.scope == ACCOUNT:
return authorizeAccountScoped(actor, action)
membership = findMembership(actor.user.id, workspaceId)
# 3. Workspace existence and membership
# A non-member and a non-existent workspace are indistinguishable, by design.
workspace = loadWorkspace(workspaceId)
if workspace is null or membership is null:
return Deny(404, "not_found")
if workspace.deleted_at is not null:
if action != "workspace.restore" or membership.role != OWNER:
return Deny(410, "workspace_deleted")
if membership.status != "active":
return Deny(403, "membership_inactive")
# 4. Session assurance requirements
if action.requires_verified_email and not actor.user.email_verified_at:
return Deny(403, "email_verification_required")
if workspace.settings.require_two_factor and not actor.session.two_factor_satisfied:
return Deny(401, "totp_required")
if action.requires_step_up and not actor.session.reauthenticated_within(5, MINUTES):
return Deny(403, "reauthentication_required")
# 5. Role capability — the matrix in Section 3.3
capability = MATRIX[action][membership.role]
if capability == DENY:
return Deny(403, "insufficient_role")
if capability == ADMIN_CANNOT_TOUCH_ADMIN # condition C3
and targetMembership(resourceRef).role in { OWNER, ADMIN }
and membership.role == ADMIN:
return Deny(403, "insufficient_role")
# 6. Resource resolution and grant scoping
if resourceRef is not null:
resource = loadResource(resourceRef, workspaceId)
if resource is null or resource.workspace_id != workspaceId:
# Cross-tenant access is answered exactly as a missing row is.
return Deny(404, "not_found")
if membershipIsScoped(membership) and resource.type in GRANTABLE_TYPES:
if not hasGrant(membership, resource):
# 404, never 403 — a scoped member must not learn the resource exists.
return Deny(404, "not_found")
# 7. Plan entitlement
entitlements = entitlementsFor(workspace) # cached, Section 22.2.6
if action.required_feature and not entitlements.has(action.required_feature):
return Deny(403, "plan_feature_unavailable",
details = [{ field: action.required_feature,
issue: "feature_unavailable",
plan: entitlements.plan }])
if action.consumes_quota:
current = quotaUsage(workspace, action.quota_key) # Section 22.2.5 defines
limit = entitlements.limit(action.quota_key) # what is counted
if limit is not UNLIMITED and current >= limit:
return Deny(403, "plan_limit_reached",
details = [{ field: action.quota_key,
issue: "limit_reached",
limit: limit,
current: current,
plan: entitlements.plan,
kind: action.quota_kind }]) # "count" | "period"
# 8. Resource state
if resourceRef is not null and action.mutates:
if resource.deleted_at is not null:
return Deny(410, "resource_gone")
if resource.archived_at is not null:
return Deny(409, "resource_archived")
if resource.locked_by_downgrade or resource.pinned_to_qr_code:
return Deny(409, "resource_locked")
# 8a. Billing enforcement mode — Section 22.7.5 owns the schedule.
# writes_blocked is FALSE on days 0-7 of past_due and TRUE from day 8.
# This function never computes the dunning day itself.
if action.mutates and entitlements.writes_blocked
and not action.exempt_from_billing_block:
return Deny(403, "billing_write_blocked",
details = [{ field: "workspace",
issue: "past_due",
grace_ends_at: entitlements.grace_ends_at,
invoice_url: entitlements.open_invoice_url }])
# 9. Confirmation token for destructive actions
if action.requires_typed_confirmation and request.confirmation != expectedConfirmation(resource):
return Deny(422, "confirmation_required")
return Allow
authorizeAccountScoped(actor, action) -> Allow | Deny(status, code, details?)
# Actions with no workspace context. `workspace.create` is the important one:
# there is no workspace yet, so steps 3 and 7 above cannot run at all.
if action.requires_verified_email and not actor.user.email_verified_at:
return Deny(403, "email_verification_required")
if action.requires_step_up and not actor.session.reauthenticated_within(5, MINUTES):
return Deny(403, "reauthentication_required")
if action == "workspace.create":
account = billingAccountFor(actor.user) # Section 22.1
entitlements = entitlementsForAccount(account) # Section 22.2
current = countActiveWorkspacesOwnedBy(account)
limit = entitlements.limit("workspaces")
if limit is not UNLIMITED and current >= limit:
return Deny(403, "plan_limit_reached",
details = [{ field: "workspaces", issue: "limit_reached",
limit: limit, current: current,
plan: entitlements.plan, kind: "count" }])
return Allow
# Rows 24 and 132 — the actor acting on their own account records.
return Allow3.5.1 Rules that constrain the algorithm #
| Rule | Rationale |
|---|---|
Rate limiting runs before authorize(), in middleware, returning 429 rate_limited (Section 21.7) |
An unauthenticated flood must be rejected before it reaches the database |
| The API-key workspace binding (step 2) runs before the scope gate and before every capability check | A key that names another tenant's workspace must be answered identically to a key that names a workspace that does not exist. Evaluating scope first would let a caller distinguish "wrong scope" from "wrong tenant" |
Steps 2, 3 and 6 all return 404 not_found for authorisation failures that would otherwise leak existence |
Enumeration resistance for tenants, workspaces and scoped resources alike. This is the same code, with the same status, in all three positions — a caller cannot tell them apart, which is the point |
| Entitlement (step 7) runs after role (step 5) | A Viewer attempting a Pro-only mutation is told they lack the role, not that their workspace needs an upgrade — the role is the more fundamental blocker and revealing plan state to a non-authorised actor is unnecessary |
| Resource state (step 8) runs after entitlement | Upgrade prompts are more actionable than "this is archived" when both are true |
The billing check (step 8a) runs last among the refusals, and reads entitlements.writes_blocked rather than a raw subscription status |
The dunning schedule is commercial policy and belongs in one place. Section 22.7.5 decides when writes stop; this function only obeys the resolved flag. A workspace on day 3 of past_due therefore has full write access here, with no special-casing in any handler |
Public redirect resolution does not call authorize() at all |
The redirect path has no actor and no permission concept. QR resolution in particular must never consult billing or permission state in a way that could deny a scan (Section 14.8) |
| The function is pure with respect to its inputs and performs no writes | It is called speculatively by the UI to decide what to render, via the batch capability endpoint POST /v1/capabilities (Section 21.8) |
Denials are logged at warn with the action, actor id, workspace id and deny code; never with request bodies |
Section 5.6.3 redaction rules |
3.5.2 Deny codes emitted here, and their registry status #
Every code below is emitted by this section — by authorize(), by the grant rules in 3.4, or by the ownership rules in 3.6. The Section 30.2 registry is generated from the union of every emitting section, so each of these must appear there with exactly the status shown. Codes that duplicated an existing code under a different name have been renamed to the surviving name rather than registered twice.
| Code as emitted here | Status | Disposition |
|---|---|---|
unauthenticated |
401 | Shared with Sections 7 and 21 |
api_key_revoked |
401 | Shared with Section 21 |
totp_required |
401 | Renamed from a rival 403 spelling; Section 7 owns the code |
insufficient_scope |
403 | Shared with Section 21 |
insufficient_role |
403 | Shared with Sections 8 and 21 |
email_verification_required |
403 | Shared with Section 7 |
plan_feature_unavailable |
403 | Renamed from a rival spelling; Section 22 owns the code |
plan_limit_reached |
403 | Shared with Section 22 |
billing_write_blocked |
403 | Renamed from a rival spelling; Section 22.7.5 owns the code and the payload |
not_found |
404 | Renamed from two rival spellings; the single not-found and cross-tenant answer |
workspace_deleted |
410 | Shared with Sections 8 and 21 |
resource_archived |
409 | Shared with Sections 18 and 22 |
rate_limited |
429 | Emitted by middleware ahead of this function; Section 21.7 owns it |
The following eleven codes are emitted by this section and Section 30.2 must carry each of them with exactly the status shown:
| # | Code | Status | Emitted when |
|---|---|---|---|
| 1 | account_suspended |
403 | Step 1 — the acting user account is suspended for abuse |
| 2 | action_not_available_to_api_key |
403 | Step 2 — the action's domain is billing, members, audit, gdpr or leads |
| 3 | membership_inactive |
403 | Step 3 — the membership row exists but is not active |
| 4 | reauthentication_required |
403 | Step 4 — condition C5 is unsatisfied |
| 5 | resource_locked |
409 | Step 8 — downgrade-locked, or a link pinned to a QR code |
| 6 | resource_gone |
410 | Step 8 — the resource is soft-deleted |
| 7 | confirmation_required |
422 | Step 9 — condition C6 is unsatisfied |
| 8 | role_not_scopable |
422 | Section 3.4.1 — a grant was issued to an Owner or Admin |
| 9 | last_owner_required |
409 | Section 3.6.1 — the operation would leave the workspace ownerless |
| 10 | transfer_already_pending |
409 | Section 3.6.2 step 3 — a second transfer was initiated |
| 11 | transfer_target_ineligible |
422 | Section 3.6.2 step 2 — the target is not an eligible active member |
3.6 Owner transfer and the last-owner rule #
3.6.1 The last-owner rule #
A workspace always has exactly one Owner. The following operations are therefore blocked, each returning 409 with code last_owner_required:
- Changing the Owner's role to Admin, Editor or Viewer.
- Removing the Owner's membership.
- The Owner using
workspace.leave. - Deleting the user account of a sole Owner of any non-deleted workspace, until every such workspace is either transferred or deleted. The account-deletion flow lists the blocking workspaces explicitly and offers transfer or workspace deletion inline.
The only way an Owner stops being an Owner is by completing a transfer.
3.6.2 Transfer flow #
| # | Step | Detail |
|---|---|---|
| 1 | Initiation | The current Owner calls workspace.transfer_ownership naming a target member. Requires step-up authentication (C5) and typed confirmation of the workspace slug (C6) |
| 2 | Eligibility | The target must be an active member of this workspace with a verified email address and, where the workspace enforces two-factor authentication, an enrolled second factor. Otherwise 422 transfer_target_ineligible with the specific reason in details |
| 3 | Pending state | A transfer record is created with a 7-day expiry. Only one transfer may be pending per workspace; a second attempt returns 409 transfer_already_pending. The initiating Owner may cancel at any time |
| 4 | Notification | The target receives an email and an in-app notification. Both parties see a banner in the workspace |
| 5 | Acceptance | The target calls workspace.accept_ownership, which requires their own step-up authentication. On acceptance, in one transaction: target's role becomes Owner, previous Owner's role becomes Admin, the transfer record is marked accepted, the entitlement and capability caches for the workspace are invalidated, and an audit entry is written naming both parties |
| 6 | Declination and expiry | The target may decline, which closes the record. An unaccepted transfer expires after 7 days. In both cases the workspace is unchanged and both parties are notified |
| 7 | Billing | The Stripe customer and subscription remain attached to the workspace, not to the person. After transfer the new Owner has full billing authority and the previous Owner, now an Admin, has none — including no access to historical invoices. This is called out explicitly in the confirmation dialog |
| 8 | Audit | Both initiation and completion are recorded, with before and after role values for both memberships |
3.7 Worked examples #
Each example is resolved against the algorithm in Section 3.5, naming the step at which the decision is made.
3.7.1 Viewer tries to change a link destination #
Setup. Workspace on Business. User has role Viewer, no grants. Action link.update_destination.
Resolution. Steps 1–4 pass. Step 5: matrix row 65 gives N for Viewer. → 403 insufficient_role. The link's existence is not concealed because a Viewer is entitled to read it; only the mutation is refused.
3.7.2 Scoped Editor fetches a link they were not granted #
Setup. Business workspace with 400 links. User is an Editor scoped to 2 bio pages and 3 links. They request GET /v1/links/{id} for a link outside their grants.
Resolution. Steps 1–5 pass (matrix row 63 gives C2 for Editor). Step 6: the resource loads and belongs to the workspace, but hasGrant() is false. → 404 not_found. The same identifier returns 200 for an Admin. The list endpoint never contained it. Priya's requirement in Section 3.1.3 — invisible, not merely non-editable — is satisfied here.
3.7.3 Admin tries to view invoices #
Setup. Admin in a Pro workspace. Action billing.view_invoices.
Resolution. Step 5: matrix row 27 gives N for Admin. → 403 insufficient_role. The billing navigation item is not rendered for Admins at all, because the UI calls the batch capability endpoint POST /v1/capabilities (Section 21.8), which runs this same function.
3.7.4 Editor on Free tries to create an A/B experiment #
Setup. Free workspace, unscoped Editor. Action experiment.create.
Resolution. Steps 1–6 pass; the matrix gives C1 for Editor. Step 7: experiments_enabled is not in the Free entitlement set. → 403 plan_feature_unavailable with
"details": [ { "field": "experiments_enabled",
"issue": "feature_unavailable",
"plan": "free" } ]The UI renders an upgrade prompt from the details payload rather than from a hard-coded string.
3.7.5 Editor on Pro tries to create an eleventh bio page #
Setup. Pro workspace with 10 bio pages counting toward the cap under Section 22.2.5. Unscoped Editor. Action bio_page.create.
Resolution. Step 5 passes. Step 7: quota check — current = 10, limit = 10. → 403 plan_limit_reached with
"details": [ { "field": "bio_pages", "issue": "limit_reached",
"limit": 10, "current": 10, "plan": "pro", "kind": "count" } ]Note the Editor is blocked but cannot upgrade (row 30 is Owner-only); the UI therefore offers "ask your workspace owner to upgrade" and names the Owner, rather than a checkout button. If the workspace also holds four archived pages, they do not appear in current, because archived resources do not consume a cap (Section 22.2.5).
3.7.6 Sole Owner tries to leave the workspace #
Setup. Owner is the only member. Action workspace.leave.
Resolution. Step 5: matrix row 11 gives C9 for Owner, and the last-owner condition fails. → 409 last_owner_required. The response details names the two valid paths: transfer ownership to an existing member, or delete the workspace. Because there is no other member, the UI surfaces the invite flow first.
3.7.7 A past-due workspace's QR code is scanned, and its owner tries to edit it #
Setup. Business workspace, day 3 of past_due, and separately the same workspace on day 10. A printed QR code is scanned by a customer; separately, the Owner opens the QR editor and changes the destination.
Scan resolution, on any day. The redirect path does not call authorize() (Section 3.5.1). The edge resolver reads the cached redirect payload, sees the billing state flag, applies LinkHub branding to the interstitial-free response, and issues a 302 to the active destination. Resolution is unaffected by billing state, permanently.
Edit resolution on day 3. Steps 1–8 pass. Step 8a reads entitlements.writes_blocked, which Section 22.7.5 sets to false for dunning days 0–7. → Allow. The destination change succeeds and is audited. The Owner sees a payment banner, not a block: a genuine card expiry is usually fixed within a week, and taking write access away on day one punishes the common case.
Edit resolution on day 10. Steps 1–8 pass identically. Step 8a: entitlements.writes_blocked is now true. → 403 billing_write_blocked, with details[0].grace_ends_at and details[0].invoice_url so the client can render the exact remedy. The Owner sees a "pay the outstanding invoice to resume editing" path; the Admin in the same workspace sees the same block with a message naming the Owner, because Admins cannot resolve billing themselves.
3.7.8 Scoped Editor creates a link, then is removed from the workspace #
Setup. Business workspace. Freelancer is an Editor scoped to 2 bio pages. They create a new short link.
On create. Steps 1–8 pass (row 64, C1, quota available). In the same transaction the link row is inserted and a grant is auto-created for the freelancer's membership with source = auto_on_create (Section 3.4.4, rule 2). The freelancer can now edit that link.
On removal. Priya removes the membership (row 17). In one transaction the membership is soft-deleted, all its grants including the auto-created one are deleted, and the cached entitlement and capability entries for that user and workspace are evicted. The link is untouched: it keeps resolving, it remains in the workspace's link list, created_by still records the freelancer for audit purposes, and any Admin or unscoped Editor can edit it. The freelancer's next request to that link fails at step 3 with 404 not_found, because they are no longer a member at all.
3.7.9 API key attempts a member invitation #
Setup. A Business workspace API key with scopes links:read, links:write. A script calls the member-invite endpoint on the workspace the key is bound to.
Resolution. Step 2: the workspace binding matches, so evaluation continues; the action's domain is members, which is denied to API-key actors unconditionally. → 403 action_not_available_to_api_key. Even a key created by the Owner cannot invite members; there is no scope that grants it.
3.7.10 API key from workspace A addresses workspace B #
Setup. Two Business workspaces under different billing accounts. A key created in workspace A calls GET /v1/links with workspace B's identifier in the path.
Resolution. Step 2, before the scope gate and before any capability evaluation: actor.api_key.workspace_id != workspaceId. → 404 not_found. The response is byte-identical to the response for a workspace identifier that has never existed, and identical to what a scoped member receives at step 6. The key's scopes are never consulted, so the caller learns nothing about workspace B — not that it exists, not who owns it, and not whether the key would have had the right scope. This is the tenancy boundary that Section 23.3 tests against.
3.7.11 Unverified user tries to publish a bio page #
Setup. New signup, Owner of their personal workspace, email not yet verified. Action bio_page.publish.
Resolution. Steps 1–3 pass. Step 4: the action is marked requires_verified_email (matrix row 50, C4). → 403 email_verification_required. Every other builder action succeeds, so the user can build the entire page before verifying; only the moment of making something publicly reachable is gated. The builder shows a persistent "verify to publish" banner with a resend control from first load.
3.7.12 A user on Pro tries to create a second workspace #
Setup. Verified user, Owner of one workspace on Pro. Action workspace.create. There is no workspace context: the user is creating one.
Resolution. The action is marked scope == ACCOUNT, so step 2a routes it to authorizeAccountScoped() before any membership or workspace lookup is attempted. The email check passes. The workspace cap for Pro is 1 (Section 22.1.2) and the billing account already owns 1. → 403 plan_limit_reached with details[0] = { field: "workspaces", issue: "limit_reached", limit: 1, current: 1, plan: "pro", kind: "count" }. On Business the same call returns Allow until the tenth workspace exists. Without this branch the action could not be evaluated at all, because steps 3 and 7 both require a workspace that does not yet exist.
4. Technology Stack & Architecture #
4.1 Stack #
4.1.1 Version policy #
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.
Version lines are stated here and nowhere else in this document. Every other section refers to dependencies by name only.
4.1.2 Core dependencies #
| Dependency | Line | Role | Why this choice |
|---|---|---|---|
| Node.js | 24 LTS | Runtime for all four deployables | Single language across web, edge, API and workers; native fetch, test runner, and stable ESM. LTS gives a predictable security-patch window |
| TypeScript | 7.x | Language for all application and library code | Types are the contract between the four deployables and six packages. Strict mode settings are in Section 5.1 |
| Next.js | 16.x (App Router) | apps/web — marketing site, authenticated dashboard, server-rendered public bio pages |
Server components let the public bio page ship zero blocking JavaScript while the dashboard remains a rich client. One framework covers both without a second rendering stack |
| React | 19.x | UI library for apps/web and packages/ui |
Server components and streaming are what make the ≤ 40 KB HTML budget achievable with a component model rather than a template language |
| Tailwind CSS | 4.x | Styling for dashboard and public surfaces | Utility CSS with a compile-time engine produces a small, deterministic critical stylesheet, which is what the ≤ 14 KB inline-CSS budget requires. Design tokens map to CSS custom properties so a workspace theme is a variable swap, not a rebuild |
| Hono | 4.x | apps/edge (redirect resolver) and apps/api (public REST API) |
Minimal routing overhead and a tiny cold-start footprint. The redirect path's p95 budget of 50 ms does not tolerate a heavyweight framework, and using the same framework for the public API keeps middleware shared |
| Drizzle ORM | 0.45.x, with drizzle-kit |
Schema definition, typed queries, migrations | Schema is TypeScript, so types flow from the database to the API without a code-generation step. Generated SQL is predictable, which matters for the partitioned analytics tables in Section 17.8.4. Migrations are plain SQL files under version control |
| PostgreSQL | 18 | System of record for every durable entity | Declarative range partitioning for click_events, native UUID support, jsonb for block content and targeting rules, and strong constraint support for the invariants in Section 6 |
| Redis | 8.x | Redirect cache, negative cache, session cache, rate-limit counters, event stream buffer | One dependency covers all five needs. Streams with consumer groups give at-least-once ingest without a separate message broker. The stream database is configured noeviction; Section 27.4.3 carries the setting and Section 26 asserts it at worker startup |
| BullMQ | 6.x | Job queues and scheduled jobs in apps/worker |
Redis-backed, so no additional infrastructure. Provides delayed jobs, repeatable jobs, per-queue concurrency, retries with backoff and dead-letter handling — all of which Section 4.8 depends on |
| Zod | 4.x | Runtime validation and the single source of schema truth | One schema definition produces the TypeScript type, the client-side form validation, the server-side request validation and the OpenAPI document (Sections 5.4, 21.10) |
| TanStack Query | 5.x | Server-state management in the dashboard | Cache invalidation, optimistic updates and request deduplication for the builder's autosave loop. Not used on public surfaces, which ship no blocking JavaScript |
| better-auth | 1.7.x | Authentication: email/password, magic link, Google OAuth, TOTP, session management | Owns its tables in the same PostgreSQL instance, so sessions and users join natively to workspaces and memberships. Avoids an external identity provider dependency on the critical sign-in path |
| Stripe Node SDK | 22.x | Subscriptions, Checkout, Billing Portal, webhooks, tax | Handles the parts of billing that are expensive to get right: proration, dunning, tax and invoice generation. Section 22 treats Stripe as the source of truth for money and LinkHub as the source of truth for entitlements |
| Vitest | 4.x | Unit and integration tests | Shares the build pipeline's transform, so tests run against the same module resolution as production. Integration tests run against real PostgreSQL and Redis via containers (Section 26.3) |
| Playwright | 1.6x | End-to-end, cross-browser and visual-regression tests | Required for the public-path guarantees: JavaScript-disabled navigation, mobile viewport authoring, and screen-reader-adjacent keyboard flows |
| sharp | 0.35.x | Image processing: avatars, block images, social preview cards, QR raster output | Produces AVIF and WebP at the sizes the public renderer requests, and rasterises QR symbols at 300 and 600 DPI for the scannability validation in Section 14.5.5 |
| pino | 9.x | Structured logging in all deployables | Low-overhead JSON logging with redaction paths configured once, which is how the redaction list in Section 5.6.3 is enforced mechanically rather than by convention |
| OpenTelemetry JS SDK | 2.x | Traces and metrics across all four deployables | Vendor-neutral instrumentation. A single trace spans the edge resolver, the stream write, the ingest worker and the rollup upsert, which is what makes the freshness metric in Section 2.8 measurable |
4.1.3 Supporting libraries #
These are installed at their current stable release at build time and recorded in the lockfile; they carry no version floor because none of them constrains the architecture.
| Library role | Choice | Notes |
|---|---|---|
| QR symbol generation | A Model 2 QR encoder with byte-level control over error-correction level, mask and module matrix | Must expose the raw module matrix so packages/core can render SVG itself with custom module shapes (Section 14.4) and quiet-zone enforcement (Section 14.5.4) |
| QR decoding for validation | A pure-JavaScript decoder run server-side against rasterised output | Used exclusively by the three-condition validation in Section 14.5.5 |
| Bot classification | A user-agent family classifier plus a maintained datacenter ASN list refreshed weekly | Section 17.6. The classifier operates on the in-memory user-agent string and emits only the family label and the daily-salted hash that Section 6 stores |
| Password strength estimation | A zxcvbn-class estimator | Section 7.3 requires a score of at least 3 |
| Drag and drop in the builder | A headless drag-and-drop library with first-class keyboard sensors | WCAG 2.2 SC 2.5.7 requires a non-dragging alternative (Section 24.6.1); a library without keyboard sensors is disqualified |
| Rich-text editing in blocks | A schema-constrained editor producing sanitised HTML restricted to an allow-list | Section 10.3.3 |
| Email template compilation | MJML-to-HTML at build time, with a plain-text alternative generated alongside | Section 7.12 |
| CSV generation and parsing | A streaming CSV library | Exports must stream to object storage without buffering entire result sets (Section 18.10) |
| Testcontainers | Container orchestration for integration tests | Section 26.3 |
4.2 The four deployables #
| Deployable | Responsibility | Traffic profile | Scaling characteristic | Why separate |
|---|---|---|---|---|
apps/web |
Marketing routes, authenticated dashboard, builder, server-rendered public bio pages | Mixed. Dashboard traffic is low-volume and session-heavy; public page traffic is high-volume, anonymous and cacheable | Horizontal, CPU-bound on server rendering. Public page routes sit behind a CDN with stale-while-revalidate; dashboard routes are never cached | It is the only deployable that needs React, the design system and a session cookie. Keeping it separate means the redirect path never inherits its bundle, its cold start or its dependency surface |
apps/edge |
Short-link and QR redirect resolution only | Highest volume by an order of magnitude. Short-lived, anonymous, latency-critical. Bursty — a single social post or a retail promotion can multiply traffic in minutes | Horizontal and near-linear. Each instance is stateless and its working set is a Redis lookup. Autoscales on request rate, not CPU, because the CPU cost per request is nearly constant | The p95 < 50 ms budget in Section 11.8.2 is only defensible if this process does one thing. It has no ORM on the hot path, no React, no session handling, and it deploys blue/green so a bad release never drops a scan |
apps/api |
Public REST API /v1, authenticated by API key |
Moderate, machine-generated, bursty per key. Long-tail of integration scripts | Horizontal, with per-key rate limits (Section 21.7) providing the primary backpressure | Third-party traffic must not be able to starve the dashboard or the redirect path. Separate deployment means separate resource limits, separate rate limits, separate alerting and an independent version lifecycle for the /v1 contract |
apps/worker |
All asynchronous work: analytics ingest, rollups, webhook delivery, ESP sync, domain verification, TLS renewal, QR rendering, exports, retention purge, reconciliation | Steady baseline with scheduled spikes at hourly and nightly boundaries | Horizontal per queue; concurrency is configured per queue (Section 4.8) so a slow webhook endpoint cannot delay analytics ingest | Long-running and retry-heavy work must never share a process with a request path that has a latency budget. Workers can be restarted, drained and scaled on entirely different signals from request-serving processes |
All four are built as OCI container images from the same monorepo, share packages/core and packages/db, and are versioned together. They are deployed independently: a change touching only the builder does not redeploy the redirect resolver.
4.3 Monorepo layout #
pnpm workspaces for dependency management, Turborepo for task orchestration and caching.
linkhub/
├── apps/
│ ├── web/ Next.js — marketing, dashboard, public bio pages
│ │ ├── app/ App Router route groups
│ │ │ ├── (marketing)/ Static: home, pricing, legal, changelog, docs index
│ │ │ ├── (auth)/ Sign in, sign up, verify, reset, 2FA challenge
│ │ │ ├── (dashboard)/ Authenticated workspace UI (see Sections 8–20)
│ │ │ ├── (public)/ Server-rendered bio pages, host- and handle-routed
│ │ │ └── api/ Route handlers: session-authenticated dashboard endpoints
│ │ ├── components/ App-specific components (shared ones live in packages/ui)
│ │ ├── lib/ App-local adapters: auth wiring, query client, formatters
│ │ └── public/ Static assets
│ ├── edge/ Hono — redirect resolver
│ │ ├── src/routes/ Resolve, health, ACME HTTP-01 challenge
│ │ ├── src/resolve/ Cache read, DB fallback, targeting, fallback chain
│ │ └── src/capture/ Fire-and-forget event emission to the Redis Stream
│ ├── api/ Hono — public REST API /v1
│ │ ├── src/routes/v1/ Resource routers: links, qr, pages, analytics
│ │ ├── src/middleware/ API-key auth, scope check, rate limit, idempotency
│ │ └── src/openapi/ OpenAPI document generated from shared Zod schemas
│ └── worker/ BullMQ consumers and schedulers
│ ├── src/queues/ One module per queue (Section 4.8)
│ ├── src/schedulers/ Repeatable job registration
│ └── src/processors/ Job handlers, one file per job type
├── packages/
│ ├── db/ Drizzle schema, migrations, seeds, typed client factory
│ │ ├── src/schema/ One file per domain: workspaces, links, qr, analytics…
│ │ ├── migrations/ Generated SQL, expand/contract, checked in
│ │ └── src/seed/ Deterministic development and test fixtures
│ ├── core/ Domain logic — the only place business rules live
│ │ ├── src/schemas/ Zod schemas; single source of truth (Section 5.4)
│ │ ├── src/entitlements/ Plan catalogue, quota evaluation (Section 22.2)
│ │ ├── src/permissions/ The matrix and authorize() from Sections 3.3 and 3.5
│ │ ├── src/resolve/ Pure redirect-resolution and fallback-chain logic
│ │ ├── src/qr/ Encoding, styling, scannability validation
│ │ ├── src/analytics/ Dimension extraction, visitor hashing, bucketing
│ │ └── src/errors/ AppError hierarchy and code registry (Section 5.5)
│ ├── ui/ Shared React components and design tokens
│ │ ├── src/primitives/ Button, Input, Dialog, Menu — accessible by construction
│ │ ├── src/blocks/ Bio page block renderers, shared by builder and public path
│ │ └── src/tokens/ Design tokens compiled to CSS custom properties
│ └── config/ Shared ESLint, TypeScript, Tailwind and Vitest configuration
├── infra/ Container definitions, compose files, deployment manifests
├── docs/ ADRs, runbooks, the OpenAPI snapshot
├── turbo.json
├── pnpm-workspace.yaml
├── DECISIONS.md
└── README.md4.3.1 Dependency rules between packages #
These are enforced by an ESLint import-boundary rule and fail the build when violated.
| Rule | Statement |
|---|---|
| 1 | packages/core may import packages/db types and schemas. It may not import from any apps/* package, and may not import packages/ui |
| 2 | packages/db imports nothing from the workspace except packages/config. It is the leaf |
| 3 | packages/ui may import packages/core types and Zod schemas only — never its data-access or entitlement functions. It never imports packages/db |
| 4 | apps/* may import any package. Apps never import from each other |
| 5 | Business rules live in packages/core. An apps/* file containing a plan limit, a permission decision or a fallback-chain branch is a review failure |
| 6 | SQL lives in packages/db or in a core module that owns a query. No SQL string in an apps/* route handler |
| 7 | apps/edge may import from packages/core only under src/resolve, src/analytics and src/errors. This keeps the redirect binary small and its dependency graph auditable |
4.4 System architecture #
4.4.1 Short-link and QR redirect #
Both surfaces are the same resolver on the same path shape. A QR code's public URL is the bare https://{host}/{slug} — there is no /q/ path prefix anywhere in the product. Two consequences follow, and both are the reason for the decision: a printed symbol encodes fewer characters, so it carries fewer modules and stays scannable at a smaller physical size; and QR slugs and short-link slugs share exactly one namespace per host, which is what allows the permanent reservation table to protect a QR slug and the identical short-link slug together (Section 14).
Visitor
│ GET https://go.acme.com/spring-sale
▼
┌─────────┐ TLS termination, no caching of redirects
│ CDN │ (Cache-Control: private, no-store is honoured; see Section 12.1.5)
└────┬────┘
▼
┌──────────────────────── apps/edge ────────────────────────┐
│ 1. Parse host + slug │
│ 2. Pipelined Redis read: │
│ MGET dom:host:{host} │
│ rd:{host}:{slug} │
│ rd:miss:{host}:{slug} │
│ 3. HIT → go to 6 │
│ MISS-CACHED → serve branded fallback, stop │
│ MISS → PostgreSQL read (single indexed query), │
│ write-through rd: key (TTL 3600s) │
│ or set rd:miss (TTL 60s) and serve fallback │
│ 4. Evaluate state: active / scheduled / expired / paused / │
│ archived / downgrade-locked (Sections 15.3, 22.5) │
│ 5. Evaluate targeting + experiment assignment │
│ (Sections 15.5, 16.2) — pure functions, no I/O │
│ 6. Derive visitor_hash + country_code/region_code │
│ in memory │
│ 7. XADD clicks:raw ← fire-and-forget, never awaited │
│ 8. Respond 302 Location: <destination> │
│ Cache-Control: private, no-store │
└────────────────────────────────────────────────────────────┘
│ │
▼ 302 ▼ (async)
Visitor Redis Stream clicks:rawdom:host:{host} is the only host-routing key in the system; nothing reads any other host key, and Section 4.7.3 is the complete list of keys that exist. Steps 2 and 3 are the only I/O on the hot path, and step 3's PostgreSQL branch is taken on cache miss only. Step 7 is dispatched without await; a failure increments a counter and is otherwise invisible to the visitor.
When the resolved resource is QR-backed, step 8 becomes the four-rung fallback chain in Section 14.8.2 rather than an unconditional 302: rungs 1 and 2 are 302 with Cache-Control: private, no-store, rungs 3 and 4 are 200 with Cache-Control: public, max-age=60. No rung is ever 404, 410 or 5xx.
4.4.2 Bio page render #
Visitor
│ GET https://acme.link/ (or handle-routed host)
▼
┌─────────┐ Cached HTML, stale-while-revalidate.
│ CDN │ Purged by tag on publish/unpublish/theme change (Section 4.7)
└────┬────┘
│ MISS
▼
┌────────────────── apps/web (public) ─────────────────────┐
│ 1. Resolve host → workspace + page (Redis dom:host:{host},│
│ then page:{host}:{handle}; PostgreSQL on miss) │
│ 2. Load published page snapshot: blocks, theme, SEO │
│ 3. Server-render React → HTML │
│ • critical CSS inlined (≤ 14 KB) │
│ • zero blocking JS; links are plain <a href> │
│ • images AVIF/WebP, LCP image eager + fetchpriority │
│ • embeds render as click-to-load facades │
│ 4. Derive visitor_hash + country_code/region_code in memory│
│ 5. XADD clicks:raw (event_type = page_view) — not awaited │
│ 6. Respond 200 with cache headers + strict nonce CSP │
└────────────────────────────────────────────────────────────┘
│
▼ (progressive enhancement only, deferred)
Analytics beacon, embed loaders, share sheetThe Content Security Policy applied at step 6 is Section 23.5's, in full; no other section defines a rival policy.
4.4.3 Authenticated dashboard mutation #
Dashboard (React, TanStack Query)
│ PATCH /api/links/{id} { destination_url }
▼
┌───────────────── apps/web (route handler) ───────────────┐
│ 1. Session cookie → session lookup (Redis sess:, then DB) │
│ 2. Zod parse of the request body (Section 5.4) │
│ 3. authorize(actor, workspace, "link.update_destination") │
│ → Section 3.5; any Deny returns the canonical │
│ error envelope with its status and code │
│ 4. Destination safety: scheme allow-list, DNS/SSRF check, │
│ Safe Browsing lookup (Section 23.6) │
│ 5. Transaction: │
│ UPDATE links … │
│ INSERT audit_log_entries (before/after) │
│ 6. Post-commit, in order: │
│ a. write-through rd:{host}:{slug} │
│ b. DEL rd:miss:{host}:{slug} │
│ c. enqueue cache-refresh job (belt and braces) │
│ d. enqueue webhook-dispatch job if configured │
│ 7. Respond 200 { "data": { … }, "meta": { } } │
└────────────────────────────────────────────────────────────┘
│
▼ TanStack Query invalidates the link list and detail keysThe cache write in step 6a happens after the transaction commits, never inside it. A crash between commit and cache write leaves a stale cache entry for at most the 3600-second TTL, and the enqueued refresh job in 6c closes that window in seconds.
Where step 5 writes an audit entry for a QR destination change, that entry is written with retention_expires_at = NULL so that no purge run can ever remove it. Sections 6 and 8 carry both the column behaviour and the matching purge predicate.
4.4.4 Analytics event, capture to rollup #
apps/edge / apps/web(public)
│ XADD clicks:raw * { event_type, workspace_id, resource_type,
│ resource_id, occurred_at, visitor_hash,
│ country_code, region_code, device_type,
│ os_family, browser_family, user_agent_family,
│ ua_hash, referrer_host, utm_*, variant_id,
│ fallback_stage, is_bot }
▼
┌──────────── Redis Stream: clicks:raw ────────────┐
│ consumer group "ingest", MAXLEN ~ 1,000,000 │
│ database configured noeviction (Section 27.4.3) │
└───────────────────────┬──────────────────────────┘
│ XREADGROUP (batch ≤ 1000 or 2s window)
▼
┌──────────── apps/worker — analytics-ingest ──────────────┐
│ 1. Validate + normalise each event (Zod) │
│ 2. Parse the user agent IN MEMORY → user_agent_family │
│ + ua_hash; discard the raw string (Section 23.12) │
│ 3. Bot classification → is_bot flag (never dropped) │
│ 4. Bulk INSERT into click_events, carrying │
│ stream_message_id + ingest_batch_id │
│ (daily range partition, PostgreSQL declarative) │
│ 5. Aggregate the same batch in memory by │
│ (workspace, resource, bucket_start, dimension, value)│
│ 6. UPSERT analytics_rollup_hourly (increment counters) │
│ 7. XACK the batch │
└───────────────────────┬──────────────────────────────────┘
│
┌───────────────┴────────────────┐
▼ ▼
┌── hourly → daily roll ──┐ ┌── nightly reconciliation ──┐
│ analytics-rollup queue │ │ recompute last 3 days from │
│ folds hourly into daily │ │ raw click_events, correct │
│ at each hour boundary │ │ any drift, then purge per │
└─────────────┬───────────┘ │ the retention table │
▼ └────────────────────────────┘
analytics_rollup_daily
│
▼
Dashboards + exports (Section 18) read rollups;
drill-downs read raw click_events within plan retentionColumn names shown here are the ones Section 6 defines; Section 6 is the sole schema authority and this diagram states no types, widths or constraints. The raw user-agent string is never a field on the stream payload's persisted form and is never written to any table.
4.5 Data flow: read and write paths #
PostgreSQL is the system of record. Redis is a cache and a buffer, never a source of truth. Every byte in Redis is either derivable from PostgreSQL or is in-flight data that has an explicit, documented loss window.
| Path | Source of truth | Cache role | Behaviour if the cache is empty |
|---|---|---|---|
| Redirect resolution | links, qr_codes, custom_domains in PostgreSQL |
rd:{host}:{slug} holds the fully resolved payload |
Correct but slower: a single indexed PostgreSQL query per miss, then write-through. Latency budget degrades to the p99 tier until the working set re-warms |
| Negative resolution | Absence of a row | rd:miss:{host}:{slug}, TTL 60s |
Every miss becomes a database read. The 60-second TTL bounds the cost of a slug-enumeration attack |
| Host routing | custom_domains |
dom:host:{host} |
Database read per request until warm |
| Session validation | sessions (hashed token) |
sess:{token_hash} |
Database read per request; sign-in still works |
| Entitlements | subscriptions + plan catalogue |
ent:workspace:{id}, TTL 300s |
Recomputed from the database; no behaviour change |
| Capability sets | Membership, role and grants | cap:{user}:{ws} |
Recomputed by authorize(); no behaviour change |
| Bio page render | bio_pages, blocks, themes |
page:{host}:{handle} plus CDN HTML cache |
Full server render from the database |
| Analytics events in flight | None until written to click_events |
clicks:raw Stream is the buffer |
This is the one place where a Redis loss loses data. The loss is bounded to un-acknowledged stream entries. It is accepted deliberately: the alternative is a synchronous write on the redirect path, which would violate both the latency budget and principle 2.3.3 |
| Rate limiting | None | rl:{scope}:{key} sliding-window counters |
Counters reset; limits become briefly permissive. Documented and accepted |
| QR rendered assets | Object storage, with the metadata row in PostgreSQL | qr:asset:{id} holds the current asset manifest |
Assets are re-fetched from object storage or re-rendered |
Write ordering is fixed everywhere: validate → authorize → transaction (data + audit) → commit → cache write-through → enqueue side effects. Nothing enqueues a job or writes a cache entry inside a database transaction, because a rolled-back transaction with a dispatched job produces a job referring to a row that does not exist.
4.6 Why the redirect path is a separate service #
The redirect budget is p50 < 20 ms, p95 < 50 ms, p99 < 120 ms of server-side processing (Section 11.8.2). That budget is the entire justification.
- The budget is a whole-process property, not a route property. Garbage-collection pauses, module-loading cost, connection-pool contention and middleware stacks are shared across every route in a process. A redirect served by the same process as a React server render inherits that process's tail latency. Separating the resolver removes the largest contributors to p99 from the hot path.
- The dependency graph must be small enough to audit.
apps/edgeimports three modules frompackages/core(Section 4.3.1, rule 7) and no ORM on the hot path. A smaller graph means faster cold start, a smaller attack surface, and a deployable whose behaviour under load can actually be reasoned about. - Scaling signals differ by an order of magnitude. Redirect traffic is bursty and anonymous; dashboard traffic is steady and session-bound. Autoscaling one on the other's signal either over-provisions the dashboard or under-provisions the resolver during a burst.
- Deployment risk differs.
apps/edgedeploys blue/green with health-gated cutover because a failed rollout means printed QR codes stop scanning. The dashboard deploys rolling, because a failed rollout means a retry. Coupling them forces the safest strategy on everything, which slows every release. - Failure isolation is the product promise. Section 2.3.7 requires that a total dashboard outage leaves every link and QR code resolving. That is only structurally true if the resolver shares no process, no memory and no request queue with the dashboard.
4.7 Caching strategy, the key set and the invalidation matrix #
This subsection is the single authority for both the Redis key namespace and cache invalidation. Where another section describes a cache effect, it references this subsection rather than restating a matrix of its own.
4.7.1 Cache layers #
| Layer | What it holds | TTL | Invalidation mechanism |
|---|---|---|---|
| CDN | Public bio page HTML and static assets | s-maxage=300, stale-while-revalidate=86400 for HTML; immutable, content-hashed URLs for assets |
Tag-based purge issued by apps/worker after commit |
| Redis — redirect | Resolved redirect payloads | 3600s | Write-through on mutation, plus explicit delete |
| Redis — negative | Known-missing host/slug pairs | 60s | Deleted on create of that slug |
| Redis — host routing | Domain → workspace, plan flags, branding, domain state | 300s | Deleted on any domain or plan mutation |
| Redis — page render | Serialised published page snapshot | 900s | Deleted on any page, block or theme mutation |
| Redis — entitlements | Resolved plan limits, feature flags and the writes_blocked mode per workspace |
300s | Deleted on subscription webhook, on plan change and on any dunning-day transition |
| Redis — capability | Resolved capability set per user per workspace | 300s | Deleted on membership, role or grant change |
| Redis — session | Session record by token hash | Session lifetime | Deleted on sign-out and on session revocation |
| In-process | Plan catalogue, block type registry, geo database handle | Process lifetime | Released on deploy only; all are immutable within a release |
Redirect payloads are never cached in the CDN or the browser. Cache-Control: private, no-store on every redirect response is what makes destination editing safe.
4.7.2 The canonical Redis key set #
These twelve keys are the complete set. A key that is not on this list does not exist, and adding one requires an ADR under Section 5.10.2 rule 6. Placeholders: {host} is a lower-cased punycode hostname, {slug} a link or QR slug, {handle} a bio page handle, {id}/{ws}/{user} are UUIDs, {date} is yyyymmdd, {token_hash} a SHA-256 digest, and {scope}/{key} identify a rate-limit bucket.
| Key | Holds | Written by | Read by |
|---|---|---|---|
rd:{host}:{slug} |
The fully resolved redirect payload for a link or QR code | Dashboard and API mutations, cache-refresh |
apps/edge |
rd:miss:{host}:{slug} |
Negative marker for a host/slug pair with no row | apps/edge on a confirmed miss |
apps/edge |
dom:host:{host} |
Host → workspace, domain state, plan flags, branding. The only host-routing key | domain-verify, tls-renew, subscription webhooks |
apps/edge, apps/web public |
page:{host}:{handle} |
Serialised published bio page snapshot | Publish, block, theme and handle mutations | apps/web public |
ent:workspace:{id} |
Resolved entitlements, including the writes_blocked enforcement mode |
authorize() on miss, subscription webhooks, dunning transitions |
authorize(), quota display |
cap:{user}:{ws} |
Resolved capability set for a member | authorize() on miss |
The batch capability endpoint, dashboard render |
qr:asset:{id} |
Current QR asset manifest for a QR code | qr-render |
Download and export endpoints |
salt:visitor:{date} |
The daily visitor-hash salt, and the same salt used for ua_hash |
Salt rotation job | apps/edge, apps/web public, analytics-ingest |
ab:seen:{...} |
Experiment exposure de-duplication marker | Assignment evaluation | Assignment evaluation |
clicks:raw |
The analytics event stream (a Redis Stream, not a string key) | apps/edge, apps/web public |
analytics-ingest |
rl:{scope}:{key} |
Sliding-window and burst-bucket counters | Rate-limit middleware | Rate-limit middleware |
sess:{token_hash} |
Session record | Sign-in, session extension | Every authenticated request |
4.7.3 The invalidation matrix #
Every mutation below lists exactly the keys it must touch. {h} is the resource's host, {s} its slug, {w} the workspace id. This table is authoritative for the whole document.
| Mutation | Keys written or deleted | CDN purge tag |
|---|---|---|
link.create |
write rd:{h}:{s}; DEL rd:miss:{h}:{s} |
link-{link_id} |
link.update_destination |
write rd:{h}:{s} |
link-{link_id} |
link.update_slug |
DEL rd:{h}:{old_s}; write rd:{h}:{new_s}; DEL rd:miss:{h}:{new_s} |
link-{link_id} |
link.update_utm / set_targeting_rules / set_schedule_expiry |
write rd:{h}:{s} |
link-{link_id} |
link.set_password / set_interstitial |
write rd:{h}:{s} |
link-{link_id} |
link.archive / unarchive |
write rd:{h}:{s} with the new state |
link-{link_id} |
link.delete (soft) |
DEL rd:{h}:{s}; set rd:miss:{h}:{s} |
link-{link_id} |
link.restore |
write rd:{h}:{s}; DEL rd:miss:{h}:{s} |
link-{link_id} |
link.bulk_import / bulk_edit |
write rd:{h}:{s} per affected row, pipelined in batches of 500 |
link-{link_id} per row |
qr.create |
write rd:{h}:{s}; DEL rd:miss:{h}:{s} |
qr-{qr_id} |
qr.update_destination |
write rd:{h}:{s} |
qr-{qr_id} |
qr.set_paused_fallback / qr.pause / qr.resume |
write rd:{h}:{s} with the new fallback_stage |
qr-{qr_id} |
qr.update_styling |
DEL qr:asset:{qr_id} (resolution payload untouched) |
qr-{qr_id} |
qr.archive / qr.delete |
write rd:{h}:{s} with the appropriate fallback_stage and rung — never delete the key, never set a miss key |
qr-{qr_id} |
bio_page.create |
DEL page:{h}:{handle}; DEL rd:miss:{h}:{handle} |
— |
bio_page.publish |
write page:{h}:{handle} |
page-{page_id} |
bio_page.unpublish |
DEL page:{h}:{handle} |
page-{page_id} |
bio_page.update_content / update_theme / update_seo (published page) |
write page:{h}:{handle} |
page-{page_id} |
bio_page.update_handle |
DEL page:{h}:{old_handle}; write page:{h}:{new_handle} |
page-{page_id}, host-{h} |
bio_page.delete (soft) |
DEL page:{h}:{handle} and every render entry for the page |
page-{page_id} |
bio_page.restore |
DEL rd:miss:{h}:{handle}; write page:{h}:{handle} if published |
page-{page_id} |
| Draft edited or autosaved | none | none — this row exists so the absence is explicit; drafts are never public |
block.add / update / reorder / delete (published page) |
write page:{h}:{handle} |
page-{page_id} |
block.set_schedule crossing a boundary (worker-driven) |
write page:{h}:{handle} |
page-{page_id} |
| Theme saved to a published page | write page:{h}:{handle} |
page-{page_id}, theme-{theme_id} |
workspace.update_brand |
DEL ent:workspace:{w}; enqueue page refresh for every published page in {w} |
ws-{w}, theme-{theme_id} |
workspace.update_settings (consent, timezone) |
DEL ent:workspace:{w}; enqueue page refresh for every published page in {w} — consent banner presence is server-rendered |
ws-{w} |
| Media asset replaced or deleted | enqueue page refresh for every published page referencing it | media-{media_id} |
| Media moderation takedown | same as above, plus removal of the stored object; runs on the priority purge queue with a target under 30 seconds | media-{media_id} |
domain.verified → active |
write dom:host:{h} |
host-{h} |
domain.set_default |
write dom:host:{h} for both old and new default |
host-{h} |
domain.remove or suspend |
DEL dom:host:{h}; DEL page:{h}:* — stale rd:{h}:* keys become unreachable because host routing is resolved first, so no key scan is required |
ws-{w}, host-{h} |
domain.tls_renewed |
write dom:host:{h} |
— |
| Subscription change (any Stripe webhook that alters plan or status) | DEL ent:workspace:{w}; write dom:host:{h} for every workspace host (branding and lock flags live there) |
ws-{w} |
Dunning day crossing into or out of read_only (Section 22.7.5) |
DEL ent:workspace:{w} so writes_blocked is re-resolved |
— |
| Workspace suspended or unsuspended | DEL ent:workspace:{w}; DEL page:{h}:*; rewrite rd:{h}:* for the workspace. QR-backed keys are rewritten with a fallback rung, never deleted |
ws-{w} |
member.remove / member.role_change / grant change |
DEL cap:{user}:{w} |
— |
| Safety flag raised on a destination | write rd:{h}:{s} for every affected link, on the priority refresh queue; enqueue page refresh for every published page linking to it |
page-{page_id} per page, link-{link_id} per link |
experiment.start / pause / promote_winner |
write rd:{h}:{s} for every link in the experiment; write page:{h}:{handle} for every page variant |
page-{page_id} per variant |
| Session sign-out / revoke | DEL sess:{token_hash} |
— |
| Daily salt rotation (00:00 UTC) | write the new salt:visitor:{date}; retain the previous for 60 minutes to cover in-flight requests |
— |
| Experiment salt rotation (7-day) | write the new experiment salt; the previous is retained for 24 hours. Note that visitor-level assignment stickiness is bounded at 24 hours by the daily visitor salt regardless of this epoch (Section 16.2.5) | — |
Three rules govern the whole matrix:
- Write-through, then delete the negative. Any mutation that makes a host/slug resolvable must write the positive key and delete the negative key, in that order. Reversing the order leaves a window in which the negative key is authoritative.
- A QR key is never deleted and never negatively cached. Archive, delete, suspension and workspace deletion all rewrite the payload with the appropriate
fallback_stageand rung. This is the cache-layer expression of the permanence rule in Section 14.8. - Purges are idempotent and bounded. Every purge job retries five times with exponential backoff and emits a metric on failure. Because payloads are revision-scoped and TTLs are short, an exhausted purge produces bounded staleness, never incorrect content.
4.8 Queue topology #
All queues are BullMQ queues backed by the same Redis instance, namespaced lh:. Queue names are kebab-case. Every job carries job_id set to its idempotency key, so a duplicate enqueue is a no-op rather than a duplicate execution.
| Queue | Producer | Concurrency | Attempts | Backoff | Dead-letter behaviour | Idempotency key |
|---|---|---|---|---|---|---|
analytics-ingest |
Stream consumer loop (self-scheduling) | 4 per worker instance | 5 | Exponential, base 2s, cap 60s | Unacknowledged stream entries are reclaimed by XAUTOCLAIM after 5 minutes and retried; after 5 failures the batch is written to jobs_dead_letter with the raw payload and alerts |
ingest_batch_id covering the stream entry range |
analytics-rollup |
Scheduler, hourly at :02 | 2 | 3 | Exponential, base 30s | Alerts; the nightly reconciliation job repairs the gap | rollup:{granularity}:{bucket_start} |
analytics-reconcile |
Scheduler, nightly 03:15 UTC | 1 | 2 | Fixed 10m | Alerts only; the next night's run covers the same window | reconcile:{date} |
retention-purge |
Scheduler, nightly 04:00 UTC | 1 | 3 | Fixed 30m | Alerts; purge is idempotent and the next run retries | purge:{date} |
qr-render |
API/dashboard on styling change; retry on failure | 6 | 3 | Exponential, base 5s | Marks the QR version render_failed, surfaces the reason in the editor, leaves the previous rendered version in place and resolving |
qr_render:{qr_version_id} |
domain-verify |
Domain add; scheduler for re-checks | 8 | 1 per scheduled attempt (the schedule is the retry) | Every 30s for 15 minutes, then every 5 minutes to 72 hours | Transitions the domain to dns_failed with the last observed DNS values; user can retry manually |
domain_verify:{domain_id}:{attempt_window} |
tls-provision |
Domain reaching verifying success |
4 | 5 | Exponential, base 60s, cap 30m | Transitions to tls_failed with the ACME error surfaced verbatim in the UI; hourly retry continues in the background |
tls:{domain_id}:{order_id} |
tls-renew |
Scheduler, hourly | 4 | 5 | Exponential, base 5m | Alert at 14 days remaining, page at 7 days (Section 25.6). The existing certificate keeps serving until expiry | tls_renew:{domain_id}:{not_after} |
webhook-dispatch |
Domain events (click aggregate, lead captured) | 10 | 5 | 10s, 1m, 10m, 1h, 6h | Moves to the workspace's dead-letter view with the full request and response; replayable from the UI (Section 19.7) | webhook:{event_id}:{workspace_id} |
esp-sync |
Lead capture | 5 | 5 | Exponential, base 30s, cap 2h | Marks the lead sync_failed with the provider error; the lead itself is never lost and remains exportable |
esp:{lead_id}:{destination_id} |
email-send |
Auth flows, invitations, reports, alerts | 10 | 4 | Exponential, base 15s, cap 15m | Alerts; auth-critical emails (verification, reset) additionally surface a "resend" path in the UI | email:{template}:{recipient_hash}:{context_id} |
export-generate |
Export request (analytics, leads, audit, GDPR bundle) | 3 | 3 | Exponential, base 60s | Marks the export failed with a reason and notifies the requester |
export:{export_id} |
cache-refresh |
Post-commit side effect; bulk operations; priority lane for safety flags and media takedowns | 8 | 3 | Exponential, base 2s | Logged and alerted at a threshold rate; the 3600s TTL bounds any residual staleness | cache:{key}:{version} |
safe-browsing-recheck |
Scheduler, weekly per destination cohort | 4 | 3 | Exponential, base 5m | Alerts; the previous verdict remains in force | sb:{destination_hash}:{week} |
geo-db-refresh |
Scheduler, weekly | 1 | 3 | Fixed 1h | Alerts; the existing database file continues to be used (Section 4.9.4) | geodb:{iso_week} |
slack-notify |
Milestone rules | 5 | 3 | Exponential, base 30s | Dropped after final attempt with a log entry; notifications are not durable state | slack:{rule_id}:{trigger_id} |
Cross-cutting rules:
- Every job handler is written to be safely re-runnable. Where a handler cannot be naturally idempotent, it takes an advisory lock on its idempotency key for the duration of the job.
- Job payloads carry identifiers, never copies of mutable state. A handler re-reads from PostgreSQL. This prevents a delayed job from applying a stale value.
- No job payload contains personal data beyond identifiers. Email addresses in
email-sendare resolved at execution time from the database, so a queued job is not a copy of a mailing list. - Queues have a per-queue concurrency limit rather than a global one, so one slow external dependency cannot consume the worker pool.
- Dead-letter contents are retained 30 days, then purged.
- Every worker asserts at startup that the Redis database backing
clicks:rawis configurednoeviction, and refuses to start otherwise. An eviction policy that can discard stream entries would silently lose analytics events, which is exactly the failure the bounded-loss model in Section 4.5 is written to exclude.
4.9 Failure modes and degradation ladder #
For each dependency: what breaks, what the user sees, and the invariant that must still hold.
4.9.1 Redis unavailable #
| Aspect | Behaviour |
|---|---|
| Redirects | Continue. The resolver detects the failure on its pipelined read within a 30 ms timeout, opens a circuit breaker, and falls through to a direct PostgreSQL query for every request. Latency moves to the p99 tier; a cache_unavailable counter drives the alert |
| Bio pages | Continue. CDN serves cached HTML with stale-while-revalidate; misses fall through to a database render |
| Analytics | Capture is suspended. XADD failures increment a counter and are otherwise silent. Events in this window are lost, which is the accepted trade in Section 4.5. Rollups resume automatically when Redis returns |
| Dashboard sessions | Continue. Session lookup falls back to the sessions table |
| Queues | Stop. BullMQ is Redis-backed. Producers enqueue into a bounded in-memory spill buffer of 10,000 jobs per instance and drain it on recovery; beyond that, jobs are dropped with an error log and the affected operations are surfaced as retriable in the UI |
| Rate limiting | Fails open for authenticated dashboard traffic and for the public API, and closed for unauthenticated auth endpoints. Availability is preferred where an actor is already identified; refusing all API traffic because the limiter is down converts a degradation into an outage, and Section 21.7.2 states the same rule |
| Entitlements | Recomputed from PostgreSQL per request. writes_blocked is therefore still correct during the incident; a cache outage never grants or removes write access |
| Invariant | Every short link and every QR code still resolves, and no QR code returns an error status. |
4.9.2 PostgreSQL unavailable #
| Aspect | Behaviour |
|---|---|
| Redirects | Continue for anything in the Redis cache — which, at a 3600-second TTL, is the entire active working set. Cache misses serve the branded "temporarily unavailable" page for links; QR codes descend the fallback chain to rung 3 (the workspace branded page) or rung 4 (the neutral platform page), and still return a 200, never a 404 or 410 |
| Bio pages | CDN-cached pages continue. Uncached pages serve a minimal branded fallback with a 503 and Retry-After |
| Dashboard | Read-only where cached, otherwise a full-page error state naming the incident, with a status-page link. All mutations fail with 503 service_unavailable |
| Public API | 503 service_unavailable with Retry-After. No partial writes |
| Workers | Pause consumption via a health gate rather than burning retry attempts. Stream entries are left unacknowledged and are reclaimed after recovery, so ingest resumes without loss provided Redis is healthy |
| Invariant | No QR scan returns an error status. The fallback chain is evaluated entirely from cached data plus static templates. |
4.9.3 Queue backlog growing #
| Aspect | Behaviour |
|---|---|
| Detection | Per-queue depth and oldest-job age are exported metrics. Warning at 5 minutes of ingest lag, page at 15 (Section 25.6) |
| Analytics ingest backlog | Redirects and page views are unaffected — capture is fire-and-forget. Dashboards show a "data delayed" indicator with the current lag when it exceeds 5 minutes, rather than showing a silently incomplete chart. The stream's MAXLEN of approximately 1,000,000 entries is the hard bound; beyond it, the oldest entries are trimmed and a data-loss counter fires |
| Webhook backlog | Delivery lags. Customer-visible in the webhook delivery log with per-delivery timestamps. Never blocks the originating operation |
| QR render backlog | The editor shows "rendering" state with an estimated time. Previously rendered assets keep resolving and downloading. A pending render never affects resolution |
| Export backlog | Requests queue; the requester sees a position-free "in progress" state and receives an email when the signed link is ready |
| Mitigation | Queue concurrency is per-queue, so a backlog in one queue does not starve others. Worker instances scale horizontally on queue depth |
| Invariant | No queue backlog can delay or fail a redirect, a scan or a page render. |
4.9.4 Geo database unavailable or stale #
| Aspect | Behaviour |
|---|---|
| Cause | The weekly refresh job fails, or the database file is missing from a fresh image |
| Behaviour | The resolver continues using the last successfully loaded database. If no database is available at all, country_code is recorded as ZZ, region_code as null, and geo_resolved as false; events are ingested normally |
| User-visible | Analytics geo breakdowns show an "Unknown" bucket for the affected period, labelled as such rather than omitted |
| Consent gating | The EEA/UK/CH geo-gate for the consent banner fails safe: with no geo result, the banner is shown and third-party pixels are withheld until consent (Section 23.13.3) |
| Alerting | The refresh job failing twice consecutively pages; a database older than 30 days warns |
| Invariant | Redirect and render latency are unaffected, and no visitor is served a pixel they have not consented to. |
4.9.5 Object storage unavailable #
| Aspect | Behaviour |
|---|---|
| Bio pages | Uploaded images are served from the CDN, which holds them with a long TTL and immutable content-hashed URLs. Cached images continue. A miss renders the page with the image's reserved intrinsic dimensions preserved, so layout does not shift, and the alt text is presented |
| QR asset downloads | Fail with 503 and an explicit retry action. Resolution is entirely unaffected — resolution reads the database and Redis, never object storage |
| Uploads | Rejected with 503 storage_unavailable and a retry action. No partial database row is created; the upload record is written only after the object is confirmed stored |
| Exports | export-generate retries per its backoff; the export stays pending |
| Invariant | No page fails to render and no code fails to resolve because a file could not be fetched. |
4.9.6 Stripe unavailable #
| Aspect | Behaviour |
|---|---|
| Entitlements | Unaffected. Entitlements are computed from the local subscriptions table, which Stripe webhooks update. A Stripe outage cannot downgrade anyone, and cannot advance a dunning day, so writes_blocked cannot flip during the incident |
| Checkout and portal | Fail with a clear retry message |
| Webhooks | Stripe retries on its own schedule; the webhook handler is idempotent on event.id and processes out-of-order events by comparing created timestamps against the stored subscription version (Section 22.10.4) |
| Invariant | No customer loses access to a paid feature because of a payment-processor outage. |
4.9.7 Degradation ladder summary #
Ordered from most to least degraded. The system always occupies the highest level it can.
| Level | State | What still works |
|---|---|---|
| 0 | Fully healthy | Everything |
| 1 | Cache degraded (Redis down) | All resolution, all rendering, dashboard; analytics capture paused, queues paused |
| 2 | Database degraded (PostgreSQL down) | Resolution from cache, CDN-cached pages, QR fallback chain; no mutations |
| 3 | Cache and database degraded | QR codes resolve at rung 4 to the neutral platform landing page; bio pages serve from CDN only; short links serve the branded unavailable page |
| 4 | Total regional failure | The static rung-4 document is served from the CDN's origin-shield-cached copy at the edge |
At no level does a QR scan produce a 404, a 410, or a connection error that LinkHub can control.
4.10 Scale assumptions and capacity targets #
All arithmetic below is shown so it can be re-run with different inputs. These are the year-one planning targets that size the reference deployment in Section 27.2.
4.10.1 Planning inputs #
| Input | Value | Basis |
|---|---|---|
| Workspaces | 50,000 | Year-one target |
| Resolvable resources (links + QR codes) | 5,000,000 | 100 per workspace average, heavily skewed |
| Published bio pages | 60,000 | 1.2 per workspace average |
| Redirect + scan events | 250,000,000 / month | 50 events per resolvable resource per month |
| Bio page views | 40,000,000 / month | Included in the same pipeline |
| Plan mix by event volume | 25% Free, 60% Pro, 15% Business | Business workspaces are lower in count but higher in traffic |
4.10.2 Redirect throughput #
290,000,000 events/month (250M redirects + 40M page views)
÷ 2,592,000 seconds/month (30 days)
= 112 events/second average
Peak factor 10× (a single viral post or retail promotion)
= 1,120 events/second peak
Design headroom 4.5×
= 5,040 events/second → target 5,000 rps sustained5,000 rps is the k6 load-test target in Section 26.7, chosen to match this arithmetic rather than being an arbitrary round number.
4.10.3 Edge instance sizing #
Measured CPU cost per cache-hit redirect ≈ 0.8 ms of one vCPU
Theoretical per-vCPU capacity = 1000 / 0.8 = 1,250 rps
Target steady-state utilisation = 60%
Effective per-vCPU capacity = 750 rps
5,000 rps ÷ 750 = 6.7 vCPU
Round up for N+1 availability across zones:
4 instances × 2 vCPU = 8 vCPU (each instance carries 1,250 rps at 5,000 rps total)Autoscaling triggers on requests per second per instance with a target of 900, scaling out at 1,100 and in at 500, with a 5-minute cool-down on scale-in to absorb bursty traffic.
4.10.4 PostgreSQL storage for raw events #
Column names below are Section 6's; this is a byte-width estimate, not a schema definition.
Row width for click_events:
tuple header + alignment 24 B
id (uuid, UUIDv7) 16 B
workspace_id, resource_id (uuid) 32 B
occurred_at (timestamptz) 8 B
visitor_hash (16 B binary) 16 B
ua_hash (16 B binary) 16 B
user_agent_family (avg) 20 B
country_code(2), region_code(≤6) 12 B
device_type, os_family, browser_family 24 B
referrer_host (avg) 28 B
utm_source/medium/campaign (avg) 48 B
variant_id, resource_type, is_bot,
fallback_stage, geo_resolved 20 B
─────────────────────────────────
≈ 264 B payload
+ 2 indexes at ~60 B each 120 B
─────────────────────────────────
≈ 384 B per event, round to 385 B
290,000,000 events/month × 385 B ≈ 111.7 GB per month of raw eventsThe raw user-agent string is not in this arithmetic because it is never stored (Section 23.12); only the 16-byte ua_hash and the short family label are, and both are included above.
Retention is per plan, so steady-state storage is a weighted sum:
Free 25% × 111.7 GB × 1 month = 27.9 GB
Pro 60% × 111.7 GB × 3 months = 201.1 GB
Business 15% × 111.7 GB × 24 months = 402.1 GB
──────────────────────────────────────────────
Steady-state raw event storage ≈ 631 GB
Provision with 2× headroom ≈ 1.3 TBDaily partitions mean the purge worker drops whole partitions rather than issuing DELETE statements, so purge cost is independent of row count. At 24-month Business retention the table carries roughly 730 live partitions, which is well within PostgreSQL's practical planning limits when queries are partition-pruned by the mandatory date filter in Section 18.14.1.
4.10.5 Rollup storage #
Assumptions:
Resources with at least one event on a given day ≈ 250,000
Median distinct dimension values per active resource/day ≈ 26
(country_code 8, region_code 6, device_type 3, os_family 3,
browser_family 3, referrer_host 2, utm_* 3, variant 1, is_bot 1
— median case)
Heavy tail: 2,000 resources × 180 rows = 360,000
Daily rollup rows/day = (250,000 × 26) + 360,000 ≈ 6.86 M
Hourly rollup rows/day ≈ 2.4 × daily ≈ 16.5 M
(more buckets, far fewer distinct values each)
Row width ≈ 120 B including indexes.
Hourly retained per plan, worst case 365 days:
16.5 M × 365 × 120 B ≈ 723 GB
Daily retained per plan (Business indefinite):
6.86 M/day × 365 × 120 B ≈ 300 GB/yearHourly rollup retention follows the plan table in Section 6 — 30 days Free, 365 days Pro, indefinite Business — so the 365-day figure above is the Pro worst case and Business is sized from the same per-day rate. Daily rollups follow the plan retention table. Dashboards beyond 90 days read daily granularity regardless of what hourly data survives.
4.10.6 Redis working set #
Hot redirect payloads: 5% of 5,000,000 resolvable resources
= 250,000 keys × 600 B payload = 150 MB
Host routing: 30,000 hosts × 400 B = 12 MB
Page render snapshots: 60,000 × 6 KB
(10% hot: 6,000 × 6 KB) = 36 MB
Sessions: 200,000 active × 250 B = 50 MB
Entitlements: 50,000 × 300 B = 15 MB
Capability sets: 60,000 × 200 B = 12 MB
Rate-limit counters = 20 MB
clicks:raw stream, MAXLEN 1,000,000 × 260 B = 260 MB
─────────────────────────────────────────────────────
Working set ≈ 555 MB
Provision = 2 GB (≈ 3.6× headroom)The stream database is provisioned separately from the cache database precisely because it is configured noeviction: a cache database may evict under pressure, and the stream database must never do so.
4.10.7 Connection budget #
PostgreSQL connections, via a transaction-mode pooler:
apps/web 6 instances × pool 10 = 60
apps/edge 4 instances × pool 5 = 20 (cache-miss path only)
apps/api 3 instances × pool 10 = 30
apps/worker 4 instances × pool 15 = 60
migrations / operator access = 10
───────────────────────────────────────
Peak pooled client connections = 180
Pooler → PostgreSQL server connections ≈ 40 (transaction mode multiplexing)The edge pool is deliberately small: on the hot path it does no database work at all, and a large pool would only mask a cache failure by amplifying database load during precisely the incident where the database is least able to absorb it.
4.10.8 Capacity targets summary #
| Dimension | Target |
|---|---|
| Sustained redirect throughput | 5,000 rps |
| Redirect p95 server processing | < 50 ms |
| Analytics ingest throughput | 8,000 events/second (60% headroom over peak redirect rate) |
| Analytics freshness p95 | < 90 seconds from capture to hourly rollup |
| Bio page render (uncached, server) p95 | < 180 ms |
| Public API p95 | < 250 ms for list endpoints at the default limit of 25 |
| Dashboard mutation p95 | < 400 ms including the destination safety check |
| QR render p95 including all three validation conditions | < 4 seconds |
| Export generation | 1,000,000 rows streamed to object storage in < 5 minutes |
5. Conventions & Standards #
5.1 TypeScript configuration #
A single base configuration lives in packages/config and every package extends it. Packages may narrow settings; they may never loosen them.
| Setting | Value | Reason |
|---|---|---|
strict |
true |
Non-negotiable baseline |
noUncheckedIndexedAccess |
true |
arr[0] is T | undefined. Prevents an entire class of runtime error in analytics dimension handling |
exactOptionalPropertyTypes |
true |
Distinguishes "absent" from "explicitly undefined", which is what makes the PATCH rule in Section 23.4.2 — absent means "leave alone", explicit null means "clear" — expressible in the type system |
noImplicitOverride |
true |
Makes the error class hierarchy in Section 5.5 explicit |
noFallthroughCasesInSwitch |
true |
Block-type and fallback-rung switches must be exhaustive |
noImplicitReturns |
true |
Every branch of a resolution function returns a decision |
verbatimModuleSyntax |
true |
Type-only imports are erased predictably; keeps the edge bundle minimal |
isolatedModules |
true |
Required for fast transpile-only builds |
moduleResolution |
bundler |
Matches the toolchain across all packages |
target / lib |
Current LTS-supported ECMAScript target | No downlevelling; the runtime is known |
skipLibCheck |
true |
Third-party type errors are not this project's build failures |
Additional rules:
anyis banned by lint rule. Useunknownand narrow. The single permitted exception is inside a type-guard function body, which must carry a comment explaining the guard.- Non-null assertion (
!) is banned by lint rule. Narrow explicitly or throw a domain error. - Type assertions (
as) require a comment stating why the compiler cannot prove the claim.as unknown as Tis banned outright. - Exhaustiveness is enforced with an
assertNever(value: never): neverhelper at the end of every discriminated-union switch. - Enums are not used. Use
as constobjects with a derived union type, which erase cleanly and are structurally comparable across package boundaries. - Public exports from
packages/*must have explicit return types. Inference is fine inside a module, never across one.
5.2 Naming conventions #
| Domain | Convention | Correct | Incorrect |
|---|---|---|---|
| Database tables | snake_case, plural |
bio_pages, click_events, qr_slug_reservations |
BioPage, click_event, tblLinks |
| Database columns | snake_case, singular |
workspace_id, created_at, destination_url |
workspaceId, CreatedAt, dest |
| Database indexes | idx_{table}_{columns}; unique uq_{table}_{columns}; foreign key fk_{table}_{ref} |
idx_click_events_workspace_id_occurred_at |
click_events_idx1 |
| TypeScript variables and functions | camelCase |
resolveRedirect, visitorHash |
ResolveRedirect, visitor_hash |
| TypeScript types, interfaces, components | PascalCase |
BioPage, RedirectDecision, LinkEditor |
bioPage, IBioPage (no Hungarian prefixes) |
| TypeScript constants | SCREAMING_SNAKE_CASE for module-level immutable values |
MAX_SLUG_LENGTH, DEFAULT_PAGE_LIMIT |
maxSlugLength |
| Zod schemas | PascalCase + Schema suffix |
CreateLinkInputSchema |
createLinkSchema |
| URLs and routes | kebab-case |
/workspaces/{slug}/short-links, /api/qr-codes |
/shortLinks, /qr_codes |
| Environment variables | SCREAMING_SNAKE_CASE, prefixed by domain |
DATABASE_URL, REDIS_URL, LH_SESSION_COOKIE_DOMAIN |
databaseUrl, db-url |
| Redis keys | namespace:entity:id, lower-case, colon-delimited, and drawn only from the twelve-key set in Section 4.7.2 |
rd:go.acme.com:spring-sale, sess:{token_hash}, ent:workspace:{id} |
Redirect/GoAcme/spring, rd-go.acme.com-spring-sale |
| Queue names | kebab-case |
analytics-ingest, domain-verify, qr-render |
analyticsIngest, QRRender |
| Job names within a queue | kebab-case verb-first |
rebuild-daily-rollup, send-invitation-email |
RollupRebuild |
| Public API JSON fields | snake_case |
destination_url, created_at, next_cursor |
destinationUrl, createdAt |
| API error codes | snake_case, stable |
qr_slug_reserved, plan_limit_reached |
QrSlugReserved, ERR_1042 |
| Feature flags | SCREAMING_SNAKE_CASE with a FF_ prefix |
FF_BULK_LINK_IMPORT |
bulkImport |
| CSS custom properties (design tokens) | --lh-{category}-{name} |
--lh-color-accent, --lh-radius-md |
--accent, --Color-Accent |
| Test files | {subject}.test.ts unit, {subject}.int.test.ts integration, {flow}.e2e.ts end-to-end |
resolve-redirect.test.ts |
test1.ts |
The boundary between camelCase in TypeScript and snake_case in JSON is crossed in exactly one place: a serialisation layer in packages/core that maps domain objects to API representations. No hand-written snake_case object literals appear in application code, and no camelCase field ever reaches a public API response.
5.3 Repository conventions #
| Concern | Rule |
|---|---|
| File naming | kebab-case.ts for every file. React component files are also kebab-case.tsx and export a PascalCase component; the file name is not the component name |
| One export per file | A module exports one primary symbol plus its directly associated types. A file exporting five unrelated functions is split |
| Folder naming | kebab-case, plural for collections of like things (processors/, schemas/), singular for a single concern (auth/, resolve/) |
| Barrel files | Permitted only at package roots (packages/*/src/index.ts) and only with explicit named re-exports. Barrel files inside a package are banned: they defeat tree-shaking, create import cycles, and inflate the edge bundle |
| Import ordering | Enforced by lint, four groups separated by a blank line: (1) Node built-ins, (2) external packages, (3) workspace packages @linkhub/*, (4) relative imports. Alphabetical within each group; type-only imports use import type |
| Path aliases | @linkhub/{package} for workspace packages; @/ for app-internal absolute imports within an app. Relative imports never go up more than one level — ../../.. means the module is in the wrong place |
| Circular imports | Banned; enforced by lint. Break the cycle with a shared types module, never with a dynamic import |
| Dead code | An exported symbol with no importer fails the unused-export lint check |
| Generated files | Committed (migrations, the OpenAPI document) so that review sees the diff, and regenerated in CI to verify they are current. A drift between generated and committed output fails the build. Generated artefacts are never gitignored |
| Secrets | Never in the repository. .env.example lists every variable with a safe placeholder and a one-line description; a lint rule rejects any file matching known secret patterns |
5.4 Validation #
One Zod schema per concept is the single source of truth. It is defined once in packages/core/src/schemas/, consumed by the client form, the server route handler, the public API and the OpenAPI document. There is no second definition anywhere, and no hand-written TypeScript interface duplicating a validated shape.
5.4.1 The pattern #
// packages/core/src/schemas/link.ts
export const SLUG_PATTERN = /^[a-z0-9-]{1,64}$/
export const CreateLinkInputSchema = z.object({
domain_id: z.uuid(),
slug: z.string().regex(SLUG_PATTERN).optional(), // absent → auto-generate
destination_url: z.url().max(2048),
title: z.string().min(1).max(120).optional(),
tags: z.array(z.string().min(1).max(32)).max(20).default([]),
publish: z.boolean().default(true), // false → status 'draft'
})
// The type is DERIVED from the schema, never written by hand.
export type CreateLinkInput = z.infer<typeof CreateLinkInputSchema>
// Output schemas are separate: what we accept is not what we return.
// The status union is exactly the state machine Section 12.1.2 defines and
// Section 6 constrains; a POST with publish:false lands on 'draft'.
export const LinkSchema = z.object({
id: z.uuid(),
workspace_id: z.uuid(),
domain_id: z.uuid(),
slug: z.string(),
short_url: z.url(),
destination_url: z.url(),
title: z.string().nullable(),
tags: z.array(z.string()),
status: z.enum(['draft', 'active', 'paused', 'scheduled', 'expired', 'archived']),
created_at: z.iso.datetime(),
updated_at: z.iso.datetime(),
})
export type Link = z.infer<typeof LinkSchema>5.4.2 Rules #
| # | Rule |
|---|---|
| 1 | Types are derived with z.infer. Writing an interface that mirrors a schema is a review failure — the two will drift |
| 2 | Input and output schemas are separate objects. Input schemas describe what the API accepts; output schemas describe what it returns and are used to generate the OpenAPI response bodies |
| 3 | Schema field names are snake_case, matching the public API contract exactly, so the same schema validates both directions without a mapping step |
| 4 | Validation runs at every boundary: client form submit, server route entry, public API route entry, queue job payload parse, and inbound webhook parse. A parse failure at a route boundary produces 400 with a details array carrying one entry per failed field (Section 5.5.3) |
| 5 | Defaults live in the schema (.default()), not in the handler. There is one place to read to know what happens when a field is omitted |
| 6 | Domain rules that need database state (slug uniqueness, quota, destination reachability) are not in the Zod schema. They run after parsing, in packages/core, and produce 409 or 422, never 400 |
| 7 | Environment variables are parsed by a Zod schema at process start. A missing or malformed variable crashes the process at boot with a message naming the variable — never at the first request that needs it |
| 8 | The OpenAPI document in Section 21.10 is generated from these schemas. A schema change that alters the public contract shows up as an OpenAPI diff in the pull request |
| 9 | A status or state union declared in a schema must match the CHECK constraint Section 6 declares for the same column, and the state machine the owning section describes. The three are compared by a unit test, so a state added in prose and forgotten in the constraint fails the build rather than failing a customer's first request |
5.5 Error handling #
5.5.1 The error class hierarchy #
Error
└── AppError abstract; carries { code, httpStatus, details?, cause? }
├── ValidationError 400 — a request failed schema parsing
├── AuthenticationError 401 — no valid actor, or an unmet assurance
│ └── AssuranceError totp_required
├── AuthorizationError 403 — actor known, action refused
│ ├── RoleError insufficient_role
│ ├── EntitlementError plan_feature_unavailable | plan_limit_reached
│ ├── ScopeError insufficient_scope
│ ├── BillingError billing_write_blocked
│ └── SessionAssuranceError email_verification_required |
│ reauthentication_required
├── NotFoundError 404 — resource, workspace, or intentionally concealed
├── ConflictError 409 — uniqueness, state, last-owner, locked resource
├── GoneError 410 — soft-deleted or permanently removed
├── PayloadTooLargeError 413 — request_too_large
├── UnsupportedMediaTypeError 415 — unsupported_media_type
├── SemanticError 422 — well-formed but not actionable
├── RateLimitError 429 — carries retry_after_seconds
└── InternalError 500 — unexpected; message is never surfaced to clients5.5.2 Mapping rules #
| Rule | Statement |
|---|---|
| 1 | Every AppError subclass declares its HTTP status. Handlers never choose a status; they throw a typed error and one central middleware serialises it |
| 2 | Error codes are stable public API surface. Once a code ships it is never renamed, never repurposed and never removed. Adding a code is a minor change; changing the meaning of one is a breaking change. The complete registry is Section 30.2 |
| 3 | The registry is a single as const object in packages/core/src/errors/, and Section 30.2 is generated from it. Throwing an unregistered code fails a unit test that compares thrown codes against the registry; a registered code that no code path throws fails the same test from the other direction, so the registry cannot accumulate orphans |
| 4 | One code, one status. A code that appears with two different HTTP statuses anywhere in the codebase fails the same test. Where two names described the same condition, one name survives and the others were removed before launch rather than aliased |
| 5 | message is human-readable, in English, aimed at a developer or an end user, and may change between releases. Clients must branch on code, never on message. This is stated in the API documentation |
| 6 | An unexpected exception is wrapped in InternalError, logged at error with the full stack and the request_id, and returned as { "error": { "code": "internal_error", "message": "An unexpected error occurred.", "request_id": "…" } }. Stack traces, SQL fragments and upstream provider messages never cross the API boundary |
| 7 | Third-party failures are wrapped at the adapter boundary. A Stripe error becomes a ConflictError or SemanticError with a LinkHub code; the provider's raw error is attached as cause for logging only |
| 8 | Every error response carries request_id, which is the same value as the X-Request-Id response header and the request_id field on every log line for that request |
| 9 | Errors are never swallowed. A caught error is either rethrown, converted to a typed AppError, or explicitly handled with a comment stating why it is safe to ignore. The single deliberate exception is the fire-and-forget analytics emit, which increments a counter |
5.5.3 Error payload shape #
The envelope is defined in Section 21.3 and is used identically by apps/web route handlers, apps/api and apps/edge. details is always an array, never a bare object, and every entry carries at least field and issue using the vocabulary in Section 21.3.2. Validation failures produce one entry per failed field; entitlement failures produce one entry describing the blocked capability.
{
"error": {
"code": "plan_limit_reached",
"message": "This workspace has reached its bio page limit.",
"details": [
{ "field": "bio_pages", "issue": "limit_reached",
"limit": 10, "current": 10, "plan": "pro", "kind": "count" }
],
"request_id": "req_01J8ZK3M9QW2X4Y6"
}
}kind is count for a numeric cap and period for a per-period allowance. The sibling code plan_feature_unavailable uses the same array shape with "issue": "feature_unavailable" and no limit, current or kind. Keeping one shape for every code means a client renders details[0] the same way regardless of which refusal it received, which is the entire reason for the constraint.
5.6 Logging #
5.6.1 Format and required fields #
All logs are single-line JSON to stdout. The container platform ships them; the application never writes log files.
| Field | Type | Present on | Description |
|---|---|---|---|
level |
string | every line | trace, debug, info, warn, error, fatal |
time |
ISO 8601 UTC | every line | Emission timestamp |
service |
string | every line | web, edge, api, worker |
env |
string | every line | local, preview, staging, production |
version |
string | every line | Build identifier (commit SHA) |
request_id |
string | every request-scoped line | Matches X-Request-Id and the API error envelope |
trace_id / span_id |
string | every request-scoped line | OpenTelemetry correlation |
msg |
string | every line | Short, lower-case, no interpolated values — values go in fields |
user_id |
uuid | authenticated lines | Never an email address |
workspace_id |
uuid | workspace-scoped lines | |
actor_type |
string | authorised lines | user, api_key, system |
duration_ms |
number | completion lines | |
outcome |
string | completion lines | success, denied, error |
error_code |
string | error and denial lines | The stable code from Section 5.5.2 |
5.6.2 Levels #
| Level | Use for | Examples |
|---|---|---|
trace |
Disabled outside local. Fine-grained internal steps |
Cache key computed, targeting rule evaluated |
debug |
Enabled in local and preview only |
Job payload shape, resolved entitlement set |
info |
Normal, notable events at a rate a human could scan | Request completed, job succeeded, domain transitioned to active, plan changed |
warn |
Expected-but-undesirable conditions requiring no immediate action | Authorization denial, rate limit hit, cache miss storm, webhook retry, Safe Browsing flag |
error |
A request or job failed and needs investigation | Unhandled exception, job exhausted retries, TLS provisioning failure |
fatal |
The process cannot continue and is exiting | Environment schema parse failure, database unreachable at boot, the stream Redis database not configured noeviction |
Per-request info lines are emitted once per request on completion, not per internal step. The redirect path logs at info sampled at 1% plus 100% of non-2xx/3xx outcomes; logging every redirect at 5,000 rps is neither useful nor affordable.
5.6.3 Redaction #
Redaction is configured once as a set of paths on the logger instance, so it applies to every call site mechanically. A field cannot be logged by accident.
| Never logged, in any environment, at any level | Handling |
|---|---|
| Raw IP addresses | Never written to any log line, any database column, or any object in storage. Used in process memory only to derive visitor_hash and the country/region lookup, then discarded. Where a network-level identifier is genuinely needed for abuse investigation, only the derived visitor_hash and the country code are recorded |
| Raw user-agent strings | Never logged and never persisted. Parsed in worker memory into user_agent_family and the daily-salted ua_hash, then discarded. Only those two derived values may appear anywhere |
| Passwords, in any form | Never leave the request body parser; the parsed value goes directly to the hashing function |
| Session tokens and session cookie values | Only the SHA-256 hash prefix (8 hex characters) is ever logged, and only at debug |
| API key secrets | Only the 6-character display prefix |
| TOTP secrets, TOTP codes and recovery codes | Redacted entirely |
| Magic-link tokens, email verification tokens, password reset tokens, invitation tokens, ownership transfer tokens | Redacted entirely |
| Webhook signing secrets | Redacted entirely |
| OAuth access tokens, refresh tokens and authorisation codes | Redacted entirely |
| Stripe secret keys and webhook signing secrets | Redacted entirely |
| Payment card data | Never touches LinkHub systems; Stripe-hosted surfaces only |
Email addresses in any public-path log (edge, and the public route group of web) |
Never logged. In dashboard and worker logs an email address may be logged at info only when it is the subject of the operation (an invitation being sent); otherwise use user_id |
| Captured lead email addresses | Never logged anywhere, at any level, including in worker ESP-sync logs. Use lead_id |
| Full request and response bodies | Never logged by default. A per-request debug capture exists behind an operator flag, is available in local and preview only, and applies the same redaction paths |
| Authorization, Cookie and Set-Cookie headers | Redacted |
Destination URLs containing a query string with token, key, password, secret or auth parameters |
The query string is truncated to the parameter names only |
A test in the logging package asserts that a log call containing each redacted key emits the redaction marker rather than the value. Adding a new sensitive field means adding a redaction path and a test case in the same commit.
6. Data Model & Database Schema #
This section is the canonical schema for the entire product. Every other section refers to the table and column names defined here. Where a behaviour is owned elsewhere (entitlement evaluation in Section 22, ingestion mechanics in Section 17, redirect resolution in Section 12), this section defines only the storage shape and its constraints.
6.1 Entity-relationship overview #
The model has one hard tenancy boundary — the workspace — and five content domains hanging off it (bio pages, links, QR codes, experiments, leads), plus four supporting domains (identity, billing, delivery/domains, analytics).
┌───────────────┐
│ users │
└───────┬───────┘
user_identities ─────────┤
sessions ────────────────┤
totp_secrets ────────────┤
recovery_codes ──────────┤
*_tokens (verify/reset/ │
magic-link/email-change)┤
│ (membership is the ONLY path from a user to data)
┌───────▼─────────────┐
│ workspace_members │──── resource_grants (Business, per-resource scoping)
└───────┬─────────────┘
│
┌───────────────────────────▼─────────────────────────────────────────┐
│ workspaces │
│ (unit of tenancy, branding, billing, retention, audit) │
└──┬─────────┬─────────┬──────────┬─────────┬─────────┬───────┬───────┘
│ │ │ │ │ │ │
│ │ │ │ │ │ └── audit_log_entries (append-only)
│ │ │ │ │ └────────── api_keys → api_key_usage
│ │ │ │ └──────────────────── integrations → integration_credentials
│ │ │ │ integrations → webhook_deliveries → webhook_dead_letters
│ │ │ └────────────────────────────── subscriptions → subscription_items
│ │ │ subscriptions → invoices; plans; entitlement_overrides
│ │ └───────────────────────────────────────── custom_domains → domain_verification_attempts
│ │ → tls_certificates
│ │
│ └── bio_pages ── bio_page_versions
│ │ └────── blocks ── block_versions
│ │ └────── themes ── fonts
│ │ └────── leads ── lead_sync_attempts ── lead_sync_targets
│ └──────── uploaded_assets
│
├── links ── link_versions
│ ├──── link_destinations (split testing: N weighted URLs)
│ ├──── link_rules (geo/device/time/language/referrer targeting)
│ └──── utm_presets
│
├── qr_codes ── qr_code_versions ── qr_render_artifacts
│ └───── qr_slug_reservations ◀── NEVER deleted, NEVER cascaded (see 6.10)
│
└── experiments ── experiment_variants
├── experiment_assignments_rollup
└── experiment_results
ANALYTICS (write-heavy, partitioned)
click_events (PARTITION BY RANGE occurred_at, daily) ─┐
page_view_events (PARTITION BY RANGE occurred_at) ─┼→ analytics_unique_visitor_days
│ (PARTITION BY RANGE event_date)
├→ analytics_rollup_hourly
├→ analytics_rollup_daily
└→ analytics_rollup_only_events (late-event idempotency)
bot_signatures · ua_parse_corpus (no visitor key, no workspace key)
analytics_share_links (read-only shared dashboards)
BILLING / ENTITLEMENT SUPPORT
billing_accounts ── subscriptions ── invoices
workspace_resource_counters (authoritative cap counters)
SAFETY / COMPLIANCE / PLATFORM (workspace-scoped or global)
consent_records · data_export_requests · data_deletion_requests
safe_browsing_checks · abuse_reports · reserved_slugs
idempotency_keys · rate_limit_violations · feature_flags · jobs_dead_letter · payment_events6.1.1 Entity catalogue #
| Entity | One-line description |
|---|---|
users |
A human login identity: email, optional password, profile, account status. |
user_identities |
An external OAuth identity (Google) linked to a user. |
sessions |
A live browser session: hashed opaque token, rolling and absolute expiry. |
email_verification_tokens |
Single-use tokens proving control of an email address. |
password_reset_tokens |
Single-use tokens authorising a password reset. |
magic_link_tokens |
Single-use passwordless sign-in tokens. |
email_change_requests |
A pending address change with confirm-new and revoke-from-old tokens. |
totp_secrets |
The encrypted TOTP shared secret and replay guard for a user. |
recovery_codes |
Hashed single-use 2FA recovery codes. |
auth_attempts |
Durable record of authentication attempts for lockout, forensics and abuse review. |
workspaces |
The tenancy, branding and billing boundary. Everything else is scoped to one. |
workspace_members |
A user's role (and scoping flag) inside one workspace. |
workspace_invitations |
A pending emailed invitation with a signed single-use token. |
resource_grants |
Business-plan per-resource allow-list entries for a scoped member. |
audit_log_entries |
Append-only record of security- and content-significant actions. |
plans |
The three published plans and their entitlement payloads. |
billing_accounts |
The payment-provider customer a workspace bills through, and the once-ever trial record. |
workspace_resource_counters |
The authoritative per-(workspace, resource type) counter pair that entitlement caps are evaluated against. |
subscriptions |
A workspace's billing subscription and lifecycle state. |
subscription_items |
Individual priced line items (base plan, seats) on a subscription. |
invoices |
Issued invoices mirrored from the payment provider. |
payment_events |
Raw, idempotent provider webhook events and their processing state. |
entitlement_overrides |
Per-workspace manual entitlement adjustments (support/grandfathering). |
custom_domains |
Customer-verified hostnames and LinkHub's own system hostnames. |
domain_verification_attempts |
One DNS check attempt against a domain, with observed values. |
tls_certificates |
ACME certificate lifecycle records for a domain. |
bio_pages |
A public link-in-bio page: handle, host, theme, publish state. |
bio_page_versions |
An immutable snapshot of a page and its blocks. |
blocks |
An ordered content unit on a bio page. |
block_versions |
Per-block history for granular restore. |
themes |
A design token set (system preset or workspace-owned). |
fonts |
A font family definition (system stack, hosted, or uploaded). |
uploaded_assets |
Any stored binary: images, logos, fonts, QR artifacts, exports. |
links |
A branded short link: host + slug + destination strategy. |
link_versions |
Immutable snapshot of a link for history and rollback. |
link_destinations |
One weighted destination URL of a split test. |
link_rules |
An ordered targeting rule that can override the destination. |
utm_presets |
A reusable named UTM parameter set. |
qr_codes |
A dynamic QR code: permanent slug, editable destination, styling. |
qr_code_versions |
Immutable QR snapshot with its scannability validation results. |
qr_slug_reservations |
The permanent, never-recycled reservation of a QR slug. |
qr_render_artifacts |
A generated QR output file (SVG/PNG/PDF/EPS) for a version. |
experiments |
An A/B test over a bio page or a link. |
experiment_variants |
One variant of an experiment with its weight and payload. |
experiment_assignments_rollup |
Daily assignment counts per variant, per salt epoch. |
experiment_results |
Computed statistics and significance verdict per variant. |
click_events |
Raw click/scan event. Daily range-partitioned. |
page_view_events |
Raw bio page view event. Daily range-partitioned. |
analytics_unique_visitor_days |
The seen-visitor-day set at resource grain that makes unique counts additive and idempotent. |
analytics_rollup_hourly |
Hourly aggregate by resource and dimension. |
analytics_rollup_daily |
Daily aggregate by resource and dimension. |
analytics_rollup_only_events |
Idempotency keys for events whose raw partition has already been dropped and which are therefore rollup-counted only. |
ua_parse_corpus |
Distinct raw user-agent strings kept for parser maintenance. Carries no visitor key, no workspace key and no timestamp finer than a day. |
analytics_share_links |
A read-only, tokenised public view of a workspace's analytics. |
bot_signatures |
Classification rules (UA regex, ASN, CIDR) used by bot filtering. |
leads |
An email capture submission from a bio page block. |
lead_sync_targets |
Where a workspace's leads are forwarded (ESP or webhook). |
lead_sync_attempts |
One delivery attempt of a lead to a target. |
integrations |
An enabled third-party integration and its non-secret config. |
integration_credentials |
The secret half of an integration, stored by reference. |
webhook_deliveries |
An outbound webhook delivery and its retry state. |
webhook_dead_letters |
A webhook that exhausted retries, replayable from the UI. |
api_keys |
A hashed, scoped public-API key. |
api_key_usage |
Hourly usage counters per API key. |
rate_limit_violations |
Durable record of rate-limit breaches for abuse response. |
consent_records |
A visitor's consent decision for a workspace's public surfaces. |
data_export_requests |
An async GDPR/portability export job and its signed download. |
data_deletion_requests |
An erasure request, its grace window and its execution report. |
safe_browsing_checks |
Cached reputation verdict for a normalised destination URL. |
abuse_reports |
An inbound abuse report and its triage state. |
reserved_slugs |
Blocked slugs: system words, profanity, brand terms, confusables. |
idempotency_keys |
Stored API responses keyed by client idempotency key. |
feature_flags |
Runtime rollout switches. |
jobs_dead_letter |
Background jobs that exhausted retries, with replay state. |
6.1.2 Structural decisions worth stating once #
- System hostnames are rows in
custom_domainswithworkspace_id IS NULLandis_system = true(linkhub.app,go.linkhub.app,lnkhb.co). Consequence:links.domain_id,qr_codes.domain_idandbio_pages.domain_idare allNOT NULL, and slug uniqueness is a plain two-column unique index rather than aCOALESCEexpression. This removes an entire class of null-handling bugs from the hottest lookup in the system. - Membership is the only path from a user to data. No content table has a
user_idowner column that grants access;created_by_user_idis provenance only. Section 8.10 defines the enforcement pattern that makes this structural. - Versions are snapshots, not diffs.
bio_page_versions,link_versions,qr_code_versionsandblock_versionseach store a completejsonbsnapshot. Restoring a version never needs to replay history. - The permanent-QR rule is expressed in the schema, not only in code.
qr_slug_reservationshas nodeleted_atcolumn, noON DELETE CASCADEpointing at it, and aBEFORE DELETEtrigger that raises an exception. See 6.10. - Enumerations are
text+CHECK, never PostgreSQLENUMtypes. Adding a value to aCHECKconstraint is a single non-blockingALTER ... ADD CONSTRAINT ... NOT VALID+VALIDATE, which fits the expand/contract migration rule in 6.6; altering a native enum type does not compose with it. - The raw user-agent string is never persisted. No table in this schema has a column holding a raw
User-Agentheader. The worker parses the string in memory intouser_agent_family(a short, non-identifying label such asChrome on Android) andua_hash(a daily-salted 16-byte digest used only to cluster bot signatures), and then discards it. Parser maintenance is served byua_parse_corpus(6.3.71), which holds distinct strings with no visitor key, no workspace key and no timestamp finer than a day, and is therefore not linkable to a person. - QR slugs and short-link slugs share one namespace per host. A public QR URL is
https://{host}/{slug}— there is no/q/prefix — soqr_slug_reservationsprotects both surfaces with one row. Creating a short link whose(domain_id, slug)matches an existing reservation is refused, and creating a QR reservation whose(domain_id, slug)matches a live short link is refused. Both refusals return 409qr_slug_reserved. The enforcement is specified in 6.3.39.
6.2 Conventions recap #
| Concern | Rule |
|---|---|
| Primary key | id uuid PRIMARY KEY — UUIDv7, generated in application code, never DB-side. Time-ordered, so B-tree inserts stay at the right edge. The UUID is the public identifier; there is no second public id column. |
| Table names | snake_case, plural (bio_pages, click_events). Column names snake_case, singular. |
| Timestamps | timestamptz, always stored UTC. Standard columns created_at, updated_at; deleted_at only where soft delete applies. |
| Standard columns (implicit on every table) | id uuid PK, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(). These are omitted from the column tables below and exist unless a table explicitly says otherwise. A set_updated_at() BEFORE UPDATE trigger is the backstop; the ORM also sets it. |
| Soft delete | deleted_at timestamptz NULL on workspaces, bio_pages, blocks, links, qr_codes, api_keys, workspace_members, leads, uploaded_assets, themes. 30-day restore window, then hard purge by the retention worker (6.8). Every list query filters deleted_at IS NULL; every uniqueness index over user-visible identifiers is partial on deleted_at IS NULL. |
| Hard delete only | click_events, page_view_events, analytics_unique_visitor_days, analytics_rollup_only_events, ua_parse_corpus, sessions, idempotency_keys, all *_tokens tables, auth_attempts. |
| Never deleted | qr_slug_reservations. No deleted_at, no cascade, trigger-enforced. |
| Money | Integer minor units (price_month_cents integer) plus currency char(3) ISO 4217. No floating point anywhere in billing. |
| Enumerations | text + CHECK (col IN (...)). Values listed in the column's Constraints cell. |
| Booleans | boolean NOT NULL DEFAULT false, named affirmatively (is_bot, not not_human). |
| Structured data | jsonb, validated by a Zod schema at the application boundary; the DB validates only shape-critical invariants. |
| Text bounds | Every free-text column carries CHECK (char_length(col) <= N). Unbounded text is not accepted. |
| Case-insensitive text | citext (extension citext) for email, slug, handle, hostname. Uniqueness is therefore case-insensitive without expression indexes. |
| Secrets | Never stored in plaintext. Tokens and keys as bytea SHA-256 digests; reversible secrets (OAuth refresh tokens, TOTP seeds, integration credentials) as AEAD ciphertext with a key_version column for rotation. |
| Foreign keys | Every FK is ON UPDATE RESTRICT (primary keys are immutable UUIDs). ON DELETE is stated per table below; the default for a workspace-scoped child is ON DELETE CASCADE. |
| Naming | PK pk_<table> · unique ux_<table>_<cols> · index ix_<table>_<cols> · partial index suffix _active/_pending · FK fk_<table>_<col> · check ck_<table>_<rule> · trigger tg_<table>_<purpose>. |
| Extensions | citext, pgcrypto (random bytes for salts only — never for password hashing), pg_stat_statements, btree_gin. No third-party extensions, so any managed PostgreSQL offering can run this schema. |
6.3 Full schema #
Column tables below omit id, created_at and updated_at per 6.2. "Null" is Y when the column is nullable.
This subsection is the whole schema. Every table the product has is defined here and nowhere else. If another section needs a column name, a type or a constraint, it cites the subsection number below rather than restating it, because two statements of one column is how they come to disagree.
The five tables that deviate from the standard columns, each for a stated reason:
| Table | Deviation | Reason |
|---|---|---|
analytics_unique_visitor_days (6.3.47) |
No id; PK is the natural five-column grain. |
It is a set, and the primary key is the set. A surrogate key would add 16 bytes per visitor-day and index nothing anyone queries. |
analytics_rollup_only_events (6.3.70) |
No id, no created_at, no updated_at; PK is event_id. |
It is an idempotency fact, not an entity. applied_at carries the only time anyone needs. |
ua_parse_corpus (6.3.71) |
No id, no created_at, no updated_at; PK is ua_sha256; dates only. |
Storing a sub-day timestamp would reintroduce exactly the linkability the table is designed to avoid (6.1.2). |
workspace_resource_counters (6.3.72) |
No id; PK is (workspace_id, resource_type). |
The natural key is the identity, and the cap check must be a single-row lock on it. |
audit_log_entries (6.3.15) |
No updated_at, no deleted_at. |
The row is immutable. There is nothing to touch and nothing to soft-delete. |
Every other table carries id, created_at and updated_at exactly as 6.2 describes.
6.3.1 users #
Purpose. One human login identity across all workspaces.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
email |
citext | N | — | unique (partial, deleted_at IS NULL), CHECK (char_length(email) BETWEEN 3 AND 254) |
Primary contact and login identifier. |
email_verified_at |
timestamptz | Y | null | — | Set when a verification token is consumed. Gates publishing (7.4). |
name |
text | Y | null | CHECK (char_length(name) <= 100) |
Display name. |
avatar_asset_id |
uuid | Y | null | FK → uploaded_assets.id |
Profile image. |
password_hash |
text | Y | null | CHECK (char_length(password_hash) <= 512) |
Argon2id encoded hash. Null for OAuth-only or magic-link-only accounts. |
password_algo |
text | Y | null | CHECK (password_algo IN ('argon2id')) |
Recorded so a future rehash-on-login can detect legacy parameters. |
password_params |
jsonb | Y | null | — | {m,t,p} actually used, for the same reason. |
password_updated_at |
timestamptz | Y | null | — | Drives "password changed" notification and session invalidation. |
locale |
text | N | 'en' |
CHECK (char_length(locale) <= 10) |
BCP-47 tag for emails and dashboard. |
timezone |
text | N | 'UTC' |
CHECK (char_length(timezone) <= 64) |
IANA zone for analytics date bucketing display. |
marketing_opt_in |
boolean | N | false | — | Product-marketing email consent, separate from transactional. |
status |
text | N | 'active' |
CHECK (status IN ('active','suspended','pending_deletion','anonymized')) |
Account lifecycle (7.11). |
suspended_reason |
text | Y | null | CHECK (char_length(suspended_reason) <= 500) |
Set with status='suspended'. |
deletion_requested_at |
timestamptz | Y | null | — | Start of the 30-day grace period. |
anonymized_at |
timestamptz | Y | null | — | When PII was scrubbed; the row is retained for referential integrity. |
last_login_at |
timestamptz | Y | null | — | Shown in the security panel. |
last_login_ip_country |
char(2) | Y | null | CHECK (last_login_ip_country ~ '^[A-Z]{2}$') |
Country only. Raw IP is never stored. |
failed_login_count |
integer | N | 0 | CHECK (failed_login_count >= 0) |
Durable mirror of the Redis counter; survives a Redis flush. |
locked_until |
timestamptz | Y | null | — | Lockout expiry (7.5). |
totp_enabled_at |
timestamptz | Y | null | — | Denormalised from totp_secrets for cheap policy checks. |
deleted_at |
timestamptz | Y | null | — | Reserved; user rows are anonymized rather than soft-deleted (7.11). |
Indexes. ux_users_email unique on (email) WHERE deleted_at IS NULL — login lookup and registration conflict detection, the single hottest identity query. ix_users_status_deletion on (status,deletion_requested_at) WHERE status = 'pending_deletion' — the nightly deletion worker scans only the tiny pending set. ix_users_locked_until on (locked_until) WHERE locked_until IS NOT NULL — lockout expiry sweep.
Foreign keys. avatar_asset_id → uploaded_assets.id ON DELETE SET NULL.
Checks. ck_users_password_pair: (password_hash IS NULL) = (password_algo IS NULL) — a hash without its algorithm is unverifiable.
Access patterns. Point lookup by email (login, registration), point lookup by id (session resolution, cached in Redis). Never scanned in a request path.
6.3.2 user_identities #
Purpose. A linked external OAuth identity. Google is the only provider in scope; the table is provider-generic so adding one is data, not schema.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
user_id |
uuid | N | — | FK → users.id |
Owning user. |
provider |
text | N | — | CHECK (provider IN ('google')) |
Identity provider key. |
provider_account_id |
text | N | — | CHECK (char_length(provider_account_id) <= 255) |
The provider's stable subject id (sub), never the email. |
provider_email |
citext | Y | null | CHECK (char_length(provider_email) <= 254) |
Email as asserted by the provider at link time. |
provider_email_verified |
boolean | N | false | — | The provider's own verification claim. Linking requires true (7.7). |
access_token_ciphertext |
bytea | Y | null | — | AEAD-encrypted; only retained when a scope needs later use. |
refresh_token_ciphertext |
bytea | Y | null | — | AEAD-encrypted. |
key_version |
integer | Y | null | CHECK (key_version > 0) |
Encryption key generation, for rotation. |
token_expires_at |
timestamptz | Y | null | — | Access-token expiry. |
profile |
jsonb | N | '{}' |
— | Name and picture URL as returned; never trusted for authorisation. |
linked_at |
timestamptz | N | now() |
— | When the link was created. |
last_used_at |
timestamptz | Y | null | — | Last successful sign-in through this identity. |
Indexes. ux_user_identities_provider_account unique on (provider,provider_account_id) — the OAuth callback lookup; also prevents one Google account being linked to two users. ix_user_identities_user on (user_id) — rendering the connected-accounts panel and the unlink safety check.
Foreign keys. user_id → users.id ON DELETE CASCADE.
Checks. ck_user_identities_key_version: (access_token_ciphertext IS NULL AND refresh_token_ciphertext IS NULL) OR key_version IS NOT NULL.
Access patterns. Exactly one point lookup per OAuth callback; one small scan per user for the settings page.
6.3.3 sessions #
Purpose. A live authenticated browser session. The token itself is never stored.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
user_id |
uuid | N | — | FK → users.id |
Session owner. |
token_hash |
bytea | N | — | unique, CHECK (octet_length(token_hash) = 32) |
SHA-256 of the opaque 256-bit session token. |
active_workspace_id |
uuid | Y | null | FK → workspaces.id |
Last active workspace, restored on next visit (8.3). |
ip_country |
char(2) | Y | null | CHECK (ip_country ~ '^[A-Z]{2}$') |
Country at creation, shown in the session list. |
user_agent_family |
text | Y | null | CHECK (char_length(user_agent_family) <= 100) |
e.g. Chrome on macOS. Full UA string is not stored. |
device_label |
text | Y | null | CHECK (char_length(device_label) <= 100) |
Human label shown in the session list. |
last_seen_at |
timestamptz | N | now() |
— | Updated at most once per 60s to avoid write amplification. |
rolling_expires_at |
timestamptz | N | — | — | Extended 30 days on each authenticated request past the 60s threshold. |
absolute_expires_at |
timestamptz | N | — | — | Hard cap 90 days after creation; never extended. |
mfa_satisfied_at |
timestamptz | Y | null | — | When TOTP was satisfied for this session. Null while a 2FA challenge is pending. |
revoked_at |
timestamptz | Y | null | — | Set by sign-out, "sign out everywhere", password change, or admin action. |
revoked_reason |
text | Y | null | CHECK (revoked_reason IN ('user_signout','signout_all','password_change','email_change','totp_change','admin','account_deleted','expired')) |
Surfaced in the audit trail. |
Indexes. ux_sessions_token_hash unique on (token_hash) — the fallback path when the Redis session cache misses; must be a single index probe. ix_sessions_user_active on (user_id,last_seen_at DESC) WHERE revoked_at IS NULL — the session-list UI. ix_sessions_expiry on (absolute_expires_at) WHERE revoked_at IS NULL — the sweeper that hard-deletes expired rows.
Foreign keys. user_id → users.id ON DELETE CASCADE. active_workspace_id → workspaces.id ON DELETE SET NULL — losing a workspace must not invalidate the session.
Checks. ck_sessions_expiry_order: rolling_expires_at <= absolute_expires_at.
Access patterns. One point lookup per uncached request; hard-deleted 7 days after absolute_expires_at by the retention worker.
6.3.4 email_verification_tokens #
Purpose. Single-use proof of control of an email address, used at registration and after an email change.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
user_id |
uuid | N | — | FK → users.id |
Target user. |
email |
citext | N | — | CHECK (char_length(email) <= 254) |
The address being verified — pinned, so a later address change cannot be verified by an older token. |
token_hash |
bytea | N | — | unique, CHECK (octet_length(token_hash) = 32) |
SHA-256 of a 256-bit random token. |
purpose |
text | N | 'registration' |
CHECK (purpose IN ('registration','email_change','resend')) |
Drives the email template. |
expires_at |
timestamptz | N | — | — | 24 hours after issue. |
consumed_at |
timestamptz | Y | null | — | Set on first successful use; a second use fails. |
requested_ip_country |
char(2) | Y | null | — | Country of the request that issued the token. |
sent_count |
integer | N | 1 | CHECK (sent_count BETWEEN 1 AND 10) |
Incremented by resend; used for throttling (7.4). |
last_sent_at |
timestamptz | N | now() |
— | Resend cooldown anchor. |
Indexes. ux_email_verification_tokens_hash unique on (token_hash). ix_email_verification_tokens_user on (user_id,created_at DESC) WHERE consumed_at IS NULL — resend throttle lookup. ix_email_verification_tokens_expiry on (expires_at) — purge sweep.
Foreign keys. user_id → users.id ON DELETE CASCADE.
Checks. ck_email_verification_tokens_window: expires_at > created_at.
Access patterns. Point lookup by hash; hard-deleted 30 days after expires_at.
6.3.5 password_reset_tokens #
Purpose. Single-use authorisation to set a new password without the old one.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
user_id |
uuid | N | — | FK → users.id |
Target user. |
token_hash |
bytea | N | — | unique, CHECK (octet_length(token_hash) = 32) |
SHA-256 of a 256-bit random token. |
expires_at |
timestamptz | N | — | — | 60 minutes after issue. |
consumed_at |
timestamptz | Y | null | — | Single use. |
invalidated_at |
timestamptz | Y | null | — | Set when a newer token is issued or the password changes by another route. |
requested_ip_country |
char(2) | Y | null | — | Shown in the "reset requested" email. |
requested_user_agent_family |
text | Y | null | CHECK (char_length(...) <= 100) |
Same. |
Indexes. ux_password_reset_tokens_hash unique on (token_hash). ix_password_reset_tokens_user_live on (user_id) WHERE consumed_at IS NULL AND invalidated_at IS NULL — used to invalidate prior tokens when a new one is issued.
Foreign keys. user_id → users.id ON DELETE CASCADE.
Access patterns. Point lookup by hash; hard-deleted 7 days after expires_at.
6.3.6 magic_link_tokens #
Purpose. Passwordless sign-in tokens.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
user_id |
uuid | Y | null | FK → users.id |
Null when the requested email has no account — the row still exists so timing and response are identical (7.14). |
email |
citext | N | — | CHECK (char_length(email) <= 254) |
Requested address. |
token_hash |
bytea | N | — | unique, CHECK (octet_length(token_hash) = 32) |
SHA-256 of a 256-bit random token. |
expires_at |
timestamptz | N | — | — | 15 minutes after issue. |
consumed_at |
timestamptz | Y | null | — | Single use. |
redirect_path |
text | Y | null | CHECK (redirect_path ~ '^/' AND char_length(redirect_path) <= 512) |
Relative path only — an absolute URL here would be an open redirect. |
requested_ip_country |
char(2) | Y | null | — | Recorded and shown in the email. |
Indexes. ux_magic_link_tokens_hash unique on (token_hash). ix_magic_link_tokens_email_recent on (email,created_at DESC) — request throttling.
Foreign keys. user_id → users.id ON DELETE CASCADE.
Access patterns. Point lookup by hash; hard-deleted 24 hours after expires_at.
6.3.7 email_change_requests #
Purpose. A two-sided email change: confirm at the new address, revoke from the old one.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
user_id |
uuid | N | — | FK → users.id |
Requesting user. |
old_email |
citext | N | — | — | Snapshot of the address at request time. |
new_email |
citext | N | — | CHECK (new_email <> old_email) |
Requested new address. |
confirm_token_hash |
bytea | N | — | unique | Sent to new_email. |
revoke_token_hash |
bytea | N | — | unique | Sent to old_email; cancels the change and locks the account pending a password reset. |
expires_at |
timestamptz | N | — | — | 24 hours. |
confirmed_at |
timestamptz | Y | null | — | Change applied. |
revoked_at |
timestamptz | Y | null | — | Cancelled from the old address. |
status |
text | N | 'pending' |
CHECK (status IN ('pending','confirmed','revoked','expired')) |
Denormalised for listing. |
Indexes. ux_email_change_requests_confirm unique on (confirm_token_hash), ux_email_change_requests_revoke unique on (revoke_token_hash). ux_email_change_requests_user_pending unique on (user_id) WHERE status = 'pending' — one in-flight change per user, enforced by the database rather than by a race-prone application check.
Foreign keys. user_id → users.id ON DELETE CASCADE.
Access patterns. Point lookup by either hash; hard-deleted 90 days after resolution (kept longer than other tokens because it is a security-incident artefact).
6.3.8 totp_secrets #
Purpose. The encrypted TOTP shared secret plus a replay guard.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
user_id |
uuid | N | — | FK → users.id, unique |
One TOTP secret per user. |
secret_ciphertext |
bytea | N | — | — | AEAD-encrypted 160-bit secret. |
key_version |
integer | N | 1 | CHECK (key_version > 0) |
Encryption key generation. |
algorithm |
text | N | 'SHA1' |
CHECK (algorithm IN ('SHA1')) |
RFC 6238 default; maximises authenticator-app compatibility. |
digits |
smallint | N | 6 | CHECK (digits = 6) |
— |
period_seconds |
smallint | N | 30 | CHECK (period_seconds = 30) |
— |
confirmed_at |
timestamptz | Y | null | — | Null while enrolment is pending; an unconfirmed secret never satisfies a challenge. |
last_used_step |
bigint | Y | null | — | The last accepted time step. A code from the same or an earlier step is rejected — this is the replay guard. |
failed_attempts |
integer | N | 0 | CHECK (failed_attempts >= 0) |
Feeds the 2FA-specific throttle. |
Indexes. ux_totp_secrets_user unique on (user_id).
Foreign keys. user_id → users.id ON DELETE CASCADE.
Access patterns. One point lookup per 2FA challenge and one narrow update to last_used_step.
6.3.9 recovery_codes #
Purpose. Ten single-use codes issued alongside TOTP enrolment.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
user_id |
uuid | N | — | FK → users.id |
Owner. |
code_hash |
bytea | N | — | unique, CHECK (octet_length(code_hash) = 32) |
SHA-256 of the normalised code (lower-cased, dashes stripped). |
position |
smallint | N | — | CHECK (position BETWEEN 1 AND 10) |
Display order in the printable list. |
batch_id |
uuid | N | — | — | Regenerating codes issues a new batch and deletes the old one atomically. |
used_at |
timestamptz | Y | null | — | Single use. |
used_ip_country |
char(2) | Y | null | — | Recorded for the notification email. |
Indexes. ux_recovery_codes_hash unique on (code_hash). ix_recovery_codes_user_unused on (user_id) WHERE used_at IS NULL — the "N codes remaining" badge and the low-codes warning email.
Foreign keys. user_id → users.id ON DELETE CASCADE.
Checks. ux_recovery_codes_user_batch_position unique on (user_id,batch_id,position).
Access patterns. Point lookup by hash on redemption; count by user for the UI.
6.3.10 auth_attempts #
Purpose. Durable authentication attempt log. Redis holds the fast counters; this table is the forensic record and the source of truth if Redis is cold.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
identifier_type |
text | N | — | CHECK (identifier_type IN ('email','ip','user_id')) |
What was throttled. |
identifier_hash |
bytea | N | — | CHECK (octet_length(identifier_hash) = 32) |
SHA-256 of the identifier with a server pepper — no raw email or IP is stored. |
method |
text | N | — | CHECK (method IN ('password','magic_link','oauth_google','totp','recovery_code','password_reset','api_key')) |
Which mechanism was attempted. |
outcome |
text | N | — | CHECK (outcome IN ('success','bad_credentials','unknown_account','locked','rate_limited','totp_required','totp_failed','oauth_denied')) |
Result. |
user_id |
uuid | Y | null | FK → users.id |
Populated only on success or on a known-account failure. |
ip_country |
char(2) | Y | null | — | Country only. |
user_agent_family |
text | Y | null | — | Family only. |
occurred_at |
timestamptz | N | now() |
— | Attempt time. |
Indexes. ix_auth_attempts_identifier_time on (identifier_hash,occurred_at DESC) — lockout evaluation. ix_auth_attempts_occurred on (occurred_at) — retention sweep. ix_auth_attempts_user_time on (user_id,occurred_at DESC) WHERE user_id IS NOT NULL — the "recent activity" security panel.
Foreign keys. user_id → users.id ON DELETE SET NULL — the attempt history outlives an anonymized account, without its identity.
Access patterns. Append-only writes; bounded range scans. Hard-deleted after 90 days.
6.3.11 workspaces #
Purpose. The unit of tenancy, branding, billing and retention. Every content row carries a workspace_id.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
name |
text | N | — | CHECK (char_length(name) BETWEEN 1 AND 80) |
Display name, shown on memorial pages and in emails. |
slug |
citext | N | — | unique (partial, deleted_at IS NULL), CHECK (slug ~ '^[a-z0-9-]{1,64}$') |
Workspace slug used in dashboard URLs (/w/<slug>/…). |
logo_asset_id |
uuid | Y | null | FK → uploaded_assets.id |
Brand logo used on public surfaces and QR overlays. |
brand_primary_color |
text | Y | null | CHECK (brand_primary_color ~ '^#[0-9a-f]{6}$') |
Lower-case hex. |
brand_secondary_color |
text | Y | null | same pattern | — |
brand_text_color |
text | Y | null | same pattern | — |
default_link_domain_id |
uuid | Y | null | FK → custom_domains.id |
Default host for new short links; null means the system default short domain. |
default_page_domain_id |
uuid | Y | null | FK → custom_domains.id |
Default host for new bio pages. |
timezone |
text | N | 'UTC' |
CHECK (char_length(timezone) <= 64) |
IANA zone used for daily analytics bucketing in the dashboard. |
locale |
text | N | 'en' |
— | Default locale for public surfaces. |
current_plan_key |
text | N | 'free' |
FK → plans.key, CHECK (current_plan_key IN ('free','pro','business')) |
Denormalised from the active subscription so entitlement checks are a single-row read on the hot path. Maintained only by the billing worker (Section 22). |
billing_status |
text | N | 'none' |
CHECK (billing_status IN ('none','trialing','active','past_due','canceled','unpaid')) |
Drives grace behaviour; never affects QR resolution. |
seats_purchased |
integer | N | 1 | CHECK (seats_purchased BETWEEN 1 AND 25) |
Enforced at invitation send and accept (8.5). |
require_totp |
boolean | N | false | — | Business-only workspace-wide 2FA enforcement (7.9). |
consent_mode |
text | N | 'geo' |
CHECK (consent_mode IN ('geo','global','off')) |
Consent banner policy (Section 23). |
webhook_url |
text | Y | null | CHECK (webhook_url ~ '^https://' AND char_length(webhook_url) <= 2048) |
The single outbound webhook URL per workspace. HTTPS only. |
webhook_secret_ciphertext |
bytea | Y | null | — | AEAD-encrypted HMAC signing secret. |
webhook_key_version |
integer | Y | null | — | Rotation generation. |
unavailable_page_url |
text | Y | null | CHECK (char_length(unavailable_page_url) <= 2048) |
Workspace-branded "temporarily unavailable" target in the QR fallback chain (Section 14). |
memorial_display_name |
text | Y | null | CHECK (char_length(memorial_display_name) <= 80) |
Frozen copy of name written at deletion time, used by memorial QR pages after the row is purged. |
branding_removed |
boolean | N | false | — | Cached entitlement: LinkHub badge hidden on paid plans. |
owner_user_id |
uuid | N | — | FK → users.id |
Denormalised pointer to the single Owner member; kept consistent by the owner-transfer transaction (8.8). |
deleted_at |
timestamptz | Y | null | — | Soft delete; 30-day restore window. |
purge_after |
timestamptz | Y | null | — | Set to deleted_at + 30 days; the retention worker keys off this column. |
Indexes. ux_workspaces_slug unique on (slug) WHERE deleted_at IS NULL — dashboard routing and slug-availability checks. ix_workspaces_owner on (owner_user_id) — "workspaces I own" and owner-transfer validation. ix_workspaces_purge on (purge_after) WHERE purge_after IS NOT NULL — retention worker. ix_workspaces_plan on (current_plan_key,billing_status) — plan-cohort operations and dunning sweeps.
Foreign keys. owner_user_id → users.id ON DELETE RESTRICT — a user with an owned workspace cannot be hard-deleted; account deletion must transfer or delete the workspace first (7.11). logo_asset_id → uploaded_assets.id ON DELETE SET NULL. default_link_domain_id/default_page_domain_id → custom_domains.id ON DELETE SET NULL. current_plan_key → plans.key ON DELETE RESTRICT.
Checks. ck_workspaces_purge_pair: (deleted_at IS NULL) = (purge_after IS NULL).
Access patterns. Point lookup by id (every authenticated request, cached), by slug (routing). Never scanned except by workers.
6.3.12 workspace_members #
Purpose. A user's role inside one workspace. The only edge that grants data access.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
workspace_id |
uuid | N | — | FK → workspaces.id |
Tenancy. |
user_id |
uuid | N | — | FK → users.id |
Member. |
role |
text | N | 'viewer' |
CHECK (role IN ('owner','admin','editor','viewer')) |
Role matrix defined in Section 3. |
is_scoped |
boolean | N | false | — | True when the member's access is restricted to resource_grants (Business only). |
invited_by_user_id |
uuid | Y | null | FK → users.id |
Provenance; null for the founding Owner. |
invitation_id |
uuid | Y | null | FK → workspace_invitations.id |
The invitation this membership came from. |
joined_at |
timestamptz | N | now() |
— | Membership start. |
last_active_at |
timestamptz | Y | null | — | Updated at most hourly; powers the member list and seat-reclaim prompts. |
deleted_at |
timestamptz | Y | null | — | Soft delete: removal is reversible for 30 days, which preserves audit joins. |
removed_by_user_id |
uuid | Y | null | FK → users.id |
Who removed the member. |
removed_reason |
text | Y | null | CHECK (removed_reason IN ('removed_by_admin','left_voluntarily','seat_reclaimed','workspace_deleted','account_deleted')) |
— |
Indexes. ux_workspace_members_ws_user unique on (workspace_id,user_id) WHERE deleted_at IS NULL — prevents duplicate membership; also the authorisation lookup on every request. ux_workspace_members_single_owner unique on (workspace_id) WHERE role = 'owner' AND deleted_at IS NULL — the database, not the application, guarantees exactly one Owner. ix_workspace_members_user on (user_id) WHERE deleted_at IS NULL — the workspace switcher (8.3). ix_workspace_members_ws_role on (workspace_id,role) WHERE deleted_at IS NULL — member list and seat counting.
Foreign keys. workspace_id → workspaces.id ON DELETE CASCADE. user_id → users.id ON DELETE CASCADE. invited_by_user_id, removed_by_user_id → users.id ON DELETE SET NULL. invitation_id → workspace_invitations.id ON DELETE SET NULL.
Checks. ck_workspace_members_owner_unscoped: NOT (role = 'owner' AND is_scoped) — an Owner can never be scoped.
Access patterns. The single hottest authorisation query: (workspace_id, user_id) → role, is_scoped, cached in the request context for the request's lifetime.
6.3.13 workspace_invitations #
Purpose. A pending emailed invitation carrying a signed single-use token.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
workspace_id |
uuid | N | — | FK → workspaces.id |
Target workspace. |
email |
citext | N | — | CHECK (char_length(email) BETWEEN 3 AND 254) |
Invitee address. |
role |
text | N | — | CHECK (role IN ('admin','editor','viewer')) |
Owner cannot be invited; ownership moves only by transfer (8.8). |
is_scoped |
boolean | N | false | — | Whether the accepted member starts scoped. |
pending_grants |
jsonb | N | '[]' |
— | Array of {resource_type, resource_id} materialised into resource_grants on accept. |
token_hash |
bytea | N | — | unique, CHECK (octet_length(token_hash) = 32) |
SHA-256 of the signed token. |
invited_by_user_id |
uuid | Y | null | FK → users.id |
Inviter. |
status |
text | N | 'pending' |
CHECK (status IN ('pending','accepted','revoked','expired')) |
Lifecycle. |
expires_at |
timestamptz | N | — | — | 7 days after the most recent send. |
accepted_at |
timestamptz | Y | null | — | — |
accepted_user_id |
uuid | Y | null | FK → users.id |
Who accepted; may differ in case from email. |
revoked_at |
timestamptz | Y | null | — | — |
revoked_by_user_id |
uuid | Y | null | FK → users.id |
— |
resend_count |
integer | N | 0 | CHECK (resend_count BETWEEN 0 AND 5) |
Hard ceiling of 5 resends. |
last_sent_at |
timestamptz | N | now() |
— | Cooldown anchor (60s). |
Indexes. ux_workspace_invitations_token unique on (token_hash). ux_workspace_invitations_ws_email_pending unique on (workspace_id,email) WHERE status = 'pending' — one live invitation per address per workspace. ix_workspace_invitations_ws_status on (workspace_id,status,created_at DESC) — the pending-invitations list. ix_workspace_invitations_expiry on (expires_at) WHERE status = 'pending' — the expiry sweeper.
Foreign keys. workspace_id → workspaces.id ON DELETE CASCADE. All user references ON DELETE SET NULL.
Checks. ck_workspace_invitations_accept_pair: (status = 'accepted') = (accepted_at IS NOT NULL).
Access patterns. Point lookup by hash on accept; small list per workspace.
6.3.14 resource_grants #
Purpose. Per-resource allow-list entries for a scoped member (Business only).
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
workspace_id |
uuid | N | — | FK → workspaces.id |
Redundant with the member's workspace, but present so every tenancy predicate is uniform and the index is covering. |
member_id |
uuid | N | — | FK → workspace_members.id |
The scoped member. |
resource_type |
text | N | — | CHECK (resource_type IN ('bio_page','link','qr_code')) |
— |
resource_id |
uuid | N | — | — | Polymorphic target. |
granted_by_user_id |
uuid | Y | null | FK → users.id |
Provenance. |
Indexes. ux_resource_grants_member_resource unique on (member_id,resource_type,resource_id) — idempotent grant creation. ix_resource_grants_member_type on (member_id,resource_type) — the scoped-list IN sub-query on every list view. ix_resource_grants_resource on (resource_type,resource_id) — "who has access to this resource" panel, and grant cleanup when the resource is purged.
Foreign keys. workspace_id → workspaces.id ON DELETE CASCADE. member_id → workspace_members.id ON DELETE CASCADE. granted_by_user_id → users.id ON DELETE SET NULL.
Checks. ck_resource_grants_ws_match is enforced by a composite FK: (workspace_id, member_id) → workspace_members(workspace_id, id) backed by ux_workspace_members_id_ws unique on (id,workspace_id). This makes a grant that crosses workspaces impossible at the storage layer.
Access patterns. resource_id is deliberately not a real foreign key — it is polymorphic across three tables. Integrity is maintained by (a) the resource-purge job deleting matching grants in the same transaction, and (b) a nightly consistency job that deletes orphan grants and records a metric. Both are specified in 6.10.
6.3.15 audit_log_entries #
Purpose. Append-only record of security- and content-significant actions. The catalogue of events is in 8.9.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
workspace_id |
uuid | N | — | FK → workspaces.id |
Tenancy. |
event_key |
text | N | — | CHECK (char_length(event_key) <= 64) |
Stable dotted key, e.g. qr.destination_changed (catalogue in 8.9). |
actor_type |
text | N | — | CHECK (actor_type IN ('user','api_key','system','support')) |
— |
actor_user_id |
uuid | Y | null | FK → users.id |
Set when actor_type='user'. |
actor_api_key_id |
uuid | Y | null | FK → api_keys.id |
Set when actor_type='api_key'. |
actor_label |
text | N | — | CHECK (char_length(actor_label) <= 200) |
Frozen human label ("Dana Reyes dana@acme.com", "API key lk_9f2c…"). Survives account anonymization so the log stays readable. |
resource_type |
text | Y | null | CHECK (char_length(resource_type) <= 40) |
e.g. qr_code. |
resource_id |
uuid | Y | null | — | Polymorphic; not a foreign key, because the log must outlive the resource. |
resource_label |
text | Y | null | CHECK (char_length(resource_label) <= 200) |
Frozen label, e.g. Spring Catalogue QR (go.acme.com/spring). |
before |
jsonb | Y | null | — | Changed fields only, pre-change values. |
after |
jsonb | Y | null | — | Changed fields only, post-change values. |
changed_fields |
text[] | Y | null | — | Field names, for filtering without JSON traversal. |
ip_country |
char(2) | Y | null | — | Country only. Raw IP is never written. |
user_agent_family |
text | Y | null | — | Family only. |
request_id |
text | Y | null | CHECK (char_length(request_id) <= 64) |
Correlates to logs and traces (Section 25). |
occurred_at |
timestamptz | N | now() |
— | Event time. |
retention_expires_at |
timestamptz | Y | null | — | Computed at write time from the workspace's plan. NULL means "retain indefinitely" and is the only value that makes an entry permanent. It is written NULL for Business-plan entries and, on every plan including Free, for the permanently-retained event keys listed below. |
Permanently-retained event keys. These are written with retention_expires_at = NULL regardless of plan, because the printed-artefact guarantee depends on the destination history of a QR code being readable for the life of the printed material (Section 14):
| Event key | Why permanent |
|---|---|
qr.created |
Establishes the slug, host and first destination of a symbol that may be printed. |
qr.destination_changed |
The single entry a print manager must be able to read years later. |
qr.styling_changed |
Distinguishes "the artwork changed" from "the destination changed". |
qr.status_changed |
Records pause, resume, archive and memorialisation of a printed symbol. |
qr.rendered |
Ties an artifact set to the version it was produced from. |
data.deletion_requested, data.deletion_executed |
Proof that a statutory obligation was performed (retained 7 years by the request row, indefinitely by the log). |
The application writes this by rule, not by remembering: the audit-write helper looks the event key up in a constant PERMANENT_AUDIT_EVENT_KEYS set and passes NULL when it matches. A test asserts the set matches this table exactly.
Indexes. ix_audit_ws_time on (workspace_id,occurred_at DESC) — the default viewer query. ix_audit_ws_event_time on (workspace_id,event_key,occurred_at DESC) — event-type filter. ix_audit_resource on (resource_type,resource_id,occurred_at DESC) — "history of this QR code", the query a print manager actually runs (8.9). ix_audit_actor on (actor_user_id,occurred_at DESC) WHERE actor_user_id IS NOT NULL — "everything this member did". ix_audit_retention on (retention_expires_at) WHERE retention_expires_at IS NOT NULL — purge sweep. The partial predicate is what keeps the permanent rows out of the sweep index entirely.
Foreign keys. workspace_id → workspaces.id ON DELETE CASCADE. actor_user_id → users.id ON DELETE SET NULL. actor_api_key_id → api_keys.id ON DELETE SET NULL. Because actor_label is frozen text, a nulled actor never makes an entry unreadable.
Checks. ck_audit_actor_shape: (actor_type='user') = (actor_user_id IS NOT NULL) AND (actor_type='api_key') = (actor_api_key_id IS NOT NULL). No updated_at column exists — the row is immutable by design.
Append-only enforcement. tg_audit_log_entries_immutable, a BEFORE UPDATE OR DELETE trigger, plus REVOKE UPDATE, DELETE ON audit_log_entries FROM app_rw. Only the retention role (app_retention) may delete. The trigger is the mechanism that makes a NULL retention_expires_at actually permanent — prose in a runbook would not survive a purge job, so the condition lives in the trigger body:
CREATE OR REPLACE FUNCTION tg_fn_audit_log_entries_immutable()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'UPDATE' THEN
RAISE EXCEPTION 'audit_log_entries is append-only';
END IF;
-- TG_OP = 'DELETE'
IF current_user <> 'app_retention' THEN
RAISE EXCEPTION 'audit_log_entries may only be purged by the retention role';
END IF;
IF OLD.retention_expires_at IS NULL OR OLD.retention_expires_at >= now() THEN
RAISE EXCEPTION 'audit entry % is not eligible for purge', OLD.id;
END IF;
RETURN OLD;
END $$;
CREATE TRIGGER tg_audit_log_entries_immutable
BEFORE UPDATE OR DELETE ON audit_log_entries
FOR EACH ROW EXECUTE FUNCTION tg_fn_audit_log_entries_immutable();The audit-retention job's own statement carries the identical predicate, so the trigger is a backstop rather than the only guard:
DELETE FROM audit_log_entries
WHERE retention_expires_at IS NOT NULL
AND retention_expires_at < now();A WHERE retention_expires_at < now() written without the IS NOT NULL limb is not merely redundant — it is the exact bug this pair of definitions exists to prevent, because a future rewrite that made the column non-nullable with a sentinel would silently start deleting permanent entries. See 8.9.
Access patterns. Very high write volume, append-only, read in bounded descending-time pages.
6.3.16 plans #
Purpose. The three published plans, their prices and their entitlement payloads. Seeded (6.7), edited only by migration.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
key |
text | N | — | unique, CHECK (key IN ('free','pro','business')) |
Stable identifier referenced by workspaces.current_plan_key. |
name |
text | N | — | CHECK (char_length(name) <= 40) |
Display name. |
price_month_cents |
integer | N | 0 | CHECK (price_month_cents >= 0) |
Monthly price in minor units. |
price_year_cents |
integer | N | 0 | CHECK (price_year_cents >= 0) |
Annual price in minor units. |
currency |
char(3) | N | 'USD' |
CHECK (currency ~ '^[A-Z]{3}$') |
ISO 4217. |
provider_price_id_month |
text | Y | null | — | Payment-provider price identifier. |
provider_price_id_year |
text | Y | null | — | Same, annual. |
entitlements |
jsonb | N | — | — | The full entitlement map (limits, feature booleans, retention days, API rate limits). Values are exactly the plan table owned by Section 22. |
sort_order |
smallint | N | 0 | — | Pricing-page ordering. |
is_public |
boolean | N | true | — | False hides a legacy plan from the pricing page while keeping it valid. |
is_active |
boolean | N | true | — | False blocks new subscriptions. |
Indexes. ux_plans_key unique on (key).
Foreign keys. None.
Access patterns. Three rows. Loaded once per process and cached in memory with a 60s TTL; entitlement evaluation never touches the database.
6.3.17 subscriptions #
Purpose. A workspace's billing subscription and lifecycle state.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
workspace_id |
uuid | N | — | FK → workspaces.id |
Tenancy. |
plan_key |
text | N | — | FK → plans.key |
Current plan. |
provider |
text | N | 'stripe' |
CHECK (provider IN ('stripe')) |
— |
provider_customer_id |
text | Y | null | CHECK (char_length(...) <= 100) |
Provider customer handle. |
provider_subscription_id |
text | Y | null | unique | Provider subscription handle. |
status |
text | N | 'active' |
CHECK (status IN ('trialing','active','past_due','canceled','unpaid','incomplete','incomplete_expired')) |
Mirrors the provider. |
billing_interval |
text | N | 'month' |
CHECK (billing_interval IN ('month','year')) |
— |
seats |
integer | N | 1 | CHECK (seats BETWEEN 1 AND 25) |
Paid seats; workspaces.seats_purchased mirrors this. |
current_period_start |
timestamptz | Y | null | — | — |
current_period_end |
timestamptz | Y | null | — | Grace and downgrade timing key off this. |
cancel_at_period_end |
boolean | N | false | — | — |
canceled_at |
timestamptz | Y | null | — | — |
trial_end |
timestamptz | Y | null | — | — |
downgrade_to_plan_key |
text | Y | null | FK → plans.key |
Scheduled downgrade target; applied at period end by the billing worker. |
downgrade_selection |
jsonb | Y | null | — | The user's guided keep/archive choices, captured before the downgrade executes (Section 22). |
Indexes. ux_subscriptions_workspace_live unique on (workspace_id) WHERE status NOT IN ('canceled','incomplete_expired') — at most one live subscription per workspace, enforced by the database. ux_subscriptions_provider_sub unique on (provider_subscription_id) WHERE provider_subscription_id IS NOT NULL — webhook idempotency. ix_subscriptions_period_end on (current_period_end) WHERE status IN ('active','past_due','trialing') — the renewal/downgrade sweeper.
Foreign keys. workspace_id → workspaces.id ON DELETE CASCADE. plan_key, downgrade_to_plan_key → plans.key ON DELETE RESTRICT.
Checks. ck_subscriptions_period_order: current_period_end IS NULL OR current_period_start IS NULL OR current_period_end > current_period_start.
Access patterns. One row per workspace, read by the billing UI and the worker, not on the hot path (see the current_plan_key denormalisation in 6.3.11).
6.3.18 subscription_items #
Purpose. Individual priced line items on a subscription.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
subscription_id |
uuid | N | — | FK → subscriptions.id |
Parent. |
provider_item_id |
text | N | — | unique | Provider line-item handle. |
provider_price_id |
text | N | — | — | Provider price handle. |
item_type |
text | N | — | CHECK (item_type IN ('base','seat')) |
Base plan or per-seat line. |
quantity |
integer | N | 1 | CHECK (quantity >= 0) |
— |
unit_amount_cents |
integer | N | 0 | CHECK (unit_amount_cents >= 0) |
Snapshot at sync time. |
currency |
char(3) | N | 'USD' |
— | — |
Indexes. ux_subscription_items_provider unique on (provider_item_id). ix_subscription_items_subscription on (subscription_id).
Foreign keys. subscription_id → subscriptions.id ON DELETE CASCADE.
Access patterns. Read with the subscription; written only by the webhook processor.
6.3.19 invoices #
Purpose. Issued invoices mirrored from the payment provider so the billing history survives provider outages.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
workspace_id |
uuid | N | — | FK → workspaces.id |
Tenancy. |
subscription_id |
uuid | Y | null | FK → subscriptions.id |
Null for one-off charges. |
provider_invoice_id |
text | N | — | unique | Provider handle. |
number |
text | Y | null | CHECK (char_length(number) <= 40) |
Human invoice number. |
status |
text | N | — | CHECK (status IN ('draft','open','paid','void','uncollectible')) |
— |
amount_due_cents |
integer | N | 0 | CHECK (amount_due_cents >= 0) |
— |
amount_paid_cents |
integer | N | 0 | CHECK (amount_paid_cents >= 0) |
— |
currency |
char(3) | N | 'USD' |
— | — |
period_start |
timestamptz | Y | null | — | — |
period_end |
timestamptz | Y | null | — | — |
hosted_url |
text | Y | null | CHECK (char_length(hosted_url) <= 2048) |
Provider-hosted invoice page. |
pdf_url |
text | Y | null | same | Provider-hosted PDF. |
issued_at |
timestamptz | Y | null | — | — |
paid_at |
timestamptz | Y | null | — | — |
voided_at |
timestamptz | Y | null | — | — |
Indexes. ux_invoices_provider unique on (provider_invoice_id). ix_invoices_ws_issued on (workspace_id,issued_at DESC) — the billing history list.
Foreign keys. workspace_id → workspaces.id ON DELETE CASCADE. subscription_id → subscriptions.id ON DELETE SET NULL.
Access patterns. Small per-workspace list, cursor-paginated.
6.3.20 payment_events #
Purpose. Raw provider webhook events with idempotent processing state. The provider is retried-at-least-once; this table makes handling exactly-once.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
provider |
text | N | 'stripe' |
CHECK (provider IN ('stripe')) |
— |
provider_event_id |
text | N | — | unique | The provider's event id — the idempotency key. |
event_type |
text | N | — | CHECK (char_length(event_type) <= 100) |
e.g. invoice.payment_failed. |
workspace_id |
uuid | Y | null | FK → workspaces.id |
Resolved during processing; null if unresolvable. |
payload |
jsonb | N | — | — | The verified raw event body. |
signature_verified |
boolean | N | false | — | Set only after signature validation; unverified events are stored and never processed. |
received_at |
timestamptz | N | now() |
— | — |
processed_at |
timestamptz | Y | null | — | Null means outstanding. |
attempt_count |
integer | N | 0 | CHECK (attempt_count >= 0) |
— |
processing_error |
text | Y | null | CHECK (char_length(processing_error) <= 2000) |
Last failure. |
Indexes. ux_payment_events_provider_event unique on (provider,provider_event_id) — the idempotency guard: a duplicate delivery hits a unique-violation and is answered 200 without reprocessing. ix_payment_events_unprocessed on (received_at) WHERE processed_at IS NULL — the retry sweeper.
Foreign keys. workspace_id → workspaces.id ON DELETE SET NULL — the financial record outlives the workspace.
Access patterns. Append + one update. Retained 24 months.
6.3.21 entitlement_overrides #
Purpose. Manual per-workspace entitlement adjustments for support cases, grandfathering and partnerships, without inventing new plans.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
workspace_id |
uuid | N | — | FK → workspaces.id |
Tenancy. |
entitlement_key |
text | N | — | CHECK (char_length(entitlement_key) <= 64) |
Must exist in plans.entitlements; validated by the application against the known key set. |
value |
jsonb | N | — | — | Overriding value (number, boolean or string). |
reason |
text | N | — | CHECK (char_length(reason) BETWEEN 3 AND 500) |
Mandatory justification; appears in the audit log. |
granted_by_user_id |
uuid | Y | null | FK → users.id |
Staff actor. |
expires_at |
timestamptz | Y | null | — | Null means permanent. |
Indexes. ux_entitlement_overrides_ws_key unique on (workspace_id,entitlement_key) WHERE expires_at IS NULL OR expires_at > now() is not index-legal (now() is not immutable), so the enforced index is ux_entitlement_overrides_ws_key unique on (workspace_id,entitlement_key) and expiry is applied at read time. ix_entitlement_overrides_expiry on (expires_at) WHERE expires_at IS NOT NULL — the expiry sweeper deletes stale rows nightly.
Foreign keys. workspace_id → workspaces.id ON DELETE CASCADE. granted_by_user_id → users.id ON DELETE SET NULL.
Access patterns. Loaded with the workspace entitlement bundle and cached for 60s alongside it.
6.3.22 custom_domains #
Purpose. Every hostname the platform serves: customer-verified domains and LinkHub's own system hostnames (workspace_id IS NULL, is_system = true). Lifecycle states are owned by Section 13.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
workspace_id |
uuid | Y | null | FK → workspaces.id |
Null only for system domains. |
hostname |
citext | N | — | unique, CHECK (hostname ~ '^[a-z0-9.-]{4,253}$') |
Globally unique across all workspaces — two tenants cannot claim one host. |
is_system |
boolean | N | false | — | True for linkhub.app, go.linkhub.app, lnkhb.co. |
kind |
text | N | — | CHECK (kind IN ('subdomain','apex')) |
Determines CNAME vs A/AAAA instructions. |
purpose |
text | N | 'both' |
CHECK (purpose IN ('links','pages','both')) |
What the host serves. |
status |
text | N | 'pending_dns' |
CHECK (status IN ('pending_dns','verifying','provisioning_tls','active','dns_failed','tls_failed','suspended')) |
State machine per Section 13. |
verification_token |
text | Y | null | CHECK (char_length(verification_token) <= 128) |
Value expected in the _linkhub-challenge TXT record. |
expected_dns |
jsonb | N | '{}' |
— | The exact records the UI tells the customer to create. |
observed_dns |
jsonb | Y | null | — | What the verifier last actually resolved — the "what we currently see" diagnostic. |
verified_at |
timestamptz | Y | null | — | Ownership proven. |
activated_at |
timestamptz | Y | null | — | Serving traffic. |
last_checked_at |
timestamptz | Y | null | — | Verifier heartbeat. |
next_check_at |
timestamptz | Y | null | — | Drives the backoff schedule. |
failure_code |
text | Y | null | CHECK (char_length(failure_code) <= 64) |
Machine-readable failure reason. |
suspended_reason |
text | Y | null | — | Abuse or billing suspension. |
redirect_root_to |
text | Y | null | CHECK (char_length(redirect_root_to) <= 2048) |
Where https://host/ goes when no page is bound to the root. |
Indexes. ux_custom_domains_hostname unique on (hostname) — the edge resolver's first lookup on every request, and the global claim guard. ix_custom_domains_ws on (workspace_id) WHERE workspace_id IS NOT NULL — the domain list. ix_custom_domains_next_check on (next_check_at) WHERE status IN ('pending_dns','verifying','provisioning_tls','active') — the verification and re-check workers.
Foreign keys. workspace_id → workspaces.id ON DELETE CASCADE.
Checks. ck_custom_domains_system_ws: (is_system AND workspace_id IS NULL) OR (NOT is_system AND workspace_id IS NOT NULL).
Access patterns. Hostname → domain row is resolved at the edge from Redis; the database is the cache-miss path only.
6.3.23 domain_verification_attempts #
Purpose. One DNS check against one domain, with what was expected and what was observed. This is the evidence trail behind the Section 13 diagnostic panel.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
custom_domain_id |
uuid | N | — | FK → custom_domains.id |
Parent. |
attempt_no |
integer | N | — | CHECK (attempt_no >= 1) |
Monotonic per domain. |
check_type |
text | N | — | CHECK (check_type IN ('txt','cname','a','aaaa','alias','http_reachability')) |
— |
expected_value |
text | Y | null | CHECK (char_length(expected_value) <= 512) |
— |
observed_values |
text[] | Y | null | — | Every value the resolver returned. |
resolver |
text | Y | null | CHECK (char_length(resolver) <= 64) |
Which resolver was queried; two independent resolvers must agree. |
result |
text | N | — | CHECK (result IN ('pass','fail','timeout','nxdomain','partial')) |
— |
error_code |
text | Y | null | — | Machine-readable detail. |
checked_at |
timestamptz | N | now() |
— | — |
Indexes. ix_domain_verification_attempts_domain_time on (custom_domain_id,checked_at DESC) — the diagnostic panel reads the most recent N. ux_domain_verification_attempts_domain_no_type unique on (custom_domain_id,attempt_no,check_type).
Foreign keys. custom_domain_id → custom_domains.id ON DELETE CASCADE.
Access patterns. Append-only; retained 90 days, then purged.
6.3.24 tls_certificates #
Purpose. ACME certificate lifecycle. The certificate material itself lives in the secret store; only references are stored here.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
custom_domain_id |
uuid | N | — | FK → custom_domains.id |
Parent. |
provider |
text | N | 'letsencrypt' |
CHECK (provider IN ('letsencrypt')) |
— |
status |
text | N | 'pending' |
CHECK (status IN ('pending','issued','renewing','expired','revoked','failed')) |
— |
challenge_type |
text | N | 'http-01' |
CHECK (challenge_type IN ('http-01','dns-01')) |
DNS-01 is the wildcard/apex fallback. |
serial |
text | Y | null | CHECK (char_length(serial) <= 100) |
Issued certificate serial. |
not_before |
timestamptz | Y | null | — | — |
not_after |
timestamptz | Y | null | — | Renewal is scheduled at not_after - 30 days. |
issued_at |
timestamptz | Y | null | — | — |
cert_ref |
text | Y | null | CHECK (char_length(cert_ref) <= 256) |
Secret-store reference for the chain. |
key_ref |
text | Y | null | same | Secret-store reference for the private key. |
renewal_attempts |
integer | N | 0 | CHECK (renewal_attempts >= 0) |
— |
last_error |
text | Y | null | CHECK (char_length(last_error) <= 2000) |
— |
revoked_at |
timestamptz | Y | null | — | — |
Indexes. ix_tls_certificates_domain_status on (custom_domain_id,status). ix_tls_certificates_not_after on (not_after) WHERE status IN ('issued','renewing') — the renewal scheduler (renew at 30 days, alert at 14, page at 7). ux_tls_certificates_domain_active unique on (custom_domain_id) WHERE status = 'issued'.
Foreign keys. custom_domain_id → custom_domains.id ON DELETE CASCADE.
Access patterns. Read by the renewal worker on a schedule; never on a request path.
6.3.25 bio_pages #
Purpose. A public link-in-bio page.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
workspace_id |
uuid | N | — | FK → workspaces.id |
Tenancy. |
domain_id |
uuid | N | — | FK → custom_domains.id |
Host. System domain row when no custom domain. |
handle |
citext | N | — | CHECK (handle ~ '^[a-z0-9-]{1,64}$') |
Path segment: https://<host>/<handle>. |
title |
text | N | — | CHECK (char_length(title) BETWEEN 1 AND 120) |
— |
description |
text | Y | null | CHECK (char_length(description) <= 300) |
Shown under the title. |
seo_title |
text | Y | null | CHECK (char_length(seo_title) <= 70) |
Falls back to title. |
seo_description |
text | Y | null | CHECK (char_length(seo_description) <= 160) |
Falls back to description. |
og_asset_id |
uuid | Y | null | FK → uploaded_assets.id |
Social preview image; a default is generated when null. |
favicon_asset_id |
uuid | Y | null | FK → uploaded_assets.id |
— |
avatar_asset_id |
uuid | Y | null | FK → uploaded_assets.id |
Page avatar. |
theme_id |
uuid | N | — | FK → themes.id |
Applied theme. |
status |
text | N | 'draft' |
CHECK (status IN ('draft','published','unpublished','archived')) |
archived is the downgrade state — read-only, still resolvable per Section 22. |
visibility |
text | N | 'public' |
CHECK (visibility IN ('public','unlisted','password')) |
unlisted adds noindex; password gates rendering. |
password_hash |
text | Y | null | — | Argon2id hash when visibility='password'. |
published_version_id |
uuid | Y | null | FK → bio_page_versions.id |
What the public sees. Null until first publish. |
draft_version_id |
uuid | Y | null | FK → bio_page_versions.id |
Working snapshot. |
published_at |
timestamptz | Y | null | — | — |
noindex |
boolean | N | false | — | Forces robots: noindex. |
locale |
text | N | 'en' |
— | lang attribute on the rendered document. |
experiment_id |
uuid | Y | null | FK → experiments.id |
Running page-level A/B test. |
view_count_cached |
bigint | N | 0 | CHECK (view_count_cached >= 0) |
Denormalised counter updated by the rollup worker, never by the render path. |
last_viewed_at |
timestamptz | Y | null | — | Same. |
created_by_user_id |
uuid | Y | null | FK → users.id |
Provenance; also the auto-grant subject in 8.6. |
deleted_at |
timestamptz | Y | null | — | Soft delete. |
purge_after |
timestamptz | Y | null | — | deleted_at + 30 days. |
Indexes. ux_bio_pages_domain_handle unique on (domain_id,handle) WHERE deleted_at IS NULL — the public resolution lookup and the handle-availability check. ix_bio_pages_ws_updated on (workspace_id,updated_at DESC) WHERE deleted_at IS NULL — the dashboard list, default sort. ix_bio_pages_ws_status on (workspace_id,status) WHERE deleted_at IS NULL — status filter and entitlement counting. ix_bio_pages_purge on (purge_after) WHERE purge_after IS NOT NULL.
Foreign keys. workspace_id → workspaces.id ON DELETE CASCADE. domain_id → custom_domains.id ON DELETE RESTRICT — deleting a domain that still hosts pages is blocked; Section 13 requires an explicit reassign-or-delete step first. theme_id → themes.id ON DELETE RESTRICT. published_version_id/draft_version_id → bio_page_versions.id ON DELETE SET NULL (deferrable initially deferred, because page and first version are inserted in one transaction). Asset references ON DELETE SET NULL. experiment_id → experiments.id ON DELETE SET NULL. created_by_user_id → users.id ON DELETE SET NULL.
Checks. ck_bio_pages_password: (visibility = 'password') = (password_hash IS NOT NULL). ck_bio_pages_published: status <> 'published' OR published_version_id IS NOT NULL.
Access patterns. Public render: one (domain_id, handle) lookup, served from Redis in the normal case. Dashboard: workspace-scoped keyset pagination.
6.3.26 bio_page_versions #
Purpose. An immutable, complete snapshot of a page and its blocks. Publishing points published_version_id at one of these; rollback is a pointer move.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
bio_page_id |
uuid | N | — | FK → bio_pages.id |
Parent. |
workspace_id |
uuid | N | — | FK → workspaces.id |
Denormalised for uniform tenancy predicates. |
version_no |
integer | N | — | CHECK (version_no >= 1) |
Monotonic per page. |
snapshot |
jsonb | N | — | — | Page settings, ordered blocks with full config, and resolved theme tokens. Self-contained: rendering a version never joins blocks. |
theme_id |
uuid | Y | null | FK → themes.id |
Theme at snapshot time. |
label |
text | Y | null | CHECK (char_length(label) <= 80) |
Optional user label ("pre-launch"). |
source |
text | N | 'editor' |
CHECK (source IN ('editor','api','revert','ab_promote','import')) |
How the version was created. |
created_by_user_id |
uuid | Y | null | FK → users.id |
— |
created_by_api_key_id |
uuid | Y | null | FK → api_keys.id |
— |
published_at |
timestamptz | Y | null | — | Non-null if this version was ever live. |
published_by_user_id |
uuid | Y | null | FK → users.id |
— |
content_hash |
bytea | N | — | CHECK (octet_length(content_hash) = 32) |
SHA-256 of the canonicalised snapshot; a no-op save is detected and skipped. |
Indexes. ux_bio_page_versions_page_no unique on (bio_page_id,version_no). ix_bio_page_versions_page_created on (bio_page_id,created_at DESC) — the version-history drawer. ix_bio_page_versions_content_hash on (bio_page_id,content_hash) — no-op detection.
Foreign keys. bio_page_id → bio_pages.id ON DELETE CASCADE. workspace_id → workspaces.id ON DELETE CASCADE. Theme and actor references ON DELETE SET NULL. No updated_at semantics apply: rows are never updated except to set published_at.
Access patterns. Read by version id on rollback and by page for history. Retention: last 50 versions per page plus every version that was ever published, whichever is larger (6.8).
6.3.27 blocks #
Purpose. An ordered content unit on a bio page. The block catalogue and per-type config shapes are owned by Section 10.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
workspace_id |
uuid | N | — | FK → workspaces.id |
Tenancy. |
bio_page_id |
uuid | N | — | FK → bio_pages.id |
Parent page. |
parent_block_id |
uuid | Y | null | FK → blocks.id |
Set only for children of a container block (e.g. a link group). One level of nesting only. |
type |
text | N | — | CHECK (char_length(type) <= 40) |
Block type key from the Section 10 catalogue. |
sort_order |
numeric(20,10) | N | — | CHECK (sort_order > 0) |
Fractional ordering: inserting between two blocks writes one row instead of renumbering the page. Rebalanced to integers when any adjacent gap falls below 1e-6. |
config |
jsonb | N | '{}' |
— | Type-specific configuration, validated by the type's Zod schema. |
is_visible |
boolean | N | true | — | Hidden blocks are omitted from the published snapshot. |
schedule_start_at |
timestamptz | Y | null | — | Block appears from this time (Pro+). |
schedule_end_at |
timestamptz | Y | null | — | Block disappears after this time. |
link_id |
uuid | Y | null | FK → links.id |
Set when the block delegates to a tracked short link. |
experiment_id |
uuid | Y | null | FK → experiments.id |
Block participates in a page experiment. |
variant_key |
text | Y | null | CHECK (variant_key ~ '^[a-z]$') |
Which variant this block belongs to; null means "all variants". |
click_count_cached |
bigint | N | 0 | CHECK (click_count_cached >= 0) |
Updated by the rollup worker. |
created_by_user_id |
uuid | Y | null | FK → users.id |
— |
deleted_at |
timestamptz | Y | null | — | Soft delete. |
Indexes. ix_blocks_page_order on (bio_page_id,sort_order) WHERE deleted_at IS NULL — the editor load and snapshot build, in display order. ix_blocks_page_parent_order on (bio_page_id,parent_block_id,sort_order) WHERE deleted_at IS NULL — container children. ix_blocks_link on (link_id) WHERE link_id IS NOT NULL — "which pages reference this link" before deleting a link. ix_blocks_ws_type on (workspace_id,type) — block-type usage reporting.
Foreign keys. bio_page_id → bio_pages.id ON DELETE CASCADE. workspace_id → workspaces.id ON DELETE CASCADE. parent_block_id → blocks.id ON DELETE CASCADE. link_id → links.id ON DELETE SET NULL — deleting a link degrades the block to a plain URL rather than destroying page content. experiment_id → experiments.id ON DELETE SET NULL.
Checks. ck_blocks_schedule_order: schedule_end_at IS NULL OR schedule_start_at IS NULL OR schedule_end_at > schedule_start_at. ck_blocks_nesting: enforced in application code — a block whose parent_block_id is itself a child is rejected; the database cannot express depth limits and a recursive trigger would cost more than it is worth on the editor path.
Access patterns. Read as a whole page in sort_order; written in small batches on reorder.
6.3.28 block_versions #
Purpose. Per-block history, so a single block can be restored without reverting the whole page.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
block_id |
uuid | N | — | FK → blocks.id |
Parent block. |
bio_page_id |
uuid | N | — | FK → bio_pages.id |
Denormalised for page-level history queries. |
bio_page_version_id |
uuid | Y | null | FK → bio_page_versions.id |
The page version this block state belongs to, when created by a publish. |
version_no |
integer | N | — | CHECK (version_no >= 1) |
Monotonic per block. |
snapshot |
jsonb | N | — | — | Full block row as JSON (type, config, order, visibility, schedule). |
changed_fields |
text[] | Y | null | — | Fields that differ from the previous version. |
created_by_user_id |
uuid | Y | null | FK → users.id |
— |
Indexes. ux_block_versions_block_no unique on (block_id,version_no). ix_block_versions_page_version on (bio_page_version_id) WHERE bio_page_version_id IS NOT NULL.
Foreign keys. block_id → blocks.id ON DELETE CASCADE. bio_page_id → bio_pages.id ON DELETE CASCADE. bio_page_version_id → bio_page_versions.id ON DELETE SET NULL.
Access patterns. Written on every block mutation; read only from the block history drawer. Retained: last 20 versions per block (6.8).
6.3.29 themes #
Purpose. A design token set. System presets have workspace_id IS NULL and is_system = true; workspace themes are copies.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
workspace_id |
uuid | Y | null | FK → workspaces.id |
Null for system presets. |
key |
text | Y | null | CHECK (key ~ '^[a-z0-9-]{1,40}$') |
Stable key for system presets only. |
name |
text | N | — | CHECK (char_length(name) BETWEEN 1 AND 60) |
— |
tokens |
jsonb | N | — | — | Colors, radii, spacing, button style, shadow, layout width. Schema owned by Section 9. |
background |
jsonb | N | '{}' |
— | Solid, gradient or image background descriptor. |
font_heading_id |
uuid | Y | null | FK → fonts.id |
Null means the system stack. |
font_body_id |
uuid | Y | null | FK → fonts.id |
Same. |
is_system |
boolean | N | false | — | System presets are read-only and cannot be deleted. |
contrast_report |
jsonb | Y | null | — | Last computed contrast ratios per token pair. |
contrast_passed |
boolean | N | true | — | False only when an override was recorded. |
contrast_override_by_user_id |
uuid | Y | null | FK → users.id |
Who typed the confirmation (Section 24). |
contrast_override_at |
timestamptz | Y | null | — | — |
created_by_user_id |
uuid | Y | null | FK → users.id |
— |
deleted_at |
timestamptz | Y | null | — | Soft delete. |
Indexes. ux_themes_key_system unique on (key) WHERE is_system — one row per preset key. ix_themes_ws on (workspace_id) WHERE deleted_at IS NULL — the theme picker.
Foreign keys. workspace_id → workspaces.id ON DELETE CASCADE. Font references ON DELETE RESTRICT — a font in use by a theme cannot be removed. contrast_override_by_user_id, created_by_user_id → users.id ON DELETE SET NULL.
Checks. ck_themes_system_ws: (is_system AND workspace_id IS NULL) OR (NOT is_system AND workspace_id IS NOT NULL). ck_themes_override_pair: contrast_passed OR contrast_override_by_user_id IS NOT NULL — a failing theme cannot exist without a recorded override.
Access patterns. Small; joined into the page snapshot at publish time and thereafter read from the snapshot, not the table.
6.3.30 fonts #
Purpose. A font family available to themes.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
workspace_id |
uuid | Y | null | FK → workspaces.id |
Null for the system catalogue. |
key |
text | Y | null | CHECK (key ~ '^[a-z0-9-]{1,40}$') |
System catalogue key. |
family |
text | N | — | CHECK (char_length(family) <= 80) |
CSS family name. |
source |
text | N | — | CHECK (source IN ('system','hosted','uploaded')) |
system = OS stack, no download. |
weights |
integer[] | N | '{400,700}' |
— | Available weights; a theme may reference at most 3 across both families (Section 11 budget). |
subsets |
text[] | N | '{latin}' |
— | Subsets generated at upload time. |
files |
jsonb | N | '{}' |
— | Map of weight+subset → asset id and URL, woff2 only. |
license |
text | Y | null | CHECK (char_length(license) <= 200) |
Recorded for uploaded fonts. |
is_system |
boolean | N | false | — | — |
Indexes. ux_fonts_key_system unique on (key) WHERE is_system. ix_fonts_ws on (workspace_id) WHERE workspace_id IS NOT NULL.
Foreign keys. workspace_id → workspaces.id ON DELETE CASCADE.
Access patterns. Read at theme edit and at publish; the published snapshot embeds the resolved font URLs so the render path never joins.
6.3.31 uploaded_assets #
Purpose. Every stored binary. Object storage holds the bytes; this table holds the metadata and is the authority on ownership.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
workspace_id |
uuid | Y | null | FK → workspaces.id |
Null for system assets (default OG images, preset backgrounds). |
uploader_user_id |
uuid | Y | null | FK → users.id |
— |
kind |
text | N | — | CHECK (kind IN ('image','logo','favicon','og','avatar','font','qr_artifact','export','import')) |
Drives allowed MIME types and size caps. |
bucket |
text | N | — | CHECK (char_length(bucket) <= 64) |
Storage bucket. |
storage_key |
text | N | — | unique, CHECK (char_length(storage_key) <= 512) |
Object key; includes the workspace id as a prefix segment. |
mime_type |
text | N | — | CHECK (char_length(mime_type) <= 100) |
Sniffed server-side, never trusted from the client. |
byte_size |
bigint | N | — | CHECK (byte_size > 0 AND byte_size <= 26214400) |
25 MiB hard ceiling. |
width |
integer | Y | null | CHECK (width > 0) |
Images only. |
height |
integer | Y | null | CHECK (height > 0) |
Images only. |
checksum_sha256 |
bytea | N | — | CHECK (octet_length(checksum_sha256) = 32) |
Dedupe within a workspace. |
variants |
jsonb | N | '{}' |
— | Generated renditions: AVIF and WebP at each breakpoint, with dimensions and byte sizes. |
alt_text |
text | Y | null | CHECK (char_length(alt_text) <= 300) |
Accessibility; prompted for on upload (Section 24). |
scan_status |
text | N | 'pending' |
CHECK (scan_status IN ('pending','clean','infected','skipped','error')) |
Assets are not publicly served until clean or skipped. |
scanned_at |
timestamptz | Y | null | — | — |
deleted_at |
timestamptz | Y | null | — | Soft delete. |
purge_after |
timestamptz | Y | null | — | Object bytes are deleted by the retention worker at this time, not before. |
Indexes. ux_uploaded_assets_storage_key unique on (storage_key). ix_uploaded_assets_ws_kind_created on (workspace_id,kind,created_at DESC) WHERE deleted_at IS NULL — the media library. ux_uploaded_assets_ws_checksum unique on (workspace_id,checksum_sha256,kind) WHERE deleted_at IS NULL — re-uploading the same file returns the existing asset instead of duplicating storage. ix_uploaded_assets_purge on (purge_after) WHERE purge_after IS NOT NULL.
Foreign keys. workspace_id → workspaces.id ON DELETE CASCADE (bytes are removed by the purge worker afterwards, driven by an outbox row — see 6.10). uploader_user_id → users.id ON DELETE SET NULL.
Access patterns. Point lookup by id; workspace-scoped list. Never on the public render path — published snapshots embed resolved URLs.
6.3.32 links #
Purpose. A branded short link. Resolution semantics are owned by Section 12.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
workspace_id |
uuid | N | — | FK → workspaces.id |
Tenancy. |
domain_id |
uuid | N | — | FK → custom_domains.id |
Host. |
slug |
citext | N | — | CHECK (slug ~ '^[a-z0-9-]{1,64}$') |
Path segment. Auto-generated slugs are 7 chars from the reduced Crockford base32 alphabet. |
title |
text | Y | null | CHECK (char_length(title) <= 120) |
Internal label. |
notes |
text | Y | null | CHECK (char_length(notes) <= 1000) |
Internal notes. |
destination_url |
text | N | — | CHECK (char_length(destination_url) BETWEEN 4 AND 2048) |
The primary destination; also the fallback when no rule or split matches. |
destination_mode |
text | N | 'single' |
CHECK (destination_mode IN ('single','split','rules')) |
rules still uses splits for its default branch. |
status |
text | N | 'active' |
CHECK (status IN ('draft','active','paused','scheduled','expired','archived')) |
draft is the state a link is created in by POST with publish: false; it has a slug reservation but never resolves publicly. archived = over-cap after downgrade: read-only, resolves for 90 days then serves a branded landing page. State semantics are owned by Section 12. |
scheduled_at |
timestamptz | Y | null | — | Before this time the link serves the pre-launch behaviour in Section 15. |
expires_at |
timestamptz | Y | null | — | After this time the link serves expiry_url or the branded expired page. |
expiry_url |
text | Y | null | CHECK (char_length(expiry_url) <= 2048) |
— |
password_hash |
text | Y | null | — | Argon2id; gates the interstitial. |
utm_preset_id |
uuid | Y | null | FK → utm_presets.id |
Applied at resolve time per the preset's append_mode. |
deep_link |
jsonb | Y | null | — | Optional iOS/Android app-scheme configuration with a web fallback. |
experiment_id |
uuid | Y | null | FK → experiments.id |
Running destination A/B test. |
safe_browsing_status |
text | N | 'unchecked' |
CHECK (safe_browsing_status IN ('unchecked','safe','flagged','blocked')) |
The only link-safety column in the schema. There is no safety_state column and no second value set. unchecked = no verdict yet; safe = provider returned clean; flagged = suspicious, interstitial shown; blocked = resolution refused (Section 23). |
safe_browsing_checked_at |
timestamptz | Y | null | — | Weekly recheck anchor. |
tags |
text[] | N | '{}' |
— | Free-form organisation; max 20 tags, each ≤ 40 chars (validated in the application). |
qr_pinned_count |
integer | N | 0 | CHECK (qr_pinned_count >= 0) |
How many live qr_codes rows resolve through this link. Maintained in the same transaction as every QR create, repoint and delete. A link with qr_pinned_count > 0 is QR-pinned: it is never archived and never counts toward the plan's link cap. |
click_count_cached |
bigint | N | 0 | CHECK (click_count_cached >= 0) |
Maintained by the rollup worker. The redirect path never writes to this table. |
last_clicked_at |
timestamptz | Y | null | — | Same. |
archived_at |
timestamptz | Y | null | — | — |
archived_reason |
text | Y | null | CHECK (archived_reason IN ('downgrade','user','abuse','domain_removed')) |
— |
created_by_user_id |
uuid | Y | null | FK → users.id |
Provenance and auto-grant subject (8.6). |
created_by_api_key_id |
uuid | Y | null | FK → api_keys.id |
— |
deleted_at |
timestamptz | Y | null | — | Soft delete. |
purge_after |
timestamptz | Y | null | — | deleted_at + 30 days. |
Indexes. ux_links_domain_slug unique on (domain_id,slug) WHERE deleted_at IS NULL — the redirect resolution lookup on a cache miss, and the slug-conflict check. This index must stay small and hot; it is the single most performance-critical index in the schema. ix_links_ws_created on (workspace_id,created_at DESC) WHERE deleted_at IS NULL — dashboard list. ix_links_ws_status on (workspace_id,status) WHERE deleted_at IS NULL — entitlement counting and status filters. ix_links_expiry on (expires_at) WHERE expires_at IS NOT NULL AND status = 'active' — the expiry sweeper. ix_links_safe_browsing_recheck on (safe_browsing_checked_at) WHERE deleted_at IS NULL — weekly reputation recheck. ix_links_ws_tags GIN on (tags) — tag filtering. ix_links_qr_pinned on (workspace_id) WHERE qr_pinned_count > 0 AND deleted_at IS NULL — the archival sweeper's exclusion list and the counter recount job.
Foreign keys. workspace_id → workspaces.id ON DELETE CASCADE. domain_id → custom_domains.id ON DELETE RESTRICT. utm_preset_id → utm_presets.id ON DELETE SET NULL. experiment_id → experiments.id ON DELETE SET NULL. Actor references ON DELETE SET NULL.
Checks. ck_links_schedule_order: expires_at IS NULL OR scheduled_at IS NULL OR expires_at > scheduled_at. ck_links_scheme: destination_url ~* '^(https?|mailto|tel|sms):' — the scheme allow-list is enforced in the database as well as the application, because a bad destination is a security incident, not a validation slip. ck_links_qr_pinned_not_archived: NOT (qr_pinned_count > 0 AND status = 'archived') — the QR-pinned rule is a storage invariant, not a convention a future sweeper can forget.
The QR-pinned rule, stated once. A link with qr_pinned_count > 0 is pinned to at least one QR code. Two consequences follow everywhere in the product: it is never archived (not by downgrade, not by the over-cap sweeper, not by a bulk action — the check constraint above refuses it), and it never counts toward the plan's link cap. Section 22's counter maintenance therefore excludes it: workspace_resource_counters.active_count for short_link counts rows where deleted_at IS NULL AND status <> 'archived' AND qr_pinned_count = 0. The reason is the printed-artefact guarantee: a downgrade must never be able to break a symbol already on a box, and charging a cap for a link the customer cannot delete without breaking print would make the cap a trap.
Slug namespace. Because QR and short-link slugs share one namespace per host (6.1.2), inserting a link whose (domain_id, slug) matches a row in qr_slug_reservations is refused by the trigger in 6.3.39 with 409 qr_slug_reserved.
Access patterns. Redirect: Redis first (rd:{host}:{slug}), then one index probe here. Dashboard: keyset pagination by (created_at, id).
6.3.33 link_versions #
Purpose. Immutable snapshot of a link, written on every mutation. Backs the history drawer, rollback, and the audit log's before/after payload.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
link_id |
uuid | N | — | FK → links.id |
Parent. |
workspace_id |
uuid | N | — | FK → workspaces.id |
Tenancy. |
version_no |
integer | N | — | CHECK (version_no >= 1) |
Monotonic per link. |
snapshot |
jsonb | N | — | — | Full link row plus destinations and rules. |
changed_fields |
text[] | Y | null | — | — |
source |
text | N | 'dashboard' |
CHECK (source IN ('dashboard','api','revert','ab_promote','system')) |
— |
created_by_user_id |
uuid | Y | null | FK → users.id |
— |
created_by_api_key_id |
uuid | Y | null | FK → api_keys.id |
— |
Indexes. ux_link_versions_link_no unique on (link_id,version_no). ix_link_versions_link_created on (link_id,created_at DESC).
Foreign keys. link_id → links.id ON DELETE CASCADE. workspace_id → workspaces.id ON DELETE CASCADE. Actor references ON DELETE SET NULL.
Access patterns. Append-only; last 50 versions retained per link.
6.3.34 link_destinations #
Purpose. One weighted destination of a split test. A single-mode link has zero rows here.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
link_id |
uuid | N | — | FK → links.id |
Parent. |
workspace_id |
uuid | N | — | FK → workspaces.id |
Tenancy. |
variant_key |
text | N | — | CHECK (variant_key ~ '^[a-z]$') |
a…z; max 10 destinations enforced in the application. |
label |
text | Y | null | CHECK (char_length(label) <= 60) |
Display name in reports. |
url |
text | N | — | CHECK (char_length(url) BETWEEN 4 AND 2048) |
Destination. |
weight |
integer | N | 50 | CHECK (weight BETWEEN 0 AND 100) |
Relative weight; weight 0 pauses a variant without deleting its data. |
is_control |
boolean | N | false | — | Baseline for significance testing. |
experiment_variant_id |
uuid | Y | null | FK → experiment_variants.id |
Set when the split is managed by a formal experiment. |
status |
text | N | 'active' |
CHECK (status IN ('active','paused','promoted','retired')) |
— |
safe_browsing_status |
text | N | 'unknown' |
same domain as links |
Each destination is checked independently. |
Indexes. ux_link_destinations_link_variant unique on (link_id,variant_key). ix_link_destinations_link_active on (link_id) WHERE status = 'active' — loaded with the link on a cache miss. ux_link_destinations_link_control unique on (link_id) WHERE is_control — exactly one control.
Foreign keys. link_id → links.id ON DELETE CASCADE. workspace_id → workspaces.id ON DELETE CASCADE. experiment_variant_id → experiment_variants.id ON DELETE SET NULL.
Access patterns. Always read as a complete set for one link and embedded in the Redis redirect payload; the redirect path does not query this table.
6.3.35 link_rules #
Purpose. An ordered targeting rule that can override the destination. Rule semantics and evaluation order are owned by Section 15.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
link_id |
uuid | N | — | FK → links.id |
Parent. |
workspace_id |
uuid | N | — | FK → workspaces.id |
Tenancy. |
priority |
integer | N | — | CHECK (priority BETWEEN 1 AND 100) |
Lower evaluates first; first match wins. |
rule_type |
text | N | — | CHECK (rule_type IN ('geo_country','geo_region','device','os','language','time_window','day_of_week','referrer_host')) |
— |
condition |
jsonb | N | — | — | Matcher, e.g. {"op":"in","values":["DE","AT","CH"]}. Validated by a per-type Zod schema. |
destination_url |
text | Y | null | CHECK (char_length(destination_url) <= 2048) |
Target when matched. |
destination_id |
uuid | Y | null | FK → link_destinations.id |
Alternative target: an existing split variant. |
is_enabled |
boolean | N | true | — | — |
created_by_user_id |
uuid | Y | null | FK → users.id |
— |
Indexes. ux_link_rules_link_priority unique on (link_id,priority) WHERE is_enabled — deterministic ordering, no ties. ix_link_rules_link on (link_id).
Foreign keys. link_id → links.id ON DELETE CASCADE. workspace_id → workspaces.id ON DELETE CASCADE. destination_id → link_destinations.id ON DELETE CASCADE — a rule pointing at a deleted variant would silently change behaviour, so it is removed with it.
Checks. ck_link_rules_target: (destination_url IS NOT NULL) <> (destination_id IS NOT NULL) — exactly one target.
Access patterns. Read as a set with the link, embedded in the Redis payload, evaluated in memory at the edge.
6.3.36 utm_presets #
Purpose. A reusable named UTM parameter set (Pro+).
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
workspace_id |
uuid | N | — | FK → workspaces.id |
Tenancy. |
name |
text | N | — | CHECK (char_length(name) BETWEEN 1 AND 60) |
— |
utm_source |
text | Y | null | CHECK (char_length(utm_source) <= 100) |
— |
utm_medium |
text | Y | null | same | — |
utm_campaign |
text | Y | null | same | — |
utm_term |
text | Y | null | same | — |
utm_content |
text | Y | null | same | — |
append_mode |
text | N | 'skip_if_present' |
CHECK (append_mode IN ('merge','override','skip_if_present')) |
How preset values combine with parameters already on the destination URL. |
is_default |
boolean | N | false | — | Applied to newly created links in this workspace. |
Indexes. ux_utm_presets_ws_name unique on (workspace_id,name). ux_utm_presets_ws_default unique on (workspace_id) WHERE is_default — at most one default.
Foreign keys. workspace_id → workspaces.id ON DELETE CASCADE.
Access patterns. Small per-workspace list; the resolved values are baked into the Redis redirect payload.
6.3.37 qr_codes #
Purpose. A dynamic QR code. Behaviour, styling and the permanence rule are owned by Section 14.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
workspace_id |
uuid | N | — | FK → workspaces.id |
Tenancy. |
slug_reservation_id |
uuid | N | — | FK → qr_slug_reservations.id, unique |
The permanent slug reservation. Created first, in the same transaction, and never deleted. |
domain_id |
uuid | N | — | FK → custom_domains.id |
Host. |
slug |
citext | N | — | CHECK (slug ~ '^[a-z0-9-]{1,64}$') |
Denormalised from the reservation so lookups need no join. |
title |
text | N | — | CHECK (char_length(title) BETWEEN 1 AND 120) |
Internal label, e.g. "Spring catalogue back cover". |
destination_url |
text | Y | null | CHECK (char_length(destination_url) <= 2048) |
Active destination. Null when destination_link_id is set. |
destination_link_id |
uuid | Y | null | FK → links.id |
The QR may resolve through a short link so it inherits rules and splits. Setting, changing or clearing this column updates links.qr_pinned_count on both the old and the new link in the same transaction. |
paused_fallback_url |
text | Y | null | CHECK (char_length(paused_fallback_url) <= 2048) |
Second step of the fallback chain. |
status |
text | N | 'active' |
CHECK (status IN ('active','paused','archived','memorial')) |
No status ever stops resolution. |
error_correction |
char(1) | N | 'M' |
CHECK (error_correction IN ('M','Q','H')) |
Never below M. |
style |
jsonb | N | '{}' |
— | Module shape, colors, gradient, frame, caption, logo placement. |
logo_asset_id |
uuid | Y | null | FK → uploaded_assets.id |
Overlay; forces error correction H. |
current_version_id |
uuid | Y | null | FK → qr_code_versions.id |
The version whose artifacts are current. |
scan_count_cached |
bigint | N | 0 | CHECK (scan_count_cached >= 0) |
Rollup-maintained. |
last_scanned_at |
timestamptz | Y | null | — | Same. |
safe_browsing_status |
text | N | 'unchecked' |
CHECK (safe_browsing_status IN ('unchecked','safe','flagged','blocked')) |
Same single column and same four values as links (6.3.32). |
created_by_user_id |
uuid | Y | null | FK → users.id |
— |
memorialized_at |
timestamptz | Y | null | — | Set when the workspace is deleted (Section 14). |
deleted_at |
timestamptz | Y | null | — | Soft delete hides the code from the dashboard. It never affects resolution — the reservation still resolves through the fallback chain. |
purge_after |
timestamptz | Y | null | — | Applies to this row only, never to the reservation. |
Indexes. ux_qr_codes_reservation unique on (slug_reservation_id) — one live QR per reservation. ux_qr_codes_domain_slug unique on (domain_id,slug) WHERE deleted_at IS NULL — dashboard-side conflict detection (global permanence is enforced by the reservation table). ix_qr_codes_ws_created on (workspace_id,created_at DESC) WHERE deleted_at IS NULL — dashboard list. ix_qr_codes_destination_link on (destination_link_id) WHERE destination_link_id IS NOT NULL — blocks deletion of a link that a printed code points at.
Foreign keys. workspace_id → workspaces.id ON DELETE CASCADE. slug_reservation_id → qr_slug_reservations.id ON DELETE RESTRICT — the child may not cascade into the permanent reservation. domain_id → custom_domains.id ON DELETE RESTRICT. destination_link_id → links.id ON DELETE RESTRICT — a link bound to a QR cannot be deleted; the user must repoint the QR first. logo_asset_id → uploaded_assets.id ON DELETE SET NULL. current_version_id → qr_code_versions.id ON DELETE SET NULL (deferrable initially deferred).
Checks. ck_qr_codes_destination: (destination_url IS NOT NULL) <> (destination_link_id IS NOT NULL). ck_qr_codes_logo_ec: logo_asset_id IS NULL OR error_correction = 'H'.
Access patterns. Scan resolution goes Redis → (domain_id, slug) probe → fallback chain. Never 404s.
6.3.38 qr_code_versions #
Purpose. Immutable QR snapshot including the scannability validation evidence required by Section 14.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
qr_code_id |
uuid | N | — | FK → qr_codes.id |
Parent. |
workspace_id |
uuid | N | — | FK → workspaces.id |
Tenancy. |
version_no |
integer | N | — | CHECK (version_no >= 1) |
Monotonic per code. |
destination_url |
text | Y | null | — | Destination at snapshot time — the field a print manager audits. |
destination_link_id |
uuid | Y | null | — | Same, when routed through a link. |
style_snapshot |
jsonb | N | — | — | Complete styling at snapshot time. |
error_correction |
char(1) | N | — | CHECK (error_correction IN ('M','Q','H')) |
Level actually used after any auto-escalation. |
escalations |
smallint | N | 0 | CHECK (escalations BETWEEN 0 AND 2) |
How many times error correction was raised by the validator. |
validation_status |
text | N | 'pending' |
CHECK (validation_status IN ('pending','passed','failed')) |
failed blocks artifact publication and returns qr_unscannable. |
validation_results |
jsonb | N | '{}' |
— | Per-condition results for the three simulated decodes, with the decoded payload and a pass/fail flag each. |
contrast_ratio |
numeric(4,2) | Y | null | CHECK (contrast_ratio >= 0) |
Measured foreground/background ratio. |
blocking_reason |
text | Y | null | CHECK (char_length(blocking_reason) <= 200) |
Which styling choice caused a failure, surfaced verbatim in the editor. |
created_by_user_id |
uuid | Y | null | FK → users.id |
— |
source |
text | N | 'dashboard' |
CHECK (source IN ('dashboard','api','revert','system')) |
— |
note |
text | Y | null | CHECK (char_length(note) <= 300) |
Optional change note, e.g. "post-reprint URL swap". |
Indexes. ux_qr_code_versions_code_no unique on (qr_code_id,version_no). ix_qr_code_versions_code_created on (qr_code_id,created_at DESC) — history drawer and the audit example in 8.9.
Foreign keys. qr_code_id → qr_codes.id ON DELETE CASCADE. workspace_id → workspaces.id ON DELETE CASCADE. created_by_user_id → users.id ON DELETE SET NULL.
Access patterns. Append-only. Never purged while the parent QR exists — the destination history of a printed code is evidence, not convenience.
6.3.39 qr_slug_reservations #
Purpose. The permanent reservation of a QR slug on a host. This table implements the product's hardest guarantee: a printed code never stops resolving.
This table has no deleted_at column and no updated_at-driven lifecycle that can remove a row. Nothing cascades into it. Nothing may delete from it.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
domain_id |
uuid | N | — | FK → custom_domains.id |
Host the slug is reserved on. |
slug |
citext | N | — | CHECK (slug ~ '^[a-z0-9-]{1,64}$') |
Reserved path segment. |
state |
text | N | 'assigned' |
CHECK (state IN ('assigned','orphaned','memorial')) |
assigned = a live QR row exists; orphaned = the QR was deleted but the slug still resolves; memorial = the workspace was deleted. |
qr_code_id_last_known |
uuid | Y | null | — | Deliberately not a foreign key: it must survive the QR row. |
workspace_id_last_known |
uuid | Y | null | — | Same reasoning. |
workspace_display_name_last_known |
text | Y | null | CHECK (char_length(...) <= 80) |
Rendered on the memorial page. |
fallback_url |
text | Y | null | CHECK (char_length(fallback_url) <= 2048) |
Frozen last-known fallback, used when no live QR row exists. |
last_destination_url |
text | Y | null | CHECK (char_length(...) <= 2048) |
Frozen last-known destination, used for the orphaned state. |
reserved_at |
timestamptz | N | now() |
— | — |
orphaned_at |
timestamptz | Y | null | — | — |
memorialized_at |
timestamptz | Y | null | — | — |
erasure_applied_at |
timestamptz | Y | null | — | Set when a GDPR erasure stripped personal data from the memorial page. The slug still resolves (Section 23 lawful-basis carve-out). |
notes |
text | Y | null | CHECK (char_length(notes) <= 500) |
Operational notes for support. |
Indexes. ux_qr_slug_reservations_domain_slug unique on (domain_id,slug) — not partial, no deleted_at predicate, so a slug can never be reissued. ix_qr_slug_reservations_state on (state) WHERE state <> 'assigned' — the memorial-page renderer and support tooling.
Foreign keys. domain_id → custom_domains.id ON DELETE RESTRICT. A custom domain that has ever hosted a QR code cannot be deleted; Section 13 requires the domain to be retained (or its DNS pointed away by the customer, at which point resolution ends outside LinkHub's control — a limit the domain-removal UI states explicitly).
Enforcement of permanence. tg_qr_slug_reservations_no_delete, a BEFORE DELETE trigger raising ERROR: qr slug reservations are permanent and cannot be deleted, plus REVOKE DELETE, TRUNCATE ON qr_slug_reservations FROM app_rw, app_retention. The retention worker has no path to this table at all. A migration that adds a cascading delete into it fails the schema-invariant test in Section 26.
Enforcement of the shared namespace. Because the public QR URL is https://{host}/{slug} with no path prefix, one reservation must protect both surfaces on that host. Two triggers, one on each side:
-- A short link may not take a slug that a QR reservation already holds.
CREATE TRIGGER tg_links_reject_reserved_slug
BEFORE INSERT OR UPDATE OF domain_id, slug ON links
FOR EACH ROW EXECUTE FUNCTION tg_fn_reject_reserved_slug();
-- raises when a qr_slug_reservations row exists for (NEW.domain_id, NEW.slug)
-- A QR reservation may not take a slug a live short link already holds.
CREATE TRIGGER tg_qr_slug_reservations_reject_live_link
BEFORE INSERT ON qr_slug_reservations
FOR EACH ROW EXECUTE FUNCTION tg_fn_reject_live_link_slug();
-- raises when a links row exists for (NEW.domain_id, NEW.slug) AND deleted_at IS NULLBoth raise a constraint error the service layer maps to 409 qr_slug_reserved. Order matters on QR creation: the reservation is inserted first, in the same transaction as the qr_codes row, so a race between two creators is decided by the unique index rather than by application timing.
Access patterns. Insert-once; state transitions are narrow updates. Read on every QR resolution that misses a live QR row, and on every short-link slug-availability check.
6.3.40 qr_render_artifacts #
Purpose. A generated output file for a QR version.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
qr_code_version_id |
uuid | N | — | FK → qr_code_versions.id |
Parent. |
workspace_id |
uuid | N | — | FK → workspaces.id |
Tenancy. |
format |
text | N | — | CHECK (format IN ('svg','png','pdf','eps')) |
— |
dpi |
integer | Y | null | CHECK (dpi IN (300,600)) |
Raster formats only. |
width_px |
integer | Y | null | CHECK (width_px > 0) |
Raster formats only. |
asset_id |
uuid | N | — | FK → uploaded_assets.id |
Where the bytes live. |
byte_size |
bigint | N | — | CHECK (byte_size > 0) |
— |
checksum_sha256 |
bytea | N | — | — | Reproducibility check for the render conformance suite. |
generated_at |
timestamptz | N | now() |
— | — |
Indexes. ux_qr_render_artifacts_version_format_dpi unique on (qr_code_version_id,format,dpi) — one artifact per format/resolution, so re-rendering is idempotent. ix_qr_render_artifacts_ws on (workspace_id,generated_at DESC).
Foreign keys. qr_code_version_id → qr_code_versions.id ON DELETE CASCADE. asset_id → uploaded_assets.id ON DELETE RESTRICT. workspace_id → workspaces.id ON DELETE CASCADE.
Access patterns. Written by the render worker; read on download. Artifacts for non-current versions are purged after 180 days and regenerated on demand from the immutable version snapshot.
6.3.41 experiments #
Purpose. An A/B test over a bio page or a link. Statistics and assignment are owned by Section 16.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
workspace_id |
uuid | N | — | FK → workspaces.id |
Tenancy. |
name |
text | N | — | CHECK (char_length(name) BETWEEN 1 AND 80) |
— |
subject_type |
text | N | — | CHECK (subject_type IN ('bio_page','link')) |
— |
subject_id |
uuid | N | — | — | Polymorphic; integrity by application plus the nightly consistency job. |
status |
text | N | 'draft' |
CHECK (status IN ('draft','running','paused','concluded','promoted','archived')) |
The six states of the Section 16.9 lifecycle, and the only six. There is no completed, no active and no retired: running is the live state, concluded is "stopped, results frozen, no winner applied", and promoted is "a winner was applied to the subject". |
goal_metric |
text | N | — | CHECK (goal_metric IN ('ctr','unique_clicks','conversion_pixel')) |
Bio pages default to ctr; links to unique_clicks unless a conversion pixel is configured. |
traffic_allocation_pct |
smallint | N | 100 | CHECK (traffic_allocation_pct BETWEEN 1 AND 100) |
Share of visitors entering the experiment. |
min_visitors_per_variant |
integer | N | 100 | CHECK (min_visitors_per_variant >= 1) |
Guard threshold. |
min_duration_days |
smallint | N | 7 | CHECK (min_duration_days >= 7) |
Never below 7 — the assignment salt rotates weekly, so a shorter test cannot claim stickiness. |
started_at |
timestamptz | Y | null | — | Set on the transition into running. |
ended_at |
timestamptz | Y | null | — | Set on the transition into concluded or promoted; traffic stops being assigned at this moment. |
winner_variant_id |
uuid | Y | null | FK → experiment_variants.id |
— |
promoted_at |
timestamptz | Y | null | — | — |
promoted_by_user_id |
uuid | Y | null | FK → users.id |
— |
promotion_mode |
text | Y | null | CHECK (promotion_mode IN ('guarded','forced')) |
forced requires typed confirmation and is written to the audit log. |
guard_passed_at |
timestamptz | Y | null | — | When the minimum-sample guard first passed. Promote is disabled until this is set, unless forced. |
Indexes. ix_experiments_ws_status on (workspace_id,status). ux_experiments_subject_running unique on (subject_type,subject_id) WHERE status IN ('running','paused') — one live experiment per subject, enforced by the database. ix_experiments_running on (started_at) WHERE status = 'running' — the nightly stats job.
Foreign keys. workspace_id → workspaces.id ON DELETE CASCADE. winner_variant_id → experiment_variants.id ON DELETE SET NULL (deferrable initially deferred). promoted_by_user_id → users.id ON DELETE SET NULL.
Checks. ck_experiments_end_pair: (status IN ('concluded','promoted')) = (ended_at IS NOT NULL). ck_experiments_promoted_winner: status <> 'promoted' OR winner_variant_id IS NOT NULL.
Access patterns. Read with its subject and embedded in the Redis payload; the edge never queries it directly.
6.3.42 experiment_variants #
Purpose. One variant with its weight and payload.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
experiment_id |
uuid | N | — | FK → experiments.id |
Parent. |
workspace_id |
uuid | N | — | FK → workspaces.id |
Tenancy. |
variant_key |
text | N | — | CHECK (variant_key ~ '^[a-z]$') |
a is conventionally the control. |
name |
text | Y | null | CHECK (char_length(name) <= 60) |
— |
weight |
integer | N | 50 | CHECK (weight BETWEEN 0 AND 100) |
Bucket allocation share. |
is_control |
boolean | N | false | — | — |
payload |
jsonb | N | — | — | For a page: {bio_page_version_id}. For a link: {link_destination_id} or {url}. |
bucket_range_start |
integer | N | — | CHECK (bucket_range_start BETWEEN 0 AND 9999) |
Materialised bucket boundaries so the edge maps bucket → variant with a comparison, never a weighted loop. |
bucket_range_end |
integer | N | — | CHECK (bucket_range_end BETWEEN 0 AND 9999) |
Inclusive upper bound. |
Indexes. ux_experiment_variants_exp_key unique on (experiment_id,variant_key). ux_experiment_variants_exp_control unique on (experiment_id) WHERE is_control. ix_experiment_variants_exp on (experiment_id).
Foreign keys. experiment_id → experiments.id ON DELETE CASCADE. workspace_id → workspaces.id ON DELETE CASCADE.
Checks. ck_experiment_variants_range: bucket_range_end >= bucket_range_start. Contiguity and non-overlap across an experiment's variants is asserted in the application transaction that writes them, and by an invariant test in Section 26.
Access patterns. Loaded as a set with the experiment and embedded in the Redis payload.
6.3.43 experiment_assignments_rollup #
Purpose. Daily assignment counts per variant per salt epoch. Individual assignments are never stored — that would require a visitor-level durable record, which the cookie-free position forbids.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
experiment_id |
uuid | N | — | FK → experiments.id |
Parent. |
variant_id |
uuid | N | — | FK → experiment_variants.id |
— |
workspace_id |
uuid | N | — | FK → workspaces.id |
Tenancy. |
bucket_date |
date | N | — | — | UTC day. |
salt_epoch |
text | N | — | CHECK (char_length(salt_epoch) <= 32) |
Identifier of the 7-day experiment salt window. Re-bucketing at rotation is therefore visible in the data rather than hidden. |
assignments |
bigint | N | 0 | CHECK (assignments >= 0) |
Exposure count. |
unique_visitors |
bigint | N | 0 | CHECK (unique_visitors >= 0) |
Distinct visitor_hash values, exact within the epoch. |
conversions |
bigint | N | 0 | CHECK (conversions >= 0) |
Goal events. |
Indexes. ux_experiment_assignments_rollup_grain unique on (experiment_id,variant_id,bucket_date,salt_epoch) — the upsert conflict target. ix_experiment_assignments_rollup_exp_date on (experiment_id,bucket_date).
Foreign keys. experiment_id → experiments.id ON DELETE CASCADE. variant_id → experiment_variants.id ON DELETE CASCADE. workspace_id → workspaces.id ON DELETE CASCADE.
Access patterns. Upserted by the rollup worker; read by the stats job and the experiment dashboard.
6.3.44 experiment_results #
Purpose. The computed statistics and significance verdict per variant at a point in time.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
experiment_id |
uuid | N | — | FK → experiments.id |
Parent. |
variant_id |
uuid | N | — | FK → experiment_variants.id |
— |
workspace_id |
uuid | N | — | FK → workspaces.id |
Tenancy. |
computed_at |
timestamptz | N | now() |
— | — |
window_start |
timestamptz | N | — | — | — |
window_end |
timestamptz | N | — | — | — |
visitors |
bigint | N | 0 | CHECK (visitors >= 0) |
Denominator. |
conversions |
bigint | N | 0 | CHECK (conversions >= 0) |
Numerator. |
conversion_rate |
numeric(8,6) | N | 0 | CHECK (conversion_rate BETWEEN 0 AND 1) |
— |
z_score |
numeric(8,4) | Y | null | — | Two-proportion z-test against the control. |
p_value |
numeric(8,6) | Y | null | CHECK (p_value BETWEEN 0 AND 1) |
— |
ci_low |
numeric(8,6) | Y | null | — | 95% confidence interval on the rate. |
ci_high |
numeric(8,6) | Y | null | — | — |
is_significant |
boolean | N | false | — | True at 95% confidence. |
guard_passed |
boolean | N | false | — | Minimum-sample AND minimum-duration both satisfied. |
Indexes. ux_experiment_results_variant_computed unique on (variant_id,computed_at). ix_experiment_results_exp_computed on (experiment_id,computed_at DESC) — the dashboard reads the latest row per variant.
Foreign keys. All three references ON DELETE CASCADE.
Access patterns. Written nightly and on demand; read as the most recent snapshot. Retained for the life of the experiment plus 365 days.
6.3.45 click_events #
Purpose. One raw click or scan — a short-link click, a QR scan, or a click on a bio page block. One table rather than three, because the columns are 90% identical and the queries are identical; three tables would triple the partition-management burden for no benefit. Written only by the ingest worker, never by the redirect path. Declaratively range-partitioned by occurred_at, one partition per UTC day (6.4).
This is the single definition of this table. No other section declares its columns.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
uuid | N | — | part of PK | UUIDv7, generated once at capture and carried unchanged through every retry, buffer replay, stream replay and dead-letter re-injection. Never regenerated. |
occurred_at |
timestamptz | N | — | part of PK, partition key | Event time as observed at the edge. |
ingested_at |
timestamptz | N | now() |
— | When the worker wrote the row. ingested_at − occurred_at is the lateness measure. |
event_date |
date | N | — | CHECK (event_date = (occurred_at AT TIME ZONE 'UTC')::date) |
Denormalised UTC date. It is the join key to analytics_unique_visitor_days and appears in most GROUP BY clauses; storing it avoids a functional expression in every index. |
schema_version |
smallint | N | 1 | CHECK (schema_version >= 1) |
Bumped when the enrichment contract changes, so a reparse can tell old rows from new. |
workspace_id |
uuid | N | — | — | Tenancy. No FK — see notes. |
resource_type |
text | N | — | CHECK (resource_type IN ('link','qr','bio_link')) |
bio_link is a click on a bio page block. |
resource_id |
uuid | N | — | — | links.id, qr_codes.id or blocks.id. |
bio_page_id |
uuid | Y | null | — | Set for bio_link, joining a click to its page view. |
domain_id |
uuid | N | — | — | Host that served the redirect. |
host |
text | N | — | CHECK (char_length(host) <= 253) |
Denormalised hostname; exports and webhook payloads stay readable after a domain is removed. |
slug |
citext | Y | null | CHECK (char_length(slug) <= 64) |
Denormalised; exports stay readable after a rename. Null for bio_link. |
link_destination_id |
uuid | Y | null | — | Which split-test destination was served. |
destination_url |
text | Y | null | CHECK (char_length(destination_url) <= 2048) |
The URL actually served, after rules, splits and UTM application. |
destination_host |
text | Y | null | CHECK (char_length(destination_host) <= 253) |
Host of that URL, denormalised because pixels, webhooks and the destination breakdown all want it without parsing 2 KB of text per row. |
fallback_stage |
text | N | 'active' |
CHECK (fallback_stage IN ('active','paused_fallback','workspace_unavailable','generic')) |
Which rung of the four-rung QR fallback chain served this event (Section 14). Always active for non-QR resources. This is the only serving-state column; there is no serving_mode. |
targeting_rule_id |
uuid | Y | null | — | Set when a targeting rule decided the destination (Section 15). |
excluded_by_rule_id |
uuid | Y | null | — | Set when a targeting rule with no experiment attached matched and therefore excluded this visitor from an experiment on the default destination. Makes exclusions visible and countable in the experiment report (Section 16). |
experiment_id |
uuid | Y | null | — | — |
variant_id |
uuid | Y | null | — | Enables the cross-surface join described in Section 16. |
experiment_epoch |
smallint | Y | null | CHECK (experiment_epoch IS NULL OR experiment_epoch >= 1) |
Which constant-arms epoch of the experiment was in force. |
assignment_source |
text | Y | null | CHECK (assignment_source IS NULL OR assignment_source IN ('deterministic','pinned')) |
How the variant was chosen: recomputed from the visitor hash, or read from a consented cookie. |
source_page_id |
uuid | Y | null | — | The bio page this click came from, when the visitor was in a page experiment. |
source_page_experiment_id |
uuid | Y | null | — | Same, the page's experiment. |
source_page_variant_id |
uuid | Y | null | — | Same, the page's variant. Together these three make "which page variant produced this click" answerable without a runtime join. |
visitor_hash |
text | N | — | CHECK (char_length(visitor_hash) = 24) |
Daily-salted rotating visitor hash. No IP, no cookie. |
user_agent_family |
text | Y | null | CHECK (char_length(user_agent_family) <= 100) |
Short human label such as Chrome on Android. The raw User-Agent header is never stored (6.1.2). |
ua_hash |
bytea | Y | null | CHECK (octet_length(ua_hash) = 16) |
First 16 bytes of a daily-salted SHA-256 of the raw user-agent string, using the same salt and the same rotation as visitor_hash. Used only to cluster bot signatures within a day; it is not stable across days and is therefore not a device identifier. |
is_bot |
boolean | N | false | — | Dimension column. Excluded from default views, toggleable. Never part of a primary key. |
bot_reason |
text | Y | null | CHECK (char_length(bot_reason) <= 40) |
The first signal that fired, so the reason is deterministic. |
bot_signals |
text[] | N | '{}' |
— | Every signal that fired. An array rather than one boolean per signal, so adding a signal is not a migration on a billion-row table. |
bot_list_version |
smallint | N | 1 | — | Which signature list produced the classification, so a reclassification pass can find stale rows. |
is_prefetch |
boolean | N | false | — | Set from Sec-Purpose: prefetch, Purpose: prefetch, X-Moz: prefetch or X-Purpose: preview. Prefetches redirect normally but never count as clicks. |
is_datacenter_asn |
boolean | N | false | — | From the edge ASN lookup. The ASN number itself is never stored — only this boolean. |
is_estimated |
boolean | N | false | — | True only for beacon-captured block clicks, which cannot be observed server-side. |
clock_skewed |
boolean | N | false | — | Set when occurred_at arrived more than 5 minutes in the future and was clamped to ingested_at. |
sample_rate |
integer | N | 1 | CHECK (sample_rate >= 1) |
1 normally; N when adaptive sampling is engaged. Event counts derived from a sampled row are multiplied by it; unique counts are never scaled. |
country_code |
char(2) | N | 'ZZ' |
CHECK (country_code ~ '^[A-Z]{2}$') |
ISO 3166-1 alpha-2. ZZ when unknown or unresolvable. Country only. |
region_code |
varchar(6) | Y | null | CHECK (region_code ~ '^[A-Z0-9]{1,6}$') |
ISO 3166-2 subdivision part only (e.g. BE for DE-BE). Never city, coordinates or postal code. |
geo_resolved |
boolean | N | false | — | False when the lookup failed, was skipped, or returned nothing. Distinguishes "genuinely unknown" from "we did not look". |
geo_db_version |
smallint | Y | null | — | Which geo database produced the values, so a database swap is auditable. |
device_type |
text | N | 'unknown' |
CHECK (device_type IN ('desktop','mobile','tablet','tv','bot','unknown')) |
— |
os_family |
text | N | 'unknown' |
CHECK (char_length(os_family) <= 40) |
Closed vocabulary maintained by the parser. |
os_version_major |
smallint | Y | null | CHECK (os_version_major >= 0) |
Major version only — a minor version is a fingerprinting surface for no analytical gain. |
browser_family |
text | N | 'unknown' |
CHECK (char_length(browser_family) <= 40) |
Closed vocabulary. |
browser_version_major |
smallint | Y | null | CHECK (browser_version_major >= 0) |
Major version only. |
ua_parser_version |
smallint | N | 1 | — | Bumped by a reparse, which is the audit trail for an append-only table. |
referrer_host |
text | Y | null | CHECK (char_length(referrer_host) <= 253) |
Host only — never the full referrer URL. |
channel |
text | N | 'direct' |
CHECK (channel IN ('direct','social','search','email','messaging','ads','referral','campaign','qr','video','ai','internal','unknown')) |
Derived channel classification. |
language |
varchar(16) | Y | null | — | Primary Accept-Language tag only. |
utm_source / utm_medium / utm_campaign / utm_term / utm_content |
text | Y | null | each CHECK (char_length(...) <= 255) |
Parsed from the inbound request. |
status_code |
smallint | N | 302 | CHECK (status_code BETWEEN 200 AND 599) |
What the edge returned. Destination redirects are always 302 (Section 12); rungs 3 and 4 of the fallback chain are 200. |
latency_ms |
smallint | Y | null | CHECK (latency_ms >= 0) |
Server processing time, for the Section 11 budget. |
capture_source |
text | N | 'edge' |
CHECK (capture_source IN ('edge','ssr','beacon')) |
Which capture path produced the event. |
ingest_batch_id |
uuid | N | — | — | Batch correlation for reconciliation and replay. |
stream_message_id |
text | N | — | CHECK (char_length(stream_message_id) <= 40) |
Redis Stream id — the second dedupe key for at-least-once delivery. |
Primary key. PRIMARY KEY (occurred_at, id) — a partitioned table's primary key must include the partition key. Leading with the partition key keeps every probe partition-local.
Exactly-once, and why two keys. id deduplicates the event; stream_message_id deduplicates the delivery. Raw insertion uses ON CONFLICT (occurred_at, id) DO NOTHING ... RETURNING, and every downstream effect — unique visitor-days, both rollup grains, live counters — is derived from that RETURNING set, so a duplicate increments by zero. ux_click_events_<part>_stream_msg catches the case where a redelivered stream message has somehow been re-keyed. Section 17's ingest pipeline must use both columns; they are not decorative.
Indexes (created on each partition by the partition-creation job, so they are small and local). The rule is at most four per partition, each justified by a real query:
pkon (occurred_at,id) — the dedupe probe on every insert.ix_click_events_<part>_ws_resource_timeon (workspace_id,resource_type,resource_id,occurred_atDESC) — per-link and per-QR detail views, per-resource exports.ix_click_events_<part>_ws_timeon (workspace_id,occurred_atDESC) — workspace overview, workspace-wide export, workspace-scoped retention delete. A composite index cannot skip its middle columns to range-scan the fourth, so keeping both is a deliberate cost.ix_click_events_<part>_experimenton (experiment_id,variant_id,occurred_atDESC)WHERE experiment_id IS NOT NULL— experiment result computation. Partial, so it is empty and free on a workspace with no experiments.ux_click_events_<part>_stream_msgunique on (stream_message_id) — the delivery-dedupe guard above. It is a uniqueness constraint rather than a query index and is exempt from the four-index rule.
Deliberately not indexed. country_code, region_code, device_type, os_family, browser_family, channel, language — these are answered from the rollups, which is the entire reason rollups exist. visitor_hash — uniques are answered by analytics_unique_visitor_days, which is that index, stored once per visitor-day instead of once per event. bot_signals — diagnostic, queried only by operators on a bounded day range. Anything covering destination_url — wide text, and the queries that need it are exports, which scan by time anyway.
Foreign keys. None, deliberately. Foreign-key checks on a table taking thousands of inserts per second, whose parents are soft-deletable and whose partitions are dropped wholesale, would cost more than they protect. Tenancy is enforced by the query layer (8.10); orphan rows are harmless because every read path is already workspace-scoped and time-bounded, they render as "(deleted resource)" via a left outer join, and they disappear when the partition is dropped.
No updated_at. The table is append-only. A reparse rewrites specific enrichment columns and bumps ua_parser_version / bot_list_version; that bump is the audit trail.
Access patterns. Append-only batch inserts, read as workspace + time-range scans that the planner satisfies by partition pruning plus one local index. Retention per plan (6.4, 6.8).
6.3.46 page_view_events #
Purpose. One raw bio page view. Same partitioning strategy, same exactly-once mechanism and same no-foreign-key rationale as click_events. Shared columns carry identical names, types and constraints to 6.3.45 and are not re-justified here.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
uuid | N | — | part of PK | As 6.3.45. |
occurred_at |
timestamptz | N | — | part of PK, partition key | — |
ingested_at |
timestamptz | N | now() |
— | — |
event_date |
date | N | — | CHECK (event_date = (occurred_at AT TIME ZONE 'UTC')::date) |
— |
schema_version |
smallint | N | 1 | CHECK (schema_version >= 1) |
— |
workspace_id |
uuid | N | — | — | Tenancy. |
bio_page_id |
uuid | N | — | — | Viewed page. |
bio_page_version_id |
uuid | Y | null | — | Which published version was rendered. |
domain_id |
uuid | N | — | — | Host. |
host |
text | N | — | CHECK (char_length(host) <= 253) |
Denormalised. |
handle |
citext | N | — | CHECK (char_length(handle) <= 64) |
Denormalised. |
experiment_id |
uuid | Y | null | — | — |
variant_id |
uuid | Y | null | — | Page variant served. |
experiment_epoch |
smallint | Y | null | CHECK (experiment_epoch IS NULL OR experiment_epoch >= 1) |
— |
assignment_source |
text | Y | null | CHECK (assignment_source IS NULL OR assignment_source IN ('deterministic','pinned')) |
— |
visitor_hash |
text | N | — | CHECK (char_length(visitor_hash) = 24) |
The same hash as any click from this view — this is what makes the cross-surface join work. |
user_agent_family |
text | Y | null | CHECK (char_length(user_agent_family) <= 100) |
The raw User-Agent header is never stored. |
ua_hash |
bytea | Y | null | CHECK (octet_length(ua_hash) = 16) |
Daily-salted, same salt and rotation as visitor_hash. Bot-signature clustering only. |
is_bot |
boolean | N | false | — | Dimension column. Never part of a primary key. |
bot_reason |
text | Y | null | CHECK (char_length(bot_reason) <= 40) |
— |
bot_signals |
text[] | N | '{}' |
— | — |
bot_list_version |
smallint | N | 1 | — | — |
is_prefetch |
boolean | N | false | — | — |
is_datacenter_asn |
boolean | N | false | — | — |
clock_skewed |
boolean | N | false | — | — |
sample_rate |
integer | N | 1 | CHECK (sample_rate >= 1) |
— |
country_code |
char(2) | N | 'ZZ' |
CHECK (country_code ~ '^[A-Z]{2}$') |
— |
region_code |
varchar(6) | Y | null | CHECK (region_code ~ '^[A-Z0-9]{1,6}$') |
Subdivision part only. |
geo_resolved |
boolean | N | false | — | — |
geo_db_version |
smallint | Y | null | — | — |
device_type |
text | N | 'unknown' |
CHECK (device_type IN ('desktop','mobile','tablet','tv','bot','unknown')) |
— |
os_family |
text | N | 'unknown' |
CHECK (char_length(os_family) <= 40) |
— |
os_version_major |
smallint | Y | null | CHECK (os_version_major >= 0) |
— |
browser_family |
text | N | 'unknown' |
CHECK (char_length(browser_family) <= 40) |
— |
browser_version_major |
smallint | Y | null | CHECK (browser_version_major >= 0) |
— |
ua_parser_version |
smallint | N | 1 | — | — |
referrer_host |
text | Y | null | CHECK (char_length(referrer_host) <= 253) |
Host only. |
channel |
text | N | 'direct' |
same vocabulary as 6.3.45 | — |
language |
varchar(16) | Y | null | — | — |
utm_source … utm_content |
text | Y | null | each CHECK (char_length(...) <= 255) |
— |
is_first_view_in_session |
boolean | N | true | — | Derived at ingest from the visitor-day set: true when this row created the visitor-day. Drives the unique-viewer denominator of page CTR. |
render_ms |
smallint | Y | null | CHECK (render_ms >= 0) |
Server render time. |
cache_hit |
boolean | N | false | — | Whether the render was served from the page cache. |
capture_source |
text | N | 'ssr' |
CHECK (capture_source IN ('edge','ssr','beacon')) |
Page views are server-rendered by default. |
ingest_batch_id |
uuid | N | — | — | — |
stream_message_id |
text | N | — | CHECK (char_length(stream_message_id) <= 40) |
Delivery dedupe key. |
render_ms and cache_hit exist because they make the Section 11 performance budgets observable against real traffic on real devices, per page and per variant, rather than only in synthetic tests.
There is deliberately no fallback_stage, targeting_rule_id, excluded_by_rule_id, source_page_*, destination_*, link_destination_id, status_code or is_estimated column here: a page view has no destination, no fallback chain, no source page other than itself, and is never beacon-estimated.
Primary key. PRIMARY KEY (occurred_at, id).
Indexes (per partition): pk on (occurred_at,id); ix_page_view_events_<part>_ws_page_time on (workspace_id,bio_page_id,occurred_at DESC); ix_page_view_events_<part>_ws_time on (workspace_id,occurred_at DESC); ix_page_view_events_<part>_experiment on (experiment_id,variant_id,occurred_at DESC) WHERE experiment_id IS NOT NULL; ux_page_view_events_<part>_stream_msg unique on (stream_message_id).
Foreign keys. None, same rationale.
Access patterns. Identical shape to click_events.
6.3.47 analytics_unique_visitor_days #
Purpose. The reason unique-visitor counts are exact and additive rather than estimated, without an approximate-counting extension. COUNT(DISTINCT) cannot be maintained by incrementing; the count of newly seen visitor-days can. Inserting with ON CONFLICT DO NOTHING ... RETURNING yields exactly the set of visitor-days this batch saw for the first time, and that count is both additive and idempotent under retry.
Range-partitioned by event_date, one partition per UTC day.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
workspace_id |
uuid | N | — | part of PK | Tenancy. |
resource_type |
text | N | — | part of PK, CHECK (resource_type IN ('link','qr','bio_page','bio_link','workspace')) |
workspace is the roll-up-of-everything row. |
resource_id |
uuid | N | — | part of PK | The all-zero UUID when resource_type = 'workspace'. |
event_date |
date | N | — | part of PK, partition key | UTC day. |
visitor_hash |
text | N | — | part of PK, CHECK (char_length(visitor_hash) = 24) |
— |
first_seen_at |
timestamptz | N | — | — | occurred_at of the event that created this row. Lets the nightly reconciliation recompute hourly first-seen counts by a direct read rather than a window function over raw. |
is_bot |
boolean | N | false | — | Dimension column, not part of the primary key. A visitor-day is one visitor-day whatever the classification; the flag lets bot and human uniques be reported separately, and a reclassification updates the flag in place instead of splitting the row. |
Primary key. PRIMARY KEY (workspace_id, resource_type, resource_id, event_date, visitor_hash) — and, because the table is partitioned on event_date, that column is in the key as required.
Grain — resource level only. There is deliberately no dimension_type / dimension_value in this key. Maintaining uniques per dimension would multiply this table by the dimension cardinality, which is the single largest storage line item in the whole design. Three consequences follow, and every one of them must be visible to the user rather than buried here:
- Unique counts exist for resource totals only. A dimension breakdown — uniques by country, by device, by campaign — is not available. Those panels report events, and the UI labels them as events. Section 17.9 states the mechanism; Sections 18.2 and 18.8 state it to the user.
- Within raw retention, a per-dimension unique figure can still be computed on demand with
COUNT(DISTINCT)over raw. Beyond raw retention it cannot, and the dashboard shows event counts only with an explicit note. - Multi-day uniques are the sum of daily uniques, which is an upper bound, because a visitor active on three days is three visitor-days. The dashboard says so in plain words — "unique visitors per day, summed" — rather than implying a cross-day distinct count it cannot produce. The visitor hash rotates daily (Section 23), so a true cross-day distinct count is not merely expensive here, it is impossible by construction, which is the privacy property working as intended.
Retention. Free 30 days, Pro 90 days, Business 24 months — the same horizon as raw events, enforced the same way (row delete for expired workspaces, whole-partition drop at the global maximum; 6.4.3). It is not a short-lived table: reconciliation, backfill and any recount of uniques within the plan's window all depend on it being present for the whole window.
Row width. ≈ 78 bytes plus index; the table runs roughly 15–20% of the raw event row count on typical traffic.
Indexes. The primary key only. It is written with ON CONFLICT DO NOTHING and read with equality-and-range predicates on its leading columns; a second index would be pure insert cost.
Foreign keys. None, same rationale as the raw tables.
6.3.48 analytics_rollup_hourly #
Purpose. Hourly aggregate powering short-range charts. Grain and dimension list are fixed (6.5).
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
workspace_id |
uuid | N | — | part of the unique grain | Tenancy. |
resource_type |
text | N | — | CHECK (resource_type IN ('link','qr','bio_page','bio_link','workspace')) |
workspace is the roll-up-of-everything row. |
resource_id |
uuid | N | — | — | The all-zero UUID when resource_type = 'workspace'. |
bucket_start |
timestamptz | N | — | CHECK (date_trunc('hour', bucket_start) = bucket_start) |
Start of the UTC hour. bucket_start is the bucket column on both analytics rollup tables — hourly and daily alike. Neither carries a bucket_date column. (experiment_assignments_rollup in 6.3.43 has its own bucket_date; it is an experiment table with a different grain and a different owner, and the two are not interchangeable.) |
dimension_type |
text | N | — | CHECK (dimension_type IN ('total','country','region','device_type','os','browser','referrer_host','channel','utm_source','utm_medium','utm_campaign','utm_term','utm_content','variant','block','fallback_stage','language')) |
Seventeen values, listed once in 6.5.1. |
dimension_value |
text | N | '*' |
CHECK (char_length(dimension_value) <= 255) |
'*' is the "all values" sentinel used for dimension_type = 'total'. Never null, so it can sit in a unique key. The other three reserved values are '(none)', '(other)' and '(unknown)'. |
metric_kind |
text | N | — | CHECK (metric_kind IN ('click','view','scan')) |
scan is a click whose resource_type='qr', stored separately so QR reporting needs no filtering. |
events |
bigint | N | 0 | CHECK (events >= 0) |
Human events. Exact, incrementally maintained, sampling-scaled. |
bot_events |
bigint | N | 0 | CHECK (bot_events >= 0) |
Bot events, counted separately and never inside events. This is why is_bot is not part of the grain: one row carries both figures instead of doubling the row count. |
unique_visitors |
bigint | Y | null | CHECK (unique_visitors IS NULL OR dimension_type = 'total') |
Visitors whose first event of that UTC day fell in this hour — the count of rows this hour contributed to analytics_unique_visitor_days. Null on every non-total dimension, per 6.3.47. |
conversions |
bigint | N | 0 | CHECK (conversions >= 0) |
Conversion postbacks attributed to this grain (Section 16). |
last_event_at |
timestamptz | Y | null | — | Drives "last clicked" without touching raw. |
reconciled_at |
timestamptz | Y | null | — | Set by the nightly reconciliation. A null value on a bucket older than the reconciliation window is an alertable anomaly. |
What unique_visitors means on this table, stated precisely because it is easy to misread. Summed across the 24 buckets of a UTC day it equals that day's unique visitors exactly. Summed across a partial range — say 12:00 to 18:00 — it is a lower bound for that window, because a visitor first seen at 09:00 and active again at 14:00 is counted in the 09:00 bucket only. The dashboard therefore uses the daily table for any range of a day or more and labels sub-day unique figures "new visitors in this hour". This is the honest reading of a day-grained dedupe set, and it is preferred to maintaining a second hourly dedupe set whose storage cost would exceed the rollups themselves.
Partitioning. PARTITION BY RANGE (bucket_start), monthly partitions. Its row count is roughly 10× the daily table's, and monthly granularity is enough because the retention boundaries it must honour (below) are all far longer than a month.
Indexes. ux_rollup_hourly_grain unique on (workspace_id,resource_type,resource_id,bucket_start,dimension_type,dimension_value,metric_kind) — the ON CONFLICT target for the upsert. ix_rollup_hourly_ws_time on (workspace_id,bucket_start DESC) INCLUDE (events, unique_visitors) — the workspace overview chart, index-only. ix_rollup_hourly_resource_time on (workspace_id,resource_type,resource_id,dimension_type,bucket_start DESC) — per-resource charts and dimension breakdowns. ix_rollup_hourly_retention on (bucket_start) — purge sweep.
Retention. Free 30 days, Pro 365 days, Business indefinite (6.8). This is the authoritative statement; no other section sets a different horizon for this table.
Foreign keys. None — same reasoning as the raw tables, and rollups must survive a soft-deleted resource so historical charts stay correct.
Access patterns. Upsert-heavy writes; range scans of at most 8,760 buckets per resource-dimension per year.
6.3.49 analytics_rollup_daily #
Purpose. Daily aggregate. The default source for every dashboard range of a day or longer; the hourly table serves shorter ranges.
Identical column set to 6.3.48, with exactly three differences:
| Difference | Detail |
|---|---|
bucket_start |
Still timestamptz, still named bucket_start, but constrained to midnight: CHECK (date_trunc('day', bucket_start) = bucket_start). Keeping one column name across both grains is what lets the dashboard query builder, the export writer and the reconciliation job take the grain as a parameter instead of branching. |
unique_visitors |
Distinct visitors within the day — the exact count of analytics_unique_visitor_days rows for the grain. Not the sum of the hourly values in general, though it equals that sum when the day is complete and unreconciled. Null on every non-total dimension, same constraint as 6.3.48. |
hourly_unique_sum |
bigint NOT NULL DEFAULT 0 — the sum of the day's hourly unique_visitors, stored so the UI can show and explain any difference rather than appearing inconsistent to a user who compares the two charts. |
Partitioning. None. At one row per (resource, dimension value, metric kind, day) the table is small enough that row-wise retention deletes are cheaper than partition management.
Indexes. ux_rollup_daily_grain unique on (workspace_id,resource_type,resource_id,bucket_start,dimension_type,dimension_value,metric_kind). ix_rollup_daily_ws_time on (workspace_id,bucket_start DESC) INCLUDE (events, unique_visitors). ix_rollup_daily_resource_time on (workspace_id,resource_type,resource_id,dimension_type,bucket_start DESC). ix_rollup_daily_retention on (bucket_start).
Retention. Free 30 days, Pro 365 days, Business indefinite (6.8).
Foreign keys. None.
6.3.50 bot_signatures #
Purpose. Classification rules used by the ingest worker's bot filter. Seeded (6.7), extended by operations without a deploy.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
kind |
text | N | — | CHECK (kind IN ('ua_regex','asn','cidr','header')) |
— |
pattern |
text | Y | null | CHECK (char_length(pattern) <= 500) |
Regex for ua_regex/header. |
asn |
integer | Y | null | CHECK (asn > 0) |
For asn. |
cidr |
cidr | Y | null | — | For cidr. Evaluated in memory against the request IP, which is then discarded. |
label |
text | N | — | CHECK (char_length(label) <= 60) |
Human name, e.g. googlebot. |
category |
text | N | — | CHECK (category IN ('crawler','monitor','preview','datacenter','ai_agent','security_scanner')) |
Reported in the bot breakdown. |
is_active |
boolean | N | true | — | — |
source |
text | Y | null | CHECK (char_length(source) <= 120) |
Provenance of the rule. |
Indexes. ux_bot_signatures_kind_pattern unique on (kind,coalesce(pattern,''),coalesce(asn,0),coalesce(host(cidr),'')) — prevents duplicate rules. ix_bot_signatures_active on (is_active) WHERE is_active.
Checks. ck_bot_signatures_shape: exactly one of pattern, asn, cidr is non-null, matching kind.
Access patterns. Loaded wholesale into worker memory at startup and refreshed every 5 minutes; never queried per event.
6.3.51 leads #
Purpose. An email capture submission from a bio page block.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
workspace_id |
uuid | N | — | FK → workspaces.id |
Tenancy. |
bio_page_id |
uuid | Y | null | FK → bio_pages.id |
Source page. |
block_id |
uuid | Y | null | FK → blocks.id |
Source block. |
email |
citext | N | — | CHECK (char_length(email) BETWEEN 3 AND 254) |
— |
name |
text | Y | null | CHECK (char_length(name) <= 100) |
— |
phone |
text | Y | null | CHECK (char_length(phone) <= 32) |
Captured only when the block asks for it. |
custom_fields |
jsonb | N | '{}' |
— | Block-defined extra fields; max 10 keys. |
consent_marketing |
boolean | N | false | — | Explicit opt-in state at submission. |
consent_text_snapshot |
text | Y | null | CHECK (char_length(...) <= 1000) |
The exact consent wording shown — the evidence a controller needs. |
source_url |
text | Y | null | CHECK (char_length(source_url) <= 2048) |
Page URL at submission. |
visitor_hash |
text | Y | null | — | Joins the lead to its analytics session. |
country |
char(2) | Y | null | — | — |
sync_status |
text | N | 'pending' |
CHECK (sync_status IN ('pending','synced','partial','failed','skipped')) |
Aggregate status across targets. |
unsubscribed_at |
timestamptz | Y | null | — | Set by the unsubscribe link. |
deleted_at |
timestamptz | Y | null | — | Soft delete; erasure requests hard-purge instead. |
Indexes. ux_leads_ws_block_email unique on (workspace_id,block_id,email) WHERE deleted_at IS NULL — deduplicates repeat submissions from the same form. ix_leads_ws_created on (workspace_id,created_at DESC) WHERE deleted_at IS NULL — the lead list and CSV export. ix_leads_sync_pending on (sync_status,created_at) WHERE sync_status IN ('pending','failed','partial') — the sync worker.
Foreign keys. workspace_id → workspaces.id ON DELETE CASCADE. bio_page_id → bio_pages.id ON DELETE SET NULL. block_id → blocks.id ON DELETE SET NULL — deleting the form must not destroy collected leads.
Access patterns. Append-heavy; workspace-scoped keyset pagination; bulk export.
6.3.52 lead_sync_targets #
Purpose. Where a workspace's leads are forwarded.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
workspace_id |
uuid | N | — | FK → workspaces.id |
Tenancy. |
integration_id |
uuid | Y | null | FK → integrations.id |
Null for the generic webhook target. |
provider |
text | N | — | CHECK (provider IN ('mailchimp','convertkit','webhook')) |
— |
list_id |
text | Y | null | CHECK (char_length(list_id) <= 120) |
Audience/list identifier. |
tag |
text | Y | null | CHECK (char_length(tag) <= 60) |
Tag applied on sync. |
field_map |
jsonb | N | '{}' |
— | LinkHub field → provider field mapping. |
target_url |
text | Y | null | CHECK (target_url ~ '^https://') |
Webhook targets only; HTTPS and SSRF-checked. |
is_active |
boolean | N | true | — | — |
last_success_at |
timestamptz | Y | null | — | — |
last_error |
text | Y | null | CHECK (char_length(last_error) <= 1000) |
Surfaced in the UI. |
consecutive_failures |
integer | N | 0 | CHECK (consecutive_failures >= 0) |
Auto-deactivates at 20 with an email to Admins. |
Indexes. ix_lead_sync_targets_ws_active on (workspace_id) WHERE is_active. ux_lead_sync_targets_ws_provider_list unique on (workspace_id,provider,coalesce(list_id,'')).
Foreign keys. workspace_id → workspaces.id ON DELETE CASCADE. integration_id → integrations.id ON DELETE CASCADE.
Access patterns. Read per lead sync; tiny table.
6.3.53 lead_sync_attempts #
Purpose. One delivery attempt of one lead to one target, with enough detail to debug an ESP rejection.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
lead_id |
uuid | N | — | FK → leads.id |
— |
target_id |
uuid | N | — | FK → lead_sync_targets.id |
— |
workspace_id |
uuid | N | — | FK → workspaces.id |
Tenancy. |
attempt_no |
integer | N | — | CHECK (attempt_no BETWEEN 1 AND 6) |
Max 5 retries plus the first attempt. |
status |
text | N | 'pending' |
CHECK (status IN ('pending','succeeded','failed','dead')) |
— |
request_snapshot |
jsonb | Y | null | — | Payload sent, with the email masked. |
response_code |
smallint | Y | null | CHECK (response_code BETWEEN 100 AND 599) |
— |
response_excerpt |
text | Y | null | CHECK (char_length(response_excerpt) <= 2000) |
First 2 KB of the response body. |
error_code |
text | Y | null | CHECK (char_length(error_code) <= 64) |
Normalised failure class. |
next_retry_at |
timestamptz | Y | null | — | Backoff 10s, 1m, 10m, 1h, 6h. |
completed_at |
timestamptz | Y | null | — | — |
Indexes. ux_lead_sync_attempts_lead_target_no unique on (lead_id,target_id,attempt_no). ix_lead_sync_attempts_retry on (next_retry_at) WHERE status = 'pending' — the retry scheduler.
Foreign keys. lead_id → leads.id ON DELETE CASCADE. target_id → lead_sync_targets.id ON DELETE CASCADE. workspace_id → workspaces.id ON DELETE CASCADE.
Access patterns. Append + narrow updates; retained 90 days.
6.3.54 integrations #
Purpose. An enabled third-party integration and its non-secret configuration.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
workspace_id |
uuid | N | — | FK → workspaces.id |
Tenancy. |
provider |
text | N | — | CHECK (provider IN ('ga4','meta_pixel','tiktok_pixel','webhook','zapier','slack','mailchimp','convertkit')) |
— |
status |
text | N | 'active' |
CHECK (status IN ('active','disabled','error','revoked')) |
— |
config |
jsonb | N | '{}' |
— | Non-secret settings: measurement id, pixel id, channel name, event filters. |
consent_category |
text | N | 'marketing' |
CHECK (consent_category IN ('necessary','analytics','marketing')) |
Which consent category gates it (Section 23). |
enabled_at |
timestamptz | Y | null | — | — |
disabled_at |
timestamptz | Y | null | — | — |
last_verified_at |
timestamptz | Y | null | — | Last successful connectivity/credential test. |
last_error |
text | Y | null | CHECK (char_length(last_error) <= 1000) |
— |
created_by_user_id |
uuid | Y | null | FK → users.id |
— |
Indexes. ux_integrations_ws_provider unique on (workspace_id,provider) — one configuration per provider per workspace, which is also how the "one webhook URL per workspace" rule is enforced structurally. ix_integrations_ws_active on (workspace_id) WHERE status = 'active'.
Foreign keys. workspace_id → workspaces.id ON DELETE CASCADE. created_by_user_id → users.id ON DELETE SET NULL.
Access patterns. Read into the published page snapshot (pixel ids) and by the workers.
6.3.55 integration_credentials #
Purpose. The secret half of an integration. Values are stored by reference to the secret store; the ciphertext column exists for credentials the secret store cannot hold as a first-class secret.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
integration_id |
uuid | N | — | FK → integrations.id, unique |
One credential set per integration. |
credential_type |
text | N | — | CHECK (credential_type IN ('api_key','oauth2','webhook_secret')) |
— |
secret_ref |
text | Y | null | CHECK (char_length(secret_ref) <= 256) |
Secret-store path. Preferred. |
ciphertext |
bytea | Y | null | — | AEAD-encrypted fallback. |
key_version |
integer | Y | null | CHECK (key_version > 0) |
Rotation generation. |
scopes |
text[] | N | '{}' |
— | Granted OAuth scopes. |
expires_at |
timestamptz | Y | null | — | Access-token expiry. |
refreshed_at |
timestamptz | Y | null | — | — |
rotated_at |
timestamptz | Y | null | — | — |
Indexes. ux_integration_credentials_integration unique on (integration_id). ix_integration_credentials_expiry on (expires_at) WHERE expires_at IS NOT NULL — proactive refresh.
Foreign keys. integration_id → integrations.id ON DELETE CASCADE.
Checks. ck_integration_credentials_material: (secret_ref IS NOT NULL) OR (ciphertext IS NOT NULL AND key_version IS NOT NULL). Column-level REVOKE SELECT (ciphertext) from any read-only analytics role.
Access patterns. Read only inside the worker process that performs the outbound call. Never returned by any API.
6.3.56 webhook_deliveries #
Purpose. One outbound webhook delivery to the workspace's single webhook URL, with retry state.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
workspace_id |
uuid | N | — | FK → workspaces.id |
Tenancy. |
event_type |
text | N | — | CHECK (event_type IN ('click.recorded','scan.recorded','lead.captured','link.created','link.updated','qr.destination_changed','page.published')) |
— |
event_id |
uuid | N | — | unique | Idempotency key echoed in the payload so the receiver can deduplicate. |
target_url |
text | N | — | CHECK (char_length(target_url) <= 2048) |
Snapshot of the workspace webhook URL at enqueue time. |
payload |
jsonb | N | — | — | The exact JSON body signed and sent. |
signature_timestamp |
bigint | N | — | — | Unix seconds used in the t= component of the signature header. |
status |
text | N | 'pending' |
CHECK (status IN ('pending','delivering','succeeded','failed','dead')) |
— |
attempt_count |
integer | N | 0 | CHECK (attempt_count BETWEEN 0 AND 6) |
First attempt plus 5 retries. |
next_attempt_at |
timestamptz | Y | null | — | Backoff 10s, 1m, 10m, 1h, 6h. |
last_response_code |
smallint | Y | null | CHECK (last_response_code BETWEEN 100 AND 599) |
— |
last_response_ms |
integer | Y | null | CHECK (last_response_ms >= 0) |
— |
last_error |
text | Y | null | CHECK (char_length(last_error) <= 1000) |
— |
delivered_at |
timestamptz | Y | null | — | — |
Indexes. ux_webhook_deliveries_event unique on (event_id). ix_webhook_deliveries_due on (next_attempt_at) WHERE status IN ('pending','failed') — the delivery scheduler. ix_webhook_deliveries_ws_created on (workspace_id,created_at DESC) — the delivery log UI.
Foreign keys. workspace_id → workspaces.id ON DELETE CASCADE.
Access patterns. High-churn append + update; retained 30 days after resolution.
6.3.57 webhook_dead_letters #
Purpose. A delivery that exhausted every retry, kept visible and replayable in the UI.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
workspace_id |
uuid | N | — | FK → workspaces.id |
Tenancy. |
delivery_id |
uuid | Y | null | FK → webhook_deliveries.id |
Source delivery; nulled when the delivery row is purged. |
event_type |
text | N | — | — | — |
event_id |
uuid | N | — | unique | Preserved so a replay is still idempotent for the receiver. |
payload |
jsonb | N | — | — | Full body, so replay needs nothing else. |
attempts |
integer | N | — | CHECK (attempts >= 1) |
— |
final_error |
text | Y | null | CHECK (char_length(final_error) <= 2000) |
— |
failed_at |
timestamptz | N | now() |
— | — |
replayed_at |
timestamptz | Y | null | — | — |
replayed_by_user_id |
uuid | Y | null | FK → users.id |
— |
expires_at |
timestamptz | N | — | — | 30 days after failed_at; replay is unavailable afterwards, stated in the UI. |
Indexes. ux_webhook_dead_letters_event unique on (event_id). ix_webhook_dead_letters_ws_failed on (workspace_id,failed_at DESC) WHERE replayed_at IS NULL. ix_webhook_dead_letters_expiry on (expires_at).
Foreign keys. workspace_id → workspaces.id ON DELETE CASCADE. delivery_id → webhook_deliveries.id ON DELETE SET NULL. replayed_by_user_id → users.id ON DELETE SET NULL.
Access patterns. Small list per workspace; a badge count on the integrations page.
6.3.58 api_keys #
Purpose. A hashed, scoped public-API key. Presentation rules are owned by Section 21.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
workspace_id |
uuid | N | — | FK → workspaces.id |
Keys are workspace-scoped. There is no account-level key. Authorisation must compare this column to the workspace being requested before evaluating any capability, and return 404 not_found when they differ (Section 3.5). A key that authenticates is not thereby a key that is in the right tenant. |
name |
text | N | — | CHECK (char_length(name) BETWEEN 1 AND 60) |
— |
key_hash |
bytea | N | — | unique, CHECK (octet_length(key_hash) = 32) |
SHA-256 of the full key. The key itself is displayed once and never stored. |
display_prefix |
char(6) | N | — | CHECK (display_prefix ~ '^[a-z0-9]{6}$') |
First 6 characters after the lk_ prefix, for identification in lists and logs. |
scopes |
text[] | N | — | CHECK (cardinality(scopes) BETWEEN 1 AND 32) plus ck_api_keys_scopes_vocabulary |
A key with no scopes could do nothing, so at least one is required. The scope vocabulary itself is owned by Section 21 and is not restated here. ck_api_keys_scopes_vocabulary is a scopes <@ ARRAY[…] constraint whose array literal is generated from Section 21's catalogue by the migration generator, so the database and the API surface cannot drift; adding a scope is one generated expand migration. Two vocabulary rules are worth naming because they are security properties rather than list membership: audit:read is not in the catalogue — API keys cannot read the audit log at all — and there is no billing:* scope, because billing is dashboard-session-only (Section 21). |
rate_limit_per_min |
integer | Y | null | CHECK (rate_limit_per_min BETWEEN 1 AND 6000) |
Per-key override; null means the plan default. |
last_used_at |
timestamptz | Y | null | — | Updated at most once per 60s. |
last_used_ip_country |
char(2) | Y | null | — | — |
expires_at |
timestamptz | Y | null | — | Optional expiry. |
revoked_at |
timestamptz | Y | null | — | Immediate; revocation also purges the Redis auth cache entry. |
revoked_by_user_id |
uuid | Y | null | FK → users.id |
— |
created_by_user_id |
uuid | Y | null | FK → users.id |
— |
deleted_at |
timestamptz | Y | null | — | Soft delete after revocation. |
Indexes. ux_api_keys_hash unique on (key_hash) — the authentication probe on every public-API request (cached in Redis with a 60s TTL). ix_api_keys_ws on (workspace_id) WHERE deleted_at IS NULL — the key list. ix_api_keys_expiry on (expires_at) WHERE expires_at IS NOT NULL AND revoked_at IS NULL.
Foreign keys. workspace_id → workspaces.id ON DELETE CASCADE. Actor references ON DELETE SET NULL.
Access patterns. Point lookup by hash; small list per workspace.
6.3.59 api_key_usage #
Purpose. Hourly usage counters per key, for the usage chart and for fair-use enforcement.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
api_key_id |
uuid | N | — | FK → api_keys.id |
— |
workspace_id |
uuid | N | — | FK → workspaces.id |
Tenancy. |
window_start |
timestamptz | N | — | CHECK (date_trunc('hour', window_start) = window_start) |
UTC hour. |
requests |
bigint | N | 0 | CHECK (requests >= 0) |
— |
errors_4xx |
bigint | N | 0 | CHECK (errors_4xx >= 0) |
— |
errors_5xx |
bigint | N | 0 | CHECK (errors_5xx >= 0) |
— |
rate_limited |
bigint | N | 0 | CHECK (rate_limited >= 0) |
Count of 429 responses. |
bytes_out |
bigint | N | 0 | CHECK (bytes_out >= 0) |
— |
Indexes. ux_api_key_usage_key_window unique on (api_key_id,window_start) — the upsert target. ix_api_key_usage_ws_window on (workspace_id,window_start DESC).
Foreign keys. api_key_id → api_keys.id ON DELETE CASCADE. workspace_id → workspaces.id ON DELETE CASCADE.
Access patterns. Counters accumulate in Redis and are flushed here once a minute. Retained 400 days.
6.3.60 rate_limit_violations #
Purpose. Durable record of rate-limit breaches, so abuse response is based on evidence rather than on volatile counters.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
scope |
text | N | — | CHECK (scope IN ('ip','api_key','account','workspace','session')) |
— |
key_hash |
bytea | N | — | — | SHA-256 of the peppered scope key; no raw IP or email. |
endpoint_group |
text | N | — | CHECK (char_length(endpoint_group) <= 60) |
e.g. auth.login, api.links.write. |
window_start |
timestamptz | N | — | — | — |
violation_count |
integer | N | 1 | CHECK (violation_count >= 1) |
— |
first_seen_at |
timestamptz | N | now() |
— | — |
last_seen_at |
timestamptz | N | now() |
— | — |
blocked_until |
timestamptz | Y | null | — | Set when an escalated block is applied. |
workspace_id |
uuid | Y | null | FK → workspaces.id |
Where attributable. |
api_key_id |
uuid | Y | null | FK → api_keys.id |
Where attributable. |
Indexes. ux_rate_limit_violations_grain unique on (scope,key_hash,endpoint_group,window_start) — the upsert target. ix_rate_limit_violations_blocked on (blocked_until) WHERE blocked_until IS NOT NULL. ix_rate_limit_violations_last_seen on (last_seen_at) — retention sweep (90 days).
Foreign keys. Both optional references ON DELETE SET NULL.
Access patterns. Upsert once per violation window; read by operations tooling.
6.3.61 consent_records #
Purpose. A visitor's consent decision for one workspace's public surfaces. Written only when a banner is actually shown (Section 23).
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
workspace_id |
uuid | N | — | FK → workspaces.id |
Tenancy. |
visitor_hash |
text | N | — | CHECK (char_length(visitor_hash) = 24) |
The same rotating hash used by analytics. |
bio_page_id |
uuid | Y | null | — | Surface where consent was captured. |
categories |
jsonb | N | — | — | {"necessary":true,"analytics":bool,"marketing":bool}. |
method |
text | N | — | CHECK (method IN ('accept_all','reject_all','custom','withdrawn','implied_non_gated')) |
— |
region_gated |
boolean | N | true | — | Whether the banner was shown because of geo gating. |
country |
char(2) | Y | null | — | — |
policy_version |
text | N | — | CHECK (char_length(policy_version) <= 20) |
Version of the consent copy shown — required to prove what was agreed to. |
occurred_at |
timestamptz | N | now() |
— | — |
expires_at |
timestamptz | N | — | — | 6 months, matching the cookie. |
Indexes. ix_consent_records_ws_visitor on (workspace_id,visitor_hash,occurred_at DESC) — the latest decision for a visitor. ix_consent_records_expiry on (expires_at) — purge sweep.
Foreign keys. workspace_id → workspaces.id ON DELETE CASCADE.
Access patterns. Append-only; read by the consent-evidence export, not on the render path (the cookie is authoritative at render time).
6.3.62 data_export_requests #
Purpose. An asynchronous export job and its signed download.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
workspace_id |
uuid | Y | null | FK → workspaces.id |
Null for an account-scope export. |
user_id |
uuid | Y | null | FK → users.id |
Subject for an account export. |
requested_by_user_id |
uuid | N | — | FK → users.id |
Actor. |
scope |
text | N | — | CHECK (scope IN ('workspace','account','analytics','leads','audit_log')) |
— |
filters |
jsonb | N | '{}' |
— | Date range, resource filter. |
format |
text | N | 'json_csv_bundle' |
CHECK (format IN ('json_csv_bundle','csv')) |
— |
status |
text | N | 'queued' |
CHECK (status IN ('queued','running','completed','failed','expired')) |
— |
asset_id |
uuid | Y | null | FK → uploaded_assets.id |
The generated archive. |
download_token_hash |
bytea | Y | null | unique | SHA-256 of the signed download token. |
expires_at |
timestamptz | Y | null | — | 24 hours after completion. |
row_counts |
jsonb | Y | null | — | Per-table counts included, so the recipient can verify completeness. |
started_at / completed_at |
timestamptz | Y | null | — | — |
error |
text | Y | null | CHECK (char_length(error) <= 2000) |
— |
Indexes. ux_data_export_requests_token unique on (download_token_hash) WHERE download_token_hash IS NOT NULL. ix_data_export_requests_ws_created on (workspace_id,created_at DESC). ix_data_export_requests_expiry on (expires_at) WHERE status = 'completed' — expiry sweep deletes the archive.
Foreign keys. workspace_id → workspaces.id ON DELETE CASCADE. user_id, requested_by_user_id → users.id ON DELETE SET NULL / RESTRICT respectively. asset_id → uploaded_assets.id ON DELETE SET NULL.
Access patterns. Small; one active export per workspace at a time, enforced in the application.
6.3.63 data_deletion_requests #
Purpose. An erasure request, its grace window, and the report of what was actually done — including the QR carve-out.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
subject_type |
text | N | — | CHECK (subject_type IN ('workspace','account','visitor','lead')) |
— |
workspace_id |
uuid | Y | null | FK → workspaces.id |
— |
user_id |
uuid | Y | null | FK → users.id |
— |
subject_ref |
text | Y | null | CHECK (char_length(subject_ref) <= 254) |
Visitor hash or lead email for visitor/lead scope. |
requested_by_user_id |
uuid | Y | null | FK → users.id |
— |
status |
text | N | 'grace' |
CHECK (status IN ('grace','executing','completed','canceled','failed')) |
— |
grace_ends_at |
timestamptz | N | — | — | 30 days after request for workspace and account scope; immediate (now()) for visitor and lead scope. |
executed_at |
timestamptz | Y | null | — | — |
canceled_at |
timestamptz | Y | null | — | — |
canceled_by_user_id |
uuid | Y | null | FK → users.id |
— |
report |
jsonb | Y | null | — | Per-table counts of rows purged and anonymized. |
qr_carve_out_applied |
boolean | N | false | — | True when QR slug reservations were preserved. The report explains this in plain language. |
failure_reason |
text | Y | null | CHECK (char_length(failure_reason) <= 2000) |
— |
Indexes. ix_data_deletion_requests_due on (grace_ends_at) WHERE status = 'grace' — the execution sweeper. ix_data_deletion_requests_ws on (workspace_id,created_at DESC).
Foreign keys. workspace_id → workspaces.id ON DELETE SET NULL — the request record must outlive the workspace it deleted. user_id, requested_by_user_id, canceled_by_user_id → users.id ON DELETE SET NULL.
Access patterns. Small; read by the compliance UI and the sweeper. Retained 7 years as the record of a legal obligation performed.
6.3.64 safe_browsing_checks #
Purpose. Cached reputation verdict for a normalised destination URL, shared across workspaces so one lookup serves everybody.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
url_hash |
bytea | N | — | unique, CHECK (octet_length(url_hash) = 32) |
SHA-256 of the normalised URL (lower-cased host, default port stripped, fragment removed). |
url_normalized |
text | N | — | CHECK (char_length(url_normalized) <= 2048) |
Kept for operator review. |
verdict |
text | N | — | CHECK (verdict IN ('clean','malware','social_engineering','unwanted_software','potentially_harmful','unknown','error')) |
— |
threat_types |
text[] | N | '{}' |
— | Raw provider classifications. |
provider |
text | N | 'google_safe_browsing' |
— | — |
checked_at |
timestamptz | N | now() |
— | — |
next_check_at |
timestamptz | N | — | — | 7 days for clean, 24 hours for anything else. |
response_raw |
jsonb | Y | null | — | Provider response for audit. |
Indexes. ux_safe_browsing_checks_url unique on (url_hash). ix_safe_browsing_checks_next on (next_check_at) — the weekly recheck worker. ix_safe_browsing_checks_verdict on (verdict) WHERE verdict <> 'clean' — the abuse queue.
Foreign keys. None — the cache is global by design and outlives any individual link.
Access patterns. Point lookup by hash at link creation and on the interstitial decision path.
6.3.65 abuse_reports #
Purpose. An inbound abuse report and its triage state.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
workspace_id |
uuid | Y | null | FK → workspaces.id |
Resolved during triage. |
resource_type |
text | Y | null | CHECK (resource_type IN ('link','qr','bio_page','domain')) |
— |
resource_id |
uuid | Y | null | — | Polymorphic, not a foreign key. |
reported_url |
text | N | — | CHECK (char_length(reported_url) <= 2048) |
What the reporter submitted. |
reporter_email |
citext | Y | null | — | Optional; reports are accepted anonymously. |
reporter_ip_country |
char(2) | Y | null | — | — |
reason |
text | N | — | CHECK (reason IN ('phishing','malware','spam','copyright','adult','csam','impersonation','other')) |
csam reports bypass the queue and page the on-call responder immediately. |
details |
text | Y | null | CHECK (char_length(details) <= 4000) |
— |
status |
text | N | 'new' |
CHECK (status IN ('new','triaging','actioned','rejected','duplicate')) |
— |
action_taken |
text | Y | null | CHECK (action_taken IN ('none','link_disabled','page_unpublished','workspace_suspended','domain_suspended','forwarded')) |
— |
handled_by_user_id |
uuid | Y | null | FK → users.id |
— |
handled_at |
timestamptz | Y | null | — | — |
evidence |
jsonb | Y | null | — | Screenshots, resolved destination, reputation verdict at the time. |
Indexes. ix_abuse_reports_status_created on (status,created_at) — the triage queue, oldest first. ix_abuse_reports_ws on (workspace_id) WHERE workspace_id IS NOT NULL. ix_abuse_reports_resource on (resource_type,resource_id).
Foreign keys. workspace_id → workspaces.id ON DELETE SET NULL — reports survive the workspace they concerned. handled_by_user_id → users.id ON DELETE SET NULL.
Access patterns. Small queue; retained 3 years.
6.3.66 reserved_slugs #
Purpose. Slugs that may not be claimed. Seeded (6.7); the check runs on every slug creation across links, pages and QR codes.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
slug |
citext | Y | null | CHECK (char_length(slug) <= 64) |
Exact match entry. |
pattern |
text | Y | null | CHECK (char_length(pattern) <= 200) |
Regex entry. |
is_regex |
boolean | N | false | — | — |
scope |
text | N | 'global' |
CHECK (scope IN ('global','links','pages','qr')) |
— |
category |
text | N | — | CHECK (category IN ('system','profanity','brand','confusable','legal')) |
— |
reason |
text | Y | null | CHECK (char_length(reason) <= 200) |
Shown to operators, never to end users. |
is_active |
boolean | N | true | — | — |
Indexes. ux_reserved_slugs_slug_scope unique on (slug,scope) WHERE slug IS NOT NULL. ix_reserved_slugs_active_regex on (is_active) WHERE is_regex AND is_active — the small regex set evaluated in memory.
Access patterns. Exact entries are checked with one index probe; regex and confusable entries are evaluated in worker memory from a 5-minute cached snapshot. The confusable check normalises the candidate slug (homoglyph folding, digit/letter confusables, repeated-character collapse) and compares against the normalised reserved set.
6.3.67 idempotency_keys #
Purpose. Stored responses for Idempotency-Key requests on the public API (Section 21).
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
workspace_id |
uuid | N | — | FK → workspaces.id |
Tenancy. |
api_key_id |
uuid | Y | null | FK → api_keys.id |
— |
key |
text | N | — | CHECK (char_length(key) BETWEEN 8 AND 255) |
Client-supplied. |
endpoint |
text | N | — | CHECK (char_length(endpoint) <= 200) |
METHOD /path template. |
request_fingerprint |
bytea | N | — | CHECK (octet_length(request_fingerprint) = 32) |
SHA-256 of method, path and canonicalised body. A reused key with a different body returns 409 idempotency_key_reused. |
state |
text | N | 'in_flight' |
CHECK (state IN ('in_flight','completed')) |
— |
response_status |
smallint | Y | null | CHECK (response_status BETWEEN 100 AND 599) |
— |
response_body |
jsonb | Y | null | — | Replayed verbatim on a repeat request. |
locked_at |
timestamptz | Y | null | — | Concurrent repeats while in_flight receive 409 idempotency_key_in_flight. |
expires_at |
timestamptz | N | — | — | 24 hours after creation. |
Indexes. ux_idempotency_keys_ws_endpoint_key unique on (workspace_id,endpoint,key) — the conflict target that makes concurrent duplicates race-safe. ix_idempotency_keys_expiry on (expires_at) — hourly hard-delete sweep.
Foreign keys. workspace_id → workspaces.id ON DELETE CASCADE. api_key_id → api_keys.id ON DELETE CASCADE.
Access patterns. One insert-or-conflict per idempotent write; hard-deleted at expiry.
6.3.68 feature_flags #
Purpose. Runtime rollout switches, so a risky path can be disabled without a deploy.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
key |
text | N | — | unique, CHECK (key ~ '^[a-z0-9_.-]{3,64}$') |
— |
description |
text | N | — | CHECK (char_length(description) <= 300) |
— |
default_enabled |
boolean | N | false | — | — |
rollout_percent |
smallint | N | 0 | CHECK (rollout_percent BETWEEN 0 AND 100) |
Hashed on workspace_id, so a workspace's experience is stable. |
workspace_allow_list |
uuid[] | N | '{}' |
— | Always-on workspaces. |
workspace_deny_list |
uuid[] | N | '{}' |
— | Always-off, evaluated first. |
environments |
text[] | N | '{local,preview,staging,production}' |
— | — |
updated_by_user_id |
uuid | Y | null | FK → users.id |
— |
Indexes. ux_feature_flags_key unique on (key).
Access patterns. Loaded into process memory at startup, refreshed every 30 seconds. Never queried per request.
6.3.69 jobs_dead_letter #
Purpose. Background jobs that exhausted their retries. This is the operational surface referenced by the Section 25 runbooks.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
queue_name |
text | N | — | CHECK (char_length(queue_name) <= 60) |
Kebab-case queue name. |
job_name |
text | N | — | CHECK (char_length(job_name) <= 100) |
— |
job_id |
text | N | — | CHECK (char_length(job_id) <= 100) |
Queue-assigned id. |
workspace_id |
uuid | Y | null | FK → workspaces.id |
Where attributable. |
payload |
jsonb | N | — | — | Full job data, so a replay needs nothing else. |
attempts_made |
integer | N | — | CHECK (attempts_made >= 1) |
— |
failed_reason |
text | Y | null | CHECK (char_length(failed_reason) <= 2000) |
— |
stack_excerpt |
text | Y | null | CHECK (char_length(stack_excerpt) <= 8000) |
First 8 KB of the stack. |
first_failed_at |
timestamptz | N | — | — | — |
last_failed_at |
timestamptz | N | — | — | — |
status |
text | N | 'open' |
CHECK (status IN ('open','replayed','discarded')) |
— |
replayed_at |
timestamptz | Y | null | — | — |
replayed_by_user_id |
uuid | Y | null | FK → users.id |
— |
Indexes. ux_jobs_dead_letter_queue_job unique on (queue_name,job_id). ix_jobs_dead_letter_open on (queue_name,last_failed_at DESC) WHERE status = 'open' — the operations dashboard and the alert query. ix_jobs_dead_letter_ws on (workspace_id) WHERE workspace_id IS NOT NULL.
Foreign keys. workspace_id → workspaces.id ON DELETE SET NULL. replayed_by_user_id → users.id ON DELETE SET NULL.
Access patterns. Low volume; a non-zero open count is an alert condition (Section 25). Retained 90 days.
6.3.70 analytics_rollup_only_events #
Purpose. Idempotency keys for events that arrive after their raw partition has already been dropped by retention. Those events cannot be written to raw, but on Pro and Business the rollup horizon is longer than the raw horizon, so the rollup increment is still owed. Without this table the rollup path would have no RETURNING set to derive from and a replayed dead-letter batch would double-count.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
event_id |
uuid | N | — | PRIMARY KEY (replaces the standard id) |
The click_events.id / page_view_events.id the event was assigned at capture, unchanged across every replay. |
workspace_id |
uuid | N | — | — | Tenancy, so the row is deleted with the workspace. |
event_date |
date | N | — | — | The UTC day the event belongs to, used by the retention sweep. |
applied_at |
timestamptz | N | now() |
— | When the rollup-only increment was applied. |
Standard columns. This table has event_id as its primary key and no id, created_at or updated_at — the row is a fact, not an entity. This is one of the five documented deviations from the standard columns listed at the head of 6.3.
Usage. Written in the same transaction as the rollup upsert with INSERT ... ON CONFLICT DO NOTHING RETURNING; the increment is applied only for rows the RETURNING set produced. A duplicate therefore increments by zero, exactly as a duplicate raw insert does.
Indexes. The primary key, plus ix_analytics_rollup_only_events_ws_date on (workspace_id,event_date) — retention and workspace purge.
Retention. Deleted when the corresponding rollup rows are deleted (6.8). Keeping the key longer than the data it protects would serve nothing.
Foreign keys. None, same rationale as the raw tables.
6.3.71 ua_parse_corpus #
Purpose. The parser-maintenance corpus. The raw User-Agent string is needed to improve the parser and to triage a new bot, and for nothing else. This table holds distinct raw strings and deliberately holds nothing that could attach one to a person, a session or a customer.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
ua_sha256 |
bytea | N | — | PRIMARY KEY, CHECK (octet_length(ua_sha256) = 32) |
Unsalted SHA-256 of the raw string, used purely as the deduplication key for this corpus. It is not ua_hash — ua_hash is daily-salted and lives on the event tables. |
user_agent_raw |
text | N | — | CHECK (char_length(user_agent_raw) <= 512) |
The distinct raw string. |
parsed_device_type |
text | Y | null | — | What the current parser makes of it, so a reparse can find strings whose classification changed. |
parsed_os_family |
text | Y | null | CHECK (char_length(parsed_os_family) <= 40) |
— |
parsed_browser_family |
text | Y | null | CHECK (char_length(parsed_browser_family) <= 40) |
— |
parsed_is_bot |
boolean | Y | null | — | — |
ua_parser_version |
smallint | N | 1 | — | Which parser produced the parsed columns. |
first_seen_on |
date | N | — | — | A date, never a timestamp. |
last_seen_on |
date | N | — | — | Same. |
observation_count |
bigint | N | 1 | CHECK (observation_count >= 1) |
How many times the string has been seen platform-wide, so parser work can be prioritised by real frequency. |
What is deliberately absent, and why it matters. No workspace_id, no visitor_hash, no ua_hash, no resource_id, no occurred_at, and no timestamp finer than a day. A raw user-agent string is weakly identifying on its own; it becomes strongly identifying when it can be joined to a tenant, a visitor or a moment. This table supports none of those joins, which is what makes retaining the raw string proportionate. Section 23.12 states the same thing in the data inventory.
Standard columns. ua_sha256 is the primary key; there is no id. created_at/updated_at are omitted — they would reintroduce the sub-day timestamp this table exists to avoid. This is one of the five documented deviations from the standard columns listed at the head of 6.3.
Write path. The ingest worker maintains an in-memory set of hashes it has already recorded today and flushes new ones once per batch with INSERT ... ON CONFLICT (ua_sha256) DO UPDATE SET last_seen_on = EXCLUDED.last_seen_on, observation_count = ua_parse_corpus.observation_count + EXCLUDED.observation_count. It is off the hot path entirely.
Retention. Rows whose last_seen_on is more than 400 days old are deleted. A string nobody has sent in over a year is not a parser problem.
Size. Bounded by the number of distinct user-agent strings in circulation — tens of thousands, not billions.
Indexes. The primary key, plus ix_ua_parse_corpus_last_seen on (last_seen_on) — the retention sweep, and ix_ua_parse_corpus_version on (ua_parser_version) WHERE parsed_is_bot IS NOT TRUE — finding strings a new parser version has not yet re-evaluated.
Foreign keys. None. There is nothing for it to reference, by design.
6.3.72 workspace_resource_counters #
Purpose. The authoritative count of capped resources per workspace. SELECT count(*) on every create is both slow and racy; this table makes a cap check one indexed row lock. Cap values and the reservation protocol are owned by Section 22; this section owns only the storage shape.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
workspace_id |
uuid | N | — | part of PK, FK → workspaces.id |
Tenancy. |
resource_type |
text | N | — | part of PK, CHECK (resource_type IN ('bio_page','short_link','qr_code','custom_domain','seat','experiment','saved_segment','analytics_share_link','scheduled_report')) |
One row per capped resource type. |
active_count |
integer | N | 0 | CHECK (active_count >= 0) |
The stock figure a numeric cap is compared against. |
period_created_count |
integer | N | 0 | CHECK (period_created_count >= 0) |
The flow figure a per-period cap is compared against. |
period_start |
timestamptz | N | — | — | Start of the current billing window. |
period_end |
timestamptz | N | — | CHECK (period_end > period_start) |
End of it; the counter resets lazily when period_end <= now(). |
last_recount_at |
timestamptz | Y | null | — | When the drift-correction job last rewrote active_count from source. |
last_recount_delta |
integer | Y | null | — | What it corrected by. A non-zero value means a code path mutated rows outside the entitlement service. |
Primary key. PRIMARY KEY (workspace_id, resource_type). This table has no id column — the natural key is the identity. One of the five documented deviations at the head of 6.3; created_at/updated_at are present as normal.
What active_count counts. Rows that are not soft-deleted and not archived. Archived resources do not count toward a cap — that rule is Section 22's and is referenced, not restated, here. For short_link there is one further exclusion, and it is a storage-level rule so it belongs here: rows with qr_pinned_count > 0 are excluded (6.3.32). A link a customer cannot delete without breaking printed material must not consume a cap slot.
Indexes. The primary key, plus ix_workspace_resource_counters_period_end on (period_end) — finding rows whose period window has rolled over.
Foreign keys. workspace_id → workspaces.id ON DELETE CASCADE.
Access patterns. SELECT ... FOR UPDATE on exactly one row inside the creating transaction, then one UPDATE. The row lock is what serialises two simultaneous requests for the last remaining slot. Recounted nightly.
6.3.73 analytics_share_links #
Purpose. A tokenised, read-only public view of a workspace's analytics, for showing a client the numbers without giving them an account. Entitlement (Pro and above, 25 active) is Section 22's; the surface is Section 18's; the storage is here.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
workspace_id |
uuid | N | — | FK → workspaces.id |
Tenancy. |
name |
text | N | — | CHECK (char_length(name) BETWEEN 1 AND 60) |
Internal label shown in the share list. |
scope_type |
text | N | — | CHECK (scope_type IN ('workspace','resource_set')) |
— |
resource_ids |
uuid[] | N | '{}' |
— | Populated only for resource_set; maximum 50 entries, validated in the application. |
metric_scope |
text | N | 'summary' |
CHECK (metric_scope IN ('summary','full')) |
summary exposes totals and the time series; full adds dimension breakdowns. |
range_mode |
text | N | — | CHECK (range_mode IN ('fixed','rolling')) |
— |
range_start |
timestamptz | Y | null | — | Required when range_mode = 'fixed'. |
range_end |
timestamptz | Y | null | — | Required when range_mode = 'fixed'. |
rolling_days |
smallint | Y | null | CHECK (rolling_days IS NULL OR rolling_days BETWEEN 1 AND 365) |
Required when range_mode = 'rolling'. |
token_hash |
bytea | N | — | unique, CHECK (octet_length(token_hash) = 32) |
SHA-256 of the 256-bit token. The token itself is shown once, at creation, and never stored. |
token_prefix |
text | N | — | CHECK (char_length(token_prefix) = 8) |
First 8 characters, in clear, so the list UI can identify a share without being guessable. |
password_hash |
text | Y | null | CHECK (char_length(password_hash) <= 512) |
Optional Argon2id passphrase on top of the token. |
expires_at |
timestamptz | N | — | — | Mandatory. A share link with no expiry is a permanent unauthenticated data export, so the schema does not allow one. |
revoked_at |
timestamptz | Y | null | — | Revocation is immediate and irreversible; rotation is not offered, because rotating a token silently breaks a recipient's bookmark with no way to notify them. |
created_by_user_id |
uuid | Y | null | FK → users.id |
Provenance. |
last_viewed_at |
timestamptz | Y | null | — | Updated at most once per minute per share. |
view_count |
integer | N | 0 | CHECK (view_count >= 0) |
— |
Indexes. ux_analytics_share_links_token unique on (token_hash) — the only lookup the public path performs. ix_analytics_share_links_ws on (workspace_id,created_at DESC) — the management list. ix_analytics_share_links_active on (workspace_id) WHERE revoked_at IS NULL AND expires_at > now() is not created: a partial index predicate cannot contain now(). Active-share counting for the 25-share entitlement uses workspace_resource_counters instead, which is exactly what that table is for.
Checks. ck_analytics_share_links_range: (range_mode = 'fixed' AND range_start IS NOT NULL AND range_end IS NOT NULL AND range_end > range_start) OR (range_mode = 'rolling' AND rolling_days IS NOT NULL). ck_analytics_share_links_scope: (scope_type = 'resource_set') = (cardinality(resource_ids) > 0).
Foreign keys. workspace_id → workspaces.id ON DELETE CASCADE. created_by_user_id → users.id ON DELETE SET NULL.
Access patterns. One indexed lookup by token hash per public view, with a constant-time comparison guarding against timing analysis. resource_ids is an array rather than a join table because it is read whole, written whole, and capped at 50.
6.3.74 billing_accounts #
Purpose. The payment-provider customer a workspace bills through, and the record of the one trial that customer is ever entitled to. It is separate from subscriptions because trial eligibility, payment methods and tax identity belong to the payer, which outlives any individual subscription and must survive a full cancel-and-resubscribe cycle.
| Column | Type | Null | Default | Constraints | Description |
|---|---|---|---|---|---|
workspace_id |
uuid | N | — | unique, FK → workspaces.id |
One billing account per workspace. |
provider |
text | N | 'stripe' |
CHECK (provider IN ('stripe')) |
Single provider today; the column exists so a second one is an expand migration rather than a rewrite. |
provider_customer_id |
text | N | — | unique, CHECK (char_length(provider_customer_id) <= 64) |
The provider's customer identifier. |
billing_email |
citext | Y | null | CHECK (char_length(billing_email) BETWEEN 3 AND 254) |
Where invoices go; defaults to the Owner's address but is independently editable. |
billing_name |
text | Y | null | CHECK (char_length(billing_name) <= 200) |
Legal entity name on the invoice. |
billing_country |
char(2) | Y | null | CHECK (billing_country ~ '^[A-Z]{2}$') |
Drives tax treatment. |
tax_id |
text | Y | null | CHECK (char_length(tax_id) <= 64) |
VAT / GST identifier as supplied. |
tax_id_status |
text | N | 'none' |
CHECK (tax_id_status IN ('none','pending','valid','invalid')) |
Provider validation outcome. |
default_payment_method_last4 |
char(4) | Y | null | — | For display only. No card number, expiry or CVC is ever stored — the provider holds them. |
default_payment_method_brand |
text | Y | null | CHECK (char_length(default_payment_method_brand) <= 20) |
For display only. |
payment_method_fingerprint |
text | Y | null | CHECK (char_length(payment_method_fingerprint) <= 64) |
The provider's stable card fingerprint, used only to detect trial recycling across accounts. |
trial_used_at |
timestamptz | Y | null | — | Set the first time a trial is granted, and never cleared. This is the column that makes a trial once-per-billing-account, ever. |
trial_ends_at |
timestamptz | Y | null | — | End of the current trial, if one is running. |
currency |
char(3) | N | 'USD' |
CHECK (currency ~ '^[A-Z]{3}$') |
ISO 4217. Fixed at first successful charge. |
balance_cents |
integer | N | 0 | — | Provider credit balance, mirrored for display. Integer minor units, per 6.2. |
delinquent |
boolean | N | false | — | Mirrored provider flag. It is a display and dunning input only; write blocking is decided by Section 22.7's day-count schedule, not by this boolean. |
provider_synced_at |
timestamptz | Y | null | — | When the projection was last refreshed from a provider event. |
Indexes. ux_billing_accounts_workspace unique on (workspace_id). ux_billing_accounts_provider_customer unique on (provider,provider_customer_id) — the webhook handler's lookup key. ix_billing_accounts_fingerprint on (payment_method_fingerprint) WHERE payment_method_fingerprint IS NOT NULL — trial-recycling detection. ix_billing_accounts_trial_ends on (trial_ends_at) WHERE trial_ends_at IS NOT NULL — the trial-ending reminder job.
Foreign keys. workspace_id → workspaces.id ON DELETE CASCADE.
Retention. Retained 7 years after the workspace is purged, with workspace_id nulled — it is a financial record (6.8). The ON DELETE CASCADE above therefore applies only to the soft-delete path; the purge job detaches the row explicitly before deleting the workspace, and 6.10.1 stage 5 states that.
Access patterns. One row per workspace, read on every billing screen and every provider webhook, cached with the entitlement payload under ent:workspace:{id}.
6.4 Partitioning strategy #
click_events, page_view_events and analytics_unique_visitor_days use PostgreSQL declarative range partitioning on the event-time column, one partition per UTC day — occurred_at on the two event tables, event_date on the visitor-day table. analytics_rollup_hourly is partitioned monthly on bucket_start; analytics_rollup_daily is not partitioned at all. Those three choices are stated per table in 6.3.47 to 6.3.49 and are not repeated here.
6.4.1 Definition and naming #
CREATE TABLE click_events (...) PARTITION BY RANGE (occurred_at);
CREATE TABLE click_events_2026_03_14
PARTITION OF click_events
FOR VALUES FROM ('2026-03-14 00:00:00+00') TO ('2026-03-15 00:00:00+00');| Rule | Value |
|---|---|
| Partition name | <parent>_<yyyy>_<mm>_<dd> — sorts lexicographically in date order, which makes \dt+ click_events_* a readable retention report. |
| Boundaries | [day 00:00:00+00, next day 00:00:00+00) — half-open, so no event can land in two partitions. |
| Default partition | None. A DEFAULT partition would silently absorb out-of-range events and then block future ATTACH operations. The ingest worker instead rejects an event whose occurred_at is more than 48 hours in the past or more than 5 minutes in the future, records it in jobs_dead_letter, and increments analytics_ingest_out_of_range_total. |
| Indexes | Created on the parent as CREATE INDEX ... ON ONLY templates and instantiated per partition by the creation job, so every index stays local and small. |
6.4.2 Pre-creation job #
analytics-partition-maintain, scheduled hourly at minute 5.
- Compute the set of required partitions: today through today + 14 days on all three daily-partitioned tables —
click_events,page_view_eventsandanalytics_unique_visitor_days— plus the current and next month foranalytics_rollup_hourly. - For each missing partition:
CREATE TABLE IF NOT EXISTS … PARTITION OF …, then create its indexes, thenANALYZEit. - Fourteen days of runway means a full week of failed maintenance runs can pass before ingest is at risk. The job emits
analytics_partition_runway_daysper parent table; an alert fires below 7, and pages below 3. - The job is idempotent and safe to run concurrently: every statement uses
IF NOT EXISTSand the whole run holds an advisory lock (pg_advisory_lockon a fixed key) so two workers cannot race.
The visitor-day table gets the same 14-day runway as the event tables, not a shorter one. It is retained for the full plan window (Free 30 days, Pro 90 days, Business 24 months), so it is a long-lived partitioned table in exactly the same sense as raw events. Any maintenance sized for a 72-hour horizon would exhaust its runway in three days and send every new visitor-day into a missing partition — which is why the runway is stated once, here, for all three tables together.
6.4.3 Drop job and the honest retention story #
Retention differs per plan (Free 30 days, Pro 90 days, Business 24 months — the same horizon for click_events, page_view_events and analytics_unique_visitor_days), but a partition is shared by every workspace that had traffic that day. A partition can therefore only be dropped when the longest retention of any workspace with rows in it has elapsed. That is the honest constraint, and the design solves it with two mechanisms rather than pretending one is enough:
| Mechanism | When it runs | What it does | Cost |
|---|---|---|---|
| Per-partition delete | Nightly, analytics-retention-purge, 02:15 UTC |
For each partition older than 30 days, DELETE FROM <partition> WHERE workspace_id = ANY($1) where $1 is the batch of workspace ids whose retention has expired for that day. Batched at 20,000 rows per statement with a short sleep between batches, so no long lock is held. |
Real I/O and bloat. Mitigated by running VACUUM (ANALYZE) on the partition immediately after the last batch, and by the fact that the deleted set is exactly the Free-plan tail, which is the smallest share of volume. |
| Whole-partition drop | Same job, same run | When a partition's date is older than the global maximum retention (24 months), DETACH PARTITION CONCURRENTLY then DROP TABLE <partition>. Concurrent detach avoids taking an ACCESS EXCLUSIVE lock on the parent while ingest is running. Instant, no bloat, no vacuum. |
None. |
Consequences stated plainly:
- A Free workspace's raw rows are deleted at 30 days, but the partition holding them survives until the 24-month boundary because a Business workspace's rows share it. Storage does not fall to zero at 30 days; only the rows do.
- The same is true of
analytics_unique_visitor_days, which is why its retention is expressed as the same two mechanisms rather than as a single drop schedule. - Dashboards are unaffected: reach beyond the plan's retention is enforced at query time from
workspaces.current_plan_key, not by the presence or absence of rows. - Rollups are governed separately — Free 30 days, Pro 365 days, Business indefinite for both grains (6.8) — and are deleted row-wise on the daily table and by monthly partition drop on the hourly one.
- Daily partitions older than 90 days on the two raw event tables are merged into monthly partitions by a weekly job, one month per execution, verifying row counts before and after and aborting on mismatch. This takes a Business workspace's partition count from roughly 730 to roughly 90 daily plus 21 monthly over its 24-month horizon.
- If retention tiers ever need to be enforced by drop alone, the migration path is sub-partitioning by retention class — this is recorded as the known future option, not as work required now.
6.4.4 Query behaviour #
Every analytics query includes a bounded occurred_at range, so the planner prunes to the relevant partitions at plan time. enable_partition_pruning stays on. A query without a time bound is rejected by the data-access layer before it reaches the database — an unbounded scan of two years of partitions is not a slow query, it is an outage.
6.5 Rollup tables in detail #
6.5.1 Grain #
| Table | Grain |
|---|---|
analytics_rollup_hourly |
(workspace_id, resource_type, resource_id, bucket_start = UTC hour, dimension_type, dimension_value, metric_kind) |
analytics_rollup_daily |
(workspace_id, resource_type, resource_id, bucket_start = UTC midnight, dimension_type, dimension_value, metric_kind) |
Both grains use the column name bucket_start. Neither analytics rollup table has a bucket_date column; the grain is distinguished by the date_trunc check, not by a different name, so every query builder, export writer and reconciliation routine takes the grain as a parameter instead of branching on a column name.
The seventeen dimension types, stated once and referenced everywhere else:
dimension_type |
dimension_value |
Cardinality per resource | Source |
|---|---|---|---|
total |
Literal '*' |
1 | Every event |
country |
country_code — ISO 3166-1 alpha-2, or ZZ |
≤ 250 | Edge geo lookup |
region |
<country_code>-<region_code>, e.g. DE-BE, or ZZ |
≤ 4,000 realistic | Edge geo lookup |
device_type |
Closed vocabulary | 6 | UA parse |
os |
os_family value |
9 | UA parse |
browser |
browser_family value |
18 | UA parse |
referrer_host |
Host string, or '(none)' |
Unbounded — capped | Request |
channel |
Closed vocabulary | 13 | Derived |
utm_source / utm_medium / utm_campaign / utm_term / utm_content |
Verbatim value, or '(none)' |
Unbounded — capped | Query string |
variant |
Variant UUID | ≤ 8 | Section 16 |
block |
Block UUID | ≤ page block count | Bio page block clicks |
fallback_stage |
active / paused_fallback / workspace_unavailable / generic |
4 | Section 14 |
language |
Primary tag | ≤ 200 realistic | Accept-Language |
The dimension names are os and browser while the raw columns are os_family and browser_family: the dimension is the concept, the column is the storage. The mapping is one line of code and is stated here so nobody has to guess it.
is_bot is deliberately not a dimension type. Bot traffic is carried by the separate bot_events counter on every row, so bot-included and bot-excluded figures are both available from one row rather than from two. Adding is_bot to the grain would double the row count of the largest tables in the system to express something a second integer column already expresses.
Reserved dimension values. '*' (total), '(none)' (absent), '(other)' (folded after a cardinality cap), '(unknown)' (unresolvable). All four are rejected at the normalisation step if they arrive from user data, so a campaign literally named (other) cannot collide with the sentinel.
Unbounded-cardinality protection. referrer_host and the five UTM dimensions can be attacked, or exploded by accident when a UTM campaign carries a session id. Applied in the worker before the upsert: at most 500 distinct values per (workspace, resource, dimension type) per hourly bucket and 2,000 per daily bucket, tracked in a Redis set with a TTL of twice the bucket width; values beyond the cap are folded into '(other)', whose aggregate counts stay correct. Values are trimmed, lower-cased for referrer_host (UTM values keep their case, because campaign names are user-facing) and truncated to 255 characters. Exceeding a cap increments a metric and, on the third occurrence for a resource within a day, raises a workspace-visible notice naming the affected parameter.
One raw event produces one total row plus one row per applicable dimension, in two grains — at most 17 rows per grain, 34 upserts per event naively. Those upserts are aggregated in worker memory across the whole batch first, keyed by the full grain tuple, so a 1,000-event batch produces on the order of a few hundred statements rather than tens of thousands. On representative traffic the collapse ratio is roughly 0.75 upsert rows per event across both grains.
6.5.2 Upsert pattern #
Unique counts come from the RETURNING set of the visitor-day insert. Only the visitor-days that were genuinely new are counted, which is what makes the increment both exact and idempotent.
-- Step 1: claim the new visitor-days for this resource and day.
-- Resource grain only, per 6.3.47. One statement per (resource, day).
WITH new_visitor_days AS (
INSERT INTO analytics_unique_visitor_days
(workspace_id, resource_type, resource_id, event_date,
visitor_hash, first_seen_at, is_bot)
SELECT $1, $2, $3, $4, v.visitor_hash, v.first_seen_at, v.is_bot
FROM unnest($5::text[], $6::timestamptz[], $7::boolean[])
AS v(visitor_hash, first_seen_at, is_bot)
ON CONFLICT DO NOTHING
RETURNING date_trunc('hour', first_seen_at) AS first_seen_hour, is_bot
)
-- Step 2: the daily total row. unique_visitors is the count of rows step 1 produced.
INSERT INTO analytics_rollup_daily
(id, workspace_id, resource_type, resource_id, bucket_start,
dimension_type, dimension_value, metric_kind,
events, bot_events, unique_visitors, conversions, last_event_at)
VALUES
($8, $1, $2, $3, $4::timestamptz,
'total', '*', $9,
$10, $11,
(SELECT count(*) FROM new_visitor_days WHERE NOT is_bot),
$12, $13)
ON CONFLICT (workspace_id, resource_type, resource_id, bucket_start,
dimension_type, dimension_value, metric_kind)
DO UPDATE SET
events = analytics_rollup_daily.events + EXCLUDED.events,
bot_events = analytics_rollup_daily.bot_events + EXCLUDED.bot_events,
unique_visitors = coalesce(analytics_rollup_daily.unique_visitors, 0)
+ EXCLUDED.unique_visitors,
conversions = analytics_rollup_daily.conversions + EXCLUDED.conversions,
last_event_at = GREATEST(analytics_rollup_daily.last_event_at, EXCLUDED.last_event_at),
updated_at = now();Three things follow from this shape and are worth stating rather than leaving to be inferred:
| Point | Detail |
|---|---|
Non-total dimension rows use the same statement without the CTE |
unique_visitors is written NULL on them, which the ck constraint in 6.3.48 enforces. A dimension breakdown reports events; it does not report uniques (6.3.47). |
The hourly total row's unique_visitors comes from the same RETURNING set |
Grouped by first_seen_hour. Summed across a complete day it equals the daily figure exactly, because both are counts of the same rows. This is the only reason an hourly unique figure can exist at all against a day-grained dedupe set. |
| Everything is one transaction | Raw insert, visitor-day insert and both rollup upserts commit together, so there is no window in which raw and rollup disagree because of a partial write. |
Idempotency. Raw insertion is deduplicated by (occurred_at, id) and by ux_click_events_<part>_stream_msg (6.3.45), so a redelivered stream message contributes an empty RETURNING set and therefore increments nothing. The visitor-day insert is independently protected by its own ON CONFLICT DO NOTHING. If a batch fails after raw insertion but before the rollup upsert, the rollup is short until the nightly reconciliation corrects it — bounded, self-healing drift rather than permanent loss.
6.5.3 Nightly reconciliation #
analytics-reconcile, 03:30 UTC, per workspace with traffic in the window:
- For each of the last three complete UTC days, recompute every grain directly from
click_eventsandpage_view_eventswith a single grouped aggregate per day. - Recompute
unique_visitorsfor thetotalrows by countinganalytics_unique_visitor_daysrows for the grain, and the hourly split by grouping the same rows ondate_trunc('hour', first_seen_at). - Write corrections by absolute assignment —
SET events = $computed, unique_visitors = $computed— not by delta, then setreconciled_at = now(). - Delete rollup rows the recomputation did not produce. This cleans up double-counted grains left by a partially failed batch.
- Emit
analytics_reconcile_drift_ratioper day and grain. Sustained drift above 0.5% is an alert; above 5% pages the on-call engineer, because it indicates a broken ingest path rather than late data. - Recompute
links.click_count_cached,qr_codes.scan_count_cached,bio_pages.view_count_cachedandblocks.click_count_cachedfrom the reconciled daily rollups in the same run. - One (day, workspace) pair per transaction, ordered by workspace size ascending so small workspaces finish early, with a short pause every 50 chunks to keep replication lag bounded.
Why three days. The window is chosen from four concrete failure modes, and it is not derived from the lifetime of any dedupe table — analytics_unique_visitor_days is retained for the full plan window (Free 30 days, Pro 90 days, Business 24 months), so an exact recomputation of uniques is possible far outside the reconciliation window. The window is bounded by cost, not by data availability:
| # | What reconciliation repairs | What three days buys |
|---|---|---|
| 1 | Bot and user-agent reclassification. A signature-list or parser update changes how yesterday's events classify. | Reclassification runs within a day; three days covers it plus a failed run. |
| 2 | Privacy-driven raw deletions. A workspace deletion, an erasure request or an abuse takedown removes raw rows, and the rollups must stop counting them. | The erasure SLA is measured in days, and the rollup must follow the raw within it. |
| 3 | Clock-skewed events relocated. Events clamped or moved into their correct day after the fact would otherwise be rolled up under the wrong bucket forever. | Skew is detected at ingest; the correction lands the same night. |
| 4 | Operator intervention. A replay, a dead-letter drain or a partially applied migration leaves raw and rollup disagreeing with no other mechanism to notice. | Three days spans a full weekend, so a Friday-evening failure is repaired before anyone reads a report on Monday. |
Authority beyond the window. After reconciliation, rollups inside the window are exactly derivable from raw. Outside the window, rollups are authoritative and are never recomputed again. That is precisely what allows raw events to be purged on a shorter horizon than rollups on Pro and Business. An operator who genuinely needs an older day recomputed runs the job with an explicit range, which is a documented runbook step rather than a schedule.
6.5.4 Late-arriving events #
An event is late when occurred_at is more than 5 minutes before ingested_at. The handling is decided by whether the two things it needs — its raw partition and its rollup horizon — still exist.
| Event age at ingest | Handling |
|---|---|
| ≤ 5 minutes (normal) | Upserted into the current bucket as usual. |
| 5 minutes – 3 days | Written to raw and upserted into the historical bucket it belongs to. Charts for that bucket change; this is correct. The dashboard labels any bucket whose reconciled_at is null and whose bucket_start is older than 2 hours as "still settling". Nightly reconciliation then confirms it. |
| Older than 3 days, raw partition still present | Written to raw and upserted into its historical bucket exactly as above. The increment is correct without special handling because the upsert targets the event's own bucket. It falls outside the reconciliation window, so an operator who wants that day re-derived runs the job with an explicit range. analytics_late_events_total increments. |
| Raw partition already dropped, rollup horizon still open | The raw insert is not attempted. The event takes the rollup-only write path: the rollup upsert proceeds, and idempotency comes from analytics_rollup_only_events (6.3.70), written in the same transaction with ON CONFLICT DO NOTHING RETURNING so the increment applies only for genuinely new rows. Counted as raw-lost, rollup-counted. |
| Both raw and rollup horizons expired for that workspace | Discarded, counted. Storing data the customer cannot see, and is not entitled to see, serves nobody. |
| More than 5 minutes in the future | occurred_at is clamped to ingested_at and clock_skewed = true is set at enrichment. The flag makes the population visible to operators instead of hiding a broken edge clock inside normal traffic. |
6.6 Migration strategy #
Migrations are generated and applied with drizzle-kit against the schema definitions in the database package. The generated SQL is committed, reviewed and applied — never regenerated at deploy time from the TypeScript schema.
6.6.1 The one rule #
Every migration must leave the database compatible with the previously deployed application release. Deployments are rolling (and blue/green for the edge app), so at every moment there are two application versions talking to one database. A migration that breaks the older version breaks production during the rollout window, not after it.
6.6.2 Expand / contract #
| Phase | Release | Contains | Compatible with |
|---|---|---|---|
| Expand | N | Add the new column/table/index. Nullable or defaulted. Backfill in a batched job, not in the migration transaction. Dual-write from the application: write both old and new, read old. | Release N−1 (which ignores the new column). |
| Migrate reads | N+1 | Read from the new shape. Keep dual-writing. | Release N. |
| Contract | N+2 | Stop writing the old shape, then drop it: drop the column, drop the constraint, drop the index. | Release N+1. |
A destructive change is therefore always three releases, never one. A column is never renamed: it is added, backfilled, cut over, and the old one dropped.
6.6.3 Naming and ordering #
- File name:
NNNN_<verb>_<subject>.sql, zero-padded and strictly increasing —0042_add_links_deep_link.sql,0043_backfill_links_deep_link.sql,0044_drop_links_legacy_app_url.sql. - Ordering is the numeric prefix. Merge conflicts on the number are resolved by renumbering the later branch before merge; two migrations with the same number fail CI.
- Every migration file starts with a comment block stating: the expand/contract phase, the release it pairs with, whether it is reversible, and the estimated lock duration.
- Applied migrations are tracked in drizzle-kit's own journal table. Migrations run as a dedicated
app_migraterole; the application roles (app_rw,app_ro) have no DDL privileges.
6.6.4 Non-negotiable safety rules #
| Rule | Reason |
|---|---|
CREATE INDEX CONCURRENTLY on any table with more than 100,000 rows, in its own migration file with no transaction wrapper. |
A plain CREATE INDEX takes an ACCESS EXCLUSIVE-adjacent write lock for the duration. |
ALTER TABLE … ADD COLUMN must be nullable or carry a constant default. |
PostgreSQL rewrites the table for a volatile default. |
ADD CONSTRAINT … NOT VALID first, VALIDATE CONSTRAINT in a second migration. |
Validation scans the table under a lock; splitting it keeps each lock short. |
SET lock_timeout = '3s' and SET statement_timeout = '5min' at the top of every migration. |
A migration blocked behind a long transaction must fail fast rather than queue behind itself and stall every writer. |
| Backfills run as batched jobs (10,000 rows per transaction, sleep between batches) driven by a migration-companion script, never inside the migration. | A single-transaction backfill of a large table holds locks and bloats WAL. |
No DROP statement may appear in the same release as the code change that stops using the dropped object. |
This is the contract rule restated as a reviewable checklist item. |
| Partition creation is never a migration. | Partitions are created by the maintenance job in 6.4.2. |
6.6.5 Verification #
Every migration is applied in CI against a Testcontainers PostgreSQL instance seeded with a representative dataset, followed by: the previous release's integration test suite (proving backward compatibility), the current release's suite, and a schema-invariant test that asserts the rules of 6.10 — no cascade path into qr_slug_reservations, no UPDATE/DELETE grant on audit_log_entries for application roles, and every workspace_id column indexed.
6.7 Seed data #
Seeds are idempotent (ON CONFLICT DO NOTHING or DO UPDATE on the natural key) and split into two sets: required (runs in every environment including production) and fixtures (local and preview only, never staging or production).
6.7.1 Required seeds #
| Target | Content |
|---|---|
plans |
Exactly three rows — free, pro, business — with the prices and the entitlement payloads defined by the plan table in Section 22. Re-running the seed updates the entitlement payload of existing rows so a plan change ships as a seed, not a hand-written migration. |
custom_domains |
Three system rows, is_system = true, workspace_id = NULL, status = 'active': linkhub.app (pages), go.linkhub.app (links and QR), lnkhb.co (links, default short domain). |
reserved_slugs |
~400 rows across four categories: system (api, app, admin, dashboard, login, signup, signin, logout, settings, billing, support, help, docs, status, blog, about, legal, privacy, terms, security, abuse, dmca, static, assets, cdn, img, favicon.ico, robots.txt, sitemap.xml, .well-known, _next, qr, r, go, l, s, u, w, v1, v2, oauth, callback, webhook, webhooks, health, metrics, test); profanity (a standard English profanity list plus the top 5 additional languages by traffic); brand (major platform and payment brand names, to block impersonation); confusable (regex entries catching rn→m, l/I/1, 0/O substitutions of system words). |
bot_signatures |
~120 rows: the well-known crawler user agents (search engines, social preview fetchers, uptime monitors, feed readers, AI crawlers), the `bot |
themes |
Six system presets (is_system = true, workspace_id = NULL): Minimal Light, Minimal Dark, Soft Gradient, Bold Contrast, Editorial Serif, Neon Night. Every preset is authored to pass 4.5:1 on every token pair out of the box, and the seed asserts this by running the same contrast check the theme editor uses; a failing preset fails the seed. |
fonts |
Seven rows: system-sans and system-serif (source system, no download), plus five hosted families covering sans, serif, slab, display and monospace, each with weights 400 and 700 and the latin and latin-ext subsets. |
feature_flags |
The flags the first release ships behind: qr_advanced_styling, ab_force_promote, lead_sync_convertkit, analytics_hourly_view, public_api_write. All default off except analytics_hourly_view. |
6.7.2 Development fixture set #
One command (pnpm db:seed:dev) produces a workspace that exercises every surface:
- Users.
owner@example.test(Owner, verified, 2FA enabled),admin@example.test,editor@example.test,viewer@example.test,scoped@example.test(scoped Editor with grants on one page and one link),unverified@example.test. Password for all:LinkHubDev!2026. - Workspaces.
acme(Business, active),solo-creator(Free),agency-client-2(Pro, past_due) — so plan-gating, seat limits and dunning states are all reachable locally. - Domains.
go.acme.cominactive,acme.linkinpending_dnswith a deliberately wrong observed TXT value so the diagnostic panel has something to show. - Content. 3 bio pages (published, draft, password-protected) with 14 blocks covering every block type in the Section 10 catalogue; 40 links including one scheduled, one expired, one split test with 3 destinations, one with 4 targeting rules, one flagged by reputation; 12 QR codes including one paused with a fallback, one memorialised, one with a logo overlay at maximum size.
- Experiments. One running page experiment past the guard, one running link experiment below the guard (so the disabled "Promote winner" state is visible), one promoted.
- Analytics. 90 days of synthetic
click_eventsandpage_view_events— roughly 250,000 rows with realistic diurnal and weekly shape, a bot share of 12%, 30 countries, and rollups built by running the real ingest and reconciliation jobs rather than by inserting rollup rows directly. This guarantees the fixtures exercise the same code path production does. - Leads. 120 leads across two forms, one Mailchimp target in a failing state with dead-lettered attempts.
- Operational. 3 dead-lettered webhooks, 2 dead-lettered jobs, 1 open abuse report, 1 completed data export, 1 in-grace deletion request.
6.8 Data retention and purge matrix #
All rules are enforced by named workers. retention-purge runs nightly at 02:15 UTC; analytics-retention-purge is the partition-aware variant described in 6.4.3.
| Table | Retention rule | Enforced by |
|---|---|---|
users |
Anonymized 30 days after deletion_requested_at; row retained for referential integrity. |
account-deletion |
user_identities, totp_secrets, recovery_codes |
Deleted with the user's anonymization. | account-deletion |
sessions |
Hard-deleted 7 days after absolute_expires_at or revoked_at. |
retention-purge |
email_verification_tokens |
30 days after expires_at. |
retention-purge |
password_reset_tokens, magic_link_tokens |
7 days / 24 hours after expires_at. |
retention-purge |
email_change_requests |
90 days after resolution (security artefact). | retention-purge |
auth_attempts |
90 days. | retention-purge |
workspaces |
Soft-deleted rows purged at purge_after (deleted_at + 30 days). |
workspace-purge |
workspace_members |
Purged with the workspace, or 30 days after removal. | workspace-purge |
workspace_invitations |
pending → expired at expires_at; rows deleted 90 days after resolution. |
invitation-expire |
resource_grants |
Deleted with the member or the resource; orphans swept nightly. | workspace-purge, integrity-check |
audit_log_entries |
Free 30 days, Pro 365 days, Business indefinite. The purge statement is WHERE retention_expires_at IS NOT NULL AND retention_expires_at < now(), run only by the app_retention role, and the same predicate is enforced by the row trigger (6.3.15). Entries written with retention_expires_at = NULL — every QR event key, and the erasure events — are therefore permanent on every plan. |
audit-retention |
plans |
Never deleted. | — |
billing_accounts |
Retained 7 years after the workspace is purged, with workspace_id detached (financial record). |
— |
workspace_resource_counters |
Deleted with the workspace. Recomputed nightly by counter-recount, never purged independently. |
workspace-purge |
subscriptions, subscription_items |
Retained 7 years after cancellation (financial record). | — |
invoices, payment_events |
Retained 7 years / 24 months. | retention-purge |
entitlement_overrides |
Deleted 30 days after expires_at. |
retention-purge |
custom_domains |
Deleted with the workspace, unless a QR reservation references the domain, in which case the domain row is retained in suspended state so the reservation's foreign key holds. |
workspace-purge |
domain_verification_attempts |
90 days. | retention-purge |
tls_certificates |
Expired certificates retained 90 days, then deleted; secret-store material deleted immediately on expiry. | tls-renew |
bio_pages, blocks, links, qr_codes, themes, uploaded_assets |
Soft-deleted rows purged at purge_after (30 days). Asset bytes deleted from object storage in the same run via the outbox in 6.10. |
retention-purge |
bio_page_versions, link_versions |
Last 50 per parent, plus every version that was ever published or promoted. | version-prune |
block_versions |
Last 20 per block. | version-prune |
qr_code_versions |
Never pruned while the QR code exists. The destination history of a printed code is evidence. | — |
qr_render_artifacts |
Non-current versions' artifacts deleted after 180 days; regenerated on demand. | retention-purge |
qr_slug_reservations |
Permanent. Exempt from every retention rule, every cascade, every purge job, and every erasure request. No worker has DELETE privilege on this table. Erasure sets erasure_applied_at and strips personal data from the memorial page; the slug keeps resolving. |
— (deliberately none) |
experiments, experiment_variants |
Retained with the subject; deleted with the workspace. | workspace-purge |
experiment_assignments_rollup, experiment_results |
Experiment lifetime + 365 days. | retention-purge |
click_events, page_view_events |
Per plan: Free 30 days, Pro 90 days, Business 24 months. Row delete for expired workspaces; partition drop at the 24-month global maximum (6.4.3). | analytics-retention-purge |
analytics_unique_visitor_days |
Per plan, the same horizon as raw events: Free 30 days, Pro 90 days, Business 24 months. Row delete for expired workspaces; whole-partition drop at the 24-month global maximum (6.4.3). It is not a short-lived table. | analytics-retention-purge |
analytics_rollup_hourly |
Free 30 days, Pro 365 days, Business indefinite. This is the authoritative horizon for this table; no other section sets a different one. Deleted by monthly partition drop once every workspace with rows in the partition has passed its horizon. | analytics-retention-purge |
analytics_rollup_daily |
Free 30 days, Pro 365 days, Business indefinite. Deleted row-wise, since the table is small. | analytics-retention-purge |
analytics_rollup_only_events |
Deleted when the rollup rows the key protects are deleted — same horizon as analytics_rollup_daily. |
analytics-retention-purge |
analytics_share_links |
Row deleted 90 days after expires_at or revoked_at, whichever is earlier. The share stops resolving the moment either is passed; the row is kept briefly so the audit trail and the view count remain readable. |
retention-purge |
ua_parse_corpus |
Rows whose last_seen_on is more than 400 days old are deleted. Never deleted per workspace, because it holds no workspace key — it is not reachable by a workspace purge or an erasure request, and it does not need to be (6.3.71). |
retention-purge |
bot_signatures |
Never deleted; deactivated instead. | — |
leads |
Retained until the workspace is deleted or the lead is erased. Soft-deleted rows purged after 30 days. | retention-purge |
lead_sync_targets |
Deleted with the workspace. | workspace-purge |
lead_sync_attempts |
90 days. | retention-purge |
integrations, integration_credentials |
Deleted with the workspace; secret-store material revoked in the same run. | workspace-purge |
webhook_deliveries |
30 days after resolution. | retention-purge |
webhook_dead_letters |
30 days after failed_at. |
retention-purge |
api_keys |
Soft-deleted rows purged 90 days after revocation. | retention-purge |
api_key_usage |
400 days. | retention-purge |
rate_limit_violations |
90 days. | retention-purge |
consent_records |
6 months (matches the consent cookie), then deleted. | retention-purge |
data_export_requests |
Archive deleted at expires_at (24h); the request row retained 2 years. |
export-expire |
data_deletion_requests |
7 years — the record that a legal obligation was performed. | — |
safe_browsing_checks |
180 days after checked_at, then re-fetched on demand. |
retention-purge |
abuse_reports |
3 years. | retention-purge |
reserved_slugs, feature_flags |
Never deleted. | — |
idempotency_keys |
Hard-deleted at expires_at (24h), swept hourly. |
retention-purge |
jobs_dead_letter |
90 days. | retention-purge |
6.9 Query patterns and performance notes #
The twelve queries that determine whether the system meets its budgets. Each is expected to be a single index probe or a bounded index scan; the CI performance test in Section 26 asserts the plan node type for each.
| # | Query | Expected plan | Supporting index |
|---|---|---|---|
| 1 | Redirect resolve — link by host and slug on a Redis miss | Index Scan (unique), 1 row | ux_links_domain_slug |
| 2 | QR resolve — code by host and slug, then reservation fallback | Index Scan (unique), 1 row; fallback Index Scan on ux_qr_slug_reservations_domain_slug |
ux_qr_codes_domain_slug, ux_qr_slug_reservations_domain_slug |
| 3 | Bio page render — page by host and handle, then its published version by id | Two Index Scans (unique), 1 row each | ux_bio_pages_domain_handle, pk_bio_page_versions |
| 4 | Authorisation check — role and scoping for (workspace, user) | Index Scan (unique), 1 row | ux_workspace_members_ws_user |
| 5 | Session resolve on a Redis miss | Index Scan (unique), 1 row | ux_sessions_token_hash |
| 6 | Dashboard link list — workspace, not deleted, keyset by (created_at, id) desc, limit 26 |
Index Scan Backward, ≤26 rows, no sort node | ix_links_ws_created |
| 7 | Workspace analytics chart — daily rollup, dimension_type='total', 90-day range |
Index Only Scan, ≤90 rows | ix_rollup_daily_ws_time with INCLUDE (events, unique_visitors) |
| 8 | Per-resource dimension breakdown — top 20 countries for one link over 30 days | Index Scan + partial sort, ≤ a few hundred rows | ix_rollup_daily_resource_time |
| 9 | Raw drill-down / CSV export — one workspace, bounded time range | Partition pruning to N daily partitions + local Index Scan per partition | ix_click_events_<part>_ws_time |
| 11 | Unique visitors for a resource over a range — sum of daily uniques | Partition pruning + Index Only Scan on the primary key | pk_analytics_unique_visitor_days |
| 12 | Cap check on create — one counter row, locked | Index Scan (unique), 1 row, FOR UPDATE |
pk_workspace_resource_counters |
| 10 | Audit log page — workspace, optional event filter, keyset by occurred_at desc, limit 26 |
Index Scan Backward, ≤26 rows | ix_audit_ws_time / ix_audit_ws_event_time |
Rules that keep these plans stable.
- Keyset pagination only.
WHERE (created_at, id) < ($cursor_ts, $cursor_id) ORDER BY created_at DESC, id DESC LIMIT $n+1.OFFSETnever appears in application code — it degrades linearly and produces duplicates under concurrent inserts. The+1row is what populatesmeta.has_more. - No
count(*)on unbounded collections.meta.totalis present only where the set is inherently small (workspace members, domains, API keys, plans). - Every analytics query is time-bounded before it reaches the database (6.4.4).
workspace_idleads every composite index on a tenant table, so the tenancy predicate is always the leading key rather than a filter.- Denormalised counters (
click_count_cachedand its siblings) are written only by the rollup worker. The redirect and render paths never write to a content table — that is what keeps p95 under 50 ms. pg_stat_statementsis enabled and the top 20 statements by total time are reviewed each release (Section 25). A statement whose mean time regresses more than 50% between releases is a release blocker.
6.10 Referential integrity and the cascade map #
6.10.1 Deleting a workspace, in order #
Workspace deletion is soft (deleted_at set, 30-day restore window). At purge_after, workspace-purge runs the following in one transaction per stage, in this exact order:
| Stage | Action |
|---|---|
| 1 | Freeze. Set workspaces.memorial_display_name = name. Invalidate every Redis entry under rd:* for the workspace's hosts and sess:* entries whose active_workspace_id matches. |
| 2 | Memorialise QR codes. For every qr_codes row: set status='memorial', memorialized_at=now(); on the matching qr_slug_reservations row set state='memorial', memorialized_at=now(), workspace_display_name_last_known, fallback_url and last_destination_url frozen from the QR. This stage must complete before any delete happens. If it fails, the purge aborts and pages the on-call engineer — deleting a workspace whose codes have not been memorialised would break printed material. |
| 3 | Retain domains that back reservations. Any custom_domains row referenced by a reservation is set to status='suspended', workspace_id left in place, and excluded from stage 6. |
| 4 | Queue object-storage deletions. Insert one row per uploaded_assets record into the storage-deletion outbox (a jobs_dead_letter-adjacent queue job), so bytes are removed after the database transaction commits rather than inside it. |
| 5 | Revoke external state and detach financial records. Revoke integration OAuth grants, delete secret-store entries for integration_credentials and tls_certificates, revoke API keys. In the same transaction, detach billing_accounts by setting workspace_id = NULL before the cascade can reach it — it is a seven-year financial record and must survive the workspace (6.8). |
| 6 | Delete the workspace row. A single DELETE FROM workspaces WHERE id = $1 then cascades, in dependency order determined by PostgreSQL, to every table carrying a workspace_id foreign key: workspace_members → resource_grants; workspace_invitations; audit_log_entries; workspace_resource_counters; subscriptions → subscription_items; invoices; entitlement_overrides; custom_domains (only those not retained in stage 3) → domain_verification_attempts, tls_certificates; bio_pages → bio_page_versions, blocks → block_versions; themes; fonts; uploaded_assets; links → link_versions, link_destinations, link_rules; utm_presets; qr_codes → qr_code_versions → qr_render_artifacts; experiments → experiment_variants, experiment_assignments_rollup, experiment_results; leads → lead_sync_attempts; lead_sync_targets; integrations → integration_credentials; webhook_deliveries → webhook_dead_letters; api_keys → api_key_usage, idempotency_keys; consent_records; data_export_requests; analytics_share_links. |
| 7 | Row-delete analytics. click_events, page_view_events, analytics_unique_visitor_days, analytics_rollup_hourly, analytics_rollup_daily, analytics_rollup_only_events and experiment_assignments_rollup carry no foreign key, so they are deleted by batched DELETE … WHERE workspace_id = $1 per partition, 20,000 rows at a time, followed by VACUUM (ANALYZE). ua_parse_corpus is not in this list and needs no entry: it holds no workspace key, which is the whole point of its design (6.3.71). |
| 8 | Preserve. payment_events, data_deletion_requests, abuse_reports and jobs_dead_letter have workspace_id nulled by ON DELETE SET NULL and are retained per 6.8. billing_accounts was detached in stage 5 for the same reason. |
| 9 | Report. Write per-table counts into data_deletion_requests.report, set qr_carve_out_applied = true, and emit workspace_purge_completed. |
The complete cascade map, generated from the table list in 6.3. Every table in this schema falls into exactly one of five classes, and a table appearing in none of them is a bug the schema-invariant test in 6.6.5 fails the build on:
The subject of the delete, workspaces itself, is not in the table because it is the row being deleted rather than a dependant of it. Everything else is:
| Class | Tables | Behaviour on workspace delete |
|---|---|---|
Cascade — has workspace_id NOT NULL with ON DELETE CASCADE |
workspace_members, resource_grants, workspace_invitations, audit_log_entries, workspace_resource_counters, subscriptions, subscription_items, invoices, entitlement_overrides, bio_pages, bio_page_versions, blocks, block_versions, themes, fonts, uploaded_assets, links, link_versions, link_destinations, link_rules, utm_presets, qr_codes, qr_code_versions, qr_render_artifacts, experiments, experiment_variants, experiment_assignments_rollup, experiment_results, leads, lead_sync_targets, lead_sync_attempts, integrations, integration_credentials, webhook_deliveries, webhook_dead_letters, api_keys, api_key_usage, idempotency_keys, consent_records, data_export_requests, analytics_share_links, rate_limit_violations |
Deleted by the database in stage 6. |
| Conditional cascade | custom_domains (+ domain_verification_attempts, tls_certificates) |
Deleted unless a QR reservation references the host, in which case the row is retained suspended (stage 3). |
Detach and retain — ON DELETE SET NULL |
billing_accounts, payment_events, data_deletion_requests, abuse_reports, jobs_dead_letter |
workspace_id nulled; row retained per 6.8. |
| No foreign key, deleted by batched statement | click_events, page_view_events, analytics_unique_visitor_days, analytics_rollup_hourly, analytics_rollup_daily, analytics_rollup_only_events |
Stage 7. |
| Global, not workspace-scoped at all | users, user_identities, sessions, email_verification_tokens, password_reset_tokens, magic_link_tokens, email_change_requests, totp_secrets, recovery_codes, auth_attempts, plans, bot_signatures, ua_parse_corpus, safe_browsing_checks, reserved_slugs, feature_flags |
Untouched. None of them carries a workspace_id. |
And one table in a class of its own: qr_slug_reservations. It appears in no row above and is deliberately absent from every cascade, every batched delete and every retention job. Nothing points at it with ON DELETE CASCADE; qr_codes.slug_reservation_id is ON DELETE RESTRICT; DELETE and TRUNCATE are revoked from app_rw and app_retention; and a BEFORE DELETE trigger raises unconditionally. Four independent mechanisms, because a printed symbol has to keep resolving after everything else about the account is gone.
6.10.2 What is deliberately NOT cascaded #
| Object | Behaviour | Why |
|---|---|---|
qr_slug_reservations |
Never deleted. qr_codes.slug_reservation_id is ON DELETE RESTRICT, so the child cannot pull the parent down; the reservation's own BEFORE DELETE trigger raises unconditionally; DELETE and TRUNCATE are revoked from every application and retention role. |
A QR code printed on packaging must resolve after the account is gone. This is the product's single strongest guarantee and it is enforced in four independent places rather than one. |
custom_domains backing a reservation |
Retained in suspended state. |
A dangling domain_id would break the reservation's foreign key and therefore the resolution path. |
qr_code_versions |
Not pruned while the QR exists. | Destination history for printed material is evidence. |
audit_log_entries actor references |
ON DELETE SET NULL, with actor_label frozen as text. |
The log must stay readable after an account is anonymized. |
payment_events, invoices, subscriptions |
Financial records retained per 6.8; payment_events.workspace_id nulled rather than cascaded. |
Statutory retention outlives the customer relationship. |
data_deletion_requests |
workspace_id nulled, row retained 7 years. |
Proof that an erasure was performed. |
leads when a block or page is deleted |
ON DELETE SET NULL on block_id and bio_page_id. |
Deleting a form must never destroy collected contacts. |
links bound to a QR code |
ON DELETE RESTRICT on qr_codes.destination_link_id, plus links.qr_pinned_count > 0. |
Deleting the link a printed code points at would break it; the user must repoint the QR first, and the API returns 409 link_bound_to_qr. The same column also keeps the link out of archival and out of the plan's link cap (6.3.32). |
ua_parse_corpus |
Not deleted by a workspace purge and not reachable by an erasure request. | It carries no workspace key and no visitor key, so there is nothing in it to attribute to the departing customer. Adding a workspace_id to make it "purgeable" would create exactly the linkability the table exists to avoid. |
billing_accounts |
Detached (workspace_id nulled) in stage 5, before the cascade runs. |
Seven-year statutory retention outlives the customer relationship, and the detach must happen before the delete rather than being repaired afterwards. |
blocks.link_id |
ON DELETE SET NULL. |
The block degrades to a plain URL rather than vanishing from the page. |
6.10.3 Polymorphic references and how they stay honest #
resource_grants.resource_id, experiments.subject_id, audit_log_entries.resource_id and abuse_reports.resource_id are polymorphic and therefore not foreign keys. Integrity is maintained by three mechanisms, stated so nobody mistakes the absence of a constraint for an absence of a rule:
- Transactional cleanup. The purge job that removes a bio page, link or QR code deletes the matching
resource_grantsrows and archives the matchingexperimentsin the same transaction. - Nightly
integrity-checkjob. Deletes orphanedresource_grants, sets orphanedexperimentstoarchived, and emitsreferential_orphans_total{table}. Any non-zero value onresource_grantsis an alert, because an orphan grant is a latent authorisation bug. - Read-side safety. Every list query joins from the resource table to grants, never the reverse, so an orphan grant can never cause a resource to appear.
audit_log_entriesandabuse_reportsintentionally tolerate orphans — they describe things that no longer exist, which is their purpose.
7. Authentication, Sessions & Account Management #
Authentication is built on better-auth with the customisations specified below. Where this section states a value that differs from a library default, the library is configured to the value stated here; the value stated here wins.
7.1 Supported methods and the reasoning #
| Method | Status | Reasoning |
|---|---|---|
| Email + password | Supported | The baseline every user can use without depending on a third party. Argon2id with the parameters in 7.3. |
| Magic link | Supported | Removes the password from the common path for creators who sign in from a phone. Same account, no separate identity — a magic link signs the user into their existing account or creates one. |
| Google OAuth | Supported | The dominant identity provider for the target segments, and the fastest path through registration. |
| TOTP 2FA | Supported, optional for all, enforceable per workspace on Business | A shared secret in an authenticator app needs no vendor, no phone number and no network. |
| Recovery codes | Supported | Ten single-use codes; the only self-service path back into an account with 2FA. |
| SMS OTP | Not supported | SIM-swap attacks make it weaker than the password it protects, and it adds per-message cost and international deliverability problems for no security gain. |
| Passkeys / WebAuthn | Roadmap, not in this release | Genuinely better than TOTP, but it needs a full lost-device recovery design that would compete with the delivery of the core product. Recorded as a roadmap item so nobody mistakes its absence for an oversight. |
| SAML / enterprise SSO | Out of scope | Stated in Section 2. |
Account model. One users row per email address. Password, Google identity and magic link are three ways into the same account, never three accounts. A user may have all three simultaneously.
7.2 Registration flow #
POST /auth/register with { email, password, name?, marketing_opt_in? }.
| Step | Rule |
|---|---|
| 1 | Normalise email: trim, lower-case (the column is citext, so comparison is case-insensitive regardless), reject anything failing RFC 5322 shape or longer than 254 characters → 400 invalid_email. |
| 2 | Reject disposable-domain addresses from a maintained blocklist → 400 email_domain_not_allowed. The message names the policy, not the list. |
| 3 | Validate the password against 7.3 → 400 password_too_weak with details[].issue naming the specific failure (too_short, low_entropy, breached). |
| 4 | Validate name ≤ 100 chars → 400 validation_failed. |
| 5 | Rate-limit: 5 registrations per hour per IP, 3 per hour per email domain for free-mail domains → 429 rate_limited. |
| 6 | If the email already exists: return 201 with the identical body and timing as a successful registration, send the "account already exists" email described in 7.12 instead of the verification email, and create nothing. Enumeration resistance (7.14) is not negotiable at the most-probed endpoint in the product. |
| 7 | Otherwise, in one transaction: insert users (status active, email_verified_at null), insert a personal workspace (name = name or the email local part, slug derived and de-duplicated with a numeric suffix, plan free), insert workspace_members with role owner, set workspaces.owner_user_id. |
| 8 | Issue an email_verification_tokens row (24h) and send the verification email. |
| 9 | Create a session (7.8) and set the cookie — the user lands in the dashboard immediately, in the unverified state described in 7.4. |
| 10 | Write auth_attempts (method='password', outcome='success') and the audit entry workspace.created. |
Response: 201 with { "data": { "user": { "id", "email", "name", "email_verified": false }, "workspace": { "id", "slug", "name" } }, "meta": {} }.
7.3 Password rules, hashing and the breach check #
| Rule | Value |
|---|---|
| Minimum length | 12 characters. No maximum below 256; the hash is computed over the raw bytes, and truncation is never applied. |
| Composition rules | None. No forced symbol, digit or mixed case — composition rules push users toward predictable patterns. Strength is measured, not prescribed. |
| Strength | zxcvbn-class estimator score ≥ 3, computed server-side (the client-side meter is advisory only). The estimator's user-specific dictionary includes the email, the local part, the display name and the workspace name. |
| Breach check | HIBP range API using k-anonymity: SHA-1 the password, send the first 5 hex characters only, match the remainder locally. The full hash never leaves the server. |
| Threshold | Any appearance in the breach corpus rejects the password. Not "more than N" — a password in the corpus at all is in an attacker's wordlist. |
| Hashing | Argon2id, m = 19456 KiB, t = 2, p = 1, 16-byte random salt, 32-byte output. |
| Rehash on login | If password_params differs from the current configuration, the password is rehashed transparently during a successful login. |
| Reuse | The new password may not equal the current one on change or reset → 400 password_reused. No longer history is kept: storing old hashes to compare against is a liability that buys little. |
When the breach API is unavailable. The call has a 1,200 ms timeout and one retry with a 200 ms backoff. On failure:
- The registration or password change proceeds — a third-party outage must not stop account creation.
- The event is counted (
hibp_check_unavailable_total) and logged atwarn. Sustained failure over 15 minutes raises an alert. - The user record is marked with
password_params.breach_check = 'deferred'. - A queued job re-checks deferred passwords when the API recovers. If a deferred password turns out to be breached, the user is emailed and prompted to change it at next sign-in with a non-dismissible banner; the account is not locked, because locking a user out over a third-party lookup they cannot see is worse than the risk it mitigates.
7.4 Email verification #
| Aspect | Value |
|---|---|
| Token | 256 bits from a CSPRNG, base64url-encoded (43 chars). Stored as SHA-256 in email_verification_tokens.token_hash. The plaintext exists only in the email. |
| Link | https://app.linkhub.app/verify-email?token=<token> |
| Lifetime | 24 hours. Expired → 410 token_expired, with a one-click resend on the landing page. |
| Single use | consumed_at set in the same transaction that sets users.email_verified_at. Reuse → 410 token_already_used. |
| Address pinning | The token stores the address it was issued for. If the user's email changed since issue, the token is rejected with 409 token_email_mismatch. |
| Resend throttling | 60-second cooldown between sends; maximum 5 sends per token row; maximum 10 verification emails per address per 24 hours. Exceeded → 429 rate_limited with Retry-After. |
| Resend response | Always 202 with an identical body regardless of whether the address exists or is already verified. |
Actions blocked until the email is verified. The account is usable; anything that puts content in front of the public, sends outbound mail, or costs money is blocked.
| Blocked | Error |
|---|---|
| Publishing a bio page | 403 email_verification_required |
| Creating or activating a short link | 403 email_verification_required |
| Creating a QR code | 403 email_verification_required |
| Adding a custom domain | 403 email_verification_required |
| Sending a workspace invitation | 403 email_verification_required |
| Creating an API key | 403 email_verification_required |
| Starting a paid subscription | 403 email_verification_required |
| Configuring an outbound webhook or ESP sync | 403 email_verification_required |
| Accepting an invitation to another workspace | Allowed — accepting an emailed invitation proves control of the address, and the accept flow sets email_verified_at if it is null. |
Everything else — creating drafts, editing content, changing settings, browsing analytics — works. The dashboard shows a persistent, dismissible-per-session banner with a resend button and the exact list above.
7.5 Login flow #
POST /auth/login with { email, password }.
- Normalise the email. Look up the user. Whether or not a user is found, an Argon2id verification is performed — against the stored hash, or against a fixed dummy hash of the same parameters. This equalises timing (7.14).
- If
users.locked_until > now()→ 429account_lockedwithRetry-Afterset to the remaining seconds. This response is returned only for genuinely locked accounts; a non-existent account never produces it, because that would be an oracle. Instead, the throttle for unknown accounts is applied by IP and returns the genericinvalid_credentialsuntil the IP limit trips. - On password mismatch: increment the per-account and per-IP counters, write
auth_attempts, return 401invalid_credentials. - On the 5th consecutive failure within 15 minutes for one account: set
locked_until = now() + 15 minutes, reset the counter, send the "account temporarily locked" email (7.12). - On success: reset
failed_login_countandlocked_until; ifusers.totp_enabled_atis set or the target workspace requires TOTP, create the session withmfa_satisfied_atnull and return200with{ "data": { "mfa_required": true, "challenge_token": "…" } }; the session grants no data access until the challenge is satisfied. - Otherwise complete the session (7.8), update
last_login_atandlast_login_ip_country, and return the user and the active workspace.
Rate limits.
| Scope | Limit | Action on breach |
|---|---|---|
| Failed logins per account | 5 per 15 minutes | 15-minute lockout, email sent |
| Login attempts per IP | 20 per hour | 429 rate_limited, Retry-After |
| Login attempts per IP across all accounts | 100 per hour | 429 and a rate_limit_violations row; sustained breach adds the IP to a 24-hour block |
| Total lockouts per account | 3 within 24 hours | Lockout extends to 2 hours and the "unusual activity" email is sent |
Counters live in Redis (rl:auth-login:<scope>:<key>, sliding window) and are mirrored durably in auth_attempts, so a Redis restart cannot reset a lockout.
7.6 Magic-link sign-in #
POST /auth/magic-link with { email, redirect_path? }.
| Aspect | Value |
|---|---|
| Token | 256 bits, base64url, SHA-256 stored. |
| Lifetime | 15 minutes. Short, because the link is a bearer credential sitting in an inbox. |
| Single use | consumed_at set atomically with session creation, using UPDATE … WHERE consumed_at IS NULL RETURNING so two concurrent clicks cannot both succeed. |
| Unknown address | A row is still inserted with user_id = NULL, the request takes the same time, and the response is identical. The email actually sent is the "no account for this address" message in 7.12. |
| Response | Always 202 with { "data": { "sent": true }, "meta": {} }. |
| Throttle | 3 per address per 15 minutes; 10 per IP per hour. |
| Redirect | redirect_path must start with / and be ≤ 512 chars; anything else is discarded silently and the user lands on the dashboard. Absolute URLs are never accepted — that is an open redirect. |
| Consumption | GET /auth/magic-link/callback?token=… verifies, creates the user if user_id is null and the address is new (registering them exactly as 7.2 step 7 would, with email_verified_at set, because clicking the link proved control), creates the session, and redirects. |
| 2FA | If the account has TOTP, the magic link satisfies the first factor only; the TOTP challenge still applies. A magic link is not a bypass. |
Security notes. The email contains the requesting country and browser family so a recipient who did not request it can recognise that. Every send is written to auth_attempts. Links are never logged: the token is stripped from access logs by the redaction rule in Section 25, and the callback immediately redirects to a token-free URL so the token does not persist in browser history or leak through the Referer header.
7.7 Google OAuth #
| Aspect | Value |
|---|---|
| Flow | Authorization Code with PKCE. state is a signed, single-use, 10-minute nonce bound to the browser by a short-lived cookie. |
| Scopes | openid, email, profile. Nothing else. No Drive, no Contacts, no offline access — the product has no use for them, and requesting them would tank the consent-screen conversion. |
| Refresh tokens | Not requested. Google identity is used for authentication only, so there is nothing to refresh. |
| Identity key | The provider sub, stored in user_identities.provider_account_id. Never the email — Google emails can change; sub cannot. |
| Unverified provider email | If email_verified is false in the token, the flow is rejected with 403 oauth_email_unverified. |
Account resolution, in order:
(provider, sub)matches an existing identity → sign that user in. If Google's email differs from the storedprovider_email, update it, but do not touchusers.email.- No identity, but the Google email matches an existing account — this is the case that matters:
- If the existing account has no password and no other identity, link automatically and sign in. There is no credential to protect.
- If the existing account has a password or any other identity, do not link automatically. Automatic linking on a provider-asserted email is a well-known account-takeover vector. Instead: return the user to a page saying "An account already exists for this address. Sign in to link Google.", require a successful password (and TOTP, if enabled) sign-in, and then complete the link with an explicit confirmation. The link is written to the audit log and an email is sent to the address (7.12).
- No identity and no matching account → register: create
userswithemail_verified_at = now()(Google asserted it and we verified the claim), no password, then the personal workspace exactly as 7.2 step 7, then the identity row. - TOTP applies after OAuth in every case where it is enabled.
Unlink rules. DELETE /auth/identities/{id}:
- Allowed only if the user retains at least one working credential afterwards: a password, or another linked identity. Otherwise 409
last_credentialwith a message telling the user to set a password first. - A user whose only credential is Google can set a password through the password-reset flow (their email is verified), and then unlink.
- Unlinking writes an audit entry and sends a notification email.
- Unlinking never deletes the account, its workspaces or its content.
7.8 Session management #
| Aspect | Value |
|---|---|
| Token | 256 bits from a CSPRNG, base64url (43 chars). Opaque — no JWT, so revocation is immediate and total. |
| Storage | SHA-256 digest in sessions.token_hash; cached in Redis at sess:{token_hash} with a 15-minute TTL. The plaintext is only ever in the cookie. |
| Cookie | Name lh_session. HttpOnly, Secure, SameSite=Lax, Path=/, Domain = the dashboard host only. Never set on customer domains — public surfaces are cookie-free by default. |
SameSite=Lax rationale |
The dashboard performs no cross-site form posts; Lax blocks CSRF on state-changing requests while keeping top-level navigation into the app working. |
| Rolling expiry | 30 days, extended on any authenticated request more than 60 seconds after the last extension (the 60-second floor prevents a write per request). |
| Absolute expiry | 90 days from creation, never extended. At the cap the user re-authenticates, full stop. |
| Revocation | Setting revoked_at plus deleting the Redis key takes effect on the next request. Because the token is opaque and checked every request, there is no window in which a revoked session still works. |
| Concurrency | Unlimited concurrent sessions per user; all are listed and individually revocable. |
Session list UI (Settings → Security → Active sessions): device label, browser family, country, created time, last-seen time, and a "this device" marker. Each row has Revoke; the page has Sign out everywhere.
"Sign out everywhere" revokes every session for the user including the current one, with revoked_reason = 'signout_all', deletes all Redis session keys, and sends the notification email. Sessions are also revoked automatically on: password change, password reset completion, email change confirmation, TOTP enable/disable, recovery-code regeneration, and account suspension. In each case the acting session may be preserved — the user who just changed their own password is not signed out of the tab they did it in — and every other session is killed.
7.9 TOTP two-factor authentication #
Enrolment. POST /auth/totp/enroll returns the otpauth:// URI, the base32 secret and a QR image; the row is written with confirmed_at = NULL. POST /auth/totp/confirm with a current code verifies it, sets confirmed_at and users.totp_enabled_at, generates ten recovery codes, and returns them once. Until the confirmation succeeds, 2FA is not active and the unconfirmed secret is deleted after 30 minutes.
| Aspect | Value |
|---|---|
| Algorithm | SHA-1, 6 digits, 30-second period (RFC 6238), for maximum authenticator compatibility. |
| Drift window | ±1 step (±30 s). Wider windows meaningfully increase the brute-force surface. |
| Replay | last_used_step rejects any code from a step ≤ the last accepted step. |
| Secret storage | AEAD-encrypted with a versioned key; never returned by any API after enrolment. |
| Verification throttle | 5 failures per 15 minutes per user → 15-minute 2FA lockout (the password session remains, but stays unauthenticated for data access). |
| Recovery codes | 10 codes, 10 characters each from an unambiguous alphabet, formatted xxxxx-xxxxx. Stored as SHA-256. Single use. Regenerating invalidates the whole previous batch atomically. A warning email is sent when 2 remain. |
| Disabling | Requires a current TOTP code or a recovery code, plus the password when one exists. Disabling deletes the secret and all codes, revokes other sessions, and emails the user. |
Per-workspace enforcement (Business). An Owner may set workspaces.require_totp = true. Then:
- Every member must have confirmed TOTP to access that workspace. Other workspaces are unaffected — enforcement is per workspace, not per account.
- A member without TOTP who opens the workspace is routed to a blocking enrolment screen. They can still switch to another workspace, and can still reach account settings.
- Enabling enforcement does not kick anyone out mid-session; the check runs at workspace entry.
- Pending invitations are unaffected at send time; the requirement is applied at first workspace entry after accepting.
- The Owner cannot enable enforcement without having TOTP themselves → 409
owner_totp_required. - Enabling or disabling enforcement writes
workspace.totp_enforcement_changedto the audit log.
Losing both the device and the recovery codes. There is no self-service path — one would be a bypass of the control itself. The documented recovery is:
- The user submits the support recovery request from the sign-in page.
- They must demonstrate control of the account email (a verification token is sent) and answer identity checks from account metadata: registration date, last-invoice last four digits or last payment date for paying accounts, and at least two owned resource identifiers (a bio page handle, link slug or QR slug).
- Support raises a ticket that a second staff member must approve. No single employee can reset 2FA.
- On approval, a 2FA reset is scheduled with a 72-hour delay, during which notification emails go to the account address at 0, 24 and 48 hours with a one-click cancel that aborts the reset and locks the account pending password reset.
- At execution, TOTP is disabled, all recovery codes are deleted, all sessions are revoked, and the audit log records
account.totp_reset_by_supportwith both staff actors.
For an account holding a workspace with require_totp, an Owner or Admin of that workspace can instead re-invite the member on a fresh identity — usually faster than the support path, and stated in the help text.
7.10 Password reset and email change #
7.10.1 Password reset #
| Step | Rule |
|---|---|
| Request | POST /auth/password-reset with { email }. Always 202, always the same body and timing. Existing accounts get the reset email; non-existent addresses get the "no account" email (7.12). |
| Token | 256 bits, SHA-256 stored, 60-minute lifetime, single use. |
| Invalidation | Issuing a new token sets invalidated_at on all previous live tokens for that user. |
| Throttle | 3 requests per address per hour, 10 per IP per hour. |
| Completion | POST /auth/password-reset/confirm with { token, password }. Validates the token, applies 7.3, sets the hash and password_updated_at, consumes the token, revokes every session, and signs the user in with a fresh session. |
| 2FA | If TOTP is enabled, a valid code is required at completion. A reset must never be a 2FA bypass. |
| Notification | The "your password was changed" email goes out immediately (7.12). |
| Expired / used token | 410 token_expired / token_already_used, with a request-a-new-link action. |
7.10.2 Email change #
| Step | Rule |
|---|---|
| Request | POST /account/email with { new_email, password } (password required when one exists; a TOTP code is also required when 2FA is on). |
| Validation | Same normalisation as registration. If the address is already in use → 202 with the standard body, and the "someone tried to add your address" email goes to the address in question. No enumeration. |
| Tokens | Two: confirm_token to the new address (24 h) and revoke_token to the old address (24 h). Both single use, both SHA-256 stored. |
| Concurrency | One pending change per user, enforced by ux_email_change_requests_user_pending. A new request supersedes the old one only after it is explicitly cancelled → 409 email_change_pending. |
| Confirmation | Consuming confirm_token updates users.email, keeps email_verified_at (the new address was just proven), sets status confirmed, revokes all other sessions, and emails both addresses. |
| Revocation from the old address | Consuming revoke_token cancels the change, revokes every session for the account, forces a password reset before next sign-in, and raises a security alert. This is the control that turns a stolen session into a recoverable incident. |
| Email to the OLD address | Sent at request time, not at completion — the point is to warn the legitimate owner while the change can still be stopped. It states the new address (partially masked: d***@newdomain.com), the requesting country and browser family, the time, and a prominent "This wasn't me — stop this change" button. |
7.11 Account deletion #
DELETE /account with password (and TOTP when enabled) plus a typed confirmation of the email address.
| Aspect | Rule |
|---|---|
| Pre-check | If the user is the sole Owner of any workspace with other members → 409 owner_transfer_required, listing the workspaces. The user must transfer ownership (8.8) or delete those workspaces first. Sole-Owner workspaces with no other members are deleted alongside the account. |
| Grace period | 30 days. users.status = 'pending_deletion', deletion_requested_at = now(), all sessions revoked, a data_deletion_requests row created with subject_type='account'. |
| During grace | Signing in with valid credentials shows a single interstitial: "Your account is scheduled for deletion on . Cancel deletion, or sign out." Cancelling restores status='active' and clears the request. Nothing is destroyed during grace. |
| Reminder | Emails at 7 days and 1 day before execution. |
| Execution | account-deletion at the end of grace: purge sessions, tokens, TOTP secret, recovery codes, OAuth identities; delete uploaded_assets the user owns that no workspace references; run the workspace purge (6.10.1) for each sole-Owner workspace; then anonymize the users row. |
| Anonymized, not deleted | email → deleted-<uuid>@deleted.invalid, name → Deleted user, avatar cleared, password and all credentials nulled, status='anonymized', anonymized_at set. The row survives so audit entries, invitations sent, and content provenance in other people's workspaces keep referential meaning. |
| Purged outright | Sessions, all token tables, TOTP secret, recovery codes, identities, auth_attempts rows for the user, personal uploaded_assets, marketing preferences. |
| Preserved | Audit entries (with actor_label frozen, actor_user_id nulled), invoices and payment events, data_deletion_requests, and content in workspaces the user did not own — a page created by a departed employee belongs to the workspace, not to them. |
| QR carve-out | Every qr_slug_reservations row touched by the deletion is preserved, set to memorial, and erasure_applied_at is stamped. Personal data is stripped from the memorial page; the slug keeps resolving forever. The deletion report and the confirmation email both say this explicitly, in plain language, before the user confirms: "QR codes you created will keep working. We keep the code's address so printed material does not break, but we remove your name and details from it." |
| Report | Per-table counts written to data_deletion_requests.report; a final confirmation email is sent to the original address before it is invalidated. |
7.12 Authentication email catalogue #
Every email is transactional, sent regardless of marketing preferences, and rendered in plain text plus HTML. All include the requesting country and browser family where an action was triggered, and a "this wasn't you" support link.
| Trigger | Subject | Contains | Expiry |
|---|---|---|---|
| Registration | Verify your email address | Verification link, what verification unlocks, requesting country/browser | 24 h |
| Registration for an existing address | Someone tried to create an account with your email | No link to create anything; a sign-in link and a password-reset link, requesting country | n/a |
| Verification resend | Verify your email address | New link, invalidates nothing (the same row is reused) | 24 h |
| Email verified | Your email is verified | What is now unlocked | n/a |
| Magic link requested | Your sign-in link | One-time sign-in link, requesting country/browser, "expires in 15 minutes" | 15 min |
| Magic link for unknown address | No account for this email | An invitation to register; no token of any kind | n/a |
| Login from a new country or new browser family | New sign-in to your LinkHub account | Country, browser family, time, a revoke-session link | n/a |
| 5 failed logins → lockout | Your account is temporarily locked | Lockout duration, a password-reset link, requesting country | Lockout 15 min |
| 3 lockouts in 24 h | Unusual sign-in activity on your account | Attempt summary, extended lockout notice, security recommendations | Lockout 2 h |
| Password reset requested | Reset your password | Reset link, requesting country/browser, "if this wasn't you, ignore this and your password stays unchanged" | 60 min |
| Password changed (any route) | Your password was changed | Time, country, "sign out everywhere" link, support link | n/a |
| Email change requested → OLD address | Confirm or stop this email change | Masked new address, requesting country/browser, Stop this change button, warning that the change proceeds if not stopped | 24 h |
| Email change requested → NEW address | Confirm your new email address | Confirmation link | 24 h |
| Email change completed → both addresses | Your email address was changed | Old and new addresses (old masked), time, support link | n/a |
| Email change revoked from old address | The email change was stopped | Confirmation, notice that all sessions were signed out and a password reset is required | n/a |
| Google account linked | Google was linked to your account | Google address, time, an unlink link | n/a |
| Google account unlinked | Google was unlinked from your account | Time, remaining sign-in methods | n/a |
| 2FA enabled | Two-factor authentication is on | Reminder to store recovery codes, count remaining | n/a |
| 2FA disabled | Two-factor authentication is off | Time, country, a re-enable link, prominent "if this wasn't you" | n/a |
| Recovery codes regenerated | New recovery codes generated | Old codes invalidated, count issued | n/a |
| 2 recovery codes remaining | You're running low on recovery codes | Regenerate link | n/a |
| Recovery code used | A recovery code was used to sign in | Time, country, remaining count | n/a |
| Support 2FA reset scheduled | Two-factor reset scheduled for your account | Execution time, Cancel this reset button, sent at 0/24/48 h | 72 h |
| Sign out everywhere | You were signed out of all devices | Time, session count revoked | n/a |
| Account deletion requested | Your account is scheduled for deletion | Execution date, what is kept and what is removed, the QR statement in 7.11, a cancel link | 30 days |
| Deletion reminders (7 d, 1 d) | Your account will be deleted on | Same content, cancel link | — |
| Account deleted | Your account has been deleted | What was removed, what was retained and why, the QR statement | n/a |
7.13 Authentication error codes #
All responses use the canonical error envelope defined in Section 21.3.
| HTTP | code |
Meaning | Notes |
|---|---|---|---|
| 400 | invalid_email |
Email fails format validation | — |
| 400 | email_domain_not_allowed |
Disposable-address domain | Names the policy, not the list |
| 400 | password_too_weak |
Fails length, strength or breach check | details[].issue = too_short / low_entropy / breached |
| 400 | password_reused |
New password equals the current one | — |
| 400 | validation_failed |
Generic field validation | details[] per field |
| 401 | invalid_credentials |
Wrong password, or unknown account | Identical for both cases |
| 401 | session_expired |
Rolling or absolute expiry reached | — |
| 401 | session_revoked |
Session explicitly revoked | — |
| 401 | totp_required |
First factor passed, second factor pending | Response carries challenge_token |
| 401 | totp_invalid |
Wrong or replayed TOTP code | — |
| 401 | recovery_code_invalid |
Unknown or already-used recovery code | — |
| 403 | email_verification_required |
Action gated behind verification | One details entry { "field": "action", "issue": "verification_required" } naming the blocked action |
| 403 | oauth_email_unverified |
Provider did not verify the email | — |
| 403 | oauth_state_invalid |
state missing, expired or replayed |
— |
| 403 | totp_enforced |
Workspace requires 2FA and the member has none | One details entry { "field": "workspace_id", "issue": "totp_enforced" } |
| 403 | account_suspended |
Account suspended for abuse | Support contact included |
| 403 | account_pending_deletion |
Sign-in during the grace period | Response carries the cancel action |
| 409 | email_change_pending |
A change is already in flight | — |
| 409 | token_email_mismatch |
Token issued for a different address | — |
| 409 | last_credential |
Unlinking would leave no way in | — |
| 409 | owner_transfer_required |
Sole Owner of a shared workspace | details.workspaces[] |
| 409 | owner_totp_required |
Owner must enable 2FA before enforcing it | — |
| 410 | token_expired |
Any auth token past its lifetime | Resend action included |
| 410 | token_already_used |
Single-use token replayed | — |
| 429 | rate_limited |
Any rate limit in 7.14 | Retry-After header always set |
| 429 | account_locked |
Account lockout active | Retry-After = remaining lockout |
| 500 | internal_error |
Unexpected failure | request_id for support |
7.14 Security controls #
7.14.1 Timing-safe comparison #
Every secret comparison uses a constant-time function over fixed-length inputs: session token hashes, verification and reset tokens, magic-link tokens, TOTP codes, recovery codes, API keys, webhook signatures and CSRF tokens. Passwords are compared by Argon2id's own verification. No secret is ever compared with === or with a database LIKE.
7.14.2 Enumeration resistance #
The rule: for any endpoint that takes an email address, the response body, the HTTP status and the response time must be indistinguishable between an existing and a non-existing account.
| Endpoint | Guarantee |
|---|---|
POST /auth/register |
201 with an identical body in both cases; a different email is sent. |
POST /auth/login |
Argon2id runs against a dummy hash when no user exists, so the timing profile matches. Always 401 invalid_credentials. account_locked is returned only for real, locked accounts, and the per-IP throttle covers the probing case. |
POST /auth/password-reset |
Always 202, identical body. |
POST /auth/magic-link |
Always 202; a token row is written either way, so the database work matches. |
POST /account/email |
Always 202 when the target address is taken. |
| Invitation accept | Never reveals whether the invited address has an account until the token is validated. |
Additionally: every one of these handlers is wrapped in a minimum-duration guard of 250 ms. The handler completes, and the response is held until 250 ms has elapsed since the request started. This makes residual timing differences — a cache hit, a slower index probe — unobservable, without adding meaningful latency to a flow the user experiences as "sending an email". The guard is asserted by a test that runs both branches 200 times and fails if the distributions differ by more than 15 ms at the median.
7.14.3 CSRF posture #
| Control | Detail |
|---|---|
| Cookie | SameSite=Lax blocks cross-site POST, PUT, PATCH and DELETE outright. |
| Double submit | Every state-changing dashboard request also carries an X-LinkHub-CSRF header whose value must equal a non-HttpOnly lh_csrf cookie, compared in constant time. Missing or mismatched → 403 csrf_invalid. |
| Origin check | Origin (falling back to Referer) must match an allow-listed dashboard origin on every state-changing request. Absent on a state-changing request → rejected. |
| Public API | /v1 uses Authorization: Bearer API keys and never cookies, so it is not reachable by an ambient-credential attack and needs no CSRF token. |
| GET safety | No GET endpoint mutates state. Sign-out is a POST. |
| OAuth | state is single-use, 10-minute, signed and bound to a browser cookie; PKCE covers code interception. |
7.14.4 Authentication rate-limit table #
Counters are sliding-window in Redis under rl:{scope}:{key}, mirrored to auth_attempts and rate_limit_violations. Every 429 sets Retry-After.
| Endpoint | Scope | Limit | Window | On breach |
|---|---|---|---|---|
POST /auth/register |
IP | 5 | 1 h | 429 rate_limited |
POST /auth/register |
Email domain (free-mail) | 3 | 1 h | 429 |
POST /auth/login |
Account | 5 failures | 15 min | 15-min lockout + email |
POST /auth/login |
IP | 20 | 1 h | 429 |
POST /auth/login |
IP, all accounts | 100 | 1 h | 429 + 24-h IP block on repeat |
POST /auth/magic-link |
3 | 15 min | 429 | |
POST /auth/magic-link |
IP | 10 | 1 h | 429 |
POST /auth/password-reset |
3 | 1 h | 429 | |
POST /auth/password-reset |
IP | 10 | 1 h | 429 |
POST /auth/verify-email/resend |
1 | 60 s | 429 | |
POST /auth/verify-email/resend |
10 | 24 h | 429 | |
POST /auth/totp/verify |
User | 5 failures | 15 min | 15-min 2FA lockout |
POST /auth/recovery-code |
User | 5 failures | 1 h | 1-h lockout + email |
POST /account/email |
User | 3 | 24 h | 429 |
POST /auth/totp/enroll |
User | 10 | 1 h | 429 |
| OAuth callback | IP | 30 | 1 h | 429 |
| Session refresh / any authenticated request | Session | 600 | 1 min | 429 |
Additional controls: all authentication endpoints are excluded from every cache layer (Cache-Control: no-store); authentication failures are logged at warn with the request id and the hashed identifier, never the raw email; and a sustained spike in auth_attempts with outcome='unknown_account' triggers the credential-stuffing alert defined in Section 25.
8. Workspaces, Members, Invitations & Audit Log #
8.1 The workspace as the unit of tenancy and branding #
A workspace is the only container in the product. It owns everything a customer would think of as "their stuff", and it is the boundary of every permission check, every entitlement, every retention rule and every analytics query.
A workspace owns: bio pages and their blocks, themes and fonts, short links and their destinations and rules, QR codes and their reservations, experiments, uploaded assets, custom domains, UTM presets, leads and their sync targets, integrations and the single webhook URL, API keys, analytics (raw and rolled up), consent records, the audit log, the subscription and its invoices, and the member list.
A workspace does not own: users. A user exists once, globally, and is connected to a workspace only through a workspace_members row. This is what makes the multi-brand model work.
The multi-brand model. Agencies and multi-brand businesses put each brand in its own workspace:
| Property | Consequence |
|---|---|
| Branding is per workspace | Each client gets its own logo, colours, custom domain, themes and public appearance. Nothing bleeds across. |
| Roles are per workspace | A freelancer can be Editor on one client and have no access at all to another. There is no global role. |
| Analytics are per workspace | No dashboard, export or API response ever mixes two workspaces. |
| Billing is per workspace | Free and Pro allow 1 workspace; Business allows 10. A user may belong to more workspaces than their own plan allows, because the limit applies to workspaces they own, not workspaces they can access. |
| Domains are per workspace | A hostname is globally unique (6.3.22), so a domain belongs to exactly one workspace at a time. |
| Content never moves between workspaces | There is no cross-workspace move. Copying is offered instead: duplicating a bio page or link into another workspace creates new rows with new ids, and — critically — a QR code is never duplicated across workspaces, because its slug reservation is permanent and singular. |
8.2 Creation, settings, and deletion #
8.2.1 Creation #
POST /workspaces with { name, slug? }.
| Rule | Detail |
|---|---|
| Entitlement | Counted against workspaces the user owns: Free 1, Pro 1, Business 10. Exceeded → 403 plan_limit_reached, whose details array carries one entry { "field": "workspaces", "issue": "limit_reached", "limit": 1, "current": 1, "plan": "free", "kind": "count" }. This check is account-scoped: it is evaluated before any workspace context exists, because there is no workspace yet to authorise against. |
| Verification | Requires a verified email → 403 email_verification_required. |
| Name | 1–80 characters. |
| Slug | Auto-derived from the name when omitted: lower-case, non-alphanumerics collapsed to -, trimmed, truncated to 64, then suffixed -2, -3, … on collision. |
| Slug validation | ^[a-z0-9-]{1,64}$; may not start or end with -; may not contain --; may not be a reserved_slugs entry in global scope; passes the confusable check → 409 workspace_slug_taken or 400 slug_reserved. |
| Result | Workspace row, workspace_members row with role owner, workspaces.owner_user_id set, plan free, all in one transaction. Audit: workspace.created. |
8.2.2 Settings #
| Group | Fields | Who can change |
|---|---|---|
| General | name, slug, timezone, locale |
Owner, Admin |
| Branding | logo_asset_id, brand_primary_color, brand_secondary_color, brand_text_color, unavailable_page_url |
Owner, Admin |
| Defaults | default_link_domain_id, default_page_domain_id |
Owner, Admin |
| Security | require_totp (Business only) |
Owner |
| Privacy | consent_mode |
Owner, Admin |
| Integrations | webhook_url and its secret |
Owner, Admin |
| Billing | plan, seats, payment method | Owner only |
| Danger zone | delete workspace | Owner only |
Changing the slug does not break public URLs: the workspace slug appears only in dashboard paths (/w/<slug>/…). Public surfaces are addressed by host plus handle or slug, which are separate fields on separate tables. The settings UI states this explicitly so nobody avoids a rename out of fear. All settings changes write audit entries with before/after values.
8.2.3 Deletion #
DELETE /workspaces/{id} — Owner only.
| Step | Detail |
|---|---|
| Confirmation | The user types the workspace slug exactly, and confirms a checklist that names the counts being affected: bio pages, links, QR codes, members, leads, custom domains, and the analytics range. |
| QR warning | If the workspace has any QR code, a distinct, non-dismissible block states: "This workspace has N QR codes. Deleting it will not break them. Each code will keep resolving and will show a page saying it belongs to . You cannot reuse or reassign those codes afterwards." The user must acknowledge this specifically. |
| Blocking condition | An active paid subscription must be cancelled first → 409 subscription_active, with a link to the billing page. This prevents deletion producing a billing dispute. |
| Grace period | 30 days. deleted_at and purge_after set. The workspace disappears from the switcher for every member and from all list views; members receive an email. |
| During grace | Public surfaces stop serving immediately (pages 404, links serve the branded expired page) except QR codes, which continue to resolve to their current destination for the whole grace period — a deletion that was a mistake must not break print during the window in which it can be undone. |
| Restore | Owner-only, from Settings → Deleted workspaces, any time before purge_after. Everything returns including analytics; sessions' active_workspace_id is repointed. |
| Purge | At purge_after, the ordered procedure in 6.10.1 runs. QR codes become memorial pages at this point, permanently. |
| Audit | workspace.deletion_requested, workspace.deletion_cancelled, workspace.purged. The final entry is written to the actor's account record and to the operations log, since the workspace's own audit rows are gone. |
8.3 Workspace switching #
Switching workspaces must be reachable in one interaction from anywhere in the dashboard, and must feel instant.
| Aspect | Decision |
|---|---|
| Placement | A persistent workspace switcher in the top-left of the dashboard shell, present on every authenticated route including deep pages like the block editor and the analytics drill-down. |
| Interaction | Click, or ⌘K / Ctrl+K → type to filter. Keyboard-navigable, role="listbox", focus returned to the trigger on close (Section 24). |
| Contents | All non-deleted workspaces the user is a member of, each with avatar/logo, name, the user's role, and a plan badge. Sorted by last-active descending, so the two or three a user actually uses are always at the top. Above 8 workspaces a filter input appears automatically. |
| Speed | The list is fetched once per session from ix_workspace_members_user and cached in the client store; switching is a client-side route change plus a prefetch, not a full page load. Target: interactive within 150 ms of the click, measured on the Section 11 reference device. |
| URL | The active workspace is carried in the path: /w/<workspace-slug>/links/<id>. This makes every dashboard URL shareable and bookmarkable with unambiguous context, and it makes cross-workspace leakage impossible to cause by copying a link — the target workspace is explicit and re-authorised on every request. |
| Persistence | The last active workspace is written to sessions.active_workspace_id (debounced to at most one write per minute) and used to resolve the bare / route on the next visit. |
| Switching context | Switching preserves the kind of page where it makes sense: from /w/acme/links the user lands on /w/other/links, not on the dashboard root. If the destination is resource-specific (/w/acme/links/<id>), the switch falls back to that section's list. |
| No membership | Navigating to a workspace slug the user is not a member of returns 404, not 403 — a 403 would confirm the workspace exists. |
| Deleted or suspended | Removed from the switcher; a direct URL shows a specific empty state with the reason and, for a soft-deleted workspace the user owns, a restore action. |
| Single workspace | The switcher renders as a static label with a "Create workspace" action, so the chrome does not imply a choice that does not exist. |
8.4 Membership lifecycle #
Roles and the permission matrix are defined in Section 3. This section defines the mechanics.
| Stage | Mechanics |
|---|---|
| Invited | A workspace_invitations row exists in pending. No workspace_members row yet, so the invitee has no access whatsoever. A pending invitation does consume a seat (8.5). |
| Active | A workspace_members row with deleted_at IS NULL. role and is_scoped govern everything. |
| Role changed | A narrow UPDATE on role, permitted to Owner and Admin, with two hard rules: nobody may change their own role, and nobody may set or unset owner (ownership moves only through 8.8). Admin may not change another Admin's role → 403 insufficient_role. Effective immediately; the target's in-flight requests re-read the role, which is cached only per request. |
| Scoping changed | Setting is_scoped = true (Business only) restricts the member to resource_grants. Setting it false restores full workspace scope for their role. |
| Removed | deleted_at set, sessions unaffected (they are account-level) but the workspace vanishes from the switcher and every request scoped to it returns 404. Reversible for 30 days by re-inviting, which restores the same row if the user id matches. |
| Left voluntarily | POST /workspaces/{id}/members/me/leave. An Owner cannot leave → 409 owner_cannot_leave; they must transfer ownership or delete the workspace. |
| Seat accounting | Seats consumed = active members + pending invitations. Free and Pro are 1 seat, so neither can have a second member or a pending invitation; the invite UI is replaced by an upgrade prompt rather than failing after the fact. |
Every transition writes an audit entry: member.invited, member.joined, member.role_changed, member.scope_changed, member.removed, member.left.
8.5 Invitations #
8.5.1 Creating an invitation #
POST /workspaces/{id}/invitations with { email, role, is_scoped?, grants? }. Permitted to Owner and Admin.
| Check | Failure |
|---|---|
| Actor role is Owner or Admin | 403 insufficient_role |
| Actor's email is verified | 403 email_verification_required |
| Plan allows teams (Business) | 403 plan_limit_reached with a details entry { "field": "seats", "issue": "limit_reached", "limit": 1, "current": 1, "kind": "count" } on Free and Pro |
role ∈ {admin,editor,viewer} |
400 validation_failed — owner cannot be invited |
| Email is valid and ≤ 254 chars | 400 invalid_email |
| Address is not already an active member | 409 already_member |
| No pending invitation for that address | 409 invitation_pending (with a resend action) |
Seats available (active members + pending invitations < seats_purchased) |
403 seat_limit_reached with a details entry { "field": "seats", "issue": "limit_reached", "limit": <seats_purchased>, "current": <consumed>, "plan": <plan_key>, "kind": "count" } |
grants reference resources in this workspace and is_scoped is true |
400 validation_failed |
| Rate limit: 50 invitations per workspace per 24 h; 10 per minute | 429 rate_limited |
On success: insert the invitation with a 7-day expiry, send the email, write member.invited. Response 201 with the invitation object — never with the token.
8.5.2 The token and the email #
| Aspect | Value |
|---|---|
| Token | 256 bits from a CSPRNG, then HMAC-signed with a server key and base64url-encoded as <invitation_id>.<random>.<signature>. The signature is verified before any database lookup, so an invalid token costs no query. token_hash is the SHA-256 of the full string. |
| Single use | status moves pending → accepted in the same transaction as the membership insert, guarded by UPDATE … WHERE status='pending' RETURNING, so two concurrent clicks produce exactly one membership. |
| Lifetime | 7 days from the most recent send. A resend extends expires_at to 7 days from the resend. |
| Link | https://app.linkhub.app/invitations/accept?token=<token> |
| Email contents | Inviter's name and email, workspace name and logo, the role being granted in plain language ("Editor — can create and edit pages, links and QR codes; cannot manage members, domains or billing"), the scoped-resource list when scoped, the expiry date, and an accept button. |
8.5.3 Pending state, resend and revoke #
| Action | Rule |
|---|---|
| Listing | Pending invitations appear in the member list with a Pending badge, the invited email, role, inviter and expiry. They visibly count against the seat total, so the seat maths in the UI always adds up. |
| Resend | Owner and Admin. 60-second cooldown per invitation, maximum 5 resends (resend_count), each extending the expiry. Exceeded → 429 rate_limited or 409 resend_limit_reached. |
| Revoke | Owner and Admin. Sets status='revoked'; the token stops working immediately. Audit member.invitation_revoked. No email is sent — a revoked invitation is not news the recipient needs. |
| Expiry | The invitation-expire job flips pending → expired past expires_at, freeing the seat. |
| Change of mind | Role and scoping on a pending invitation cannot be edited. Revoke and re-invite; this keeps the token bound to exactly one set of terms. |
8.5.4 Accepting #
POST /invitations/accept with { token }.
With an existing signed-in account:
- Verify the signature, then load the invitation by id.
- Validate
status='pending'andexpires_at > now()→ 410invitation_expired/ 409invitation_revoked/ 410invitation_already_used. - Re-check the seat limit at accept time. Seats can have been consumed or the plan downgraded since the invitation was sent. If full → 403
seat_limit_reached; the invitation stayspendingso an Admin can free a seat, and both the invitee and the workspace Admins are emailed. - If the signed-in account's email differs from the invited address, show an explicit confirmation: "This invitation was sent to
dana@acme.com. You're signed in asdana@personal.com. Accept as this account?" Accepting is allowed and binds the membership to the signed-in user — people legitimately receive work invitations at one address and use another. The audit entry records both addresses. - In one transaction: insert
workspace_members(or restore a soft-deleted row for the same user), materialisepending_grantsintoresource_grants, setstatus='accepted',accepted_at,accepted_user_id; setusers.email_verified_atif null and the addresses match. - Redirect into the workspace. Audit
member.joined.
Without an account: the accept page shows the workspace and role, then offers registration (password or Google) with the email pre-filled and non-editable. Registration runs 7.2 but skips the personal-workspace creation — a user who arrives through an invitation lands in the inviting workspace, not in an empty one of their own. Their email is marked verified because they proved control of it by opening the invitation. Then the accept transaction above runs.
Failure cases, all of them:
| Case | Response |
|---|---|
| Malformed or bad signature | 400 invitation_token_invalid |
| Unknown invitation id | 404 not_found |
| Expired | 410 invitation_expired, with a "request a new invitation" action that notifies the inviter |
| Revoked | 409 invitation_revoked |
| Already accepted | 410 invitation_already_used |
| Already a member of the workspace | 409 already_member, and the invitation is marked accepted so it stops occupying a seat |
| Seat limit reached at accept time | 403 seat_limit_reached, invitation stays pending, both parties emailed |
| Workspace deleted or suspended | 410 workspace_unavailable |
| Workspace requires TOTP and the user has none | Accepted, then routed to the blocking enrolment screen (7.9) — the membership is created, access is gated |
| Account suspended | 403 account_suspended |
8.6 Per-resource grants (Business) #
UI. Two entry points, deliberately, because people think about this from both directions:
- Member → resources. On a member's detail panel, a "Limit access to specific resources" toggle sets
is_scoped. Below it, three searchable multi-selects (Bio pages, Links, QR codes) with select-all-visible, current-filter and clear controls. Changes save on an explicit Save access button, never optimistically, and a summary line reads "Dana can access 3 bio pages, 12 links and 2 QR codes." - Resource → members. On any bio page, link or QR code, an "Access" panel lists unscoped members (with "full workspace access" as the reason) and scoped members who hold a grant, with add and remove controls.
Data model behaviour. A grant is a row in resource_grants keyed by (member_id, resource_type, resource_id). Grants only ever narrow: they intersect with the role. A scoped Viewer with a grant on a link can view that link and its analytics; they still cannot edit it, because the role governs the verb and the grant governs the object.
What a scoped member sees in every list view:
| Surface | Behaviour |
|---|---|
| Bio pages / Links / QR codes lists | Only granted resources. Counts, pagination and search all operate on the granted subset. No "N hidden" indicator — that would leak the size of the workspace. |
| Analytics dashboard | Aggregates computed only over granted resources. The workspace-level total row is not shown; the header reads "Analytics for your 3 pages and 12 links". |
| Experiments | Only experiments whose subject is a granted resource. |
| Leads | Only leads whose bio_page_id is a granted page. |
| Audit log | Only entries whose resource_id is granted, plus their own actions. Membership and billing events are hidden. |
| Search / command palette | Granted resources only. |
| Direct URL to an ungranted resource | 404 not_found, never 403 — a 403 confirms existence. |
| Public API | Every list and read endpoint applies the same grant filter; an ungranted id returns 404. |
| Exports | Scoped to granted resources; the export header names the scope so the file is not mistaken for a full workspace export. |
| Empty state (scoped, no grants) | "You don't have access to any resources in this workspace yet. Ask an Admin to grant access." — with the Admins' names. |
Resources a scoped member creates. A scoped Editor may create resources (their role permits it). On creation, a grant for that member is inserted automatically in the same transaction, so they can immediately see and edit what they just made. This is the only path by which a grant is created without an Admin acting. It is recorded as resource_grants.granted_by_user_id = <the creator themselves> and audited as member.grant_auto_created. Admins can revoke it like any other grant; revoking it does not delete the resource, which belongs to the workspace.
Edge cases. Un-scoping a member (is_scoped = false) leaves their grant rows in place but inert, so re-scoping restores the previous set rather than starting empty. Deleting a resource deletes its grants in the same transaction. Downgrading from Business sets every member to is_scoped = false and preserves the grant rows, with a warning shown during the guided downgrade that scoped members will gain full workspace access.
8.7 Removing a member and reassigning content #
DELETE /workspaces/{id}/members/{member_id} — Owner and Admin; Admin may not remove another Admin or the Owner.
The removal dialog shows exactly what the member touched: resources they created, experiments they started, API keys they own, and pending invitations they sent. It then requires a decision:
| Item | Options | Default |
|---|---|---|
| Bio pages, links, QR codes they created | Keep in the workspace (content belongs to the workspace) | Keep. There is no "delete their content" option — one person leaving must never be able to break a printed QR code or a published page. |
created_by_user_id |
Reassign to the acting admin, or leave as historical provenance | Leave. Provenance is historical fact; the audit log already records the removal. |
| API keys they created | Revoke all, or keep | Revoke all. A key created by a departed member is an unowned credential. Revocation is immediate and audited. |
| Pending invitations they sent | Keep or revoke | Keep (they are workspace-level acts) |
Their resource_grants |
Deleted | Always — grants are personal |
| Running experiments they own | Reassigned to the acting admin | Always, so nobody is blocked from promoting a winner |
| Leads and analytics | Untouched | Always — workspace data |
Execution, in one transaction: soft-delete the membership, delete grants, revoke keys, reassign experiments, then invalidate the member's cached authorisation and any sessions.active_workspace_id pointing at this workspace. The removed member is emailed. Audit: member.removed with the full decision set in after.
Seats are freed immediately. Re-inviting the same user within 30 days restores their original membership row, which preserves their joined_at and their audit continuity.
8.8 Owner transfer #
POST /workspaces/{id}/owner-transfer with { target_member_id } — Owner only.
| Rule | Detail |
|---|---|
| Target | Must be an active, unscoped member of the workspace. A scoped member must be un-scoped first → 409 target_scoped. |
| Target verification | The target's email must be verified → 409 target_email_unverified. |
| 2FA | If require_totp is on, the target must have confirmed TOTP → 409 target_totp_required. |
| Current Owner confirmation | Password (and TOTP when enabled), plus typing the workspace slug. |
| Target acceptance | The transfer is not immediate. The target receives an email and an in-app banner and must explicitly accept. Pending transfers expire after 7 days. Either party can cancel before acceptance. |
| Execution | One transaction: target's role → owner, previous Owner's role → admin, workspaces.owner_user_id updated. The single-owner partial unique index means a bug here fails loudly instead of silently producing two Owners. |
| Billing | The subscription stays with the workspace; the new Owner inherits payment management. If the payment method belonged to the previous Owner personally, the billing page shows a prominent "Update payment method" prompt to the new Owner and emails both parties. Nothing about resolution or entitlements changes during the handover. |
| Notification | Both parties and all Admins are emailed. |
| Audit | workspace.owner_transfer_initiated, …_accepted, …_cancelled, …_expired, each recording both user ids. |
The previous Owner becomes an Admin rather than losing access, because the common cases are a role change inside a company and an agency handing a workspace to a client — abrupt removal would be surprising and destructive. They can then be removed normally if that is the intent.
8.9 The audit log #
Append-only, immutable, workspace-scoped. Storage is audit_log_entries (6.3.15).
8.9.1 Event catalogue #
This table is the canonical catalogue of audit events for the entire product. There are 64 event keys. An action that is not in this table is not audited; a key that is not in this table does not exist. Any list of audit events anywhere else in this document — including the appendix — is a copy of this table and carries no independent authority, so if a copy and this table disagree, this table is right.
before and after contain only the fields that changed, with secrets redacted ("[redacted]") and long values truncated to 500 characters with an ellipsis marker.
The Retention column is not documentation of a convention — it is the value the audit-write helper passes for retention_expires_at, and it is enforced as described in 6.3.15. Per plan means Free 30 days, Pro 365 days, Business indefinite, computed from the workspace's plan at write time. Permanent means the column is written NULL on every plan including Free, which makes the entry exempt from the purge statement and from the purge trigger, both of which test retention_expires_at IS NOT NULL AND retention_expires_at < now().
| # | Event key | Trigger | Actor types | before / after | Retention |
|---|---|---|---|---|---|
| 1 | workspace.created |
Workspace created | user | — / {name, slug, plan_key} |
Per plan |
| 2 | workspace.settings_changed |
Any settings save | user, api_key | changed keys only | Per plan |
| 3 | workspace.branding_changed |
Logo or colour change | user | changed keys only | Per plan |
| 4 | workspace.totp_enforcement_changed |
require_totp toggled |
user | {require_totp} |
Per plan |
| 5 | workspace.deletion_requested |
Deletion started | user | — / {purge_after, counts, qr_count} |
Per plan |
| 6 | workspace.deletion_cancelled |
Restored during the grace window | user | — | Per plan |
| 7 | workspace.owner_transfer_initiated |
Transfer offered | user | {owner_user_id} |
Per plan |
| 8 | workspace.owner_transfer_accepted |
Transfer accepted | user | {owner_user_id} |
Per plan |
| 9 | workspace.owner_transfer_cancelled |
Transfer withdrawn | user | {owner_user_id} |
Per plan |
| 10 | workspace.owner_transfer_expired |
Offer lapsed | system | {owner_user_id} |
Per plan |
| 11 | member.invited |
Invitation created | user | — / {email, role, is_scoped} |
Per plan |
| 12 | member.invitation_resent |
Resend | user | — | Per plan |
| 13 | member.invitation_revoked |
Revoke | user | {status} |
Per plan |
| 14 | member.joined |
Invitation accepted | user | — / {role, is_scoped} |
Per plan |
| 15 | member.role_changed |
Role updated | user | {role} |
Per plan |
| 16 | member.scope_changed |
is_scoped toggled |
user | {is_scoped} |
Per plan |
| 17 | member.grant_added |
Per-resource grant added | user | {resource_type, resource_id} |
Per plan |
| 18 | member.grant_removed |
Per-resource grant removed | user | {resource_type, resource_id} |
Per plan |
| 19 | member.grant_auto_created |
Scoped member created a resource and was granted it | system | — / {resource_type, resource_id} |
Per plan |
| 20 | member.removed |
Membership ended by an admin | user | {deleted_at, keys_revoked, experiments_reassigned} |
Per plan |
| 21 | member.left |
Member left voluntarily | user | {deleted_at} |
Per plan |
| 22 | link.created |
Short link created | user, api_key | — / {slug, destination_url, status} |
Per plan |
| 23 | link.destination_changed |
Destination edited | user, api_key | {destination_url} both sides |
Per plan |
| 24 | link.rules_changed |
Targeting rules edited | user, api_key | full rule array both sides | Per plan |
| 25 | link.split_changed |
Split destinations or weights edited | user, api_key | full destination array both sides | Per plan |
| 26 | link.status_changed |
Draft, active, paused, scheduled, expired or archived | user, api_key, system | {status} |
Per plan |
| 27 | link.deleted |
Deleted | user, api_key | {deleted_at} |
Per plan |
| 28 | qr.created |
QR created | user, api_key | — / {slug, host, destination_url, error_correction} |
Permanent |
| 29 | qr.destination_changed |
QR destination edited | user, api_key | {destination_url} both sides |
Permanent |
| 30 | qr.styling_changed |
Style, colours, logo or error correction edited | user, api_key | {style, error_correction} |
Permanent |
| 31 | qr.status_changed |
Paused, resumed, archived, memorialised | user, system | {status, paused_fallback_url} |
Permanent |
| 32 | qr.rendered |
Artifacts regenerated | user, api_key, system | — / {version_no, formats, validation_status, escalations} |
Permanent |
| 33 | page.created |
Bio page created | user, api_key | — / {handle} |
Per plan |
| 34 | page.published |
Version published | user, api_key | {status, published_version_id} |
Per plan |
| 35 | page.unpublished |
Page taken down | user, api_key | {status} |
Per plan |
| 36 | page.reverted |
Version restored | user | {published_version_id} |
Per plan |
| 37 | page.deleted |
Deleted | user, api_key | {deleted_at} |
Per plan |
| 38 | theme.contrast_override |
Failing theme saved with typed confirmation | user | {contrast_passed, failing_pairs} |
Per plan |
| 39 | domain.added |
Custom domain added | user | — / {hostname, kind, purpose} |
Per plan |
| 40 | domain.verified |
DNS verification passed | system | {status} |
Per plan |
| 41 | domain.tls_issued |
Certificate issued or renewed | system | {status, not_after} |
Per plan |
| 42 | domain.tls_failed |
Certificate issuance failed | system | {status, failure_code} |
Per plan |
| 43 | domain.removed |
Domain removed | user | {status, reassigned_counts} |
Per plan |
| 44 | api_key.created |
Key created | user | — / {name, display_prefix, scopes} |
Per plan |
| 45 | api_key.revoked |
Key revoked | user, system | {revoked_at} |
Per plan |
| 46 | experiment.started |
Draft → running | user, api_key | {status, started_at} |
Per plan |
| 47 | experiment.paused |
Running → paused | user, api_key | {status} |
Per plan |
| 48 | experiment.concluded |
Stopped without applying a winner | user, api_key, system | {status, ended_at} |
Per plan |
| 49 | experiment.promoted |
Winner promoted under the guard | user | {winner_variant_id, status} |
Per plan |
| 50 | experiment.force_promoted |
Promoted before the guard passed | user | {winner_variant_id, promotion_mode} plus visitors per variant, days elapsed and the typed confirmation text |
Per plan |
| 51 | integration.connected |
Integration enabled | user | changed keys, secrets redacted | Per plan |
| 52 | integration.disconnected |
Integration removed | user | changed keys, secrets redacted | Per plan |
| 53 | webhook.url_changed |
Workspace webhook URL edited | user | {webhook_url} — host only, never the full path or any query string |
Per plan |
| 54 | analytics.share_link_created |
Read-only analytics share created | user | — / {name, scope_type, metric_scope, expires_at, token_prefix} |
Per plan |
| 55 | analytics.share_link_revoked |
Share revoked | user, system | {revoked_at} |
Per plan |
| 56 | plan.changed |
Plan up- or downgraded | user, system | {plan_key, seats} plus archived resource counts |
Per plan |
| 57 | billing.payment_failed |
A charge failed | system | {billing_status} plus invoice id and amount |
Per plan |
| 58 | billing.past_due |
Subscription entered past-due | system | {billing_status} |
Per plan |
| 59 | data.export_requested |
Export job created | user, system | — / {scope, filters} |
Per plan |
| 60 | data.export_completed |
Export archive ready | system | — / {row_counts, expires_at} |
Per plan |
| 61 | data.deletion_requested |
Erasure requested | user, system | — / {subject, grace_ends_at} |
Permanent |
| 62 | data.deletion_executed |
Erasure performed | system | {status, qr_carve_out_applied} plus per-table counts |
Permanent |
| 63 | abuse.action_taken |
Abuse response applied | support, system | {status, action_taken} |
Per plan |
| 64 | account.totp_reset_by_support |
Support reset a user's 2FA | support | {totp_enabled_at} plus both staff actors and the ticket reference |
Per plan |
Why the five QR keys are permanent, and not merely long-lived. A symbol printed on packaging outlives every retention tier this product sells. The question a print manager asks — "what has this box pointed at since it was printed" — has to be answerable in year three on a Free workspace, and the only honest way to guarantee that is to keep the entries forever rather than to pick a number and hope the boxes are gone by then. qr_code_versions (6.3.38) carries the same guarantee from the other side, and 8.9.4 works the example end to end.
Why data.deletion_* are permanent. They are the evidence that a statutory obligation was discharged. Deleting the proof of a deletion after 30 days would leave a Free workspace unable to demonstrate compliance with the very request it honoured.
Actor types. user (a signed-in member), api_key (a public-API key, with actor_label carrying the key name and display prefix), system (a scheduled job or an internal state transition), support (a staff action, which additionally records both staff actors and a ticket reference in after). Every row's Actor types cell is exhaustive: an entry with an actor type not listed for its key is a bug the ingest-side validator rejects.
8.9.2 Append-only guarantee #
Four independent mechanisms, because a log that can be edited is not evidence:
- No
UPDATEpath in code. The data-access layer exposesinsertandselectfor this table only; there is no update or delete function to call. - Database privileges.
REVOKE UPDATE, DELETE, TRUNCATE ON audit_log_entries FROM app_rw, app_ro;. Onlyapp_retentionholdsDELETE. - Trigger.
tg_audit_log_entries_immutable, aBEFORE UPDATE OR DELETEtrigger, raises unconditionally onUPDATE; onDELETEit raises for any session whose role is notapp_retention, and forapp_retentionit raises unlessOLD.retention_expires_at IS NOT NULL AND OLD.retention_expires_at < now(). Both limbs of that condition matter. Retention cannot be used as an editing back door, and — becauseNULL < now()isNULL, nottrue— an entry written with a null expiry can never satisfy it, which is what makes the permanent keys in 8.9.1 actually permanent rather than merely intended to be. The trigger body is given in full in 6.3.15, and theaudit-retentionjob's ownDELETEcarries the identical predicate so the trigger is a backstop rather than the only guard. - Schema shape. The table has no
updated_atand nodeleted_at. There is nothing to soft-delete and nothing to touch.
The Section 26 test suite asserts all four: it attempts an UPDATE and a DELETE as app_rw and requires both to fail, and it fails the build if a migration grants either privilege.
8.9.3 Viewer UI #
A dedicated Audit log section, visible to Owner and Admin in full; Editors and Viewers see only their own actions; scoped members see only their own actions and events on granted resources.
| Feature | Detail |
|---|---|
| Layout | Reverse-chronological rows: timestamp (in the workspace timezone, with UTC on hover), actor, a plain-language sentence, resource link, and an expander for the before/after diff. |
| Filters | Date range; event key (grouped by domain: Members, Content, Links, QR, Domains, Billing, Security, Data); actor; resource type; resource (typeahead); actor type (user / API key / system / support). |
| Search | Free-text over actor_label, resource_label and event_key. |
| Pagination | Cursor-based on occurred_at descending, 25 per page, max 100, with meta.next_cursor and meta.has_more. No total count — the collection is unbounded. |
| Diff rendering | Field-by-field, old struck through, new highlighted, secrets shown as [redacted], URLs shown in full with the changed portion emphasised. |
| Deep link | Every entry has a permalink; a resource's detail page has a "History" tab pre-filtered to that resource. |
| Export | CSV and JSON for the current filter set, generated asynchronously through data_export_requests with a signed 24-hour download link. Pro and Business only; Free sees an upgrade prompt. Exports are themselves audited (data.export_requested). |
| Empty state | "No activity matches these filters." with a clear-filters action; for a brand-new workspace, "Activity will appear here as your team works." |
| Retention notice | A persistent line states the plan's retention: "Showing the last 30 days. Your plan keeps audit history for 30 days." with an upgrade link on Free and Pro. |
| Accessibility | The log is a table with proper headers and a caption; the expander is a <button> with aria-expanded; filter changes announce the result count via a live region. |
Retention per plan. Free 30 days, Pro 365 days, Business indefinite — retention_expires_at is computed at write time from the workspace's plan at that moment, so an upgrade does not retroactively resurrect entries and a downgrade does not retroactively destroy them (the audit-retention job re-stamps retention_expires_at on a plan change, extending on upgrade and never shortening below 30 days from the entry date on downgrade).
The permanent exception, which the retention notice must state. The seven event keys marked Permanent in 8.9.1 — the five qr.* keys and the two data.deletion_* keys — are written with retention_expires_at = NULL on every plan, and the re-stamping job skips them. On a Free workspace the retention notice therefore reads: "Showing the last 30 days. Your plan keeps audit history for 30 days. QR code history and data-deletion records are kept permanently on every plan." Hiding that would be worse than useless: a customer who believed their QR history expired with everything else would have no reason to look for it three years later, which is exactly when they need it.
8.9.4 Worked example: the QR destination change a print manager needs to read #
Scenario: 40,000 product boxes carry go.acme.com/spring. On 12 May a marketer repointed it from the spring campaign page to the summer one. In August, boxes are still in circulation and the print manager needs to know exactly what happened, when, and by whom.
They open the QR code, click History, and see this entry:
QR destination changed — Spring Catalogue (go.acme.com/spring) Dana Reyes (dana@acme.com) · 12 May 2026, 14:32 (Europe/Berlin) · Germany · Chrome on macOS · Dashboard
destination_urlchanged fromhttps://acme.com/campaigns/spring-2026tohttps://acme.com/campaigns/summer-2026Version 4 · Symbol not re-rendered — printed codes are unaffected
The stored row:
{
"id": "0195f2c1-7a3e-7c11-9e2a-4b1d6f0a8e77",
"workspace_id": "0194ab02-4c7d-7f10-b3a1-8c2e5d9f1a44",
"event_key": "qr.destination_changed",
"actor_type": "user",
"actor_user_id": "0194aa71-2b19-7e02-9c55-71a0f3b8d612",
"actor_label": "Dana Reyes <dana@acme.com>",
"resource_type": "qr_code",
"resource_id": "0194ac93-91f2-7b44-8d0e-2f7c6b1e9a03",
"resource_label": "Spring Catalogue (go.acme.com/spring)",
"before": { "destination_url": "https://acme.com/campaigns/spring-2026" },
"after": { "destination_url": "https://acme.com/campaigns/summer-2026" },
"changed_fields": ["destination_url"],
"ip_country": "DE",
"user_agent_family": "Chrome on macOS",
"request_id": "req_01J9XQ2M4T7B3K",
"occurred_at": "2026-05-12T12:32:11Z",
"retention_expires_at": null
}retention_expires_at is null here because the event key is qr.destination_changed, not because Acme is on Business. The same entry on a Free workspace is byte-for-byte identical in that field. Null is the value that takes the row out of the purge predicate in 6.3.15 and out of the partial index the purge sweep scans, so the row is not merely scheduled far ahead — it is not reachable by the purge at all.
Why each element is present:
| Element | Why the print manager needs it |
|---|---|
resource_label with the full public URL |
They know the code by what is printed on the box, not by a UUID. |
Both before and after URLs in full |
The question is always "what did it used to point at" — a diff that only shows the new value is useless for reconciling a campaign. |
actor_label frozen as text |
Readable even if Dana later leaves and her account is anonymized. |
| Workspace-timezone timestamp with UTC on hover | Print schedules are local; incident reconstruction is UTC. |
ip_country and user_agent_family |
Enough to spot "this was changed from a country nobody on the team was in" without storing an IP address. |
| Version number | Links directly to qr_code_versions, which is never pruned, so the full destination history of a printed code is always reachable. |
| "Symbol not re-rendered" | The single most important reassurance: the printed artwork is unchanged, so no reprint is needed. |
The same view, filtered to qr.destination_changed, gives a complete chronological answer to "where has this box pointed since it was printed" — which is the question the whole product exists to answer.
8.10 Cross-workspace behaviour and the isolation guarantee #
8.10.1 What a user with many workspaces sees #
| Surface | Behaviour |
|---|---|
| Sign-in landing | The workspace from sessions.active_workspace_id, or the most recently active, or — for a first-time multi-workspace user — a chooser. |
| Switcher | Every non-deleted membership, with role and plan badges (8.3). |
| Notifications | Grouped by workspace and always labelled with it, so a notification is never ambiguous. |
| Account settings | Global (profile, password, 2FA, sessions, connected accounts). Never workspace-scoped. |
| Workspace settings | Always workspace-scoped, always reached through a workspace URL. |
| Search / command palette | Current workspace only. A global search would be a cross-tenant query, which is exactly what this design forbids. |
| Analytics | Never aggregated across workspaces. There is no "all workspaces" view — building one would require a query without a single-workspace predicate. |
| API keys | Workspace-scoped. There is no account-level key, so a leaked key can never reach a second workspace. |
| Email digests | One per workspace, so nothing is joined across tenants even in a summary. |
| Different roles in different workspaces | Fully supported and visible in the switcher: Owner of acme, Viewer of client-two. |
8.10.2 The isolation guarantee and how it is made structural #
Guarantee: no request, query, export, API response, cache entry, background job or email may combine data from two workspaces, and no member of workspace A can observe the existence, contents or metadata of workspace B.
This is enforced by construction, not by discipline:
- The workspace is in the route. Every dashboard path and every workspace-scoped API path carries the workspace identifier. There is no implicit "current workspace" resolved from session state deep inside a handler, so there is no ambient value to get wrong.
- A single authorisation gate. Every request passes through one middleware that resolves
(workspace, user) → role, is_scopedfromux_workspace_members_ws_userand produces aTenantContext { workspaceId, userId, role, isScoped, grantedResourceIds }. A handler that does not receive aTenantContextcannot query tenant data — the repository functions require it as their first parameter, so the type system rejects the mistake at compile time. - Repository-level enforcement. Every tenant-scoped query is built by a helper that injects
WHERE workspace_id = $ctx.workspaceId(plus the grant filter whenisScoped). Raw SQL against a tenant table is forbidden by an ESLint rule and by a code-review checklist item; the Section 26 suite includes a static check that every tenant table is only reached through the helper. - 404, never 403, for another tenant's resource. A resource id that exists in a different workspace is indistinguishable from one that does not exist. This holds on the dashboard, the public API and every export.
- Cache keys carry the tenant. Redis keys are
rd:{host}:{slug}(host is globally unique, so it is already tenant-bound),sess:{token_hash}, andrl:{scope}:{key}. No cache key is derived from a resource id alone, so a cache lookup cannot cross a boundary. - Background jobs are tenant-scoped. Every job payload carries
workspace_id, and every job builds itsTenantContextthe same way a request does. A job with no workspace (partition maintenance, reputation rechecks) touches no tenant-readable data. - Analytics queries are tenant-first.
workspace_idis the leading column of every composite index on every analytics table (6.9), so a query without the predicate is not merely wrong — it is catastrophically slow and fails the CI plan assertion. - The test suite proves it. A dedicated cross-tenant suite creates two workspaces with overlapping content and, for every read and write endpoint, asserts that workspace A's credentials receive 404 for workspace B's resources, that list endpoints never include foreign rows, that exports contain no foreign rows, and that an API key scoped to A cannot act on B. New endpoints are enumerated from the route table, so adding an endpoint without a cross-tenant test fails the build.
The one deliberate exception, stated honestly: qr_slug_reservations is global by design — a slug reserved by one workspace can never be claimed by another, which is a cross-workspace constraint, not a cross-workspace disclosure. A conflicting claim returns 409 qr_slug_reserved with no information about who holds it, when it was reserved, or whether the holding workspace still exists. The same code and the same silence apply when the conflict is with a short link rather than a QR code, because the two share one slug namespace per host (6.1.2). The same applies to custom_domains.hostname and users.email: uniqueness is global, visibility is not.
9. Bio Page Builder — Editor Experience #
The bio page builder is the highest-touch authoring surface in LinkHub. It is a dashboard feature and therefore lives in apps/web behind session authentication; it has no bearing on the public delivery path, which is specified separately in Section 11. The editor produces exactly one artifact: a published page document — a versioned JSON structure containing page settings, theme tokens and an ordered block list — which the public renderer consumes. Nothing in the editor runtime ships to visitors.
Editor route: /w/{workspace_slug}/pages/{page_id}/edit.
All editor mutations go through route handlers under /api/editor/* in apps/web, authenticated by the session cookie, authorised by the workspace role matrix in Section 3 (Editor and above may mutate; Viewer receives 403 with code insufficient_role) and, on Business, intersected with per-resource grants. Every response uses the canonical success/error envelope and status codes defined in Section 21. The public REST API equivalents (/v1/pages/...) are specified in Section 21 and share the same Zod schemas from packages/core; the editor is not privileged — it cannot express a page state the public API could not.
9.1 Editor layout and information architecture #
9.1.1 Regions #
The editor is composed of five persistent regions. Each region is a landmark with an accessible name so screen-reader users can jump between them.
| Region | Landmark | Accessible name | Purpose |
|---|---|---|---|
| Publish bar | <header role="banner"> |
"Page editor toolbar" | Page title, save/connection status, preview toggle, device selector, View live, Publish |
| Block canvas | <main> → <ol> |
"Page blocks" | The ordered, editable list of blocks. Primary work surface |
| Inspector | <aside role="complementary"> |
"Block settings" | Settings for the selected block, or page/theme settings when nothing is selected |
| Live preview | <section> containing an <iframe> |
"Live preview" | Real public template rendering the current draft |
| Global rail | <nav> |
"Editor sections" | Switches the inspector between Blocks, Design, Settings, Version history, Trash |
The canvas is the source of selection. Selecting a block sets ?block={block_id} in the URL (via history.replaceState) so a reload restores context and a link to a specific block can be shared inside a team.
9.1.2 Responsive behaviour #
Breakpoints match the shared Tailwind token set defined in Section 5. The editor is desktop-first but must be fully operable on a phone; nothing is desktop-only.
| Breakpoint | Width | Layout | Notes |
|---|---|---|---|
2xl |
≥ 1536 px | Three columns: rail 72 px, canvas 1fr (max 560 px), inspector 400 px, preview 1fr | Preview device frame defaults to mobile 390 px |
xl |
1280–1535 px | Three columns: rail 72 px, canvas 1fr, inspector 380 px, preview 1fr (frame may downscale to 0.85×) | |
lg |
1024–1279 px | Two columns: canvas + inspector. Preview collapses to a toggle button in the publish bar; opening it overlays the canvas at 60% width | Preview state persists per user in localStorage |
md |
768–1023 px | Single column. Rail becomes a horizontal segmented control under the publish bar. Inspector opens as a right-side sheet (85% width, focus-trapped) | |
sm and below |
< 768 px | Single column, full-bleed. Inspector opens as a bottom sheet at 90 dvh with a drag handle and a visible Close button. Preview opens full-screen from the publish bar | Publish bar becomes sticky, 56 px tall, with an overflow menu for secondary actions |
Additional responsive rules, all mandatory:
- The layout must reflow without loss of function or content at 320 px width and at 400% zoom (WCAG 2.2 §1.4.10). Verified in CI — see Section 26.
- On touch pointers (
@media (pointer: coarse)) every interactive control has a minimum hit area of 44×44 CSS px; the absolute floor everywhere is 24×24 CSS px (WCAG 2.2 §2.5.8). - The publish bar never scrolls out of view; the Publish button is always reachable without scrolling on every breakpoint.
- Below
lg, drag-and-drop reordering is disabled by default in favour of the explicit move controls in 9.3.3, because dragging on a scrolling touch surface is error-prone. A "Reorder" mode toggle re-enables long-press dragging for users who want it. prefers-reduced-motion: reducedisables sheet slide animations, canvas reorder transitions and preview cross-fades; state changes become instantaneous.
9.1.3 Editor state model #
type EditorState = {
pageId: string; // UUIDv7
baseRevision: number; // revision the client last received from the server
draft: PageDocument; // authoritative local copy
selection: { blockId: string | null; field: string | null };
dirtyBlockIds: Set<string>; // blocks changed since last successful save
saveState: 'idle' | 'pending' | 'saving' | 'saved' | 'conflict' | 'offline' | 'error';
validation: Record<string /* blockId | '@page' | '@theme' */, ValidationIssue[]>;
history: { past: Patch[]; future: Patch[] }; // client undo/redo, max 50 entries
};PageDocument is the single serialisable shape shared by editor, renderer and API:
{
"page_id": "0192f3b2-...",
"revision": 42,
"handle": "acme",
"host": "linkhub.app",
"status": "draft", // draft | published | scheduled | unpublished
"settings": { /* 9.2 */ },
"theme": { /* 9.6 */ },
"blocks": [ /* Section 10 */ ]
}9.2 Page-level settings #
Page settings live in the Settings tab of the inspector. All fields validate live (9.4.2) and autosave (9.5.1).
9.2.1 Field table #
| Field | Type | Required | Default | Validation | Description |
|---|---|---|---|---|---|
handle |
string | Yes | auto-suggested from workspace name | 9.2.2 | The public path segment: linkhub.app/{handle} |
host |
string | Yes | linkhub.app |
Must be a verified custom domain owned by the workspace, or the platform default. Custom domains per Section 13 | Which hostname serves this page |
title |
string | Yes | "Untitled page" | 1–80 chars after trim | Internal name shown in dashboard lists. Not rendered publicly |
seo_title |
string | No | falls back to profile display name, then title |
≤ 60 chars; no control characters | <title> and og:title |
seo_description |
string | No | falls back to first 160 chars of the profile bio, else empty | ≤ 160 chars | <meta name="description"> and og:description |
social_image_media_id |
uuid | null | No | null → auto-generated (9.2.4) |
Must reference a ready media asset owned by this workspace |
Open Graph / Twitter card image |
favicon_media_id |
uuid | null | No | null → LinkHub default mark |
PNG only, square, ≥ 256×256, ≤ 1 MB | Site icon |
language |
string | Yes | workspace default, else en |
BCP-47 tag from the supported list in 9.2.5 | Sets <html lang> and text direction |
text_direction |
enum | Yes | derived from language |
ltr | rtl; derived value is editable |
Sets <html dir> |
indexable |
boolean | Yes | true |
— | When false, emits noindex, nofollow and excludes the page from the sitemap (11.11) |
password |
string | null | No | null |
6–128 chars when set; stored Argon2id-hashed, never returned by any API | Enables the password gate. The interstitial itself is specified in Section 12.7 |
nsfw_gate |
boolean | Yes | false |
— | Shows an age/content interstitial before the page (11.10) |
custom_head_html |
string | null | No | null |
Business plan only; ≤ 4 KB; sanitiser allow-list of <meta>, <link rel="canonical|alternate|icon"> only. <script> and <style> are rejected with custom_head_forbidden_tag |
Escape hatch for verification meta tags |
publish_at |
timestamptz | null | No | null |
Must be ≥ now + 5 minutes | Scheduled publishing (9.9.4) |
unpublish_at |
timestamptz | null | No | null |
Must be > publish_at when both set |
Automatic unpublish |
custom_head_html is deliberately narrow: it exists so a marketing team can paste a domain-verification meta tag without a support ticket, and for nothing else. Arbitrary script injection would break the zero-blocking-JS budget and the nonce-based CSP in Section 23.
9.2.2 Handle validation rules #
Evaluated in this order. The first failure is returned; the editor shows all independently-checkable failures at once.
| # | Rule | Error code | Message shown |
|---|---|---|---|
| 1 | Normalise: NFKC, trim, lowercase, collapse repeated - |
— | Applied silently before validation; the normalised value is what is saved |
| 2 | Charset ^[a-z0-9-]+$ |
handle_invalid_characters |
"Use lowercase letters, numbers and hyphens only." |
| 3 | Length 1–64 characters | handle_invalid_length |
"Handles are 1 to 64 characters." |
| 4 | Must not start or end with - |
handle_invalid_format |
"Handles can't start or end with a hyphen." |
| 5 | Must not be a pure UUID or a 7-character auto-slug pattern | handle_reserved_pattern |
"That format is reserved for system-generated links." |
| 6 | Not in the reserved-word list (9.2.3) | handle_reserved |
"That handle is reserved." |
| 7 | Not matched by the profanity or brand-impersonation corpora maintained in Section 23 | handle_blocked |
"That handle isn't available. Contact support if you believe this is an error." |
| 8 | Confusable check (9.2.3) against existing handles on the same host and the protected-brand set | handle_confusable |
"That handle is too similar to an existing page." |
| 9 | Unique per (host, handle) among non-deleted pages, case-insensitive |
handle_taken |
"That handle is already in use on this domain." |
Notes:
- Uniqueness is scoped to the host.
acmeonlinkhub.appandacmeongo.acme.comare different pages and both are permitted. - Soft-deleted pages hold their handle for the 30-day restore window. Attempting to claim it returns
handle_takenwithdetails[0].issue = "held_by_deleted_page"; a workspace Admin sees an extra affordance to permanently purge their own deleted page and free the handle immediately. - Changing a published page's handle is allowed. The previous handle is registered as an alias: the old path serves a
302redirect — never a301or308, because the alias is temporary and the author may reuse the old handle — to the new path for 90 days, then returns the not-found page (11.10). Aliases are listed in Settings and individually removable. A handle released by an expired alias returns to the free pool, unless the handle is permanently reserved (Section 14), in which case it never returns to the pool. - Availability is checked live via
GET /api/editor/handles/check?host=...&handle=...(debounced 350 ms), rate-limited to 30 requests/minute/session to prevent handle enumeration; the endpoint returns onlyavailable: booleanand areasoncode, never the owning workspace.
9.2.3 Reserved words and confusable detection #
Reserved words. The corpus is stored in packages/core/src/reserved-handles.ts and covers, at minimum:
Infrastructure: www, api, app, admin, cdn, static, assets, media, img, images, files,
mail, smtp, ftp, ns1, ns2, mx, edge, origin, go, cname, s, r, q, e, u, l
Platform: login, logout, signin, signup, register, auth, oauth, account, settings,
dashboard, billing, invoice, upgrade, plans, pricing, checkout, invite, join
Content: help, support, docs, developers, blog, status, security, legal, terms,
privacy, dpa, cookies, abuse, report, contact, about, press, careers
Files/paths: robots.txt, sitemap.xml, favicon.ico, manifest.json, ads.txt,
.well-known, apple-app-site-association, assetlinks.json, oembed, embed, widget
Traps: null, undefined, true, false, none, nan, admin1, root, test, demo, example,
linkhub, lnkhbThe list is versioned. Adding a word never invalidates an existing handle — existing holders keep it; only new claims are blocked. This is recorded as a decision because retroactive seizure of a live public URL is unacceptable.
Confusable check (UTS #39 skeleton comparison):
- Apply NFKC to the candidate.
- Map each code point through the Unicode confusables table to its prototype (
0→o,1→l,rn→m,vv→w, Cyrillicа→a, and so on). - Remove hyphens and collapse repeated characters (
aa→a). - The result is the skeleton. Store it in an indexed
handle_skeletoncolumn. - Reject when the skeleton collides with (a) the skeleton of an existing handle on the same host owned by a different workspace, or (b) the skeleton of any entry in the protected-brand set.
Because the charset is already restricted to [a-z0-9-], step 2's non-ASCII mappings only matter for input normalisation, but they are retained so that pasted Cyrillic or full-width text is folded rather than silently stripped into a different word.
The check has a documented false-positive path: a workspace blocked by rule 8 sees a "Request review" action that files a support case. There is no automatic override.
9.2.4 Social share image and auto-generation fallback #
When social_image_media_id is null, the renderer requests a generated card from GET /og/{page_id}.png?v={revision}.
| Property | Value |
|---|---|
| Dimensions | 1200 × 630 px, PNG, sRGB |
| Content | Avatar (circular, 160 px) or workspace mark; display name at 64/700; bio text truncated to 120 chars at 32/400; page theme background and accent colour; LinkHub wordmark bottom-right on Free only (per the plan table in Section 22) |
| Contrast | Text/background contrast is computed at generation time and the text colour is auto-flipped between the theme's on_surface and on_surface_inverse token to guarantee ≥ 4.5:1 |
| Fonts | The page's heading font when it is one of the bundled families; otherwise the system stack, rasterised server-side |
| Generation | sharp-based composition in apps/web, executed on request, then cached |
| Caching | Cache-Control: public, max-age=86400, s-maxage=604800, immutable keyed on ?v={revision}; CDN surrogate key page-{page_id} so a publish purges it, per the cache-invalidation matrix in Section 4.7 |
| Cold-start budget | p95 < 400 ms; on timeout or failure the response falls back to a static workspace-branded card, and never to a 5xx |
Uploaded images are validated at ≥ 600 × 315 px and ≤ 5 MB, and are re-encoded to PNG or JPEG (never AVIF/WebP — several social crawlers still reject them).
9.2.5 Language support #
Supported UI and content language tags at launch: en, en-GB, es, pt-BR, fr, de, it, nl, pl, sv, da, tr, id, ja, ko, zh-Hans, zh-Hant, ar, he. ar and he set dir="rtl" by default. Public-page strings (button labels, form errors, consent copy, error pages) are translated for all of the above; the dashboard ships en at launch with the string catalogue structured for later locales. Choosing a language never changes the handle rules or the URL structure — LinkHub does not use locale path prefixes on public pages.
9.3 Block management #
9.3.1 Operations #
| Operation | Trigger | Endpoint | Result | Undoable |
|---|---|---|---|---|
| Add | "+ Add block" button, / inline command, or the type picker |
POST /api/editor/pages/{id}/blocks |
New block inserted after the current selection (or at the end when nothing is selected), auto-selected, inspector focused | Yes |
| Reorder | Drag handle, move buttons, or "Move to…" dialog | PATCH /api/editor/pages/{id}/blocks/order |
Full ordered id array replaces the current order | Yes |
| Duplicate | Block overflow menu, Cmd/Ctrl+D |
POST /api/editor/pages/{id}/blocks/{block_id}/duplicate |
Deep copy with new ids, inserted immediately after the source, title suffixed " (copy)" where the type has a title | Yes |
| Hide | Visibility toggle on the block card | PATCH /api/editor/blocks/{block_id} {"visible": false} |
Block remains in the document, is excluded from public render, and is shown in the canvas at 45% opacity with a "Hidden" chip | Yes |
| Delete | Overflow menu, Delete key when the card has focus |
DELETE /api/editor/blocks/{block_id} |
Soft delete (deleted_at set). Removed from the canvas, moved to Trash |
Yes, for 10 s via the toast; afterwards restore from Trash |
| Restore | Trash tab | POST /api/editor/blocks/{block_id}/restore |
Re-inserted at its recorded position, or at the end when that position no longer exists | Yes |
| Purge | Trash tab, "Delete permanently" | DELETE /api/editor/blocks/{block_id}?purge=true |
Hard delete. Requires a confirm dialog. Irreversible | No |
Trash retains deleted blocks for 30 days (matching the platform-wide soft-delete window), after which the retention worker purges them. The Trash tab shows the block type, a content preview, who deleted it and when.
9.3.2 Ordering, limits and structural rules #
- Order is expressed as an explicit array of block ids. The reorder endpoint accepts the complete ordered id list for a container and rejects any request whose id set does not exactly match the container's current non-deleted children (
400,block_order_mismatch). This makes reordering idempotent and immune to fractional-index drift. - Maximum 200 blocks per page (counting children of groups). Exceeding it returns
403plan_limit_reachedwithdetails.limit = 200. This is a structural limit, identical on every plan — it protects the 40 KB HTML budget in Section 11.2, not revenue. - Nesting depth is exactly 1. Only the group block (10.14) may contain children, and a group may not contain another group. Attempting it returns
422block_nesting_unsupported. - A group may hold at most 50 children.
- Moving a block into or out of a group is a reorder against a different container; the request carries
container_id(nullfor page root).
9.3.3 Reordering: drag-and-drop and the mandatory non-drag alternative #
WCAG 2.2 §2.5.7 (Dragging Movements) requires a single-pointer alternative to any drag operation. In LinkHub this alternative is a first-class, always-visible control set — not a hidden accessibility fallback.
Pointer drag. Each block card has an explicit drag handle (⠿, 24×24 px minimum, aria-hidden="true" because the adjacent controls carry the accessible operation). Dragging shows an insertion line, auto-scrolls the canvas within 80 px of an edge, and animates neighbours unless reduced motion is requested. Dropping issues one reorder request. Dragging is disabled below lg unless "Reorder mode" is on (9.1.2).
Button alternative (always available, all breakpoints, all pointer types). Every block card exposes:
| Control | Accessible name | Behaviour |
|---|---|---|
| Move up | "Move {block label} up" | Swaps with the previous sibling. Disabled with aria-disabled="true" at position 1 |
| Move down | "Move {block label} down" | Swaps with the next sibling. Disabled at the last position |
| Move to… | "Move {block label} to a position" | Opens a dialog with a numeric position input (1–N), a target-container select when groups exist, and Move to top / Move to bottom shortcuts |
Keyboard alternative. With a block card focused: Alt+↑ / Alt+↓ move it by one; Alt+Home / Alt+End move it to the top/bottom of its container; Alt+→ moves it into the group immediately above; Alt+← lifts it out of its group to the position after that group.
After any reorder — pointer, button or keyboard — an aria-live="polite" region announces: "{block label} moved to position 3 of 9 in {container name}." Focus stays on the moved block's card so repeated moves need no re-targeting.
The move buttons and the keyboard bindings are covered by a dedicated E2E test that runs with the pointer disabled (Section 26). A build in which the button alternative is absent or non-functional fails the accessibility gate.
9.3.4 Adding blocks #
The type picker groups block types by intent: Links (link, buy/product), Identity (header/profile, social icons, contact), Content (text, image, video, FAQ, divider), Embeds (the eight providers in 10.7), Convert (email capture, countdown), Structure (group). Each entry shows a thumbnail, a one-line description and, where relevant, a plan badge sourced from the entitlement service (Section 22). Selecting a gated type on an insufficient plan opens the upgrade sheet instead of inserting the block; it never inserts a broken block.
The picker supports type-ahead search over type names, descriptions and synonyms ("yt" → YouTube, "newsletter" → email capture, "gap" → divider/spacer).
9.4 The inspector pattern #
9.4.1 Schema-driven rendering #
Every block type registers a settings descriptor: a Zod schema plus per-field UI metadata. The inspector is a generic renderer over that descriptor. No block type ships a bespoke inspector component unless it declares a custom control, and custom controls are registered — never hard-coded into the inspector. The contract is specified in 10.16.
type FieldDescriptor = {
key: string;
control: 'text' | 'textarea' | 'rich-text' | 'url' | 'number' | 'toggle' | 'select'
| 'color' | 'media' | 'icon' | 'date-time' | 'repeater' | 'segmented'
| `custom:${string}`;
label: string; // becomes the <label>, never a placeholder
help?: string; // rendered under the control, referenced by aria-describedby
placeholder?: string;
group?: string; // collapsible section, e.g. "Advanced"
showIf?: (settings: unknown) => boolean;
entitlement?: string; // e.g. 'scheduling' — renders locked with an upgrade affordance
charCount?: { max: number; warnAt: number };
};Inspector sections appear in a fixed order for every block type, so users learn one layout: Content → Appearance → Behaviour → Visibility & scheduling (Section 15) → Advanced → Danger zone (delete). Sections with no fields are omitted.
9.4.2 Live validation #
- Validation runs on every keystroke against the same Zod schema the server uses. Client and server can never disagree, because it is literally the same module.
- Errors render on blur for format issues (a URL is not "wrong" while you are typing it) and immediately for hard constraints such as max length, which are also enforced by the input itself.
- Error text sits directly below the control, is referenced by
aria-describedby, and the control getsaria-invalid="true". - A block with any error shows an error chip on its canvas card and is counted in the publish-gate summary (9.9.1).
- Errors never block autosave of other fields. An invalid field is saved to the draft as-is so work is not lost; it is the publish gate that refuses invalid content. The draft document is explicitly allowed to be invalid; the published document is not.
- Warnings (missing alt text, a link with no label, a countdown already in the past) are non-blocking, styled distinctly from errors, and listed in the pre-publish review.
9.4.3 Field-level behaviours #
| Control | Behaviour |
|---|---|
url |
Auto-prefixes https:// when the user types a bare host. Trims whitespace. Runs the destination safety checks from Section 23 asynchronously and shows a warning chip for flagged hosts. Shows a favicon preview once the URL parses |
media |
Opens the media library (9.7.5) with tabs for Upload, Library, Unsplash-style stock is not included, and a URL-import field. Shows the selected asset with dimensions and file size |
color |
Swatch grid of the current theme tokens first, then a custom picker (hex/OKLCH). Every custom colour is contrast-checked against its computed background in real time (9.6.4) |
rich-text |
Constrained editor emitting only the tag subset in 10.3. Paste is sanitised on paste, not on save |
date-time |
Always shows the timezone in use, defaulting to the workspace timezone, with an explicit selector. Never relies on the browser's implicit zone |
repeater |
Add/remove/reorder rows with the same non-drag alternative rules as 9.3.3 |
icon |
Searchable picker over the bundled icon set; icons are inlined SVG sprites, never an icon font |
9.4.4 Autosave binding #
The inspector never has a Save button. Field changes flow into the local draft immediately (optimistic), mark the block dirty, and schedule an autosave per 9.5.1. A field whose value fails a hard constraint still saves; a field whose control is mid-interaction (an open colour picker, an in-flight media upload) defers its flush until the interaction completes.
9.5 Autosave, draft versus published state, version history and conflict resolution #
9.5.1 Autosave model #
| Property | Decision |
|---|---|
| Trigger | Debounced 800 ms after the last change; forced flush on control blur, on tab/window blur, on route change, and on visibilitychange → hidden |
| Granularity | Patch-based. The request carries only dirty blocks plus changed page/theme keys — never the whole document |
| Transport | POST /api/editor/pages/{id}/save with keepalive: true so a flush survives page unload |
| Concurrency | One in-flight save at a time. Changes made during a save are queued and sent immediately after it resolves |
| Idempotency | Each save carries Idempotency-Key (UUIDv7); replays return the original result (Section 21) |
| Response | {"data": {"revision": 43, "saved_at": "...", "block_ids": ["..."]}, "meta": {}} |
| Failure | See 9.12.2. The local draft is never discarded on failure |
| Status display | Publish bar shows: "Saving…" → "Saved 12:04" → on failure "Couldn't save — retrying" with a manual Retry action |
Request body:
{
"base_revision": 42,
"page_patch": { "seo_title": "Acme — links" },
"theme_patch": null,
"block_patches": [
{ "block_id": "0192f3b2-...", "op": "update", "settings": { "label": "Shop the drop" } },
{ "block_id": "0192f3c0-...", "op": "delete" }
],
"order": null
}9.5.2 Draft versus published #
Every page carries two documents:
| Document | Storage | Read by | Written by |
|---|---|---|---|
| Draft | bio_pages.draft_document + normalised blocks rows (Section 6) |
Editor, preview | Autosave |
| Published | page_versions row flagged is_current_published, plus a denormalised render payload |
The public renderer (Section 11) | Publish only |
Consequences, stated explicitly because they drive every other rule here:
- Editing never affects the live page. There is no "live editing" mode and no partial publish.
- A page with
status = publishedand unsaved-to-published changes shows a "3 unpublished changes" chip in the publish bar, expandable into a diff summary listing changed blocks by label and change type. - Unpublishing does not delete the published version; it clears
is_current_publishedso the public path serves the not-found page (11.10) while history remains intact. - Deleting a block that exists in the published version removes it from the draft only; it disappears publicly on the next publish.
9.5.3 Version history #
| Property | Decision |
|---|---|
| Snapshot points | Every publish; every restore; every theme preset application; and an automatic snapshot at most once per hour while a page is actively edited |
| Stored form | Complete PageDocument JSON, gzip-compressed, plus actor, timestamp, trigger and an auto-generated change summary |
| Retention | Free: 10 versions or 7 days, whichever is greater. Pro: 100 versions or 90 days. Business: 500 versions or 365 days. The current published version is never pruned |
| Listing | GET /api/editor/pages/{id}/versions?limit=25&cursor=... — cursor pagination per Section 21 |
| Viewing | Opens the version read-only in the preview pane with a banner "Viewing version from 4 Aug, 14:22 by Dana" |
| Diff | Block-level: added / removed / modified / moved, with per-field before-and-after for modified blocks |
| Restore | POST /api/editor/pages/{id}/versions/{version_id}/restore replaces the draft only. It never publishes. A confirmation dialog states this in one sentence. The pre-restore draft is snapshotted first, so restore is itself undoable |
| Naming | A version can be labelled (≤ 60 chars). Labelled versions are exempt from count-based pruning |
9.5.4 Conflict resolution — the exact rule #
Two people (or two tabs, or a tab and the public API) can edit the same page. The rule is optimistic concurrency with disjoint-set auto-merge, and an explicit dialog otherwise.
Server algorithm on POST /api/editor/pages/{id}/save:
1. Load current_revision within a transaction (SELECT ... FOR UPDATE on bio_pages).
2. If body.base_revision == current_revision:
apply patches; current_revision += 1; return 200 with the new revision.
3. Else compute the set of "server changes" = fields and block_ids changed by
revisions (body.base_revision, current_revision].
4. AUTO-MERGE if ALL of the following hold:
a. body.block_patches touch no block_id in the server-changed block set;
b. body.page_patch and body.theme_patch share no key with server-changed
page/theme keys;
c. body.order is null OR the server made no order change in that range;
d. no server change in that range was a publish or a version restore.
-> apply patches on top of current, current_revision += 1,
return 200 with meta.merged = true and meta.merged_from = base_revision.
5. Otherwise return 409 with:
{ "error": { "code": "page_revision_conflict",
"message": "This page was changed somewhere else.",
"details": [ { "field": "revision", "issue": "stale" } ],
"request_id": "req_..." } }
plus a conflict payload in the response body's error.details entries listing
the conflicting block_ids and the actor who changed them.Editor behaviour on 409:
- Autosave stops. The publish bar switches to a red "Editing conflict" state. No further writes are attempted.
- A modal appears, focus-trapped, naming the other actor and the time: "Dana changed this page 30 seconds ago. Your changes to 2 blocks conflict."
- Three actions, all explicit — LinkHub never silently discards work:
| Action | Effect |
|---|---|
| Keep mine | Re-sends the save with force_base_revision: <current>, overwriting the conflicting blocks. A version snapshot of the server state is taken first, so the other person's work is recoverable from history |
| Keep theirs | Discards the local conflicting patches, reloads the document at the current revision, and preserves the user's non-conflicting local changes as a normal pending save |
| Open a copy | Duplicates the page as a new draft containing the local state, leaving the original untouched. Used when both versions are genuinely wanted |
- Whichever is chosen, the local draft is first written to
IndexedDBunderconflict-backup:{page_id}:{timestamp}and kept for 7 days, surfaced in the editor's Trash tab as "Recovered draft".
Presence and prevention. The editor maintains a soft lock to prevent most conflicts from happening at all:
- On open,
POST /api/editor/pages/{id}/presenceregisters the session; a heartbeat refreshes it every 20 s; presence is held in a short-lived Redis entry keyed on the page id (a hash of session → actor, TTL 60 s) within thepage:namespace of Section 4's Redis catalogue. - When another session already holds presence, the second editor opens in read-only mode with a banner: "Dana is editing this page." and a Take over button.
- Take over is a deliberate action: it requires one confirmation click, immediately notifies the other session over the same polling channel (5 s interval — the editor uses polling, not websockets, to keep the dashboard's infrastructure surface small), and the displaced session is switched to read-only with its unsaved changes preserved locally and offered as "Open a copy".
- Presence is advisory. It is not a lock in the database sense, and the revision check in step 2 above remains the authority. A page edited through the public REST API bypasses presence entirely and is caught by the revision check.
9.6 Theme editor #
The Design tab edits a theme document scoped to one page. A theme can be saved to the workspace as a reusable brand theme (Admin and above) and applied to other pages.
9.6.1 Theme document #
{
"preset": "midnight",
"colors": {
"background": "#0B0D12",
"surface": "#151922",
"on_surface": "#F2F4F8",
"muted": "#9BA3B2",
"accent": "#6E8BFF",
"on_accent": "#0B0D12",
"button_bg": "#151922",
"button_text": "#F2F4F8",
"button_border": "#2A3040",
"border": "#232A38"
},
"background": {
"type": "solid", // solid | gradient | image | pattern
"gradient": { "from": "#0B0D12", "to": "#1A2140", "angle": 160 },
"image_media_id": null,
"overlay_opacity": 0.35, // 0–1, applied over image backgrounds
"blur": 0 // 0–20 px, image backgrounds only
},
"typography": {
"heading_family": "system", // one of the bundled families (9.6.3)
"body_family": "system",
"scale": "default", // compact | default | large
"heading_weight": 700,
"body_weight": 400,
"letter_spacing": "normal" // tight | normal | wide
},
"buttons": {
"shape": "rounded", // sharp | rounded | pill
"fill": "solid", // solid | outline | soft | glass
"shadow": "sm", // none | sm | md | hard
"border_width": 1, // 0–4 px
"size": "md" // sm | md | lg (min height 44px at sm)
},
"spacing": { "block_gap": 12, "page_padding": 20, "max_width": 640 },
"effects": { "animate_on_load": false, "hover_lift": true },
"contrast_override": null // see 9.6.5
}Constraints: block_gap 0–48 px, page_padding 12–48 px, max_width 480–840 px, overlay_opacity 0–1 in 0.05 steps, blur 0–20 px.
9.6.2 Presets #
Eight presets ship, each a complete, contrast-passing theme document: Clean (light, neutral), Midnight (dark), Paper (warm off-white, serif headings), Neon (dark, high-chroma accent), Sunset (gradient), Mono (black/white, sharp buttons), Editorial (large scale, generous spacing), Bold (pill buttons, hard shadow). Applying a preset replaces all colour, typography, button and spacing values, snapshots the previous theme to version history, and is undoable via Cmd/Ctrl+Z for the session. Custom values set after a preset is applied mark the theme preset: "custom" in the UI while retaining the base name for reference.
9.6.3 Typography #
Bundled families (self-hosted, subset to Latin + Latin-Extended, with per-family Cyrillic and Greek subsets loaded only when the page language requires them): System stack (default), Inter, Söhne-class grotesk substitute General Sans, Source Serif, Fraunces, Space Grotesk, JetBrains Mono. A theme may use at most 2 families and 3 total weights; the picker disables further choices once the cap is reached and explains why ("Extra fonts would push this page past its load budget"). Font strategy on the public path — preload, font-display: swap, subsetting — is specified in 11.5.3.
9.6.4 The contrast gate #
Contrast is evaluated continuously as the user edits, not only on save.
Pairs that must pass 4.5:1 (WCAG 2.2 §1.4.3, normal text):
| Pair | Foreground | Background |
|---|---|---|
| Body text | on_surface |
effective page background |
| Muted text | muted |
effective page background |
| Button label | button_text |
button_bg (or the effective page background when fill: outline) |
| Accent text | on_accent |
accent |
| Link/emphasis on surface | accent |
surface |
Pairs that must pass 3:1 (§1.4.11 non-text contrast): button_border vs page background; focus-ring colour vs both button_bg and page background.
Effective background computation for image and gradient backgrounds — this is the part naive implementations get wrong:
- For a solid background, use it directly.
- For a gradient, sample the gradient at 9 evenly spaced stops and take the worst-case (lowest-contrast) stop.
- For an image background, downscale the asset to 32×32, composite the overlay colour at
overlay_opacity, then take the worst-case luminance across the 5×5 central region where text actually sits, plus the four corners. Use that worst case. - Compute the WCAG 2.x relative-luminance contrast ratio. Round down to 2 decimal places — 4.499 is a failure.
The panel displays each pair with its computed ratio, a pass/fail state and a plain-language explanation. Save and Publish are both blocked while any 4.5:1 pair fails.
9.6.5 "Fix for me" suggestion algorithm #
Every failing pair offers a one-click fix. The algorithm preserves brand identity by holding hue constant and moving only lightness.
fixContrast(fg, bg, target = 4.5, locked: 'fg' | 'bg' | null):
1. Convert both colours to OKLCH.
2. Choose the mover:
if locked == 'fg' -> mover = bg
else if locked == 'bg' -> mover = fg
else mover = fg // default: move the foreground,
// it is the smaller visual area
3. Determine direction: if L(fg) >= L(bg), push the mover away from the other
(lighter fg / darker bg); otherwise push the opposite way.
4. Binary search L over [0, 1] for 12 iterations, holding H fixed and holding
C at min(C_original, maxChromaFor(L, H)) so the result stays in sRGB gamut:
find the L closest to L_original whose contrast >= target.
5. If no L in range reaches the target with the original hue (possible for
mid-lightness high-chroma hues against mid-lightness backgrounds):
a. Reduce C by 20% and repeat step 4.
b. Repeat (a) up to 3 times.
c. If still failing, fall back to pure #FFFFFF or #000000, whichever
has higher contrast against the fixed colour.
6. Produce up to THREE candidates and present them side by side:
- "Adjust text" (mover = fg)
- "Adjust background" (mover = bg)
- "Safe pair" (theme-derived neutral pair guaranteed to pass:
on_surface vs surface from the nearest preset)
7. Each candidate shows: the new hex, the resulting ratio, and a live preview
swatch of a button and a line of body text.
8. Applying a candidate writes only the changed token and is a single undo step.When multiple pairs fail simultaneously, a Fix all action runs the algorithm over the failing pairs in this priority order — body text, button label, muted text, accent text, non-text — re-evaluating after each fix so earlier corrections are respected. Fix all is one undo step.
9.6.6 The override path #
An override exists because a small number of legitimate designs (a deliberately low-contrast decorative section, a brand mandated by a client's legal team) cannot pass, and silently shipping an inaccessible page under a false claim of compliance is worse than recording the exception.
- The user opens Override contrast check.
- The dialog states the exact failing pairs and ratios, states that the page will not meet WCAG 2.2 AA, and requires typing the phrase
OVERRIDE CONTRASTexactly (case-sensitive, no auto-fill, paste disabled). - A reason field (10–280 chars) is required.
- On confirm,
theme.contrast_overrideis written:
{
"acknowledged_by_user_id": "0192f3...",
"acknowledged_at": "2026-08-19T09:14:22Z",
"reason": "Client brand guide mandates this pairing; approved by their legal team.",
"failing_pairs": [
{ "pair": "button_label", "fg": "#8A8F98", "bg": "#6E8BFF", "ratio": 2.11 }
]
}- An audit-log entry
theme.contrast_override_appliedis written per Section 8, capturing before/after values, the reason and the actor. - A persistent, non-dismissible warning banner appears in the editor for as long as the override is active.
- The workspace settings area lists all pages with an active override so an Admin can review them.
- Any subsequent change to a colour token clears the override and re-runs the gate. An override is scoped to the exact failing pairs recorded at acknowledgement time, never blanket.
- Overrides are reported in the workspace accessibility summary described in Section 24.
Only Admin and Owner may apply an override; an Editor sees the dialog with the confirm control disabled and an explanation.
9.7 Media uploads #
9.7.1 Accepted input #
| Kind | Formats | Max file size | Min dimensions | Max dimensions | Max pixels |
|---|---|---|---|---|---|
| Image (general) | JPEG, PNG, WebP, AVIF, non-animated GIF | 10 MB | 64 × 64 | 8192 × 8192 | 40 MP |
| Avatar | JPEG, PNG, WebP, AVIF | 5 MB | 128 × 128 | 8192 × 8192 | 40 MP |
| Favicon | PNG only | 1 MB | 256 × 256 (square, ±2 px tolerance) | 1024 × 1024 | — |
| Animated GIF | GIF | 5 MB | 64 × 64 | 2048 × 2048 | 30 frames/s max, 300 frames max |
| Video (self-hosted) | MP4 (H.264/AAC), MOV, WebM (VP9/Opus) | 200 MB | 240 p | 4096 × 2160 | ≤ 10 minutes |
| Audio | MP3, M4A, WAV | 50 MB | — | — | ≤ 30 minutes |
SVG is rejected for all user uploads, without exception and including favicons. SVG is an executable document format; accepting it on a surface that renders under a strict CSP with user-controlled content is not worth the flexibility. The rejection message says so plainly and suggests PNG. LinkHub's own bundled icons are inlined SVG sprites shipped from the repo and are unaffected.
Self-hosted video and audio uploads are Pro and Business only; Free workspaces use the external embed blocks (10.7). This is a bandwidth-cost decision and is enforced by the entitlement service, returning 403 plan_limit_reached.
Per-workspace storage quotas: Free 250 MB, Pro 10 GB, Business 100 GB. Exceeding returns 403 plan_limit_reached with details.limit, details.current and details.plan.
9.7.2 Upload flow #
1. Client requests an upload ticket:
POST /api/editor/media/uploads
{ "filename": "hero.jpg", "content_type": "image/jpeg",
"byte_size": 2481923, "purpose": "block_image" }
Server validates extension/content-type/size against 9.7.1 and the workspace
quota, then returns a presigned PUT URL, a media_id (UUIDv7) and required headers.
-> 201 { "data": { "media_id": "...", "upload_url": "...", "expires_at": "..." } }
Ticket TTL: 15 minutes. Presign is scoped to the exact key, size and content type.
2. Client PUTs the bytes directly to object storage. Progress is shown per file.
Multipart upload is used above 8 MB with 8 MB parts and up to 3 concurrent parts.
3. Client confirms:
POST /api/editor/media/{media_id}/complete
-> 202 Accepted; media.status = 'processing'.
Server enqueues a `media-process` job on the queue described in Section 17's
worker topology.
4. Worker processes (9.7.3). Terminal status is 'ready', 'rejected' or 'failed'.
5. Client polls GET /api/editor/media/{media_id} every 1s (capped at 60s, then
every 5s to 5 minutes). The editor shows a skeleton with a progress state and
allows the user to keep working; the block holds the media_id and renders a
placeholder until the asset is ready.If the client never calls complete, an orphan-sweep job deletes unconfirmed objects older than 24 hours.
9.7.3 Processing pipeline #
Executed in apps/worker, queue media-process, concurrency 4 per worker, per-job timeout 120 s for images and 900 s for video.
| Step | Action | Failure behaviour |
|---|---|---|
| 1. Fetch | Stream the object from storage into a bounded temp file | Retry 3× with backoff, then failed |
| 2. Sniff | Detect the real type from magic bytes. Mismatch with the declared content type is fatal | rejected, code media_type_mismatch |
| 3. Bomb check | Reject if declared pixel count > 40 MP, if the compression ratio exceeds 200:1, or if a GIF's frame count × area exceeds the limit | rejected, code media_too_large |
| 4. Sanitise | Strip all metadata: EXIF (after applying orientation and discarding it), GPS, IPTC, XMP, embedded colour profiles other than sRGB (converted, not preserved), and any embedded thumbnails | Fatal → failed |
| 5. Moderate | 9.7.4 | rejected or quarantined |
| 6. Analyse | Compute intrinsic width/height, aspect ratio, dominant colour, average luminance, and a 32-char LQIP (a 4×3 blurred base64 WebP, ≤ 400 bytes) | Non-fatal; defaults applied |
| 7. Derive | Images: re-encode to AVIF (primary) and WebP (fallback) at widths [64, 128, 256, 512, 768, 1080, 1440], skipping widths above the intrinsic width. Never upscale. Quality: AVIF q50 / WebP q78, tuned to keep an 800 px hero under 40 KB. Avatars additionally get a 1:1 centre-crop set at [64, 128, 256, 512]. Favicons produce 16/32/48/180/192/512 PNGs plus a multi-size .ico |
Any derivative failure → failed, nothing published |
| 8. Animated GIF | Convert to muted, looping MP4 (H.264) and WebM (VP9), plus a static WebP poster from frame 1. The original GIF is discarded | Falls back to a static poster if transcode fails |
| 9. Video | Transcode to H.264/AAC MP4 at up to three ladders (360 p, 720 p, 1080 p — never above the source), generate a poster at 10% duration, extract duration and dimensions | Partial ladder is acceptable; zero renditions → failed |
| 10. Store | Write derivatives to their final keys (9.7.5), delete the temp file and the incoming original after 7 days (retained briefly so a processing bug can be re-run) | — |
| 11. Publish | Set status = 'ready', write the variant manifest to the media_assets row, invalidate the media CDN surrogate key |
— |
Images are processed with sharp; video with a bundled ffmpeg binary in the worker image. The worker is the only component with either dependency — neither ships in apps/web or apps/edge.
9.7.4 Moderation and safety check #
Every uploaded image and video poster passes through moderation before it can be referenced publicly.
| Check | Mechanism | Threshold | Action |
|---|---|---|---|
| Known-illegal content | Perceptual hash (PDQ-class) compared against the hash list supplied by the configured trust-and-safety provider | Exact/near match | Hard block, asset deleted, workspace flagged, incident raised per Section 25, mandatory report per the operator's legal obligations. The uploading user sees a generic "This file can't be uploaded" message with no detail |
| Adult/explicit content | Bundled image classifier, scores 0–1 | ≥ 0.98 | rejected, code media_moderation_blocked |
| Adult/explicit content | Same | 0.85 – 0.979 | quarantined — usable only on a page with nsfw_gate = true; otherwise the block shows a placeholder and the publish gate blocks |
| Violence/gore | Same classifier head | ≥ 0.95 | quarantined, manual review queue |
| Embedded text scam patterns | OCR pass, matched against a phishing-phrase corpus | Match | quarantined, manual review |
| Malware | Object storage antivirus scan hook on the raw object | Detection | Hard block, object purged |
The moderation provider is behind an interface (ModerationProvider) with a bundled local classifier as the default implementation, so the deployment has no mandatory third-party dependency. When the provider is unavailable, assets enter pending_review rather than ready; they can be used in the draft and preview but the page cannot be published until moderation completes. Failing open on moderation is not permitted.
Appeals: a rejected asset shows a "Request review" action which creates a support case. Quarantine decisions are logged to the audit log (Section 8) as media.quarantined.
9.7.5 Storage paths and delivery #
Incoming (private, lifecycle-expired after 7 days):
incoming/{workspace_id}/{media_id}/original.{ext}
Derivatives (public-read, immutable):
media/{workspace_id}/{media_id}/{width}.avif
media/{workspace_id}/{media_id}/{width}.webp
media/{workspace_id}/{media_id}/poster.webp
media/{workspace_id}/{media_id}/video-{height}p.mp4
media/{workspace_id}/{media_id}/video-{height}p.webm
media/{workspace_id}/{media_id}/favicon-{size}.png
media/{workspace_id}/{media_id}/favicon.icoPublic URL: https://{MEDIA_CDN_HOST}/m/{workspace_id}/{media_id}/{variant} — for example https://cdn.linkhub.app/m/0192.../0192.../768.avif. MEDIA_CDN_HOST is environment configuration (Section 27); the object store is never addressed directly from public HTML.
Derivative objects are immutable and content-addressed by media_id; editing an image produces a new media_id. Headers: Cache-Control: public, max-age=31536000, immutable, Content-Type per variant, Cross-Origin-Resource-Policy: cross-origin, X-Content-Type-Options: nosniff. CDN surrogate key media-{media_id} exists solely so a moderation takedown can purge instantly.
Deleting a media asset soft-deletes the record, immediately purges the CDN key, and blocks new references; blocks already referencing it fall back to their empty state (Section 10) and are flagged in the pre-publish review. Hard purge of the objects happens after the 30-day window.
9.7.6 Media library #
GET /api/editor/media?limit=25&cursor=...&type=image&q=hero — cursor pagination per Section 21. Grid view with filename, dimensions, size, upload date and a usage count ("used on 3 blocks"). Deleting an in-use asset requires confirmation naming the affected pages. Alt text is stored on the asset as a default and can be overridden per block, so a logo uploaded once does not need alt text written five times.
9.8 Live preview #
9.8.1 Mechanism #
The preview is an <iframe> loading the real public template from GET /preview/{page_id}?token={preview_token}&mode={draft|published|version}&version_id={...}. It is not a re-implementation of the public renderer; it is the public renderer with a different data source. A second rendering path would drift, and drift in a WYSIWYG editor destroys trust.
preview_token: HMAC-SHA256 over(page_id, user_id, session_id, exp), 30-minute expiry, single workspace scope. Presented as a query parameter so the iframe works without third-party-cookie access.- The preview route sets
X-Robots-Tag: noindex, nofollow,Cache-Control: private, no-store, andContent-Security-Policy: frame-ancestors https://app.linkhub.app(plus the origin's own dashboard host in non-production environments). - Updates are patched, not reloaded: the editor posts the changed document over
postMessageto the iframe, which applies a targeted DOM update. A full reload happens only when the theme's font families change, when the language or direction changes, or on explicit Refresh. Patch latency target: < 120 ms from keystroke-debounce to painted change. - If the
postMessagechannel fails three consecutive times, the preview falls back to full reloads and shows a small "Preview is reloading fully" note.
9.8.2 Device frames #
| Frame | Viewport | Notes |
|---|---|---|
| Mobile (default) | 390 × 844, DPR 2 | The primary target — the large majority of bio-page traffic is mobile |
| Mobile small | 320 × 568, DPR 2 | The reflow floor; used to check §1.4.10 compliance |
| Tablet | 834 × 1112, DPR 2 | |
| Desktop | 1280 × 800, DPR 1 |
Frames scale to fit the available pane using a CSS transform; the iframe's internal viewport is always the true pixel size so media queries evaluate correctly. A rotate control swaps width and height. The chosen frame persists per user.
9.8.3 How preview differs from production #
Every difference is deliberate and is listed in the UI under "About preview".
| Aspect | Preview | Production |
|---|---|---|
| Content source | Draft (or a selected version) | Published version only |
| Hidden blocks | Rendered at 45% opacity with a "Hidden" chip | Not rendered at all |
| Scheduled blocks outside their window | Rendered with a "Scheduled" chip and the window shown | Not rendered |
| A/B variants | A variant selector lets you view each variant | Assigned per Section 16 |
| Analytics | Never emitted. No view, click or scan events are recorded | Emitted per Section 17 |
| Third-party pixels | Never fired | Per consent state (Section 23) |
| Consent banner | Hidden by default; shown in first-time-visitor mode | Per Section 23 |
| Caching | no-store; every render is fresh |
CDN + Redis per 11.3 |
| Password gate | Bypassed | Enforced |
| NSFW gate | Bypassed unless first-time-visitor mode | Enforced |
| LinkHub branding | Shown exactly as the plan dictates | Same |
| Embeds | Facade only; providers are never contacted unless the facade is clicked | Same facade behaviour |
| Robots | noindex |
Per indexable |
| Short links | Outbound links open in a new tab and are not counted | Counted |
9.8.4 "Preview as a first-time visitor" #
A toggle in the preview toolbar. When enabled the preview reloads with ?fresh=1 and:
- The iframe is recreated with a fresh, partitioned storage context, so
localStorage,sessionStorageand any consent state from earlier previews are gone. - The consent banner renders exactly as an EEA visitor would see it (region simulation is selectable: EEA / UK / US / other).
- The password gate renders, so the author can confirm their gate copy.
- The NSFW gate renders when enabled.
- Embed facades are in their un-clicked state.
- Countdown blocks recompute from the current time.
- Optional network throttling to the reference device profile from 11.2 (4G, 1.6 Mbps, 150 ms RTT), implemented by delaying subresource responses in the preview route — an approximation, clearly labelled as such, and never presented as a Lighthouse score.
- A "Simulate JavaScript off" switch renders the page with the enhancement bundle omitted, so the author can verify the no-JS experience described in 11.4 for their specific page. This is the single most useful control in the preview toolbar and is not buried in a menu.
9.9 Publishing #
9.9.1 Publish flow #
1. User activates Publish (button, or Cmd/Ctrl+Shift+P).
2. Client flushes any pending autosave and waits for it to succeed.
A failed flush aborts the publish with the save error.
3. POST /api/editor/pages/{id}/publish { "base_revision": 43 }
4. Server gate — all must pass, evaluated together so the user sees every problem
at once rather than one at a time:
a. Actor has Editor+ role and, on Business, a grant covering this page.
b. Actor's email is verified (Section 7). Otherwise 403 `email_verification_required`.
c. Workspace is not suspended. Otherwise 403 `workspace_suspended`.
d. Page count within the plan's bio-page entitlement (Section 22).
Otherwise 403 `plan_limit_reached`.
e. Handle is valid and still available (9.2.2).
f. Every block passes its Zod schema. Otherwise 422 `page_validation_failed`
with one details entry per failing field, keyed `blocks[{block_id}].{field}`.
g. Theme passes the contrast gate, or carries a valid override (9.6.6).
Otherwise 422 `theme_contrast_failed`.
h. No referenced media asset is in `rejected`, `quarantined` (on a page
without nsfw_gate), `processing` or `pending_review`.
Otherwise 422 `media_not_ready`.
i. No destination URL is currently flagged by the safety service (Section 23).
Otherwise 422 `destination_blocked` listing the offending block ids.
j. base_revision matches (9.5.4).
5. Server writes, in ONE transaction:
- a page_versions row (full document, is_current_published = true,
previous current cleared);
- bio_pages.status = 'published', published_at, published_revision;
- the denormalised render payload;
- an audit-log entry `bio_page.published` (Section 8).
6. After commit, the publish job enqueues cache invalidation (9.9.3; the
authoritative mutation-to-purge mapping is Section 4.7).
7. Response 200:
{ "data": { "status": "published", "revision": 44,
"published_at": "2026-08-19T09:20:11Z",
"url": "https://linkhub.app/acme" },
"meta": { "cache_purge": "queued" } }Warnings (missing alt text, a block with no label, a countdown whose end date has passed, a link to a domain that has never resolved) do not block. They are shown in a pre-publish review sheet with a per-item "Fix" jump link and a "Publish anyway" action. The review sheet is skippable via a per-user preference, except when warnings include an accessibility item — those always surface at least once per session.
9.9.2 What goes live, and when #
- Publishing is atomic at the page level: the entire document flips together. There is no partial publish and no per-block publish.
- Once the transaction commits, the new version is authoritative at origin. Visitor-visible propagation is governed by cache purge: p95 < 5 seconds globally, p99 < 15 seconds. The publish confirmation states "Live in a few seconds" rather than claiming instant global consistency.
- Media assets are already public before publish (they are immutable and content-addressed), so no media propagation delay exists.
- If the purge job fails entirely, TTLs alone bound staleness to 60 seconds at the CDN and 300 seconds at Redis (11.3.3), so a purge failure is a latency defect, never a correctness one.
9.9.3 Cache invalidation on publish #
The publish transaction's after-commit hook enqueues one cache-purge job carrying the surrogate keys and Redis keys to drop. The complete mapping from mutation type to purged key is the cache-invalidation matrix in Section 4.7, which is authoritative and is not restated here; publishing purges, at minimum, the page-{page_id} CDN surrogate key and the page's page:{host}:{handle} Redis entries. Purges are idempotent and retried 5× with exponential backoff; a permanently failing purge raises the operational alert defined in Section 25.
9.9.4 Scheduled publishing #
| Property | Decision |
|---|---|
| Field | publish_at (and optional unpublish_at) on page settings |
| Minimum lead time | 5 minutes |
| Maximum lead time | 365 days |
| Timezone | Selected explicitly in the picker, defaulting to the workspace timezone. Stored UTC |
| Plan gating | Pro and Business (scheduling is a Pro+ entitlement per Section 22). Free users see the control with an upgrade affordance |
| Mechanism | A BullMQ delayed job on queue page-publish, plus a sweeper that runs every minute and publishes any page whose publish_at has passed and which is still scheduled — the sweeper exists so a lost delayed job cannot silently skip a launch |
| Gate re-evaluation | The full publish gate (9.9.1 step 4) re-runs at execution time. A failure leaves the page in scheduled with schedule_error set, notifies the actor and every workspace Admin by email and in-app, and retries every 15 minutes for 6 hours before moving to schedule_failed |
| Editing while scheduled | Permitted. Edits update the draft; the scheduled publish will publish whatever the draft contains at execution time. The editor states this explicitly next to the schedule chip |
| Cancelling | One click, returns the page to its previous status |
| Status | status = 'scheduled' with the target time shown in the publish bar and in page lists |
| Audit | bio_page.publish_scheduled, bio_page.published (with trigger: "schedule"), bio_page.unpublished |
9.9.5 Unpublish #
POST /api/editor/pages/{id}/unpublish requires a confirmation dialog stating that the public URL will stop working. It clears is_current_published, sets status = 'unpublished', purges the same keys as publish, and writes bio_page.unpublished to the audit log. The public URL then serves the not-found page (11.10.1) with 404 — unless the handle is permanently reserved on that host (Section 14), in which case it resolves through the fallback rungs in 11.9.3 like any other reserved slug. LinkHub deliberately does not serve a "this page was removed" message, because that leaks the prior existence of a page a user chose to take down. Version history is untouched, and republishing is one click.
9.10 Empty states, onboarding and templates #
9.10.1 Empty states #
| Context | Content |
|---|---|
| No pages in workspace | Illustration, heading "Create your first page", one-sentence explanation, primary Start from a template, secondary Start blank. Free workspaces additionally see "Your plan includes 1 page" |
| Empty canvas (blank page) | A dashed drop zone with "Add your first block", the three most common types as one-tap chips (Link, Header, Social icons), and Browse all blocks |
| Empty group block | "This section is empty. Add blocks here." with an inline add control |
| Empty media library | "No media yet", upload affordance, accepted formats and limits stated inline |
| Empty version history | "No versions yet. A version is saved every time you publish." |
| Empty trash | "Nothing here. Deleted blocks stay for 30 days." |
| Search with no results (block picker) | "No blocks match '{query}'", plus the three nearest matches by edit distance |
| Page limit reached | "You've used 1 of 1 pages on Free." with a comparison of what Pro adds and an Upgrade action, plus Manage pages |
Every empty state names the next action; none is decorative only.
9.10.2 First-page onboarding #
A four-step wizard, skippable at any point, resumable, and never modal-blocking after the first step:
- Purpose — "What is this page for?" (Creator / Business / Event / Portfolio / Something else). Chooses a starting template and default block set.
- Identity — display name, avatar upload (skippable), one-line bio. Populates the header/profile block.
- Your links — up to five URL fields with auto-fetched titles and favicons; each becomes a link block. Pasting a URL from a known social network offers to add it to a social icons block instead.
- Handle — with live availability checking and three suggestions derived from the display name.
Completing the wizard lands the user in the editor with a populated draft and a two-step product tour (canvas, publish button) that can be dismissed permanently. Progress is checkpointed after each step, so an abandoned wizard leaves a usable draft rather than nothing.
9.10.3 Templates #
Templates are complete page documents (blocks + theme) with placeholder content. Twelve ship at launch: Creator, Musician, Podcaster, Restaurant, Local business, Event, Portfolio, Newsletter, Shop, Nonprofit, Personal, Minimal.
| Property | Decision |
|---|---|
| Application | Replaces the draft entirely. When the draft is non-empty, a confirmation dialog offers Replace or Add blocks below, and a version snapshot is taken first |
| Placeholder content | Clearly marked; the pre-publish review lists any remaining placeholder text as a warning |
| Theme | Applied unless the user unticks "Also apply the theme" |
| Contrast | Every shipped template passes 4.5:1 in its default state, verified by an automated test in CI (Section 26) |
| Custom templates | Business plan: an Admin may save any page as a workspace template, visible to all members. Stored as a normal page document with is_template = true |
| Marketplace | Out of scope; named as roadmap only |
9.11 Editor keyboard shortcuts and keyboard operability #
9.11.1 Shortcuts #
Cmd on macOS, Ctrl elsewhere. All shortcuts are listed in a dialog opened with ? and are individually discoverable from tooltips.
| Shortcut | Action |
|---|---|
Cmd/Ctrl + S |
Force save now (autosave already covers this; the binding exists because users press it regardless) |
Cmd/Ctrl + Shift + P |
Publish (opens the pre-publish review) |
Cmd/Ctrl + Z / Cmd/Ctrl + Shift + Z |
Undo / redo (50-step stack) |
Cmd/Ctrl + D |
Duplicate the selected block |
Cmd/Ctrl + K |
Open the command palette (add block, jump to block, switch tab, toggle preview) |
/ (canvas focused, no selection) |
Open the block type picker inline |
Cmd/Ctrl + Enter |
Add a new block after the selection |
Delete / Backspace (card focused) |
Delete the selected block (undo toast for 10 s) |
↑ / ↓ (canvas focused) |
Move selection between blocks |
Alt + ↑ / Alt + ↓ |
Move the selected block up/down |
Alt + Home / Alt + End |
Move the selected block to top/bottom |
Alt + → / Alt + ← |
Move into the group above / out of the current group |
Tab / Shift + Tab |
Standard focus order |
Esc |
Close the topmost sheet/dialog; from a field, return focus to the block card |
Cmd/Ctrl + 1..5 |
Switch the rail to Blocks / Design / Settings / History / Trash |
Cmd/Ctrl + \ |
Toggle the preview pane |
Cmd/Ctrl + Shift + V |
Toggle "preview as a first-time visitor" |
? |
Shortcut reference |
Shortcuts are suppressed while a text input, textarea or rich-text field has focus, except Cmd/Ctrl+S, Cmd/Ctrl+Z, Cmd/Ctrl+K and Esc.
9.11.2 Full keyboard operability requirements #
- Every action available by pointer is available by keyboard. This is verified by an E2E suite that drives the complete create-a-page-and-publish journey with the pointer disabled (Section 26).
- A skip link ("Skip to block canvas") is the first focusable element.
- The canvas is a roving-tabindex list: one Tab stop enters the list, arrows move between cards, Tab from a card moves into that card's controls. This keeps a 40-block page from becoming a 200-Tab-stop maze.
- Focus indicators meet WCAG 2.2 §2.4.13: a 2 px outline with a 2 px offset, contrast ≥ 3:1 against both adjacent colours, never removed and never replaced by colour change alone.
- Sticky headers, sheets and toasts must not obscure the focused element (§2.4.11). The canvas applies
scroll-margin-block: 96pxand toasts are anchored bottom-centre, offset above any focused control. - Dialogs and sheets trap focus, are dismissible with
Esc, and return focus to the invoking control on close. - Drag-and-drop is never the only way to accomplish anything (§2.5.7, see 9.3.3).
- No keyboard trap exists anywhere, including inside the rich-text control (where
EscthenTabexits) and the preview iframe (which istabindex="-1"with an explicit "Enter preview" button that focuses inside it and an in-frame "Leave preview" control). - Live regions announce save state, reorder results, validation errors on publish, and upload completion — politely, never assertively, except for the conflict dialog and save failures.
9.12 Editor error handling and offline behaviour #
9.12.1 Connection state machine #
online ──(fetch fails / navigator.onLine=false)──► degraded ──(3 consecutive failures)──► offline
▲ │ │
└──────────(successful save)─────────────────────────┴────(successful heartbeat)────────────┘| State | Editor behaviour |
|---|---|
online |
Normal. Save status shows "Saved {time}" |
degraded |
Amber chip "Reconnecting…". Autosave retries with backoff. Editing continues without restriction |
offline |
Red chip "Offline — changes saved on this device". Autosave halts; every change is written to IndexedDB. Publish, version restore and media upload are disabled with an explanatory tooltip |
A lightweight heartbeat (GET /api/editor/ping, ~40 bytes) runs every 15 s while degraded or offline, with jitter, to detect recovery without hammering the origin.
9.12.2 Save failure taxonomy #
| Condition | HTTP | Code | Editor behaviour |
|---|---|---|---|
| Network failure / timeout | — | — | Retry at 1 s, 2 s, 4 s, 8 s, 16 s, 30 s, then every 30 s. Never gives up while the tab is open |
| Validation rejected | 400 | validation_failed |
Field-level errors rendered. The draft keeps the invalid value locally; the save is not retried until the value changes |
| Session expired | 401 | unauthenticated |
Non-dismissible dialog: "Your session expired." with Sign in again, which opens auth in a popup and resumes the save on success. Local changes are preserved throughout |
| Role/grant lost mid-session | 403 | insufficient_role |
Editor switches to read-only, banner explains, local changes offered as a downloadable JSON export |
| Entitlement exceeded | 403 | plan_limit_reached |
Upgrade sheet with details.limit / details.current / details.plan rendered into the copy |
| Page deleted elsewhere | 404 | not_found |
"This page was deleted." with Restore (if within the 30-day window and the actor may) or Save as a new page |
| Revision conflict | 409 | page_revision_conflict |
Conflict dialog per 9.5.4 |
| Payload too large | 413 | request_too_large |
Occurs only on pathological rich-text pastes. The offending field is identified and truncation is offered |
| Rate limited | 429 | rate_limited |
Respect Retry-After; status shows "Saving shortly…". Autosave debounce temporarily increases to 3 s |
| Server error | 5xx | — | Retry with backoff, surface request_id in the error detail so support can trace it |
9.12.3 Local durability #
- Every change is written to IndexedDB (
linkhub-editordatabase,draftsstore, key{page_id}) within 250 ms, independently of network state. The store holds the full draft document plus the last-knownbase_revisionand a change log. - On editor open, if IndexedDB holds a draft newer than the server's
updated_atfor that page, a "Restore unsaved changes?" bar appears with a diff summary and Restore / Discard. It is never applied automatically. - IndexedDB drafts are cleared on successful save, and expire after 7 days regardless.
beforeunloadwarns only when unsaved changes exist and a save is not in flight — never as a blanket guard, because a spurious "are you sure" dialog on every navigation trains users to dismiss it.- When IndexedDB is unavailable (private browsing modes, storage denied), the editor falls back to an in-memory buffer, shows a one-time notice that local recovery is unavailable, and reduces the autosave debounce to 300 ms to narrow the loss window.
9.12.4 Media upload errors #
| Condition | Behaviour |
|---|---|
| Upload interrupted | Multipart uploads resume from the last completed part; single-part uploads restart. Three automatic attempts, then a manual Retry on the file chip |
| Ticket expired (15 min) | A new ticket is fetched transparently and the upload restarts |
| Processing failed | The block shows an error placeholder with the reason and Replace file. The page remains publishable only if the block's image is optional for its type |
| Moderation rejected | Clear message, asset removed from the block, Request review action |
| Quota exceeded | Upgrade sheet plus a link to the media library sorted by size, so the user can delete something |
| Unsupported format | Rejected client-side before upload starts, naming the accepted formats |
9.12.5 Error reporting #
Every unexpected editor exception is captured by a global error boundary that (a) renders a recoverable panel with Reload editor and Download my draft rather than a blank screen, (b) reports to the error tracker with request_id, page id, workspace id, editor state summary and the last 20 user actions as breadcrumbs — never field values, never media content — and (c) leaves the IndexedDB draft intact so reloading loses nothing.
10. Bio Page Block Catalog #
This section is the reference catalogue for every block type a bio page can contain. It is written so that a developer can implement any block without reading the others, and so that the renderer's behaviour on the public path is fully determined.
Every block specification below follows the same eleven-part structure: purpose · settings schema · rendered markup · responsive behaviour · accessibility · analytics · no-JavaScript behaviour · error states · empty states · plan gating · notes. Where a part is genuinely not applicable, it says so rather than being omitted.
10.0 Conventions shared by every block #
10.0.1 The common block envelope #
Every block, regardless of type, is stored and serialised with the same envelope. Type-specific data lives entirely inside settings. The database schema backing this is defined in Section 6.
| Field | Type | Required | Default | Validation | Description |
|---|---|---|---|---|---|
block_id |
uuid | Yes | UUIDv7 generated in app code | — | Public identifier; stable across edits; used in analytics and in the DOM id |
page_id |
uuid | Yes | — | Must belong to the actor's workspace | Owning page |
container_id |
uuid | null | Yes | null |
null = page root, else a group block id |
Parent container |
type |
string | Yes | — | Must be a registered type (10.16) | Discriminator |
position |
integer | Yes | append | 0-based within the container; managed by the reorder endpoint (9.3.2) | Order |
visible |
boolean | Yes | true |
— | Manual hide toggle |
settings |
jsonb | Yes | type default | Validated by the type's Zod schema | Type-specific configuration |
schedule |
object | null | No | null |
See Section 15 | Time-window and targeting rules |
settings_version |
integer | Yes | current type version | — | Drives the migration chain (10.16.5) |
created_at / updated_at / deleted_at |
timestamptz | Yes / Yes / No | now / now / null |
— | Standard lifecycle columns |
10.0.2 Rendered wrapper #
The page body is a semantic list. Every root-level block is one list item:
<main id="content">
<ol class="lh-blocks" role="list">
<li class="lh-block lh-block--link" id="b-0192f3b2" data-b="0192f3b2">
<!-- block markup -->
</li>
</ol>
</main>data-bcarries the first 8 characters of the block id; the enhancement bundle uses it for analytics attribution. The full id is never needed client-side.role="list"is stated explicitly because several CSS resets that remove list markers also remove list semantics in Safari.- Class names use the
lh-prefix and BEM-style modifiers. Blocks never rely on utility classes in the public HTML — the public stylesheet is generated, inlined and budgeted (11.5.2), which rules out shipping a utility framework's output.
10.0.3 Analytics events #
All public-path events are emitted per the pipeline in Section 17. Blocks emit these event types:
| Event | When | Notes |
|---|---|---|
page_view |
Once per page render, server-side | Not a block event; listed for context |
block_click |
Any activation of an outbound or in-page target inside a block | Carries block_id, block_type, target_url_hash, variant_id |
block_impression |
First time a block enters the viewport, once per page view | Enhancement-only; absent without JS. Batched, sent at most once per second |
form_submit |
Email capture submission accepted | Server-side, never client-side |
embed_load |
A facade is clicked and the provider iframe is inserted | Enhancement-only |
media_play |
Self-hosted video/audio play starts | Enhancement-only |
Every event carries the standard envelope defined in Section 17: workspace_id, resource_type, resource_id, visitor_hash, variant_id, ts, plus the dimension fields resolved at the edge. No event ever carries raw IP, precise location, or the visitor's link destination in cleartext beyond what is already public.
Click tracking on outbound links is server-side by default: a link's href points at the tracked redirect endpoint (10.1.7), so clicks are counted with JavaScript disabled. The enhancement bundle does not intercept clicks and does not need to.
10.0.4 Accessibility baseline for every block #
Applies to all types; block-specific requirements are additional, never a replacement.
- Interactive targets: ≥ 24 × 24 CSS px always; ≥ 44 × 44 on coarse pointers.
- Every interactive element has a non-empty accessible name computed from visible text where possible, from
aria-labelonly when there is no visible text. - Colour is never the only carrier of meaning (badges, sold-out, error states all carry text or an icon with a label).
- Focus order equals DOM order equals visual order. No positive
tabindexanywhere. prefers-reduced-motion: reducedisables all block-level animation, autoplay, marquee and parallax.- Text is resizable to 200% and reflows at 320 px without horizontal scrolling.
- Every block template is covered by an automated axe-core check in CI plus the manual screen-reader matrix defined in Section 24.
10.0.5 No-JavaScript baseline #
Every block must render complete, useful, navigable HTML from the server. The block-by-block guarantees below are the specifics; the surface-level contract and the test that proves it are in 11.4.
10.0.6 Plan gating mechanism #
Gated blocks are enforced in three places, and all three must agree: the block picker (hidden or badged), the publish gate (9.9.1), and the renderer (a gated block on a downgraded workspace renders nothing rather than a broken or nagging element). Downgrade never deletes a block — it becomes inert and reappears on re-upgrade, consistent with the never-delete downgrade policy in Section 22.
10.1 Link block #
10.1.1 Purpose #
The primary conversion unit of a bio page: a large, tappable, tracked outbound link with an optional icon, thumbnail, description and badge. Everything else on the page exists to support this block.
10.1.2 Settings schema #
| Field | Type | Required | Default | Validation | Description |
|---|---|---|---|---|---|
label |
string | Yes | "" |
1–80 chars after trim; no line breaks | The visible link text and the accessible name |
url |
string | Yes | "" |
Absolute URL; scheme in http, https, mailto, tel, sms; ≤ 2048 chars; passes the SSRF and safety checks in Section 23 |
Destination |
description |
string | null | No | null |
≤ 140 chars | Secondary line under the label |
icon |
string | null | No | null |
A key from the bundled icon set, or null |
Leading glyph |
thumbnail_media_id |
uuid | null | No | null |
Ready image asset in this workspace | Leading image, 1:1 |
thumbnail_shape |
enum | Yes | rounded |
square | rounded | circle |
Thumbnail mask |
badge |
object | null | No | null |
See below | Corner pill |
badge.text |
string | Cond. | "New" |
1–12 chars | Pill label |
badge.style |
enum | Yes | accent |
accent | neutral | success | warning |
Pill colour token; all four pass 4.5:1 against their own background |
badge.expires_at |
timestamptz | null | No | null |
Future timestamp | Badge auto-hides after this instant; the link stays |
layout |
enum | Yes | stacked |
stacked (full-width) | inline (label + chevron, compact) | card (large thumbnail above text) |
Visual layout |
open_in_new_tab |
boolean | Yes | false |
— | Adds target="_blank" rel="noopener" |
prominence |
enum | Yes | normal |
normal | featured |
featured applies the accent fill and a subtle scale |
track_clicks |
boolean | Yes | true |
— | When false the anchor points straight at url and no block_click is recorded |
short_link_id |
uuid | null | No | null |
Must be a link in this workspace | Bind this block to an existing branded short link so analytics unify (Section 12) |
utm |
object | null | No | null |
Section 15 | UTM parameters merged into the destination |
nofollow |
boolean | Yes | true |
— | Adds rel="nofollow"; default true because bio pages are a common link-spam target |
Scheduling and visibility rules (schedule) come from the shared envelope (10.0.1) and are specified in 10.15 and Section 15.
10.1.3 Rendered markup #
<li class="lh-block lh-block--link" id="b-0192f3b2" data-b="0192f3b2">
<a class="lh-link lh-link--stacked lh-link--featured"
href="https://linkhub.app/acme/-/c/0192f3b2"
rel="nofollow noopener"
data-t="click">
<img class="lh-link__thumb" src="https://cdn.linkhub.app/m/…/128.avif"
srcset="…/64.avif 64w, …/128.avif 128w, …/256.avif 256w"
sizes="48px" width="48" height="48" alt="" loading="lazy" decoding="async">
<span class="lh-link__body">
<span class="lh-link__label">Shop the summer drop</span>
<span class="lh-link__desc">Free shipping until Sunday</span>
</span>
<span class="lh-link__badge lh-link__badge--accent">New</span>
<svg class="lh-link__chev" aria-hidden="true" focusable="false" width="16" height="16">…</svg>
</a>
</li>Rules:
- The
<a>is the only interactive element; the entire block area is the hit target. There is never a nested button or a click handler on the<li>. - The thumbnail carries
alt=""because the label already names the destination; a duplicated name is noise for screen-reader users. If a thumbnail is the only content (no label), validation rejects the block. - The chevron is decorative (
aria-hidden). width/heightare always present so no layout shift occurs (11.6).
10.1.4 Responsive behaviour #
| Viewport | Behaviour |
|---|---|
| < 480 px | Full-width; thumbnail 48 px; label wraps to a maximum of 2 lines then ellipsis; description 1 line then ellipsis; badge moves inline after the label when the label is short, otherwise pins to the top-right corner |
| 480–767 px | Thumbnail 56 px; label up to 2 lines |
| ≥ 768 px | Block max-width follows theme.spacing.max_width; thumbnail 64 px; description up to 2 lines |
card layout |
Thumbnail becomes a 16:9 banner above the text at every width, with aspect-ratio reserved |
inline layout |
Single row, label truncated with ellipsis at one line, minimum height 44 px |
10.1.5 Accessibility #
- Accessible name: the
labeltext. Whendescriptionis set it is included via the anchor's text content, so the announced name is"Shop the summer drop, Free shipping until Sunday". When a badge is present its text is appended:", New". This is deliberate — the badge conveys meaning and must not be hidden. - Screen-reader announcement (NVDA/Chrome): "link, Shop the summer drop, Free shipping until Sunday, New — list item 3 of 9".
open_in_new_tabappends a visually hidden" (opens in a new tab)"to the anchor's text. Users must be told before activation, not after.- Focus style is the theme's focus ring at ≥ 3:1 against both the button fill and the page background (enforced by 9.6.4).
- Minimum block height 44 px at all layouts.
- Blocks are
<li>inside<ol>, so position and set size are announced.
10.1.6 Analytics #
| Event | Trigger | Extra fields |
|---|---|---|
block_click |
Server-side, when the tracked redirect endpoint is hit | block_id, block_type: "link", destination_host, short_link_id, variant_id, utm_* |
block_impression |
Enhancement bundle, on first intersection ≥ 50% for ≥ 300 ms | block_id, position |
10.1.7 The tracked click path #
When track_clicks is true, the anchor's href is https://{page_host}/{handle}/-/c/{block_id_short} (path segment - is reserved on every bio page host and cannot be a handle child). That endpoint is served by the public renderer, not apps/edge, because it needs page context; it performs exactly the same work as the short-link resolver in 11.8 steps 8–12: emit the event fire-and-forget, then 302 to the resolved destination with Cache-Control: private, no-store. Its processing budget is p95 < 30 ms.
When short_link_id is set, the anchor instead points at that branded short link's public URL, so the click is attributed to both the block and the link with one event (Section 12 defines the join).
When track_clicks is false, href is the raw destination and nothing is recorded. The setting exists for links where the author has a contractual reason to avoid an intermediary hop.
10.1.8 No-JavaScript behaviour #
Fully functional. The anchor is a real <a href> resolved server-side; the click is counted by the redirect endpoint, not by a beacon. Thumbnails, badges, descriptions, layout and focus styling are all CSS and HTML. Nothing about this block requires JavaScript, which is the point: the core conversion path of the product has zero JS dependency.
10.1.9 Error states #
| Condition | Editor | Public render |
|---|---|---|
url empty |
Field error "Add a destination"; publish blocked | Block omitted |
url fails safety check |
Warning chip "This destination was flagged"; publish blocked (destination_blocked) |
Block omitted; if flagged after publish, the link resolves to the safety interstitial (11.10.5) |
label empty |
Field error; publish blocked | Block omitted |
thumbnail_media_id references a deleted or rejected asset |
Warning; publish allowed | Renders without a thumbnail, layout unaffected (dimensions still reserved only when an image is expected — the reserved box collapses cleanly) |
| Destination returns 404 on the weekly recheck | Editor shows a "Destination not reachable" warning | Unchanged; LinkHub does not decide a link is dead on the author's behalf |
10.1.10 Empty state #
Not applicable — a link block with no destination cannot be published, and an unpublished draft block renders only in preview, where it shows a dashed placeholder reading "Add a destination".
10.1.11 Plan gating #
The block itself is available on every plan. Gated sub-features: utm (Pro+), schedule (Pro+), variant participation in an A/B test (Pro+). Gated fields render locked in the inspector with an upgrade affordance; a gated field already populated from a previous plan stays stored and simply stops applying, per Section 22.
10.2 Header / profile block #
10.2.1 Purpose #
Identity at the top of the page: avatar, display name, short bio, optional verified badge. Typically the LCP element, so its rendering is performance-critical (11.5.4, 11.6.2).
10.2.2 Settings schema #
| Field | Type | Required | Default | Validation | Description |
|---|---|---|---|---|---|
avatar_media_id |
uuid | null | No | null |
Ready image asset, square crop set available | Profile image |
avatar_shape |
enum | Yes | circle |
circle | rounded | square |
Mask |
avatar_size |
enum | Yes | md |
sm (72 px) | md (96 px) | lg (128 px) |
Rendered size |
display_name |
string | Yes | page title | 1–60 chars | Primary heading |
name_element |
enum | Yes | h1 |
h1 | p |
Set to p when another block should own the page heading. Exactly one h1 per page is enforced by the publish gate |
bio |
string | null | No | null |
≤ 280 chars; plain text; single \n line breaks preserved, consecutive breaks collapsed |
Supporting text |
verified |
boolean | Yes | false |
Only settable when the workspace has completed identity verification (Section 8); otherwise rejected with verified_badge_not_earned |
Verified indicator |
alignment |
enum | Yes | center |
left | center |
Layout |
avatar_alt |
string | null | No | asset default alt | ≤ 140 chars | Overrides the media asset's alt text |
location |
string | null | No | null |
≤ 60 chars | Optional secondary line with a pin icon |
pronouns |
string | null | No | null |
≤ 24 chars | Optional inline chip after the name |
The verified badge is not a cosmetic toggle. Allowing self-assigned verification badges manufactures a trust signal that impersonators will use; the flag is therefore server-gated on a workspace-level verification state and its change is written to the audit log.
10.2.3 Rendered markup #
<li class="lh-block lh-block--profile" id="b-0192f3a1" data-b="0192f3a1">
<header class="lh-profile lh-profile--center">
<img class="lh-profile__avatar lh-profile__avatar--circle"
src="https://cdn.linkhub.app/m/…/192.avif"
srcset="…/96.avif 96w, …/192.avif 192w, …/256.avif 256w"
sizes="96px" width="96" height="96"
alt="Acme Studio logo"
fetchpriority="high" decoding="async">
<h1 class="lh-profile__name">
Acme Studio
<span class="lh-profile__verified" role="img" aria-label="Verified account">
<svg aria-hidden="true" focusable="false" width="16" height="16">…</svg>
</span>
</h1>
<p class="lh-profile__pronouns">they/them</p>
<p class="lh-profile__bio">Independent design studio. Prints, posters, occasional chaos.</p>
<p class="lh-profile__location">
<svg aria-hidden="true" focusable="false" width="14" height="14">…</svg>Lisbon
</p>
</header>
</li>The avatar is the only image on the page rendered with fetchpriority="high" and without loading="lazy", because it is the presumed LCP element. See 11.5.4.
10.2.4 Responsive behaviour #
| Viewport | Behaviour |
|---|---|
| < 480 px | Avatar renders at its configured size capped to 96 px; name at clamp(1.375rem, 6vw, 1.75rem); bio at 0.9375 rem, max 6 lines then a native <details>-free "…" (no truncation control — truncating identity text hurts more than a slightly long block) |
| ≥ 480 px | Configured avatar size; name at clamp(1.5rem, 4vw, 2rem) |
alignment: left |
Avatar and text left-aligned in a row on ≥ 480 px, stacked below |
| RTL languages | Mirrors automatically via logical properties; no separate stylesheet |
10.2.5 Accessibility #
- Accessible name of the page: the
h1content. Exactly oneh1per page — the publish gate rejects a document with zero or multiple. - The verified badge is
role="img"witharia-label="Verified account"so it is announced; a purely decorative checkmark would silently hide a meaningful claim. - Avatar alt text: required when the avatar conveys information (a logo, a photo of the person). The editor warns on an empty alt and offers "This image is decorative" which explicitly sets
alt="". A missing decision produces the asset's default alt. - Screen-reader announcement: "heading level 1, Acme Studio, Verified account", then "they/them", then the bio paragraph.
- Bio line breaks are rendered as real block boundaries, not
<br>runs, so screen readers pause correctly.
10.2.6 Analytics #
Emits no click events (it contains no links). block_impression is not emitted for the profile block — it is above the fold on every render and the datum is worthless.
10.2.7 No-JavaScript behaviour #
Fully functional. Pure HTML and CSS. No JavaScript is involved in any part of this block.
10.2.8 Error and empty states #
| Condition | Behaviour |
|---|---|
| No avatar set | Renders a monogram: the first grapheme cluster of display_name, uppercased, on the theme's accent fill with a guaranteed-contrasting text token. Same reserved dimensions, so no CLS |
| Avatar still processing | Renders the monogram; the editor shows a spinner; publishing is blocked (media_not_ready) |
| Avatar rejected by moderation | Monogram; editor error; publish blocked |
display_name empty |
Publish blocked; preview shows "Add your name" |
| Two profile blocks on one page | Permitted, but only the first may use h1; the publish gate forces the second to p and warns |
10.2.9 Plan gating #
None. Available on all plans. The verified badge is not a plan feature.
10.3 Text block #
10.3.1 Purpose #
Free-form prose: announcements, disclaimers, short paragraphs between link groups. Deliberately constrained — a bio page is not a CMS.
10.3.2 Settings schema #
| Field | Type | Required | Default | Validation | Description |
|---|---|---|---|---|---|
content_html |
string | Yes | "" |
Sanitised against the allow-list in 10.3.3; ≤ 8000 characters after sanitisation | Rich text |
alignment |
enum | Yes | left |
left | center | right | justify |
Text alignment. justify is available but the editor warns that it harms readability |
size |
enum | Yes | md |
sm | md | lg |
Relative to the theme's type scale |
emphasis |
enum | Yes | none |
none | card | quote |
card renders on the surface token with padding; quote renders as a <blockquote> with a leading rule |
max_width |
enum | Yes | page |
page | narrow (52 ch) |
Measure control |
10.3.3 Allowed tags and attributes #
Sanitisation runs server-side on save with an allow-list, and again on render as a defence-in-depth pass. The client editor enforces the same list on paste. Anything not on this list is stripped, with its text content preserved.
| Element | Allowed attributes | Notes |
|---|---|---|
<p> |
— | Default block |
<br> |
— | Collapsed to a maximum of one consecutive |
<strong>, <b> |
— | <b> normalised to <strong> |
<em>, <i> |
— | <i> normalised to <em> |
<u> |
— | Permitted, but the editor warns it is easily confused with a link |
<s>, <del> |
— | <del> normalised to <s> |
<code> |
— | Inline only |
<a> |
href, title |
href scheme allow-list http, https, mailto, tel, sms only. rel="nofollow noopener" and target="_blank" are added by the sanitiser, not by the author. Same safety checks as 10.1 |
<ul>, <ol>, <li> |
<ol start> |
Nesting depth ≤ 2 |
<h2>, <h3> |
— | h1 is forbidden (owned by the profile block); heading levels must not skip, and a skipped level is auto-corrected on save |
<blockquote> |
— | |
<hr> |
— | |
<span> |
class limited to lh-mark |
Highlight only |
Explicitly forbidden and stripped: <script>, <style>, <iframe>, <object>, <embed>, <form>, <input>, <img> (use the image block), <video>, <audio>, <svg>, <math>, all on* handlers, all style attributes, all data-* attributes, javascript:/data:/vbscript: URLs, HTML comments, and CSS expression() remnants. The sanitiser operates on a parsed DOM tree, never on a regular expression over the string.
10.3.4 Rendered markup #
<li class="lh-block lh-block--text" id="b-0192f3c4" data-b="0192f3c4">
<div class="lh-text lh-text--md lh-text--left">
<p>Pre-orders close <strong>Friday at 6pm</strong>.</p>
<ul><li>Ships worldwide</li><li>30-day returns</li></ul>
</div>
</li>10.3.5 Responsive, accessibility, analytics, no-JS #
- Responsive: font size scales with the theme type scale;
max_width: narrowusesmax-width: 52ch; long words break withoverflow-wrap: anywhereso a pasted URL cannot cause horizontal scrolling at 320 px. - Accessibility: heading order validated on save; links inside text inherit the same focus and target-size rules as 10.1; the block has no interactive wrapper and no accessible name of its own (it is static content). Contrast of
lh-markagainst the surface is included in the theme contrast gate (9.6.4). - Screen-reader announcement: read as ordinary document content; lists announce item counts.
- Analytics:
block_clickfor any<a>inside, attributed withblock_type: "text"and the anchor's index within the block. Links in text blocks are tracked through the same redirect endpoint as 10.1.7 unless the page hastrack_clicksdisabled globally. - No-JS: fully functional; static markup only.
10.3.6 Error and empty states #
Empty content_html (or content that sanitises to whitespace only) makes the block invalid: publish is blocked with block_content_required, and preview shows "Add some text". A block whose content sanitises down from a large paste is flagged in the editor with a note naming how many elements were removed, so the author is not surprised.
10.3.7 Plan gating #
None.
10.4 Image block #
10.4.1 Purpose #
A standalone image, optionally linked, with an optional caption.
10.4.2 Settings schema #
| Field | Type | Required | Default | Validation | Description |
|---|---|---|---|---|---|
media_id |
uuid | Yes | — | Ready image asset in this workspace | The image |
alt |
string | null | Yes (nullable) | asset default | ≤ 140 chars. null is only accepted when decorative is true |
Alternative text |
decorative |
boolean | Yes | false |
When true, renders alt="" and role="presentation" |
Explicit decorative declaration |
caption |
string | null | No | null |
≤ 200 chars, plain text | Rendered in a <figcaption> |
link_url |
string | null | No | null |
Same URL rules as 10.1 | Makes the image a tracked link |
aspect |
enum | Yes | original |
original | 1:1 | 4:5 | 16:9 | 3:2 |
Crop applied at render via aspect-ratio + object-fit: cover |
width |
enum | Yes | full |
full | wide (page width) | narrow (60%) |
Rendered width |
corner |
enum | Yes | theme |
theme | none | sm | lg | full |
Corner radius |
priority |
boolean | Yes | false |
Only one block per page may set true; the publish gate enforces it |
Marks this as the LCP candidate instead of the avatar |
10.4.3 Rendered markup #
<li class="lh-block lh-block--image" id="b-0192f3d0" data-b="0192f3d0">
<figure class="lh-image lh-image--full">
<a href="https://linkhub.app/acme/-/c/0192f3d0" rel="nofollow noopener" data-t="click">
<img src="https://cdn.linkhub.app/m/…/768.avif"
srcset="…/512.avif 512w, …/768.avif 768w, …/1080.avif 1080w, …/1440.avif 1440w"
sizes="(max-width: 640px) 100vw, 640px"
width="1080" height="1350"
style="aspect-ratio:4/5;background-image:url(data:image/webp;base64,…)"
alt="Poster print in a walnut frame"
loading="lazy" decoding="async">
</a>
<figcaption class="lh-image__caption">Limited run of 50.</figcaption>
</figure>
</li>The inline background-image is the LQIP produced in 9.7.3 step 6 (≤ 400 bytes), removed by CSS once the image paints. It is inline rather than a separate request because a second request for a placeholder defeats the purpose.
AVIF is served with a <picture> fallback to WebP only when the request's Accept header does not advertise AVIF; the renderer inspects Accept server-side and emits a single <img> in the common case, avoiding <picture> markup weight for the 95%+ of traffic that supports AVIF.
10.4.4 Responsive behaviour #
sizes is computed from width and the theme's max_width. Images never exceed their intrinsic width (no upscaling). At < 480 px, narrow becomes 80% rather than 60% so images stay legible.
10.4.5 Accessibility #
- Accessible name: when linked, the accessible name of the anchor is the
alttext, or thecaptionwhendecorativeis true and a caption exists. A linked image withdecorative: trueand no caption is rejected at publish (link_missing_accessible_name) — a link with no name is unusable. - The editor requires an explicit decision: either alt text or an affirmative "this image is decorative" tick. There is no silent empty-alt path.
<figcaption>is associated by being inside<figure>; it is not duplicated intoalt.- Screen-reader announcement (linked): "link, Poster print in a walnut frame", then the caption as following content.
10.4.6 Analytics, no-JS, errors, empty states, gating #
- Analytics:
block_clickwhen linked;block_impressionon first intersection. - No-JS: fully functional. Images, captions, links and the LQIP all work; only the LQIP fade-out is CSS-driven and requires nothing.
- Errors: missing
media_idblocks publish (block_media_required); a deleted asset renders the empty state and warns; an asset still processing blocks publish. - Empty state: preview shows a dashed 4:5 placeholder with "Choose an image".
- Plan gating: none.
10.5 Social icons block #
10.5.1 Purpose #
A compact row of platform icons linking to the author's profiles. Distinct from link blocks because the icon is the label and the row is horizontally packed.
10.5.2 Settings schema #
| Field | Type | Required | Default | Validation | Description |
|---|---|---|---|---|---|
items |
array | Yes | [] |
1–16 entries; publish blocked when empty | The icons |
items[].network |
enum | Yes | — | One of the networks in 10.5.3, or custom |
Platform |
items[].value |
string | Yes | — | Validated by the network's pattern (10.5.3) | Handle, URL, address or phone number depending on network |
items[].label_override |
string | null | No | null |
≤ 40 chars | Overrides the generated accessible name |
items[].custom_icon |
string | null | Cond. | null |
Required when network = custom; a key from the bundled icon set |
Icon for custom entries |
size |
enum | Yes | md |
sm (32 px) | md (40 px) | lg (48 px) |
Icon box size; hit area is always ≥ 44 px on coarse pointers regardless |
style |
enum | Yes | plain |
plain | filled | outlined | brand |
brand uses each platform's official colour and is contrast-checked against the page background |
alignment |
enum | Yes | center |
left | center | right | space-between |
Row alignment |
show_labels |
boolean | Yes | false |
— | Renders the network name under each icon |
open_in_new_tab |
boolean | Yes | true |
— | Social profiles default to a new tab |
10.5.3 Supported networks #
value is normalised on save: a full URL is accepted and reduced to the canonical form; a bare handle is expanded. Leading @ is stripped. The resulting href is always the canonical URL below.
| Network | Icon key | Accepted input | Canonical URL | Value pattern |
|---|---|---|---|---|
instagram |
handle or URL | https://instagram.com/{h} |
^[A-Za-z0-9._]{1,30}$ |
|
| TikTok | tiktok |
handle or URL | https://tiktok.com/@{h} |
^[A-Za-z0-9._]{1,24}$ |
| X | x |
handle or URL | https://x.com/{h} |
^[A-Za-z0-9_]{1,15}$ |
| YouTube | youtube |
handle, channel URL, or /c/, /user/, /channel/ URL |
https://youtube.com/@{h} or the given channel URL |
^@?[A-Za-z0-9._-]{3,30}$ or a channel id ^UC[\w-]{22}$ |
facebook |
page name, numeric id, or URL | https://facebook.com/{h} |
^[A-Za-z0-9.]{5,50}$ or ^\d{5,20}$ |
|
linkedin |
in/{h}, company/{h}, or URL |
https://linkedin.com/{path} |
^(in|company|school)/[A-Za-z0-9-]{3,100}$ |
|
| Threads | threads |
handle or URL | https://threads.net/@{h} |
^[A-Za-z0-9._]{1,30}$ |
| Bluesky | bluesky |
handle or URL | https://bsky.app/profile/{h} |
^[a-z0-9.-]{3,253}$ |
| Mastodon | mastodon |
@user@instance or URL |
https://{instance}/@{user} |
^@?[\w.-]+@[a-z0-9.-]+\.[a-z]{2,}$ |
pinterest |
handle or URL | https://pinterest.com/{h} |
^[A-Za-z0-9_]{3,30}$ |
|
| Snapchat | snapchat |
handle or URL | https://snapchat.com/add/{h} |
^[A-Za-z][\w.-]{2,14}$ |
reddit |
u/{h}, r/{h} or URL |
https://reddit.com/{path} |
^(u|r)/[A-Za-z0-9_-]{2,21}$ |
|
| Twitch | twitch |
handle or URL | https://twitch.tv/{h} |
^[A-Za-z0-9_]{4,25}$ |
| Discord | discord |
invite code or URL | https://discord.gg/{code} |
^[A-Za-z0-9-]{2,32}$ |
| Telegram | telegram |
handle or URL | https://t.me/{h} |
^[A-Za-z][\w]{4,31}$ |
whatsapp |
E.164 phone | https://wa.me/{digits} |
^\+?[1-9]\d{7,14}$ |
|
| Spotify | spotify |
artist/user URL | https://open.spotify.com/{type}/{id} |
^(artist|user)/[A-Za-z0-9]{22}$ |
| Apple Music | apple-music |
artist URL | https://music.apple.com/{storefront}/artist/{slug}/{id} |
Full URL only |
| SoundCloud | soundcloud |
handle or URL | https://soundcloud.com/{h} |
^[a-z0-9_-]{3,25}$ |
| Bandcamp | bandcamp |
subdomain or URL | https://{h}.bandcamp.com |
^[a-z0-9-]{3,63}$ |
| GitHub | github |
handle or URL | https://github.com/{h} |
^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$ |
| Behance | behance |
handle or URL | https://behance.net/{h} |
^[A-Za-z0-9_-]{3,40}$ |
| Dribbble | dribbble |
handle or URL | https://dribbble.com/{h} |
^[A-Za-z0-9_-]{3,40}$ |
| Substack | substack |
subdomain or URL | https://{h}.substack.com |
^[a-z0-9-]{3,63}$ |
| Patreon | patreon |
handle or URL | https://patreon.com/{h} |
^[A-Za-z0-9_-]{3,50}$ |
| Ko-fi | kofi |
handle or URL | https://ko-fi.com/{h} |
^[A-Za-z0-9_-]{3,50}$ |
email |
address | mailto:{address} |
RFC 5322 addr-spec, ≤ 254 chars | |
| Phone | phone |
E.164 phone | tel:{e164} |
^\+[1-9]\d{7,14}$ |
| SMS | sms |
E.164 phone | sms:{e164} |
^\+[1-9]\d{7,14}$ |
| Website | globe |
URL | as given | http(s) only |
| Custom | chosen custom_icon |
URL | as given | http(s), mailto, tel, sms |
Icons are inlined from a single SVG sprite emitted per page containing only the networks actually used, so an unused network costs nothing. The sprite is part of the HTML budget in 11.2 and is counted by the CI gate.
10.5.4 Rendered markup #
<li class="lh-block lh-block--social" id="b-0192f3e2" data-b="0192f3e2">
<ul class="lh-social lh-social--md lh-social--center" role="list">
<li>
<a class="lh-social__item" href="https://instagram.com/acmestudio"
target="_blank" rel="me noopener nofollow" data-t="click">
<svg class="lh-social__icon" aria-hidden="true" focusable="false" width="20" height="20">
<use href="#i-instagram"></use>
</svg>
<span class="lh-vh">Instagram</span>
</a>
</li>
…
</ul>
</li>rel="me" is included on profile links so the page can act as a verification back-link for platforms that check it (Mastodon in particular). .lh-vh is the visually-hidden utility; when show_labels is true the same span loses that class and becomes visible text.
10.5.5 Responsive behaviour #
The row wraps rather than scrolling horizontally — a horizontally scrolling icon strip hides content on touch devices with no affordance. At < 360 px with more than 6 items, size steps down one level automatically while preserving the 44 px hit area via padding. With show_labels, the row becomes a wrapping grid with a minimum column of 72 px.
10.5.6 Accessibility #
- Accessible name per item:
label_override, else"{Network name}", e.g."Instagram". Foremail/phone/sms, the name is"Email","Call"and"Text"respectively — never the raw address, which is unpleasant to hear read aloud character by character. The address is available in thetitleand visible on hover/focus. - Screen-reader announcement: "list, 5 items. link, Instagram, opens in a new tab. link, TikTok, opens in a new tab…".
style: brandcolours are contrast-checked; a brand colour failing 3:1 against the page background falls back to the theme'son_surfacetoken with a note in the editor rather than silently rendering an invisible icon.- Icon-only controls always carry a visually hidden text label; no
aria-label-only icons, so that translation tools and voice-control users see the same string.
10.5.7 Analytics, no-JS, errors, empty states, gating #
- Analytics:
block_clickper item withnetworkas an extra dimension. - No-JS: fully functional.
- Errors: an item whose
valuefails its pattern shows an inline error naming the expected format with an example; publish blocked. Acustomitem missingcustom_iconblocks publish. - Empty state: zero items blocks publish; preview shows "Add a social profile".
- Plan gating: none.
10.6 Video block (self-hosted or external) #
10.6.1 Purpose #
Plays a video inline. Two sources: an uploaded asset (self-hosted) or a direct URL to an MP4/WebM file. Provider-hosted videos (YouTube, Vimeo, TikTok) are embed blocks (10.7), not this block, because their delivery, privacy and CSP characteristics are completely different.
10.6.2 Settings schema #
| Field | Type | Required | Default | Validation | Description |
|---|---|---|---|---|---|
source |
enum | Yes | upload |
upload | url |
Where the file comes from |
media_id |
uuid | null | Cond. | null |
Required when source = upload; must be a ready video asset |
Uploaded video |
file_url |
string | null | Cond. | null |
Required when source = url; https only; must end .mp4 or .webm; must pass the SSRF checks in Section 23; HEAD request must return a video/* content type |
External file |
poster_media_id |
uuid | null | No | auto-generated at 10% duration | Ready image asset | Poster frame |
title |
string | Yes | "" |
1–80 chars | Accessible name and visible caption |
captions_url |
string | null | No | null |
https WebVTT file, or an uploaded .vtt asset |
Subtitles track |
captions_language |
string | Cond. | page language | BCP-47 | Track language |
autoplay |
boolean | Yes | false |
Forces muted: true and playsinline: true when enabled |
Autoplay |
muted |
boolean | Yes | false |
— | Start muted |
loop |
boolean | Yes | false |
— | Loop playback |
controls |
boolean | Yes | true |
Forced true unless autoplay && muted && loop (a decorative background clip) |
Native controls |
aspect |
enum | Yes | 16:9 |
16:9 | 9:16 | 1:1 | 4:5 |
Reserved box |
10.6.3 Rendered markup #
<li class="lh-block lh-block--video" id="b-0192f3f1" data-b="0192f3f1">
<figure class="lh-video" style="aspect-ratio:16/9">
<video controls preload="none" playsinline
poster="https://cdn.linkhub.app/m/…/poster.webp"
width="1280" height="720"
aria-label="Studio tour, 2 minutes">
<source src="https://cdn.linkhub.app/m/…/video-1080p.mp4" type="video/mp4">
<source src="https://cdn.linkhub.app/m/…/video-720p.webm" type="video/webm">
<track kind="captions" src="…/captions.vtt" srclang="en" label="English" default>
<p>Your browser can't play this video.
<a href="https://cdn.linkhub.app/m/…/video-720p.mp4">Download it instead.</a></p>
</video>
<figcaption class="lh-video__title">Studio tour</figcaption>
</figure>
</li>preload="none" is mandatory: a preloading video destroys the LCP and transfer budgets in 11.2. The poster carries the visual weight until the user acts.
10.6.4 Responsive behaviour #
The <figure> reserves aspect-ratio at every width, so no shift occurs when metadata loads. Below 480 px the video is full-bleed to the page padding. The browser selects the rendition; LinkHub does not ship an adaptive-bitrate player (no HLS/DASH), because the added JavaScript would violate the enhancement budget in 11.7 for a marginal benefit on short clips.
10.6.5 Accessibility #
- Accessible name:
title, applied asaria-labelon the<video>and rendered visibly in the<figcaption>. - Native controls are used, so keyboard operation, screen-reader support and platform captions UI come from the browser. A custom player is explicitly out of scope.
autoplayis only permitted whenmutedis true; the editor explains that browsers block unmuted autoplay and that autoplaying video is disorienting. Whenprefers-reduced-motion: reduceis set, autoplay is suppressed entirely by the enhancement bundle and the poster remains until the user presses play.- A captions track is strongly encouraged; the pre-publish review lists videos without captions as a warning (Section 24 requires captions for prerecorded media at AA, so this is a real compliance item, not a nicety).
- Screen-reader announcement: "Studio tour, 2 minutes, media player" followed by the browser's own control announcements.
10.6.6 Analytics, no-JS, errors, empty states, gating #
- Analytics:
media_playon first play (enhancement bundle, via theplayevent);block_impressionon intersection. No quartile tracking — it costs bytes and nobody acts on it at this scale. - No-JS: fully functional.
<video controls>is native; playback, captions, fullscreen and download fallback all work. Onlymedia_playanalytics and reduced-motion autoplay suppression are lost; with JS off, autoplay is left to the browser's own policy. - Errors: a processing failure shows the poster with an error note in the editor and blocks publish; an external
file_urlthat fails its HEAD check shows an inline error naming the received content type. A video whose transcode produced zero renditions blocks publish (media_not_ready). - Empty state: preview shows a 16:9 dashed placeholder with "Add a video".
- Plan gating:
source: uploadis Pro and Business (self-hosted media bandwidth).source: urlis available on all plans. Free workspaces attempting an upload get the upgrade sheet.
10.7 Embed blocks #
10.7.0 The shared embed contract #
All eight embed providers share one implementation and one set of rules. Only the parsing, the iframe URL, the poster source and the CSP allowance differ. Read this subsection once; each provider subsection below states only its deltas.
The facade pattern (mandatory, no provider exempt). A published embed renders as a static server-rendered card — poster image, provider mark, title, duration where known, and a real <a href> to the canonical provider URL. No provider script, no provider iframe and no provider request happens on page load. The provider iframe is inserted only after the visitor activates the facade. This is the single most important rule in this subsection: a page with three unfacaded embeds cannot meet the LCP, transfer or blocking-JS budgets in 11.2, and it leaks every visitor's IP to three third parties before they have chosen to interact.
Settings common to all embed blocks:
| Field | Type | Required | Default | Validation | Description |
|---|---|---|---|---|---|
provider |
enum | Yes | — | One of the eight below | Discriminator |
url |
string | Yes | "" |
Must match the provider's accepted patterns | Source URL as pasted |
resource_id |
string | Yes | derived | Extracted at save time and stored, so a later provider URL change cannot break the block | Canonical id |
title |
string | Yes | fetched via oEmbed, else "{Provider} embed" |
1–120 chars | Accessible name and visible caption |
poster_media_id |
uuid | null | No | fetched and re-hosted at save time | Ready image asset | Facade image |
aspect |
enum | Yes | provider default | 16:9 | 9:16 | 1:1 | 4:5 | auto-fixed-height |
Reserved box |
start_seconds |
integer | null | No | null |
0–86399; only for time-addressable providers | Start offset |
theme_hint |
enum | Yes | auto |
auto | light | dark |
Passed to providers that accept it |
consent_required |
boolean | Yes | true |
— | When true and the visitor is in a consent-gated region without marketing consent, the facade's activate control is replaced by a link-out (see below) |
Metadata fetch at save time. When the author pastes a URL, apps/web performs a server-side fetch (never from the browser) of the provider's oEmbed endpoint or, where none is usable, the page's Open Graph tags. It extracts title, author, duration and thumbnail; the thumbnail is downloaded and re-hosted through the media pipeline (9.7.3) so the published page makes zero third-party requests. Fetches are: 5 s timeout, 2 retries, SSRF-checked per Section 23, and cached in Redis under embed:meta:{provider}:{resource_id} for 24 hours.
Facade markup (identical for every provider):
<li class="lh-block lh-block--embed" id="b-0192f401" data-b="0192f401">
<figure class="lh-embed" style="aspect-ratio:16/9"
data-embed="youtube" data-src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?rel=0"
data-title="Studio tour">
<a class="lh-embed__facade" href="https://www.youtube.com/watch?v=dQw4w9WgXcQ"
rel="noopener nofollow" target="_blank" data-t="click">
<img src="https://cdn.linkhub.app/m/…/768.avif" srcset="…" sizes="(max-width:640px) 100vw, 640px"
width="1280" height="720" alt="" loading="lazy" decoding="async">
<span class="lh-embed__play" aria-hidden="true"><svg …></svg></span>
<span class="lh-embed__meta">
<span class="lh-embed__title">Studio tour</span>
<span class="lh-embed__provider">Watch on YouTube</span>
</span>
</a>
</figure>
</li>Activation (enhancement bundle, embeds chunk, ≤ 5 KB gzip for all eight providers combined): on click or Enter/Space, the script prevents default, replaces the <a> with an <iframe> built from data-src, copies data-title to the iframe's title, applies loading="lazy", allowfullscreen, referrerpolicy="strict-origin-when-cross-origin", the provider's minimal allow list, and moves focus into the iframe. It emits embed_load. The reserved box is unchanged, so activation causes zero layout shift.
Consent interaction. When the workspace has consent gating active (Section 23) and marketing consent has not been granted, activation does not insert the iframe. Instead the facade shows a short in-place notice — "Loading this player shares data with {Provider}." — with two controls: Allow and play (grants marketing consent for this session and then activates) and Open on {Provider} (navigates out). This keeps the decision at the moment of consequence.
CSP. The base public-page CSP (Section 23) contains no provider origins. A page's response headers are computed from the set of embed providers actually present on it, and only those origins are added. A page with one Spotify embed does not carry YouTube's allowances. default-src 'self' remains; the additions are per-provider frame-src entries, plus img-src entries only where a provider's post-activation player pulls its own artwork.
Fallback when the provider is unreachable. Three distinct cases, all handled:
| Case | Behaviour |
|---|---|
| oEmbed/metadata fetch fails at save time | The block still saves. title defaults to "{Provider} embed", the facade uses a generic provider-branded placeholder with the theme's surface colour, and the editor shows a warning with a Retry action. The author can set a title and poster manually. Publishing is never blocked by a third party being down |
| Poster re-host fails | Same as above — generic placeholder, no hotlinking to the provider's CDN as a fallback |
| Provider iframe fails after activation (blocked, offline, geo-restricted, deleted video) | The iframe's load is watched with a 8-second timer; on timeout or on an error event the script restores the facade and swaps the caption to "Couldn't load the player. Open on {Provider}." with the canonical link. The visitor is never left with an empty box |
Privacy note (rendered to visitors, not just documented here). Every embed block renders a small, dismissible-per-session note under the first embed on the page: "Players load only when you press play. Playing shares your IP address and device information with the provider." Workspaces can reword it; they cannot remove it. The DPIA reasoning behind this is in Section 23.
Shared accessibility requirements. The facade is a link with an accessible name of "Play {title} on {Provider}" (the poster's alt is empty because the name comes from the visible text). After activation the iframe carries title="{title} — {Provider} player", and an aria-live="polite" announcement says "{Provider} player loaded." Keyboard activation works via the native link. Focus moves into the iframe on activation and Esc returns focus to the block and restores the facade.
Shared no-JavaScript behaviour. The facade is a plain <a> around a poster image. With JavaScript disabled it is a fully working, clearly labelled link that opens the content on the provider's site in a new tab. No embed is ever the only path to the content. This is the entire reason the facade is an <a> and not a <button>.
Shared analytics. block_click when the facade link is followed (server-side via the tracked redirect endpoint, so it counts without JS); embed_load when the iframe is inserted (enhancement only).
Shared errors, empty state and gating. An unparseable URL is an inline field error naming the accepted formats with an example. An empty url blocks publish. Preview shows a dashed 16:9 placeholder with "Paste a {Provider} link". No embed block is plan-gated; embeds are available on every plan.
Global limit: a maximum of 8 embed blocks per page, enforced at publish (block_limit_exceeded). Even facaded, each embed costs a poster image.
10.7.1 YouTube #
| Aspect | Value |
|---|---|
| Accepted URLs | youtube.com/watch?v={id}, youtu.be/{id}, youtube.com/shorts/{id}, youtube.com/live/{id}, youtube.com/embed/{id}, with or without www/m |
| Id pattern | ^[A-Za-z0-9_-]{11}$ |
| Extra params honoured | t / start → start_seconds; list → playlist id (stored, appended as list=) |
| Metadata | oEmbed at https://www.youtube.com/oembed?url={url}&format=json — no API key required |
| Poster | https://i.ytimg.com/vi/{id}/maxresdefault.jpg, falling back to hqdefault.jpg on 404. Re-hosted |
| Iframe URL | https://www.youtube-nocookie.com/embed/{id}?rel=0&modestbranding=1&playsinline=1&start={n} |
allow |
accelerometer; encrypted-media; picture-in-picture; web-share; fullscreen |
| CSP additions | frame-src https://www.youtube-nocookie.com |
| Default aspect | 16:9; Shorts ids detected from the URL path default to 9:16 |
| Privacy | youtube-nocookie.com is used unconditionally. It reduces, but does not eliminate, tracking — the visitor note is still shown |
10.7.2 Vimeo #
| Aspect | Value |
|---|---|
| Accepted URLs | vimeo.com/{id}, vimeo.com/{id}/{hash} (unlisted), player.vimeo.com/video/{id}, vimeo.com/channels/{c}/{id}, vimeo.com/groups/{g}/videos/{id} |
| Id pattern | ^\d{6,12}$; optional unlisted hash ^[a-f0-9]{6,12}$ |
| Metadata | oEmbed at https://vimeo.com/api/oembed.json?url={url} |
| Poster | From the oEmbed thumbnail_url (upgraded to the largest available size), re-hosted |
| Iframe URL | https://player.vimeo.com/video/{id}?h={hash}&dnt=1&title=0&byline=0&portrait=0#t={n}s |
allow |
fullscreen; picture-in-picture; encrypted-media |
| CSP additions | frame-src https://player.vimeo.com |
| Default aspect | From oEmbed width/height; 16:9 when unavailable |
| Privacy | dnt=1 suppresses Vimeo's own analytics cookies and is always set |
10.7.3 Spotify #
| Aspect | Value |
|---|---|
| Accepted URLs | open.spotify.com/{type}/{id} where type ∈ track, album, playlist, artist, show, episode; also spotify:{type}:{id} URIs |
| Id pattern | ^[A-Za-z0-9]{22}$ |
| Metadata | oEmbed at https://open.spotify.com/oembed?url={url} |
| Poster | oEmbed thumbnail_url (album/show artwork), re-hosted |
| Iframe URL | https://open.spotify.com/embed/{type}/{id}?theme={0|1} |
allow |
encrypted-media; clipboard-write; fullscreen; picture-in-picture |
| CSP additions | frame-src https://open.spotify.com |
| Default aspect | auto-fixed-height: 152 px for track/episode, 352 px for album/playlist/show, 400 px for artist. Heights are reserved exactly, so no shift |
| Privacy | Playback beyond a 30-second preview requires the visitor's own Spotify session; the facade states "Preview on Spotify" when no session can be assumed |
10.7.4 Apple Music #
| Aspect | Value |
|---|---|
| Accepted URLs | music.apple.com/{storefront}/{type}/{slug}/{id} where type ∈ album, playlist, song, artist; ?i={song_id} honoured for a track within an album |
| Patterns | storefront ^[a-z]{2}$; id ^(pl\.)?[A-Za-z0-9.-]{6,64}$ |
| Metadata | No public oEmbed. Server-side Open Graph scrape of the canonical page (title, og:image), 5 s timeout |
| Poster | og:image, re-hosted |
| Iframe URL | https://embed.music.apple.com/{storefront}/{type}/{slug}/{id}?i={song_id}&theme={light|dark} |
allow |
autoplay *; encrypted-media *; clipboard-write |
| CSP additions | frame-src https://embed.music.apple.com; img-src https://*.mzstatic.com is not required because posters are re-hosted |
| Default aspect | auto-fixed-height: 175 px for song, 450 px for album/playlist |
| Privacy | Full playback requires an Apple Music subscription and session; otherwise a preview plays. Stated in the facade caption |
10.7.5 SoundCloud #
| Aspect | Value |
|---|---|
| Accepted URLs | soundcloud.com/{user}/{track}, soundcloud.com/{user}/sets/{playlist}, on.soundcloud.com/{short} (resolved server-side by following one redirect) |
| Patterns | ^[a-z0-9_-]{3,25}$ per path segment |
| Metadata | oEmbed at https://soundcloud.com/oembed?format=json&url={url} |
| Poster | oEmbed thumbnail_url, re-hosted; falls back to the waveform-less generic card |
| Iframe URL | https://w.soundcloud.com/player/?url={encoded_canonical}&color=%23{accent}&auto_play=false&hide_related=true&show_comments=false&show_user=true&show_reposts=false&visual=false |
allow |
autoplay |
| CSP additions | frame-src https://w.soundcloud.com |
| Default aspect | auto-fixed-height: 166 px for a track, 300 px for a set |
| Privacy | The player's colour is set from the page accent so it does not clash; no other data is passed |
10.7.6 Instagram #
| Aspect | Value |
|---|---|
| Accepted URLs | instagram.com/p/{code}, /reel/{code}, /tv/{code}, with or without www, trailing slash and query string stripped |
| Code pattern | ^[A-Za-z0-9_-]{5,20}$ |
| Metadata | Instagram's oEmbed requires an approved app token, which LinkHub does not require operators to obtain. Therefore: server-side Open Graph scrape of https://www.instagram.com/p/{code}/ with a 5 s timeout. When the scrape fails — which happens often, because Instagram rate-limits unauthenticated fetches — the block falls back to the generic provider placeholder and asks the author for a title and poster. This limitation is stated in the editor UI, not hidden |
| Poster | og:image when available, re-hosted; otherwise author-supplied or generic |
| Iframe URL | https://www.instagram.com/p/{code}/embed/captioned/ |
allow |
(none) |
| CSP additions | frame-src https://www.instagram.com |
| Default aspect | 4:5 (the most common feed ratio); 9:16 when the URL path is /reel/ |
| Privacy | Instagram's embed sets cookies and loads its own scripts inside the iframe. This is precisely why the facade is unconditional here |
| No-JS | The facade links to the post on instagram.com; the content is always reachable |
10.7.7 TikTok #
| Aspect | Value |
|---|---|
| Accepted URLs | tiktok.com/@{user}/video/{id}, vm.tiktok.com/{short}, vt.tiktok.com/{short} (short forms resolved server-side by following at most 3 redirects, SSRF-checked) |
| Id pattern | ^\d{15,25}$ |
| Metadata | oEmbed at https://www.tiktok.com/oembed?url={url} — public, no token |
| Poster | oEmbed thumbnail_url, re-hosted |
| Iframe URL | https://www.tiktok.com/embed/v2/{id} |
allow |
encrypted-media; fullscreen |
| CSP additions | frame-src https://www.tiktok.com |
| Default aspect | 9:16, minimum rendered height 500 px, maximum 780 px |
| Privacy | The TikTok player is heavy and highly instrumented; the facade keeps it entirely off the page until activation |
10.7.8 X #
| Aspect | Value |
|---|---|
| Accepted URLs | x.com/{user}/status/{id}, twitter.com/{user}/status/{id}, with or without www and query strings |
| Id pattern | ^\d{10,25}$ |
| Metadata | Attempt https://publish.twitter.com/oembed?url={url}&omit_script=1&dnt=true. This endpoint's availability is not guaranteed; on any non-200 the block falls back to a stored static card built from the author-supplied title and optional poster |
| Poster | oEmbed does not return one. The author may set a poster; otherwise the facade renders a text card using the fetched post text (≤ 280 chars, sanitised per 10.3.3) on the theme surface |
| Iframe URL | https://platform.twitter.com/embed/Tweet.html?id={id}&dnt=true&theme={light|dark} |
allow |
(none) |
| CSP additions | frame-src https://platform.twitter.com |
| Default aspect | auto-fixed-height with a reserved 320 px, adjusted after load via the provider's postMessage height signal (post-activation only, so no shift on initial render) |
| Privacy | dnt=true is always passed. No X script is ever loaded into the page document — only into the sandboxed iframe after activation |
| Degradation | Because this provider's embed infrastructure is the least stable of the eight, the static text card is a first-class rendering mode, not an error state. A page whose X embed never activates still shows the post's text and a link |
10.8 Buy / product block #
10.8.1 Purpose #
Presents a single product with an image, price and a tracked outbound purchase link.
LinkHub has no cart, no checkout, no order management and no payment processing. This block does not collect, transmit, store or touch payment data of any kind. It renders product information and a link. Payment happens entirely on the destination — the merchant's own store, or a Stripe-hosted Payment Link page. LinkHub is therefore outside PCI DSS scope for this feature, and this block must never be extended in a way that changes that without a separate compliance programme. Full e-commerce is named as roadmap in Section 2 and is not specified anywhere in this document.
10.8.2 Settings schema #
| Field | Type | Required | Default | Validation | Description |
|---|---|---|---|---|---|
name |
string | Yes | "" |
1–80 chars | Product name; the accessible name of the link |
description |
string | null | No | null |
≤ 200 chars | Short description |
image_media_id |
uuid | null | No | null |
Ready image asset | Product image |
image_aspect |
enum | Yes | 1:1 |
1:1 | 4:5 | 16:9 |
Reserved box |
price_cents |
integer | Yes | — | ≥ 0, ≤ 99,999,999 | Price in minor units |
currency |
string | Yes | workspace default | ISO 4217, uppercase, from the supported list | Currency |
compare_at_price_cents |
integer | null | No | null |
When set, must be > price_cents |
Struck-through original price |
price_suffix |
string | null | No | null |
≤ 16 chars | e.g. "/month", "+ VAT" |
url |
string | Yes | "" |
Same URL rules as 10.1; when is_stripe_payment_link is true, must additionally match 10.8.3 |
Destination |
is_stripe_payment_link |
boolean | Yes | false |
— | Marks the destination as a Stripe Payment Link |
cta_label |
string | Yes | "Buy now" |
1–24 chars | Button text |
sold_out |
boolean | Yes | false |
— | Disables the link and shows the sold-out treatment |
sold_out_label |
string | Yes | "Sold out" |
1–24 chars | Shown when sold_out |
badge |
string | null | No | null |
≤ 12 chars | Corner pill, e.g. "Sale" |
layout |
enum | Yes | card |
card (image above) | row (image left) |
Layout |
track_clicks |
boolean | Yes | true |
— | As 10.1 |
10.8.3 Stripe Payment Link passthrough #
When is_stripe_payment_link is true, the URL is validated against:
^https://buy\.stripe\.com/(test_)?[A-Za-z0-9]{10,80}(\?[A-Za-z0-9_%=&.-]{0,512})?$and additionally:
- The host must be exactly
buy.stripe.com. No subdomain, no lookalike, no redirector. - The scheme must be
https. - Any query string is limited to Stripe's documented prefill parameters (
prefilled_email,client_reference_id,locale,utm_*); unknown parameters are stripped on save with a note to the author. client_reference_idis auto-populated with the block id at render time when not already set, so the merchant can attribute a completed payment back to a LinkHub block using their own Stripe dashboard. LinkHub does not read Stripe's data to do this.- A
test_link is accepted but the editor shows a persistent "This is a Stripe test link" warning, and the pre-publish review lists it as a warning. Publishing a test link is allowed — blocking it would break legitimate staging workflows — but nobody can claim they were not told. - Validation failure returns
422with codeinvalid_stripe_payment_link.
LinkHub never calls the Stripe API on behalf of this block, never holds the merchant's Stripe credentials for it, and never receives a webhook about it. The workspace's own Stripe integration for LinkHub's billing (Section 22) is entirely unrelated and shares no code path.
10.8.4 Price formatting #
Formatting is performed server-side with Intl.NumberFormat, using the page's language as the locale and the block's currency:
new Intl.NumberFormat(page.language, {
style: 'currency',
currency: settings.currency,
minimumFractionDigits: minorUnitsFor(settings.currency), // 0 for JPY/KRW, 3 for BHD/KWD, else 2
}).format(settings.price_cents / 10 ** minorUnitsFor(settings.currency));Zero-decimal and three-decimal currencies are handled by the minorUnitsFor table in packages/core, not by dividing everything by 100. price_cents = 0 renders the localised word for "Free" rather than "$0.00". Formatting is server-side so it is identical with and without JavaScript, and so it does not shift after hydration.
10.8.5 Rendered markup #
<li class="lh-block lh-block--product" id="b-0192f412" data-b="0192f412">
<article class="lh-product lh-product--card">
<a class="lh-product__link" href="https://linkhub.app/acme/-/c/0192f412"
rel="nofollow noopener" target="_blank" data-t="click">
<img class="lh-product__img" src="…/512.avif" srcset="…" sizes="(max-width:640px) 100vw, 640px"
width="800" height="800" style="aspect-ratio:1/1" alt="" loading="lazy" decoding="async">
<span class="lh-product__body">
<span class="lh-product__name">Riso poster — Harbour</span>
<span class="lh-product__desc">A2, 3-colour, edition of 50</span>
<span class="lh-product__price">
<span class="lh-product__now">€38.00</span>
<s class="lh-product__was">€48.00</s>
<span class="lh-vh">, reduced from €48.00</span>
</span>
</span>
<span class="lh-product__cta">Buy now</span>
</a>
<span class="lh-product__badge">Sale</span>
</article>
</li>Sold-out state replaces the <a> with a <div> carrying aria-disabled="true", applies a muted treatment and 60% image opacity, and renders sold_out_label in place of the CTA. A disabled link is not a link: there is no href, so keyboard users are not sent to a dead target, and the sold-out status is conveyed by text, not by opacity alone.
10.8.6 Responsive, accessibility, analytics, no-JS, errors #
- Responsive:
cardis full-width below 640 px; at ≥ 640 px two product blocks placed consecutively inside a group block (10.14) withlayout: gridrender side by side.rowkeeps a 96 px image at all widths, dropping the description below 400 px. - Accessible name:
"{name}, {formatted price}{, reduced from compare_at}{, sold out}". The image isalt=""because the name is adjacent text. The struck price is wrapped in<s>and given a visually hidden clarification, because<s>alone is announced inconsistently across screen readers. - Screen-reader announcement: "link, Riso poster — Harbour, A2, 3-colour, edition of 50, €38.00, reduced from €48.00, Buy now, opens in a new tab".
- Analytics:
block_clickwith extra dimensionsprice_cents,currency,is_stripe_payment_link. No conversion tracking is attempted from LinkHub's side; conversion attribution, where configured, comes from the pixel integrations in Section 19. - No-JS: fully functional. Price is server-formatted, the CTA is a real anchor, the sold-out state is server-rendered.
- Errors: missing
urlornameblocks publish;compare_at_price_cents ≤ price_centsis a field error; an invalid Payment Link is a field error with the expected format shown; a flagged destination blocks publish as in 10.1.9. - Empty state: preview shows a dashed square with "Add a product".
- Plan gating: none. The block is available on every plan, because gating a merchant's ability to sell would be a poor trade for the platform.
10.9 Email capture block #
10.9.1 Purpose #
Collects an email address (and optionally a name) from a visitor. The block owns the UI, validation, consent and submission response. Everything downstream — storage schema, deduplication, double opt-in, ESP synchronisation to Mailchimp/ConvertKit/webhook, export and suppression — is specified in Section 20 and is not restated here.
10.9.2 Settings schema #
| Field | Type | Required | Default | Validation | Description |
|---|---|---|---|---|---|
heading |
string | Yes | "Join the list" |
1–60 chars | Form heading |
description |
string | null | No | null |
≤ 200 chars | Supporting copy |
collect_name |
boolean | Yes | false |
— | Adds a name field |
name_required |
boolean | Yes | false |
Only meaningful when collect_name |
Whether the name is mandatory |
email_label |
string | Yes | "Email" |
1–40 chars | Visible label |
name_label |
string | Yes | "Name" |
1–40 chars | Visible label |
button_label |
string | Yes | "Subscribe" |
1–24 chars | Submit text |
consent_mode |
enum | Yes | checkbox |
checkbox | notice |
checkbox renders a required, unticked consent box; notice renders explanatory text only |
consent_text |
string | Yes | see below | 10–300 chars | Consent wording |
consent_required |
boolean | Yes | true when consent_mode = checkbox |
— | Whether submission is blocked without a tick |
privacy_url |
string | null | No | workspace privacy URL | https URL |
Linked from the consent text |
success_message |
string | Yes | "Thanks — you're on the list." |
1–200 chars | Shown after success |
success_redirect_url |
string | null | No | null |
https URL, safety-checked |
Redirect instead of showing a message |
destination_id |
uuid | null | No | workspace default destination | Must be a configured destination (Section 20) | Where leads sync |
tags |
string[] | No | [] |
≤ 10 tags, each ≤ 32 chars | Passed to the destination |
Default consent_text: "I agree to receive emails from {workspace_name}. You can unsubscribe at any time." The consent checkbox is never pre-ticked, and the editor will not save a pre-ticked default — a pre-ticked consent box is not consent under GDPR, and offering the option would be offering a compliance trap.
10.9.3 Rendered markup #
<li class="lh-block lh-block--capture" id="b-0192f420" data-b="0192f420">
<form class="lh-capture" method="post"
action="/-/f/0192f420"
aria-labelledby="cap-0192f420-h">
<h2 class="lh-capture__heading" id="cap-0192f420-h">Join the list</h2>
<p class="lh-capture__desc">Monthly, short, no spam.</p>
<div class="lh-field">
<label for="cap-0192f420-email">Email</label>
<input id="cap-0192f420-email" name="email" type="email" inputmode="email"
autocomplete="email" required spellcheck="false"
aria-describedby="cap-0192f420-err">
</div>
<div class="lh-field lh-field--check">
<input id="cap-0192f420-consent" name="consent" type="checkbox" value="1" required>
<label for="cap-0192f420-consent">I agree to receive emails from Acme Studio.
<a href="https://acme.com/privacy" rel="noopener" target="_blank">Privacy policy</a>.</label>
</div>
<p class="lh-capture__error" id="cap-0192f420-err" role="alert"></p>
<input type="text" name="company_website" tabindex="-1" autocomplete="off"
class="lh-hp" aria-hidden="true">
<input type="hidden" name="ts" value="1755594000">
<input type="hidden" name="csrf" value="…">
<button type="submit" class="lh-capture__submit">Subscribe</button>
</form>
</li>10.9.4 Submission #
- Endpoint:
POST /-/f/{block_id}on the page's own host — same-origin, so no CORS and no preflight. - Native path (no JavaScript): a standard form POST. The server responds
303 See Otherto{page_url}?subscribed={block_id}#b-{block_id}, and the re-rendered page shows the success message inline at that block, with focus directed there via the fragment. On failure it responds303to?form_error={code}&form_block={block_id}#b-{block_id}and the page renders the error inline. Using a redirect rather than a 200 response body prevents a resubmission prompt on refresh. - Enhanced path: the enhancement bundle intercepts submit, POSTs with
fetch, and swaps the form for the success message in place with no navigation. Identical endpoint, identical validation, identical response codes. - Success redirect: when
success_redirect_urlis set, both paths navigate there (303 natively,location.assignwhen enhanced).
10.9.5 Validation, anti-abuse and rate limits #
| Check | Rule | Failure |
|---|---|---|
| Email syntax | HTML5 type="email" client-side, RFC 5322 addr-spec plus a public-suffix-valid domain server-side |
400 invalid_email, message "Enter a valid email address." |
| Disposable domains | Checked against a maintained disposable-domain list; the workspace can disable the check | 400 disposable_email_blocked |
| MX presence | DNS MX lookup, 1 s timeout, result cached 24 h. On timeout the check passes (fail-open — a slow DNS server must not cost a real lead) | 400 email_domain_unreachable |
| Consent | Required when consent_required |
400 consent_required |
| Honeypot | The company_website field must be empty |
Silently accepted with a success response, discarded server-side. Bots are not told they were caught |
| Timing | ts must be ≥ 2 s and ≤ 24 h old, HMAC-signed so it cannot be forged |
Discarded as above |
| CSRF | Origin/Referer check plus a signed same-site token | 403 csrf_failed |
| Rate limit — per visitor | 5 submissions / 10 minutes per visitor_hash per page |
429 rate_limited, Retry-After set |
| Rate limit — per IP | 20 submissions / hour (IP used in-memory only, never stored — consistent with the visitor-identity rules) | 429 |
| Rate limit — per page | 300 submissions / hour | 429 |
| Duplicate | Same email on the same block within 24 h | 200 success with the normal message; the lead is deduplicated per Section 20. Never reveal that an address is already subscribed |
The bot-challenge mechanism. There is exactly one, and it is the platform-wide mechanism, not a block-local invention:
- Default, always on, on every submission: the honeypot field plus the submission-timing heuristic in the two rows above. Both are server-evaluated. Neither requires JavaScript, neither loads a third party, neither changes the Content Security Policy, and neither presents the visitor with a puzzle, a distorted image, a logic question or any other cognitive task.
- Escalation, armed only by the named abuse triggers: Cloudflare Turnstile in managed mode is armed for a page when one of the abuse triggers defined for this surface fires — the page exceeds its hourly submission ceiling, three or more rate-limit rejections arrive from one
visitor_hashwithin an hour, or the workspace has been placed under manual abuse review. Turnstile is never armed by default and is disarmed automatically 24 hours after the last trigger. Its script and frame origins are carried in the frame-src and connect-src allow-lists owned by Section 23.5; no other origin is added anywhere. - The no-JavaScript path is never challenged. A visitor without JavaScript cannot solve a Turnstile widget, so one is never rendered for them and their submission is never rejected for the absence of a token. When escalation is armed and a submission arrives with no valid Turnstile token, the lead is accepted and stored with status
pending_review(Section 20), the visitor sees the ordinary success message, and the workspace triages the queue in the dashboard. A real subscriber is never turned away, and a bot gains nothing it can measure. - There is no arithmetic question, no word puzzle, no "select the images", no proof-of-work and no first-party challenge of any kind. A cognitive-function test on a public marketing surface is a WCAG 2.2 SC 3.3.8 failure, and this surface is in accessibility scope per Section 24. Excluding it is a conformance requirement, not a preference.
10.9.6 States #
| State | Presentation |
|---|---|
| Idle | Form as above |
| Submitting (enhanced) | Button shows a spinner and aria-busy="true"; inputs disabled; aria-live announces "Submitting" |
| Success | Form replaced by a success panel: a check icon (aria-hidden), success_message, and focus moved to the panel which has role="status" and tabindex="-1" |
| Field error | Inline message below the field, aria-invalid="true", aria-describedby wired, focus moved to the first invalid field |
| Form error | The role="alert" paragraph is populated; focus moves to it |
| Rate limited | "Too many attempts. Try again in a few minutes." Submit disabled with a countdown |
| Challenge armed (enhanced) | The Turnstile widget renders above the submit button in managed mode, labelled and keyboard-reachable. On success the form submits normally |
| Challenge armed (no JavaScript) | No widget renders and nothing extra is asked of the visitor. The form submits normally and the lead is stored pending_review |
| Offline (enhanced) | "You're offline. We'll try again." The submission is retried once on reconnect within the same page session; it is never silently dropped |
| Destination disconnected | The lead is still stored; a warning is raised in the dashboard per Section 20. The visitor always sees success — a broken integration is not the visitor's problem |
10.9.7 Accessibility, analytics, no-JS, empty state, gating #
- Every input has a real
<label>(never a placeholder-as-label). Required fields use the nativerequiredattribute plus a visible "(required)" in the label text. - Accessible name of the form: the heading, via
aria-labelledby. - Screen-reader announcement on error: the
role="alert"region reads the message immediately; on success, therole="status"panel reads the confirmation. - The consent checkbox label is clickable and at least 24 × 24 px; the label text and the linked privacy policy are separate targets with adequate spacing.
- Analytics:
form_submitserver-side on accepted submissions only, carryingblock_idanddestination_id. The email address itself is never part of an analytics event.block_impressionon intersection. - No-JS: fully functional via the native POST path described in 10.9.4 — including validation, consent, honeypot, timing, rate limiting and the success message. No challenge of any kind is ever presented on this path; when escalation is armed the submission is accepted into
pending_reviewinstead (10.9.5). This is the only interactive block with a server round-trip, and it is deliberately built native-first. - Empty state: none; the block always renders a usable form.
- Plan gating: the block is available on all plans. ESP destinations (Mailchimp, ConvertKit, webhook) are Pro and Business per Section 20; on Free, leads are stored in LinkHub and exportable only by upgrading, with the CSV export entitlement in Section 22 applying.
10.10 Divider / spacer block #
10.10.1 Purpose #
Visual separation. Two modes in one block type because they are the same decision from the author's point of view.
10.10.2 Settings schema #
| Field | Type | Required | Default | Validation | Description |
|---|---|---|---|---|---|
mode |
enum | Yes | line |
line | space | label |
Rendering mode |
size |
enum | Yes | md |
xs (8 px) | sm (16 px) | md (24 px) | lg (40 px) | xl (64 px) |
Vertical space |
line_style |
enum | Yes | solid |
solid | dashed | dotted |
line mode only |
line_width |
enum | Yes | full |
full | wide (60%) | short (25%) |
line mode only |
line_color |
enum | Yes | theme |
theme | muted | accent |
Uses theme tokens; contrast-checked at 3:1 |
label |
string | null | Cond. | null |
Required for label mode; ≤ 40 chars |
Text centred on the rule |
label_level |
enum | Yes | none |
none | h2 | h3 |
When set, the label becomes a real heading and participates in the document outline |
10.10.3 Markup, behaviour and the rest #
<!-- mode: line -->
<li class="lh-block lh-block--divider" id="b-…" data-b="…"><hr class="lh-divider lh-divider--full"></li>
<!-- mode: space -->
<li class="lh-block lh-block--spacer lh-spacer--lg" id="b-…" data-b="…" aria-hidden="true"></li>
<!-- mode: label, label_level: h2 -->
<li class="lh-block lh-block--divider-label" id="b-…" data-b="…">
<h2 class="lh-divider__label"><span>Merch</span></h2>
</li>- Responsive: sizes scale by 0.75× below 480 px so a page of spacers does not become a scroll marathon on a phone.
- Accessibility:
spacemode isaria-hidden="true"and contains nothing — presenting an empty list item to a screen reader is pure noise.linemode uses a real<hr>, which carries theseparatorrole natively.labelmode withlabel_level: nonerenders a<p>; withh2/h3it renders the heading and is validated for heading-order correctness at publish. Screen-reader announcement: "separator" forline, nothing forspace, "heading level 2, Merch" for a labelled heading. - Analytics: none. This block emits no events at all.
- No-JS: fully functional.
- Errors:
labelmode with an empty label blocks publish. Three or more consecutive spacer blocks produce a pre-publish warning suggesting a single larger one. - Empty state: not applicable.
- Plan gating: none.
10.11 FAQ / accordion block #
10.11.1 Purpose #
A list of collapsible question-and-answer pairs. Implemented with the native disclosure element so it works, and is accessible, without a single line of JavaScript.
10.11.2 Settings schema #
| Field | Type | Required | Default | Validation | Description |
|---|---|---|---|---|---|
heading |
string | null | No | null |
≤ 60 chars | Optional block heading |
heading_level |
enum | Yes | h2 |
h2 | h3 |
Outline level for the block heading |
items |
array | Yes | one empty item | 1–30 items | Q&A pairs |
items[].question |
string | Yes | "" |
1–160 chars, plain text | Summary text |
items[].answer_html |
string | Yes | "" |
Sanitised per 10.3.3; ≤ 2000 chars | Answer body |
items[].open_by_default |
boolean | Yes | false |
At most 2 items may be true; the editor enforces it |
Initially expanded |
allow_multiple_open |
boolean | Yes | true |
— | When false, items share a name attribute so the browser closes siblings natively |
structured_data |
boolean | Yes | true |
— | Emits FAQPage JSON-LD (11.11.5) |
10.11.3 Rendered markup — the correct disclosure pattern #
<li class="lh-block lh-block--faq" id="b-0192f431" data-b="0192f431">
<section class="lh-faq" aria-labelledby="faq-0192f431-h">
<h2 class="lh-faq__heading" id="faq-0192f431-h">Questions</h2>
<details class="lh-faq__item" name="faq-0192f431">
<summary class="lh-faq__q">
<span class="lh-faq__q-text">Do you ship internationally?</span>
<svg class="lh-faq__chev" aria-hidden="true" focusable="false" width="16" height="16">…</svg>
</summary>
<div class="lh-faq__a"><p>Yes — worldwide, 5–10 working days.</p></div>
</details>
…
</section>
</li>Rules that make this correct rather than merely functional:
<details>/<summary>is the native disclosure widget. It already exposesaria-expanded, is keyboard-operable withEnterandSpace, and is announced correctly by every screen reader in the Section 24 test matrix. Norole="button", noaria-expandedand notabindexare added — overriding native semantics here is the most common way this pattern is broken.- Exclusive accordions use the native
nameattribute on<details>, not JavaScript. Whenallow_multiple_openis true the attribute is omitted. - The chevron is decorative and rotates with CSS keyed on
details[open], so it is correct with JavaScript disabled. - The
<summary>marker is removed with::-webkit-details-markerandlist-style: none, and replaced by the explicit chevron so the affordance is never lost. content-visibility: autois applied to.lh-faq__aso collapsed answers cost nothing to lay out, while remaining in the DOM and findable by in-page search.- The
<summary>has a minimum height of 44 px and full-width hit area.
10.11.4 The rest #
- Responsive: questions wrap; no truncation, because a truncated question is useless. Padding tightens below 480 px.
- Accessible name: each disclosure's name is its question text. The block's name is
headingviaaria-labelledby, or "Frequently asked questions" when no heading is set. - Screen-reader announcement: "Do you ship internationally?, collapsed, button" → on activation "expanded" followed by the answer content.
- Analytics:
block_impressiononly. Individual expansions are not tracked; the value is low and the enhancement code required is not worth the bytes. Links inside answers emitblock_clickas normal. - No-JS: fully functional, including exclusive-accordion behaviour and the chevron animation. This block requires zero JavaScript by design.
- Errors: an item with an empty question or answer blocks publish; more than 2
open_by_defaultitems is a field error. - Empty state: a block with one empty item shows "Add a question" in preview and blocks publish.
- Plan gating: none.
10.12 Contact / vCard block #
10.12.1 Purpose #
Presents contact details and offers a one-tap "Save contact" download producing a vCard file. Common on QR-code landing pages for physical signage and business cards.
10.12.2 Settings schema #
| Field | Type | Required | Default | Validation | Description |
|---|---|---|---|---|---|
display_name |
string | Yes | page display name | 1–80 chars | FN in the vCard |
first_name / last_name |
string | null | No | parsed from display_name |
≤ 40 chars each | N components |
organization |
string | null | No | null |
≤ 80 chars | ORG |
job_title |
string | null | No | null |
≤ 80 chars | TITLE |
phones |
array | No | [] |
≤ 3 entries; each { type: 'mobile'|'work'|'home', value: E.164 } |
TEL |
emails |
array | No | [] |
≤ 3 entries; each { type: 'work'|'personal', value: address } |
EMAIL |
website_url |
string | null | No | page URL | https URL |
URL |
address |
object | null | No | null |
{ street, city, region, postal_code, country }, each ≤ 100 chars |
ADR |
note |
string | null | No | null |
≤ 300 chars | NOTE |
include_avatar |
boolean | Yes | true |
— | Embeds the page avatar as PHOTO (base64, only when the source is ≤ 100 KB after resizing to 256 px) |
show_map_link |
boolean | Yes | false |
Requires address |
Adds a geo link |
button_label |
string | Yes | "Save contact" |
1–24 chars | Download button text |
show_details |
boolean | Yes | true |
— | Whether the details are also displayed on the page |
10.12.3 The vCard endpoint #
GET /-/vcf/{block_id}.vcf returns vCard 3.0 (chosen over 4.0 because iOS and Android contact importers handle 3.0 most reliably), UTF-8, CRLF line endings, folded at 75 octets.
Content-Type: text/vcard; charset=utf-8
Content-Disposition: attachment; filename="acme-studio.vcf"
Cache-Control: public, max-age=300, s-maxage=300
X-Content-Type-Options: nosniffThe file is generated from the published block only; a draft block's vCard is available under the preview token. All field values are escaped per RFC 6350 §3.4 (,, ;, \ and newlines). A vCard is capped at 100 KB; exceeding it drops the PHOTO property first.
10.12.4 Rendered markup and the rest #
<li class="lh-block lh-block--contact" id="b-0192f440" data-b="0192f440">
<section class="lh-contact" aria-labelledby="c-0192f440-h">
<h2 class="lh-contact__name" id="c-0192f440-h">Dana Reis</h2>
<p class="lh-contact__role">Studio Manager, Acme Studio</p>
<ul class="lh-contact__list" role="list">
<li><a href="tel:+351912345678">+351 912 345 678<span class="lh-vh"> (mobile)</span></a></li>
<li><a href="mailto:dana@acme.com">dana@acme.com</a></li>
<li><a href="https://maps.google.com/?q=…" rel="noopener" target="_blank">Rua do Norte 12, Lisbon</a></li>
</ul>
<a class="lh-contact__save" href="/-/vcf/0192f440.vcf" download data-t="click">Save contact</a>
</section>
</li>- Responsive: single column at all widths; the save button is full-width below 480 px and inline-block above.
- Accessibility: phone and email links carry a visually hidden type qualifier so
"+351 912 345 678"is announced as "link, +351 912 345 678, mobile". The download link's accessible name is"{button_label}, vCard file", and a visually hidden hint states the file type — a download whose type is unannounced is a hostile surprise. Phone numbers are marked up with the digits grouped so screen readers do not read them as one enormous integer. - Analytics:
block_clickfor each contact link and for the vCard download (the endpoint emits it server-side). - No-JS: fully functional. The vCard is a plain link to a server-generated file;
tel:,mailto:and map links are native. - Errors: a phone failing E.164 or an email failing syntax is a field error; a block with no phone, email, address or website blocks publish (
contact_block_empty). - Empty state: preview shows "Add a phone number or email".
- Plan gating: none.
10.13 Countdown block #
10.13.1 Purpose #
Counts down to a fixed instant. Used for drops, launches and event starts.
10.13.2 Settings schema #
| Field | Type | Required | Default | Validation | Description |
|---|---|---|---|---|---|
ends_at |
timestamptz | Yes | — | Must be within 10 years | The target instant, stored UTC |
timezone |
string | Yes | workspace timezone | IANA zone name (e.g. Europe/Lisbon) |
The zone the author authored in; used for display and for the fallback label |
display_timezone |
enum | Yes | visitor |
visitor | fixed |
visitor shows the local countdown; fixed always shows the author's zone |
heading |
string | null | No | null |
≤ 60 chars | Text above the digits |
units |
enum | Yes | dhms |
dhms | hms | dh |
Which units to show |
style |
enum | Yes | boxes |
boxes | inline | minimal |
Presentation |
on_expiry |
enum | Yes | message |
hide | message | zero | reveal |
Behaviour after ends_at |
expiry_message |
string | Cond. | "This has ended." |
Required for message; ≤ 120 chars |
Post-expiry text |
reveal_block_id |
uuid | null | Cond. | null |
Required for reveal; must be a hidden block on the same page |
Block revealed at expiry |
cta_url |
string | null | No | null |
Same URL rules as 10.1 | Optional tracked link under the timer |
cta_label |
string | null | Cond. | null |
Required when cta_url set; ≤ 24 chars |
Link text |
10.13.3 Timezone handling — exactly #
This is where countdown implementations usually go wrong, so the rule is stated precisely:
ends_atis a single absolute instant stored astimestamptzin UTC. It is the same instant for every visitor on earth. There is no per-visitor target.timezonerecords the zone the author typed the date in. It is used for two things only: rendering the author's intended wall-clock time in the editor, and producing the human-readable fallback label ("Ends 24 Aug at 18:00 WEST").- The server renders the initial countdown values using the elapsed duration from server time to
ends_at. Duration arithmetic is timezone-independent, so the server-rendered digits are correct for every visitor without knowing their zone. - The enhancement bundle then ticks the display forward once per second using the browser's clock, correcting for the offset between server time and browser time measured from the
Dateresponse header at page load. A visitor with a badly wrong device clock still sees the correct remaining time. display_timezone: visitoradditionally renders the target as a local wall-clock string usingIntl.DateTimeFormatafter hydration. Before hydration and with JavaScript disabled, the author's zone label from step 2 is shown. Both are correct statements about the same instant.- DST transitions require no special handling because the target is an absolute instant. An author setting a target inside a DST "spring forward" gap is warned in the editor and the instant is resolved forward to the first valid wall-clock time.
10.13.4 Rendered markup #
<li class="lh-block lh-block--countdown" id="b-0192f452" data-b="0192f452">
<section class="lh-count lh-count--boxes" aria-labelledby="cd-0192f452-h"
data-ends="2026-08-24T17:00:00Z" data-expiry="message">
<h2 class="lh-count__heading" id="cd-0192f452-h">Drop closes in</h2>
<p class="lh-count__digits" role="timer" aria-live="off">
<span class="lh-count__u"><b data-u="d">05</b><small>days</small></span>
<span class="lh-count__u"><b data-u="h">04</b><small>hrs</small></span>
<span class="lh-count__u"><b data-u="m">32</b><small>min</small></span>
<span class="lh-count__u"><b data-u="s">11</b><small>sec</small></span>
</p>
<p class="lh-vh" id="cd-0192f452-sr">5 days, 4 hours and 32 minutes remaining.</p>
<p class="lh-count__target"><time datetime="2026-08-24T17:00:00Z">Ends 24 Aug at 18:00 WEST</time></p>
<a class="lh-count__cta" href="/acme/-/c/0192f452" data-t="click">Get notified</a>
</section>
</li>10.13.5 Post-expiry behaviour #
on_expiry |
Server (next render after ends_at) |
Client (page open across the boundary) |
|---|---|---|
hide |
Block is not rendered at all | Block is removed from the DOM and an aria-live polite message says "The countdown has ended." |
message |
Digits replaced by expiry_message |
Same swap in place |
zero |
All units render 00, with a visually hidden "Ended" |
Same |
reveal |
The countdown is not rendered; the target block is rendered in its own position as if visible | The countdown is replaced by the revealed block's server-rendered markup, fetched once from /-/reveal/{block_id}; on failure the page reloads |
Because the public page is cached (11.3), an expired countdown could otherwise be served stale. The renderer therefore sets the page's cache TTL to min(default_ttl, seconds_until_next_countdown_boundary) — capped to a minimum of 10 seconds — so a page containing a countdown expiring in 40 seconds is cached for 40 seconds, not 300. This is the only content-driven TTL override in the product and it is listed in 11.3.3.
10.13.6 The rest #
- Responsive:
boxeswraps to two rows below 360 px; digits usefont-variant-numeric: tabular-numsand a fixedchwidth per unit so the layout never jitters as numbers change. - Accessibility:
role="timer"witharia-live="off"— a live region ticking every second is unusable. A visually hidden sentence states the remaining time at page load, and is updated at most once per minute via a separatearia-live="polite"region. At expiry, one polite announcement fires. Screen-reader announcement: "Drop closes in. 5 days, 4 hours and 32 minutes remaining. Ends 24 August at 18:00 WEST." - Analytics:
block_clickon the CTA;block_impressionon intersection. - No-JS: the server-rendered digits, the target time, the CTA and the correct post-expiry state (per the table above) all work. The only loss is the per-second tick — the values are accurate as of page load and the visible target time removes any ambiguity.
- Errors:
ends_atin the past is a field warning, not an error (an author may deliberately publish an already-ended countdown withon_expiry: message); arevealtarget that is missing, deleted or itself visible blocks publish. - Empty state: preview shows "Pick an end date and time".
- Plan gating: none for the block.
on_expiry: revealcombined with block scheduling uses the rule engine and is Pro+ per Section 15.
10.14 Group / section block #
10.14.1 Purpose #
A container that groups related blocks under an optional heading, optionally collapsible, optionally laid out as a grid. The only block type with children.
10.14.2 Settings schema #
| Field | Type | Required | Default | Validation | Description |
|---|---|---|---|---|---|
heading |
string | null | No | null |
≤ 60 chars | Section heading |
heading_level |
enum | Yes | h2 |
h2 | h3 |
Outline level |
description |
string | null | No | null |
≤ 200 chars | Sub-heading text |
layout |
enum | Yes | stack |
stack | grid-2 | grid-3 | carousel |
Child arrangement |
collapsible |
boolean | Yes | false |
— | Renders as a disclosure |
open_by_default |
boolean | Yes | true |
Only meaningful when collapsible |
Initial state |
background |
enum | Yes | none |
none | surface | accent-soft | outline |
Container treatment |
padding |
enum | Yes | md |
none | sm | md | lg |
Inner padding |
gap |
enum | Yes | theme |
theme | xs | sm | md | lg |
Space between children |
Structural rules (also stated in 9.3.2): maximum 50 children; a group may not contain another group; nesting depth is exactly 1.
10.14.3 Rendered markup #
<li class="lh-block lh-block--group" id="b-0192f460" data-b="0192f460">
<section class="lh-group lh-group--grid-2 lh-group--surface" aria-labelledby="g-0192f460-h">
<h2 class="lh-group__heading" id="g-0192f460-h">Merch</h2>
<p class="lh-group__desc">Printed in Lisbon.</p>
<ol class="lh-group__items" role="list">
<li class="lh-block lh-block--product" id="b-…" data-b="…">…</li>
<li class="lh-block lh-block--product" id="b-…" data-b="…">…</li>
</ol>
</section>
</li>When collapsible is true the <section> becomes <details> with the heading inside <summary> — the same native disclosure pattern as 10.11.3, with the same prohibition on adding ARIA over native semantics.
10.14.4 The rest #
- Responsive:
grid-2collapses to one column below 480 px;grid-3goes to two columns at 480–767 px and one column below 480 px.carouselis a CSS scroll-snap row withoverflow-x: auto, visible scrollbar styling,scroll-padding, and — because a horizontally scrolling region must be keyboard-reachable (WCAG 2.2 §2.1.1) —tabindex="0"with an accessible name and previous/next buttons that are real buttons operatingscrollBy. With JavaScript disabled the carousel is still scrollable by touch, trackpad and keyboard arrows; only the arrow buttons are inert, and they are therefore rendered only by the enhancement bundle rather than shipped dead in the HTML. - Accessibility: the group is a
<section>named by its heading viaaria-labelledby, or unnamed and rendered as a plain<div>when there is no heading (an unnamed<section>adds a useless landmark). Children remain a semantic list. Heading order is validated at publish. - Screen-reader announcement: "Merch, region" then "list, 4 items".
- Analytics: no events of its own; children emit their own events with the group id attached as
container_id. - No-JS: fully functional in
stack,grid-2,grid-3andcollapsiblemodes.carouselis scrollable but without the arrow buttons. - Errors: exceeding 50 children or attempting to nest a group blocks the operation with
422 block_nesting_unsupported;collapsiblewith no heading blocks publish (a disclosure with no label is unusable). - Empty state: an empty group renders nothing at all publicly — an empty bordered box is worse than absence — and shows "Add blocks to this section" in the editor and preview.
- Plan gating: none.
10.15 Block-level scheduling and visibility rules #
Every block type carries the same optional schedule object from the common envelope (10.0.1). The rule engine, the field definitions, the evaluation order, the targeting dimensions and the plan gating are owned by Section 15 and are not restated here. This subsection specifies only how blocks behave under it.
10.15.1 Evaluation #
Visibility is decided server-side, at render time, before any HTML is produced. A block that fails the rules is not rendered as hidden markup — it is not in the document at all. Client-side hiding would leak unpublished content to anyone who reads the source, and would break the transfer budget by shipping bytes nobody sees.
Order of evaluation for each block, root-level and children alike:
1. deleted_at IS NOT NULL -> not rendered
2. visible == false -> not rendered
3. block type requires an entitlement the workspace lacks -> not rendered
4. schedule.starts_at in the future -> not rendered
5. schedule.ends_at in the past -> not rendered
6. targeting rules evaluate to false (Section 15) -> not rendered
7. A/B variant assignment excludes this block (Section 16) -> not rendered
8. required media asset is not `ready` -> block-specific fallback (see each type)
otherwise -> renderedA group block that fails any check is omitted along with all of its children, regardless of the children's own rules. Children are evaluated independently only when their parent renders.
10.15.2 Caching interaction #
- A page whose blocks carry only time-based rules is fully cacheable; the render sets the page TTL to
min(default_ttl, seconds_until_the_next_schedule_boundary), floor 10 seconds, exactly as 10.13.5 does for countdowns. A block appearing at 09:00 appears within 10 seconds of 09:00 for every visitor. - A page with targeting rules that depend on the request (country, device, language, referrer) is cached per resolved rule-key, not per visitor: the cache key gains a short
:rk={hash}suffix computed from only the dimensions the page's rules actually reference. A page whose rules use onlycountryproduces at most one cache entry per country that visits, not one per visitor. This is specified in 11.3.2. - A page participating in an A/B test is cached per variant (11.3.2).
- Blocks are never assembled client-side. There is no "personalisation" round trip.
10.15.3 Editor presentation #
Scheduled and targeted blocks show a chip on their canvas card stating the condition in plain language ("Live 24–31 Aug", "Portugal and Spain only", "Variant B"). The preview's variant and region selectors (9.8.3) let the author see each combination. The pre-publish review lists any block whose rules mean it would render for nobody at publish time — for example a window entirely in the past — as a warning, never as a blocker.
10.16 The block extension contract #
This subsection is the complete checklist for adding a new block type. A developer who follows it needs no other guidance and touches no file outside the list below.
10.16.1 What a block type is #
A block type is one directory under packages/core/src/blocks/{type}/ plus one renderer under apps/web/src/blocks/{type}/. It exports a single object satisfying BlockTypeDefinition. There is no other integration point: no switch statement to extend, no enum to edit by hand, no renderer registry to update manually.
export interface BlockTypeDefinition<S = unknown> {
/** Stable, kebab-case, permanent. Never renamed — it is persisted in every row. */
type: string;
/** Current settings version. Increment on any breaking settings change. */
version: number;
/** Zod schema for `settings`. The single source of truth for validation, everywhere. */
schema: ZodType<S>;
/** Settings for a freshly inserted block. Must satisfy `schema`. */
defaults: () => S;
/** Editor metadata for the block picker. */
meta: {
name: string;
description: string;
category: 'links' | 'identity' | 'content' | 'embeds' | 'convert' | 'structure';
icon: string;
keywords: string[];
entitlement?: string; // e.g. 'self_hosted_video'
maxPerPage?: number;
allowedInGroup: boolean;
};
/** Inspector field descriptors (9.4.1). Rendered generically. */
inspector: FieldDescriptor[];
/** Server-side validation beyond the schema: cross-field rules, remote checks. */
validate?: (settings: S, ctx: ValidationContext) => Promise<ValidationIssue[]>;
/** Ordered migrations from version N to N+1. Index i migrates i+1 -> i+2. */
migrations: Array<(prev: unknown) => unknown>;
/** Server component. MUST be synchronous-renderable and MUST NOT import client code. */
render: (props: BlockRenderProps<S>) => ReactElement | null;
/** Layout reservation for CLS prevention (11.6). Required. */
reserve: (settings: S) => { aspectRatio?: string; minHeight?: number };
/** Media ids referenced, so the publish gate can check readiness and the
* purge matrix can invalidate correctly. */
mediaRefs: (settings: S) => string[];
/** Outbound destinations, so the safety checker and the link-health job see them. */
urlRefs: (settings: S) => string[];
/** CSP directives this block requires when present on a page. Empty for most types. */
csp?: (settings: S) => Partial<Record<'frame-src' | 'img-src' | 'media-src', string[]>>;
/** Enhancement chunk this block needs, if any. Loaded only when the block is present. */
enhancement?: 'embeds' | 'countdown' | 'capture' | 'carousel' | 'video';
/** JSON-LD contribution for structured data (11.11.5). */
structuredData?: (settings: S, ctx: RenderContext) => object | null;
}10.16.2 Registration #
// packages/core/src/blocks/index.ts
import { linkBlock } from './link';
import { myNewBlock } from './my-new-block';
export const BLOCK_TYPES = defineBlockTypes([
linkBlock,
profileBlock,
// …
myNewBlock, // <- the only line added outside the new directory
]);defineBlockTypes builds the type union, the discriminated settings schema, the picker manifest and the renderer map at module load, and throws at startup on a duplicate type, a defaults() that fails its own schema, a missing reserve, or a migrations array whose length does not equal version - 1. These are startup failures, not runtime surprises.
10.16.3 The renderer #
- A React Server Component. It receives
{ blockId, settings, theme, page, request, plan }and returns markup, ornullto render nothing. - It must not import any client component, any browser API, or anything from
apps/web's client bundle. The public page ships zero blocking JavaScript (11.2), and a renderer that pulls in client code breaks that budget immediately. This is enforced by an ESLint rule and by the bundle-size gate in CI. - It must emit the
<li class="lh-block lh-block--{type}" id="b-{short}" data-b="{short}">wrapper by returning its inner content — the wrapper is applied by the page renderer, so a block cannot get it wrong. - It must reserve its own layout box per
reserve()(11.6). - It must escape all user content. React does this by default; any use of
dangerouslySetInnerHTMLrequires the sanitiser from 10.3.3 and is flagged for review by a lint rule that requires an explicit allow-comment. - All styles must be expressible in the generated public stylesheet. A block contributes a CSS module under
apps/web/src/blocks/{type}/style.css, which is concatenated into the critical stylesheet only when the block type is present on the page (11.5.2).
10.16.4 The inspector #
No custom inspector component is written for a typical block: the inspector descriptor array drives the generic renderer (9.4.1). A genuinely novel control (the QR styling preview, for example) is registered as custom:{name} and implemented under apps/web/src/inspector/controls/{name}.tsx. Custom controls must be keyboard-operable, must expose a label, and are covered by the same axe-core check as everything else.
10.16.5 Migrating stored settings #
Settings are stored as JSON and can outlive any code. The rules:
- Additive changes need no migration. Add the field to the schema with a default. Existing rows validate because the default fills the gap. Do not bump
version. - Any rename, removal, type change or semantic change requires a migration. Bump
versionand append exactly one function tomigrations. - A migration is a pure function from the previous shape to the next. It receives
unknown, must not throw, and must return a value that satisfies the new schema. When the input is unrecognisable it returns the type'sdefaults()rather than throwing — a corrupt block must degrade, never take a page down. - Migrations run lazily on read and the result is written back on the next save. There is no big-bang data migration and no downtime. A row at version 1 read by code at version 4 runs migrations 1→2→3→4 in order, in memory.
- Migrations are append-only. An existing migration function is never edited; correcting a bad migration means appending another one.
- Every migration ships with a fixture test containing at least one real captured settings payload from the previous version.
- The
settings_versioncolumn is updated on write-back so the migration chain shortens over time. A background job may migrate rows opportunistically, but correctness never depends on it. - Removing a block type entirely is not permitted while any non-deleted row references it. Deprecate it instead: hide it from the picker via
meta, keep the renderer, and let existing pages continue to work.
10.16.6 Test checklist #
A new block type is not mergeable until every item passes. This list is the definition of done, and is enforced by the quality gates in Section 26.
| # | Test | Where |
|---|---|---|
| 1 | defaults() satisfies schema |
Unit |
| 2 | Schema accepts every documented valid value and rejects every documented invalid one, including boundary lengths | Unit, table-driven |
| 3 | validate() cross-field rules covered, including the failure messages |
Unit |
| 4 | Every migration has a fixture from the real previous version, and the chain from version 1 to current produces valid settings | Unit |
| 5 | A corrupt/unrecognisable settings payload yields defaults() and does not throw |
Unit |
| 6 | Renderer snapshot for each meaningful settings permutation (layout, empty optional fields, longest permitted strings, RTL) | Component |
| 7 | Renderer emits no client-component import — verified by the bundle gate | Build |
| 8 | reserve() matches the rendered box: a Playwright CLS measurement with the image/embed delayed by 3 s yields 0 shift |
E2E |
| 9 | axe-core: zero violations in light theme, dark theme and at 320 px width | E2E a11y |
| 10 | Keyboard: every interactive element reachable, operable, with a visible focus indicator that is not obscured | E2E a11y |
| 11 | Screen-reader announcement string matches the documented expectation | Manual matrix (Section 24) |
| 12 | No-JS: renders and is fully usable with javaScriptEnabled: false; every documented no-JS guarantee holds |
E2E (11.4.4) |
| 13 | Analytics: documented events fire with the documented fields, and none fire in preview mode | Integration |
| 14 | Plan gating: gated block renders nothing on an insufficient plan and is blocked in the picker and at publish | Integration |
| 15 | Scheduling: block is absent from the HTML outside its window (10.15.1) | Integration |
| 16 | CSP: csp() output is present in the response headers when the block is on the page and absent when it is not |
Integration |
| 17 | HTML weight: the block's contribution at its heaviest realistic configuration is measured and recorded against the 40 KB page budget | Build |
| 18 | Round-trip: create via the public API (Section 21), read back, and confirm the serialised settings are byte-identical | Contract |
| 19 | Visual regression baseline captured for the default theme and one dark preset | Visual |
| 20 | Documentation: the block's row is added to this catalogue with all eleven parts | Review |
11. Public Delivery Path — Rendering & Performance #
11.1 Why the public path is architecturally separate #
Everything a visitor touches is served by code that shares no request path, no bundle and no deployment unit with the authenticated dashboard. This is a deliberate architectural split, not an optimisation applied later.
11.1.1 The three public surfaces #
| Surface | URL shape | Served by | Response | Budget |
|---|---|---|---|---|
| Bio page | https://linkhub.app/{handle}, or https://{custom_domain}/{handle}, or a custom domain root |
apps/web, route group (public) — server-rendered HTML |
200 HTML |
FCP < 0.8 s, LCP < 1.2 s |
| Short-link redirect | https://lnkhb.co/{slug}, https://go.linkhub.app/{slug}, https://{custom_domain}/{slug} |
apps/edge — Hono resolver |
302 with Location |
Server processing p95 < 50 ms |
| QR landing / redirect | https://lnkhb.co/{slug} (and any custom QR host) |
apps/edge — same resolver, QR branch |
302, or 200 HTML for a landing/fallback page |
Same as above |
A QR code's public URL is a bare /{slug} on the host — there is no /q/ prefix and no other path segment anywhere in the product. Two consequences follow, and both are load-bearing rather than cosmetic. First, a printed symbol encodes fewer characters, so it carries fewer modules and stays scannable at a smaller physical size and from further away. Second, QR slugs and short-link slugs occupy one namespace per host, which is precisely what allows the permanent slug reservation in Section 14 to protect both: a QR reservation blocks the identical short-link slug on that host, and a short-link slug blocks the identical QR slug. Section 14 owns that rule.
These three, plus their error pages, the generated Open Graph images, the vCard endpoint, the form-post endpoint and the tracked click endpoint, are the entire public surface. Nothing else is reachable without a session or an API key.
11.1.2 The five reasons for the split #
- Traffic asymmetry. Redirects and bio-page views outnumber dashboard requests by orders of magnitude, and their load is spiky and externally driven. They must scale independently, and a dashboard incident must not degrade a printed QR code.
- Latency class. The redirect path has a p95 budget of 50 ms of server processing. That is achievable only with a minimal runtime, a single Redis round trip on the hot path, no ORM hydration, no session lookup, no feature-flag evaluation and no React.
apps/edgeis a Hono service that does exactly one thing. - Bundle discipline. The public bio page ships zero blocking JavaScript. Sharing a codebase with an authenticated React dashboard makes accidental imports inevitable; a separate route group with a lint boundary and an independently measured bundle makes the budget enforceable rather than aspirational.
- Blast radius.
apps/edgedeploys blue/green and can be rolled back in seconds without touching the dashboard. A bad dashboard release cannot break resolution. The QR immortality guarantee in Section 14 depends on the resolver having the fewest possible reasons to fail. - Security posture. The public path runs under a strict nonce-based CSP with no authenticated session, no CSRF-bearing cookies beyond the form token, and no access to workspace-scoped mutation code. It reads from Redis and, on a miss, from read-optimised queries. It has no write access to any table other than the analytics stream.
11.1.3 What the public path is not allowed to do #
- It never issues a database write on the request path. Analytics goes to a Redis Stream, fire-and-forget (Section 17).
- It never performs a synchronous third-party call. Embeds are facaded (10.7.0); pixels are deferred; safety data is precomputed.
- It never requires a cookie to function. The only cookies that may exist are the consent record and the optional A/B stickiness cookie, both defined in Section 23 and Section 16, and neither is required for any content to render.
- It never depends on the dashboard being up, the billing system being reachable, or the worker fleet running.
11.2 Performance budgets #
11.2.1 Reference conditions #
All budgets are measured against a single, fixed reference profile. A number without its conditions is meaningless, so the conditions are part of the budget:
| Parameter | Value |
|---|---|
| Device | Mid-range Android handset, Moto G Power class |
| CPU | 4× throttle applied to the CI runner to approximate that device |
| Network | 4G: 1.6 Mbps down, 750 Kbps up, 150 ms RTT |
| Viewport | 390 × 844, DPR 2 |
| Cache | Cold — first visit, empty cache, no service worker |
| Page under test | The reference page fixture: profile block with avatar, 8 link blocks (3 with thumbnails), 1 image block, 1 embed facade, 1 email capture block |
11.2.2 The budget table #
| Surface | Metric | Budget |
|---|---|---|
| Bio page | FCP | < 0.8 s |
| Bio page | LCP | < 1.2 s |
| Bio page | CLS | < 0.02 |
| Bio page | INP | < 200 ms |
| Bio page | HTML transferred (gzip) | ≤ 40 KB |
| Bio page | Critical inline CSS | ≤ 14 KB |
| Bio page | Blocking JS | 0 bytes |
| Redirect | Server processing p95 | < 50 ms |
Supporting budgets, derived from the above and enforced alongside them:
| Item | Budget |
|---|---|
| Deferred enhancement JS, total across all chunks | ≤ 14 KB gzip |
| Enhancement JS actually loaded by the reference page | ≤ 9 KB gzip |
| LCP image transferred | ≤ 45 KB |
| Total page weight, reference page, cold | ≤ 180 KB |
| Requests before LCP | ≤ 3 (document, LCP image, optional preloaded font) |
| Redirect processing p50 / p99 | < 20 ms / < 120 ms |
| TTFB, cache hit at edge | < 60 ms |
| TTFB, origin render, cache miss | < 250 ms p95 |
11.2.3 These are gates, not aspirations #
Every budget above is enforced in continuous integration. A pull request that regresses any of them fails and cannot merge. There is no advisory mode and no dashboard that people learn to ignore.
| Gate | Tool | Runs on | Failure condition |
|---|---|---|---|
| Field metrics (FCP, LCP, CLS, INP, TBT) | Lighthouse CI against a preview deployment, 5 runs, median | Every PR touching apps/web, packages/ui or any block |
Median exceeds budget |
| HTML transfer size | Custom assertion over the reference page's gzipped response | Every PR | > 40 KB |
| Critical CSS size | Build-time measurement of the generated inline stylesheet | Every PR | > 14 KB |
| Blocking JS | Parse the rendered document; assert zero <script> without defer, async or type="module" and zero synchronous stylesheet-blocking script |
Every PR | Any blocking script |
| Enhancement bundle size | Bundle-size gate with per-chunk ceilings | Every PR | Any chunk over its ceiling |
| Redirect latency | k6 load test against a staging edge deployment, 5,000 rps sustained for 5 minutes | Every PR touching apps/edge, and nightly |
p95 > 50 ms or error rate > 0.01% |
| Layout shift per block | Playwright with subresources delayed 3 s | Every PR touching a block | CLS > 0.002 attributable to that block |
Budget changes require an explicit, reviewed edit to the budget file with a written justification in the PR description. Raising a budget is a product decision, not a build fix.
11.3 Bio page rendering strategy #
11.3.1 Rendering model #
Bio pages are server-rendered on demand and cached aggressively, not statically generated at build time. Static generation is wrong here: pages change at the author's whim, blocks have time-based and targeting rules, and the catalogue is unbounded and multi-tenant. Rendering on demand with a short TTL plus explicit purge gives the freshness of dynamic rendering with the cost profile of static.
The render pipeline, on a full miss:
1. Resolve host -> workspace + domain config (Redis, then Postgres)
2. Resolve (host, handle) -> page_id + published revision
3. Load the published render payload (Redis, then Postgres)
4. Evaluate block visibility rules (10.15.1)
5. Resolve A/B variant if the page has an active experiment (Section 16)
6. Render React Server Components to an HTML stream
7. Inline the critical CSS for exactly the block types present
8. Compute the response CSP from the embed providers present (10.7.0)
9. Emit `page_view` to the analytics stream, fire-and-forget (Section 17)
10. Write the rendered HTML to Redis and return it with CDN cache headersSteps 1–3 are the only I/O on a warm path, and on a warm path they are Redis hits. The published render payload is a denormalised, ready-to-render document written at publish time (9.9.1), so rendering never joins across blocks, media_assets, themes and workspaces at request time.
Streaming: the document <head> and the profile block are flushed as soon as they are ready, before the remaining blocks finish. This puts the LCP image's preload in the browser's hands roughly 40–90 ms earlier than a buffered response on the reference network.
11.3.2 Cache layers and keys #
Four layers, each with a defined role:
| Layer | Contents | Lifetime | Role |
|---|---|---|---|
| Browser | HTML (no-cache, revalidated), immutable static assets |
Assets 1 year | Repeat visits |
| CDN / edge | Full HTML responses, images, fonts, OG images | s-maxage per 11.12.2 |
Absorbs the overwhelming majority of traffic |
| Redis | Rendered HTML, published render payloads, host and handle resolution, negative caches | 60–3600 s | Absorbs CDN misses and multi-region CDN fill |
| PostgreSQL | System of record | — | Cold path only |
The canonical Redis key catalogue is owned by Section 4 (namespace:entity:id, lower-case, colon-delimited). The public path introduces no key namespace of its own; it uses exactly these members of that catalogue, and every one of them is written by a producer named in the same catalogue:
| Key | Value | TTL | Written by |
|---|---|---|---|
dom:host:{host} |
{workspace_id, domain_id, status, root_page_id, branding, plan} |
300 s | Domain config change, publish |
page:{host}:{handle} |
{page_id, revision, status} |
600 s | Publish, handle change |
page:{host}:{handle}:doc:{revision} |
Published render payload JSON (gzip) | 3600 s | Publish |
page:{host}:{handle}:html:{revision}:{variant}:{rk} |
Rendered HTML (gzip) | 300 s | Render |
page:{host}:{handle}:miss |
Negative marker | 60 s | Render when the handle resolves to nothing |
page:{host}:{handle}:og:{revision} |
Generated OG image bytes | 86400 s | OG render |
The redirect path uses rd:{host}:{slug} and rd:miss:{host}:{slug} from the same catalogue (11.8.1), the daily visitor salt lives at salt:visitor:{date}, the analytics stream is clicks:raw, and edge rate-limit counters live under rl:{scope}:{key}. No other key is read or written by any public-path code, and a key not in Section 4's catalogue is a defect — a reader with no writer silently degrades every request to the cold path, which is the failure mode this rule exists to prevent.
Key components explained:
{revision}— the published revision number. Including it makes every publish produce a new key space rather than mutating an old one, so a purge failure degrades to serving a bounded-stale page rather than serving a wrong one, and rollback is instant.{variant}— the A/B variant id, or_when the page has no active experiment. Variant assignment is computed before the cache lookup (Section 16).{rk}— the rule key: a short hash over only the request dimensions the page's visibility rules actually reference (10.15.2). A page with no targeting rules hasrk = _and exactly one cache entry. A page whose rules reference onlycountryhas one entry per country that visits. A page referencing country and device has at mostcountries × 3entries. The renderer computes the referenced-dimension set at publish time and stores it on the payload, so the cache key is known before any rule is evaluated.
Cardinality guard: when a page's rule set would produce more than 64 distinct rule keys, the renderer marks the page uncacheable_html and serves it with s-maxage=0, private from the origin on every request, while still caching the render payload. The author sees a warning in the editor explaining the trade-off. This is preferable to silently exploding the cache.
11.3.3 TTLs and stale-while-revalidate #
| Response | Cache-Control |
Redis TTL | Notes |
|---|---|---|---|
| Bio page, standard | public, max-age=0, s-maxage=60, stale-while-revalidate=600, stale-if-error=86400 |
300 s | The browser always revalidates; the CDN absorbs the load |
| Bio page containing a countdown or time-scheduled block | s-maxage = min(60, seconds_to_next_boundary), floor 10 |
same, floored at 10 s | 10.13.5, 10.15.2 |
| Bio page, password-protected | private, no-store |
not cached | |
Bio page, uncacheable_html |
private, no-store |
payload only | 11.3.2 |
| Bio page, preview | private, no-store |
not cached | 9.8.1 |
stale-while-revalidate=600 means that after s-maxage expires, the CDN serves the stale copy instantly and refreshes it in the background. Combined with explicit purge on publish, the practical result is: authors see their change live within seconds, and visitors essentially never wait on an origin render.
stale-if-error=86400 is the availability backstop: if the origin returns 5xx or times out, the CDN keeps serving the last good copy for up to 24 hours. A LinkHub outage does not take down a customer's page.
11.3.4 Surrogate keys #
Every cacheable response is tagged with surrogate keys so purges are precise:
Surrogate-Key: page-{page_id} ws-{workspace_id} host-{host} theme-{theme_id} media-{m1} media-{m2} …The CDN purges by key, never by URL, because one page can be reachable at several URLs (custom domain plus platform domain plus a handle alias) and URL-based purging misses them.
11.3.5 Cache invalidation #
The authoritative mapping from every mutation to the CDN surrogate keys and Redis keys it purges is the cache-invalidation matrix in Section 4.7. It is not restated here, and no second matrix exists anywhere in this specification: a mutation not listed in Section 4.7 purges nothing, and any mutation added later is added there.
What Section 11 fixes is only how the public path consumes that matrix:
- Purges are issued by surrogate key at the CDN and by exact key at Redis, never by URL, for the reason given in 11.3.4.
- Purge jobs are idempotent and are retried 5× with exponential backoff, emitting a metric on every failure. A purge that has failed all retries raises the alert defined in Section 25.
- Because HTML keys are revision-scoped and every TTL in 11.3.3 is short, a failed purge degrades to bounded staleness, never incorrect content: the worst case is a visitor seeing the previous published revision for up to 60 seconds at the CDN and 300 seconds at Redis.
- Media moderation takedowns and safety flags are placed on the priority purge queue with a target of under 30 seconds end to end, because those two are the only purges where staleness is a safety problem rather than a freshness one.
- Redirect resolution never depends on a purge succeeding for correctness within its own TTL window; the destination-change purge shortens the window, it does not create it.
11.3.6 Cold path and failure modes #
| Failing dependency | Behaviour |
|---|---|
| Redis unavailable | Every layer falls through to PostgreSQL. Latency rises (TTFB p95 ~ 250 ms instead of ~ 60 ms) but pages render correctly. A circuit breaker opens after 5 consecutive Redis failures and retries every 2 s, so a dead Redis does not add its own timeout to every request |
| PostgreSQL read replica unavailable | Falls back to the primary. If both are unavailable, the CDN's stale-if-error serves the last good copy for up to 24 h; requests with no cached copy get the error page in 11.10.9 with 503 and Retry-After: 30 |
| Render throws | The error boundary returns the 11.10.9 page with 500. The failure is reported with request_id. A single bad block cannot take the page down: each block renders inside its own boundary and a throwing block is omitted with a logged error, so a page with one broken block renders the other twenty |
| Analytics stream unavailable | The page renders and returns normally. A counter increments. Analytics is never on the critical path |
11.4 The no-JavaScript guarantee #
LinkHub's public pages work with JavaScript disabled. Not "mostly work" — the entire primary purpose of the product, which is getting a visitor from a bio page to a destination, functions with zero JavaScript executed. This section states exactly what that means, with no hedging.
11.4.1 What works completely, with JavaScript disabled #
| Capability | Why it works |
|---|---|
| Every link on the page | Real <a href> elements, server-rendered, pointing at real URLs |
| Click tracking on every link | Tracking is a server-side 302 through the tracked click endpoint (10.1.7), not a client beacon. Analytics is complete without JS |
| Every short-link redirect | An HTTP 302 from apps/edge |
| Every QR code scan | Same resolver, same 302 |
| All text content | Server-rendered HTML |
| All images, including responsive variants, AVIF/WebP negotiation and the LQIP placeholder | srcset/sizes and an inline background-image; no JS involved |
| Full layout and theme | CSS only. All theming is CSS custom properties emitted server-side |
| Profile, header, avatar, monogram fallback | HTML + CSS |
| Social icons | Inline SVG sprite + anchors |
| FAQ / accordion, including exclusive mode | Native <details>/<summary> with the name attribute (10.11.3) |
| Group sections, including collapsible | Native <details> |
| Self-hosted video and audio playback, with captions and fullscreen | Native <video controls> (10.6.3) |
| Email capture — the complete flow: render, client-side type validation via native constraints, submit, server validation, consent, honeypot, timing check, rate limiting, success message, error messages, success redirect | Native form POST to a same-origin endpoint with a 303 response (10.9.4) |
| vCard download | A link to a server-generated file (10.12.3) |
| Countdown initial values, target time and post-expiry state | Server-rendered from duration arithmetic (10.13.3) |
| Product blocks, formatted prices, sold-out state | Server-side Intl.NumberFormat (10.8.4) |
| Block scheduling, targeting and A/B variant assignment | Evaluated server-side before HTML is produced (10.15.1) |
| Password gate | A native form POST setting a signed cookie, exactly as specified in Section 12.7 |
| Consent banner | Server-rendered with a native form POST that sets the consent cookie; the page reloads with the choice applied |
| All error and edge pages | Static server-rendered HTML |
| Skip link, focus order, keyboard navigation | HTML + CSS |
11.4.2 What degrades gracefully #
Each item below loses an enhancement and keeps a complete, usable alternative. Nothing becomes unavailable.
| Feature | With JS | Without JS | The fallback |
|---|---|---|---|
| Embeds (all eight providers) | Facade → click → inline player | Facade is a link | The facade is an <a> to the canonical provider URL with the poster, title and "Watch on {Provider}". The content is one tap away, on the provider's own site (10.7.0) |
| Analytics view beacon | sendBeacon on visibilitychange, batched |
Server-side | The page_view event is emitted server-side during render, so views are counted without JS at all. The client beacon adds only engagement signals (scroll depth, block impressions), which are explicitly optional dimensions |
| Analytics for engagement dimensions | block_impression, embed_load, media_play |
Absent | These dimensions are documented as enhancement-only in Section 17 and dashboards label them as such. No metric silently under-reports without saying so |
| Share sheet | navigator.share on supported devices |
A visible, selectable URL plus a mailto: and an SMS link |
The share control renders as a <details> disclosure containing the full page URL in a read-only, pre-selected <input> with the text "Copy this link", plus native share links. The copy button appears only when JS is present, because a dead button is worse than none |
| Countdown ticking | Updates every second | Static values as of page load | The exact target time is always rendered as a <time> element next to the digits, so the visitor can compute it themselves. Values are accurate at load |
| Email capture inline submit | fetch, no navigation |
Full page POST + 303 redirect |
Identical validation and identical outcomes; the page simply reloads at the block's anchor |
| Carousel arrow buttons | Rendered and functional | Not rendered | The carousel remains scrollable by touch, trackpad, scrollbar and keyboard arrows (10.14.4) |
| Reduced-motion autoplay suppression | Autoplay suppressed | Browser policy applies | Autoplay is muted-only by validation, so the worst case is a silent looping clip |
| Consent-gated third-party pixels | Fire after consent | Never fire | Pixels are inherently JS. Their absence changes nothing the visitor can see |
| Lazy-loaded below-fold images | loading="lazy" |
loading="lazy" |
This is a native attribute; it works identically |
11.4.3 What is JS-only, and why that is acceptable #
Exactly three things, all of them purely additive instrumentation or convenience: block-impression and scroll-depth analytics; the third-party pixel integrations in Section 19; and the clipboard copy button. None of them affects what a visitor can read, reach, submit or buy.
11.4.4 The test that proves it #
The guarantee is enforced by an automated suite, public-no-js.spec.ts, which runs on every pull request and blocks merge on failure. It uses a Playwright browser context created with javaScriptEnabled: false — a genuinely JS-free context, not a page with scripts stubbed out.
The suite runs against a fixture page containing every block type in the catalogue, and asserts:
1. Response is 200 and the document parses.
2. Zero <script> elements execute: assert every <script> in the document carries
`defer` or `type="module"`, and assert a sentinel global set by the enhancement
bundle is undefined.
3. Every block type in the catalogue is present in the DOM with its documented
wrapper class and a non-empty accessible name where one is documented.
4. Every <a href> on the page resolves: each is requested and must return 2xx or 3xx.
Zero anchors have href="#", href="javascript:*", or an empty href.
5. Every link block's anchor points at the tracked click endpoint; following it
returns 302 with a Location header matching the configured destination, and an
analytics event is observed on the stream.
6. Every image has non-zero natural dimensions after load, and explicit width and
height (or aspect-ratio) attributes.
7. The FAQ block: clicking a <summary> expands it and the answer text becomes
visible; with allow_multiple_open=false, expanding a second item collapses the first.
8. The group block in collapsible mode expands and collapses.
9. The video block exposes native controls and reports a readable duration.
10. Email capture: fill the email field, tick consent, submit. Assert a 303 response,
assert the redirect target contains ?subscribed=<block_id>, assert the reloaded
page shows the success message at the block anchor, and assert the lead was
persisted. Then submit with an invalid email and assert the documented error is
rendered inline. Then submit with the honeypot filled and assert a success
response with no lead persisted. Then arm the escalated bot challenge on the
fixture and submit again: assert no challenge widget is present anywhere in
the document, assert the response is still a 303 to the success target, and
assert the lead was persisted with status pending_review.
11. Each embed facade is an <a> whose href is the canonical provider URL and whose
accessible name matches "Play {title} on {Provider}". No provider origin appears
in the page's network log at any point.
12. Countdown: the rendered digits match the expected remaining duration within 2
seconds of the fixture's frozen clock, and the <time> element carries the correct
machine-readable datetime.
13. Product block: the rendered price string equals Intl.NumberFormat output for the
fixture's locale and currency; the sold-out fixture renders no anchor.
14. vCard: request the .vcf endpoint and assert a parseable vCard 3.0 with the
expected FN, TEL and EMAIL properties.
15. Password gate: GET the protected URL and assert 200 with the password form
present; POST the correct password, assert 303 and that the page then renders.
16. Consent banner: POST the "reject" choice, assert the cookie is set and that no
pixel markup is present on the reloaded page.
17. Layout: at 320 px width, assert document.scrollingElement.scrollWidth equals the
viewport width (no horizontal scroll).
18. Total transferred bytes for the document are under the 40 KB budget.
19. A full-page screenshot is compared against the JS-enabled screenshot; the visual
diff must be under 1% excluding the deliberately JS-only share button.A parallel suite runs the same fixture in a JS-enabled context and asserts that the outcomes of steps 4, 5, 10, 12 and 13 are identical, which is what makes the guarantee meaningful: both paths produce the same result, not merely two paths that each happen to run.
11.5 The critical rendering path #
11.5.1 The target #
One HTML request, plus at most one image request and at most one font request, produce first contentful paint. On the reference profile that is a document round trip (~150 ms RTT + TTFB) plus parse, and it lands inside 0.8 s with margin.
11.5.2 Critical CSS #
- All CSS for the page is inlined in a single
<style>element in the head. There is no external stylesheet on a bio page, and therefore no render-blocking stylesheet request. On the reference network, one avoided request is ~150 ms of RTT — more than a sixth of the entire FCP budget. - The inlined CSS is assembled per page from the block types actually present: a base layer (reset, tokens, layout, typography, focus, list semantics) plus one CSS module per distinct block type on the page (10.16.3). A page with four link blocks costs the link module once.
- Theme values are emitted as CSS custom properties in a
:rootblock generated from the theme document (9.6.1), so a theme change costs no additional bytes and no additional request. - Budget: ≤ 14 KB uncompressed inline CSS, measured at build time on the reference fixture and enforced by the gate in 11.2.3. 14 KB is chosen deliberately: it fits comfortably within the first congestion window, so the CSS arrives with the first flush of the document.
- The base layer is ~6 KB; each block module is 300–900 bytes. A page with all sixteen block types would exceed the budget, which is why the enhancement-free design is per-page assembly rather than one global sheet.
- No CSS framework output is shipped to the public path. The dashboard uses the shared utility-first setup; the public renderer uses hand-authored, minified modules. This is a deliberate divergence and it is the reason the budget is achievable.
- A single non-blocking
<link rel="stylesheet">is used for print styles only, loaded withmedia="print", which browsers never treat as render-blocking.
11.5.3 Fonts #
| Rule | Detail |
|---|---|
| Default | The system font stack. A page using the default ships zero font bytes and zero font requests. This is the default because it is the fastest possible answer and it looks native on every platform |
| Custom fonts | Maximum 2 families and 3 total weights (enforced by the theme editor, 9.6.3) |
| Format | WOFF2 only |
| Subsetting | Latin + Latin-Extended by default, ~28 KB per weight. Cyrillic and Greek subsets are separate files served only when the page language requires them, via unicode-range |
| Loading | <link rel="preload" as="font" type="font/woff2" crossorigin> in the head for the first weight only — the one used by the profile name. Other weights load normally and swap in |
font-display |
swap, always. A page must never be invisible waiting on a font |
| Fallback metrics | Every bundled family declares size-adjust, ascent-override and descent-override in an @font-face fallback rule tuned to the system stack, so the swap causes zero layout shift. This is what keeps CLS under budget when custom fonts are used |
| Self-hosting | Fonts are served from the same CDN host as media, never from a third-party font service. No third-party request, no third-party cookie, no consent question |
11.5.4 Images #
| Rule | Detail |
|---|---|
| Formats | AVIF primary, WebP fallback, selected server-side from the request's Accept header so a single <img> is emitted rather than <picture> markup (10.4.3) |
| Dimensions | width and height attributes always present, or an explicit aspect-ratio style. No exceptions — this is checked by the layout-shift gate |
| Responsive | srcset from the generated widths (9.7.3) with a sizes value computed from the block's layout and the theme's max width |
| LCP candidate | Exactly one image per page is the LCP candidate: the profile avatar, or an image block with priority: true (10.4.2). It is rendered with fetchpriority="high", without loading="lazy", and with a <link rel="preload" as="image" imagesrcset imagesizes> in the head |
| Everything else | loading="lazy", decoding="async", fetchpriority="low" |
| Placeholders | Inline LQIP as a background-image data URI, ≤ 400 bytes, removed on load (10.4.3) |
| Budget | LCP image ≤ 45 KB transferred at the reference viewport |
11.5.5 Why zero blocking JavaScript #
A blocking script must be downloaded, parsed and executed before the parser continues. On the reference device — 4× CPU throttle, 1.6 Mbps — a 30 KB script costs roughly 150 ms of network plus 100–200 ms of parse and execute, all of it in front of the paint. That alone would consume a third of the FCP budget for functionality that, on this product, does nothing the server has not already done.
The public page therefore contains no synchronous script of any kind: no framework runtime, no hydration payload, no inline configuration blob, no tag manager, no polyfill loader. The enhancement bundle is a single <script type="module" defer> at the end of the body (11.7). There is no legacy nomodule counterpart — browsers old enough to need one are old enough to be served the no-JS experience, which is fully functional.
React Server Components render to HTML on the server and ship no client runtime for any block. Blocks that need behaviour (embeds, countdown, capture, carousel, video) express it through the enhancement chunks, which attach to server-rendered markup by data- attribute rather than hydrating a component tree.
11.5.6 The exact document head order #
The order below is normative. It is what the renderer emits, top to bottom, and it exists because head order determines discovery order and therefore paint time.
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8"> <!-- 1. must be in the first 1024 bytes -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Acme Studio</title> <!-- 3. early, for the tab and for crawlers -->
<!-- 4. Connection setup for the media CDN, before anything requests from it -->
<link rel="preconnect" href="https://cdn.linkhub.app" crossorigin>
<!-- 5. LCP image preload — the single most important line in the head -->
<link rel="preload" as="image" fetchpriority="high"
imagesrcset="https://cdn.linkhub.app/m/…/96.avif 96w, …/192.avif 192w"
imagesizes="96px">
<!-- 6. First custom font weight, if the theme uses one. Omitted for system stack -->
<link rel="preload" as="font" type="font/woff2" crossorigin
href="https://cdn.linkhub.app/f/general-sans-600.woff2">
<!-- 7. All critical CSS, inline, ≤14 KB -->
<style>:root{--lh-bg:#0b0d12;…}…</style>
<!-- 8. Theme colour and icons -->
<meta name="theme-color" content="#0B0D12">
<link rel="icon" href="/favicon-32.png" sizes="32x32">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<!-- 9. Canonical and alternates -->
<link rel="canonical" href="https://linkhub.app/acme">
<!-- 10. SEO and social metadata (11.11) -->
<meta name="description" content="…">
<meta name="robots" content="index, follow, max-image-preview:large">
<meta property="og:type" content="profile"> …
<meta name="twitter:card" content="summary_large_image"> …
<!-- 11. Structured data -->
<script type="application/ld+json">{"@context":"https://schema.org",…}</script>
<!-- 12. Author-supplied verification meta, sanitised (9.2.1) -->
</head>
<body>
<a class="lh-skip" href="#content">Skip to content</a>
<main id="content">…</main>
<footer>…</footer>
<!-- 13. The only script on the page. Module, deferred, nonce'd, at the end of body -->
<script type="module" defer nonce="…" src="https://cdn.linkhub.app/e/core.{hash}.js"></script>
</body>
</html>Rationale for the ordering decisions that matter:
charsetfirst, unconditionally, so the parser never restarts.preconnectbefore the preloads that use it, so the TLS handshake overlaps the CSS parse.- The LCP image preload before the CSS, because the browser's preload scanner can start that fetch while the CSS is still being parsed. Placing it after the inline
<style>measurably delays it on slow CPUs. - The
application/ld+jsonblock last among head content because it is large and irrelevant to paint; crawlers do not care about its position. - The enhancement script at the end of
<body>, not in the head, so it is discovered after all content and cannot compete for bandwidth with the LCP image.
11.6 Layout-shift prevention #
CLS under 0.02 means, in practice, that nothing on the page may change size after first paint. That is achieved by reserving space for every element before its content arrives, without exception.
11.6.1 Reserved dimensions by block type #
Every block type implements reserve() (10.16.1) and the value is verified against the rendered output by the CI gate in 11.2.3.
| Block | Reservation strategy |
|---|---|
| Link | min-height from the layout and theme size (stacked 64 px, inline 48 px, card computed from the 16:9 banner). Thumbnail box is a fixed square. Badge is absolutely positioned and never reflows text |
| Header / profile | Avatar box is a fixed square from avatar_size; the monogram fallback occupies exactly the same box. Name and bio have min-height derived from their line-height and clamp, so a slow font swap cannot resize them (11.5.3 fallback metrics) |
| Text | Intrinsic. Fallback font metrics prevent swap shift; images are not permitted inside text blocks precisely because they would be unreservable |
| Image | aspect-ratio on the <img> plus explicit width/height. The LQIP occupies the box from the first frame |
| Social icons | Fixed icon box per size; wrapping is computed from a fixed item width, so the row height is known before the sprite paints |
| Video | aspect-ratio on the <figure>, poster fills it. preload="none" means metadata never arrives to change anything |
| Embeds | aspect-ratio for video providers; an exact fixed height for the fixed-height providers (Spotify 152/352/400 px, Apple Music 175/450 px, SoundCloud 166/300 px, X 320 px). The facade fills the identical box, so activation swaps an image for an iframe of the same size — zero shift on activation |
| Buy / product | Image aspect-ratio; price line has min-height and tabular-nums so a longer formatted price cannot reflow |
| Email capture | Fully static. The error paragraph is present in the DOM from first render with min-height: 1lh, so showing an error does not push the button down |
| Divider / spacer | Fixed height from size |
| FAQ | Collapsed answers use content-visibility: auto with contain-intrinsic-size set from a server-side estimate, so scroll height is stable. Expanding is a user action and is therefore excluded from CLS by definition |
| Contact | Static text |
| Countdown | tabular-nums and a fixed ch width per unit; the digits never change width as they tick |
| Group | Reserved height is the sum of children plus gaps; grid modes use grid-auto-rows: 1fr with reserved child boxes |
11.6.2 The avatar, specifically #
The avatar is usually the LCP element and it is the most common source of shift in this product category. The mitigation is layered:
- The
<img>carries explicitwidth/heightmatching the rendered CSS box, so the box exists before any bytes arrive. - The LQIP data URI paints in the same box on the first frame, so the space is never visibly empty.
- The image is preloaded from the head (11.5.6 item 5).
- When no avatar is set, the monogram fallback occupies the identical box — switching between them cannot shift anything.
- If the avatar request fails entirely, the
<img>keeps its reserved box; CSS renders the LQIP background and the alt text is styled to fit. A broken image never collapses to zero height.
The result: a deliberately delayed avatar (3-second delay injected in the Playwright test) produces a measured CLS contribution of exactly 0.
11.6.3 Other shift sources and their fixes #
| Source | Fix |
|---|---|
| Web font swap | Fallback @font-face with size-adjust and metric overrides tuned per family (11.5.3) |
| Consent banner | Rendered server-side, at the bottom, as a fixed-position element outside document flow. It never inserts into the layout after paint |
| Password gate, safety interstitial | Separate documents, not overlays |
| Countdown reaching zero | The expiry swap preserves the block's reserved height |
| Enhancement bundle attaching | The bundle is forbidden from inserting, removing or resizing any element before user interaction. This is asserted in the CLS test: the page's CLS with JS enabled must equal its CLS with JS disabled, within 0.002 |
| Scrollbar appearance | scrollbar-gutter: stable on the root |
| Late-loading embed poster | Poster is re-hosted and in the same responsive pipeline as any other image (10.7.0) |
11.7 Progressive enhancement #
11.7.1 What the deferred bundle does #
Six things, and nothing else:
| Chunk | Size ceiling (gzip) | Responsibility |
|---|---|---|
core |
3.0 KB | Loads on every page. Attaches the analytics beacon (block impressions, scroll depth, engaged time), reads the consent state, and lazily imports the other chunks based on data- attributes present in the DOM |
embeds |
5.0 KB | Facade activation for all eight providers, the consent interaction, the load-failure watchdog, and the embed_load event (10.7.0) |
countdown |
1.2 KB | Per-second ticking, clock-skew correction, expiry swap (10.13.3) |
capture |
2.0 KB | Intercepts the form submit for an inline experience; falls back to native POST on any error (10.9.4) |
share |
1.0 KB | navigator.share where available, clipboard copy button injection |
carousel |
0.8 KB | Injects and wires the previous/next buttons for layout: carousel groups (10.14.4) |
| Total | ≤ 14 KB | Enforced per chunk and in aggregate by the bundle gate |
The reference page loads core + embeds + capture = 10 KB, inside the 9 KB target once tree-shaking removes the seven unused embed providers (each provider is a ~200-byte entry in a lookup table plus a shared activation routine, so unused providers cost almost nothing and the per-page number lands at ~8.4 KB).
11.7.2 How it loads #
<script type="module" defer nonce="…" src="https://cdn.linkhub.app/e/core.{hash}.js"></script>type="module"— never parser-blocking, and gives module scope for free.defer— executes after parsing, beforeDOMContentLoaded.- Placed at the end of
<body>so it is discovered last. - Served with
Cache-Control: public, max-age=31536000, immutableunder a content-hashed filename, so a repeat visitor and every visitor to every other LinkHub page fetches it zero times. - Nonce'd to satisfy the strict CSP in Section 23. The nonce is per-response and never cached (11.12.3).
- Secondary chunks are loaded with dynamic
import()only when the corresponding markup is present, and forembedsonly on first interaction rather than at load, so a page with three embeds still fetches nothing extra until someone presses play.
11.7.3 Hard rules for enhancement code #
- It may not insert, remove, resize or reposition any element before a user interaction (11.6.3).
- It may not be required for any documented capability in 11.4.1.
- It may not import anything from the dashboard's dependency graph. Enforced by a lint boundary and by the size ceiling, which no framework runtime could fit inside.
- It must be resilient to its own failure: every chunk is wrapped so that a throw leaves the server-rendered markup untouched and functional. A failed
embedschunk leaves working facade links. - It must not use
document.write, synchronous XHR, or any API that can block the main thread for more than 16 ms in a single task. INP is budgeted at 200 ms and the enhancement layer's own contribution is measured at under 20 ms on the reference device. - It must not read or write cookies other than the two defined in Section 16 and Section 23.
11.8 The short-link redirect path #
This is the highest-traffic, lowest-latency code in the product. It is a single Hono handler in apps/edge.
11.8.1 Resolution algorithm #
Latency figures are the per-step budget contributing to the p95 of 50 ms. Steps that normally cost nothing on a warm path are marked accordingly.
INPUT: method, host, path, headers, client IP (in memory only)
1. NORMALISE [< 0.2 ms]
a. Lower-case the host, strip the port, strip a leading "www.".
b. Extract the first path segment as `slug`. There is no path prefix: a QR
code and a short link are both served from a bare /{slug} on the host and
share ONE slug namespace per host (11.1.1, Section 14). The surface is
therefore NOT derivable from the path; it is a property of the record the
slug resolves to, and it is set in step 4.
c. Reject with the not-found page (step 11) when: the method is not GET or
HEAD; the slug exceeds 64 characters; or the slug contains characters
outside [a-z0-9-] after case folding. An empty slug with no root page
configured for the host also goes to step 11. These three are the only
rejections that precede the reservation guard, and they are safe to
precede it because no reservable slug can ever take those forms.
d. Capture the query string — it is forwarded to the destination (11.8.4).
2. HOST LOOKUP [0.3 ms warm]
a. Read Redis `dom:host:{host}`.
b. HIT -> workspace_id, domain status, surface config, plan, branding.
c. MISS -> single indexed Postgres query on the custom-domain table (Section 6);
write back, TTL 300 s. Cost on miss: ~4 ms.
d. Host unknown, host removed, or host not yet active (status in
`pending_dns`, `verifying`, `provisioning_tls`, `dns_failed`, `tls_failed`)
-> DO NOT respond yet. Go to step 2A.
e. Domain suspended -> DO NOT respond yet. Go to step 2A.
f. Domain active -> continue to step 3.
2A. RESERVATION GUARD ON A HOST THAT CANNOT SERVE [0.3 ms]
This step exists because a printed QR code outlives the domain record behind
it. A host that is unknown, removed, mid-provisioning or suspended is exactly
the state in which a customer's already-printed codes are most likely to be
scanned, and returning 404 here would bypass the QR guarantee entirely.
a. Read the permanent slug reservation table (Section 14) for (host, slug).
This table is never purged, so it answers for hosts whose domain record no
longer exists. Warm reads come from the resolver's in-process LRU; the
cold read is a single indexed lookup, ~3 ms, and it happens only on a
host that is already failing.
b. RESERVED, and workspace branding is still available (the workspace exists
and has not been erased)
-> rung 3 `workspace_unavailable`, 200 (11.9.3).
c. RESERVED, and no branding is available (workspace deleted, account
deleted, or personal data erased under Section 23)
-> rung 4 `generic`, 200 (11.9.3).
d. NOT RESERVED -> step 11 (not found). The slug was never printed on
anything, so there is nothing to protect.
A suspended workspace reaches rung 3 or rung 4 by this same path; it never
reaches the workspace-suspended page for a reserved slug.
3. SLUG LOOKUP — REDIS [0.4 ms]
a. Read Redis `rd:{host}:{slug}`.
b. HIT -> the resolved payload, including its surface; jump to step 5.
c. Read Redis `rd:miss:{host}:{slug}`. HIT -> jump to step 11 (not found).
The negative cache is only ever written for a slug that step 4 has already
confirmed is unreserved, so it can never short-circuit the guard.
4. SLUG LOOKUP — POSTGRES (cold path only) [3–8 ms]
a. One indexed query resolves (host_id, slug) across the shared namespace and
returns the owning record and its surface (`link` or `qr`), selecting only
the columns the resolver needs: destination, status, rules, experiment_id,
expires_at, starts_at, password_hash, safe_browsing_status, fallback_url,
workspace_id, plan_state. Column definitions are in Section 6.
b. FOUND -> build the payload, write through to `rd:{host}:{slug}`
with TTL 3600 s.
c. NOT FOUND -> consult the permanent slug reservation table as in step 2A.
RESERVED -> rung 3 or rung 4 per 2A(b)/2A(c).
UNRESERVED-> set `rd:miss:{host}:{slug}` TTL 60 s; step 11.
d. Postgres unavailable -> see 11.8.3.
5. STATUS AND SCHEDULE CHECKS [< 0.1 ms, in memory]
Every branch below is 200 for a link and a rung for a QR. No branch is 404,
410 or 5xx on any surface, for the reason stated in 11.10.
a. deleted_at set -> step 11 (link) / step 12 (QR).
b. status == 'paused' -> 11.10 paused/unavailable page, 200 (link) /
step 12 (QR, which prefers the fallback URL).
c. starts_at in future -> 11.10.3 "not available yet" page, 200, no-store
(QR: step 12).
d. expires_at in past -> 11.10.2 expired page, 200, cached 300 s
(QR: step 12).
e. Plan cap exceeded and the 90-day grace has passed
-> 11.10.6 plan-limited page, 200, cached 300 s.
(QR: exempt entirely, always resolves.)
6. PASSWORD CHECK [0.1 ms]
a. No password -> continue.
b. A valid password cookie is presented -> continue.
c. Otherwise -> serve the password interstitial with 200. The interstitial's
status, markup, cookie name, HMAC inputs, lifetime, verification algorithm
and rate limit are specified once, in Section 12.7; the resolver only
decides that it is needed and delegates. Password verification happens on
the POST that Section 12.7 defines, never on this GET, so a GET costs no
Argon2id work.
7. SAFETY CHECK [< 0.1 ms, in memory]
a. safe_browsing_status == 'blocked' -> 11.10.5 interstitial, 200, no-store.
b. safe_browsing_status == 'flagged' -> 11.10.5 interstitial with a proceed
control, 200, no-store.
c. safe_browsing_status in ('safe','unchecked') -> continue.
The value is precomputed by the safety worker (Section 23) and carried in the
cached payload. No third-party call is ever made on the request path.
8. TARGETING RULE EVALUATION [0.2–0.8 ms]
a. If the payload has no rules, skip.
b. Resolve the request dimensions needed by the rules, and only those:
country/region (from the edge geo header, else the in-memory geo database —
never a network call), device type, OS, browser, language, referrer host,
and local time in the rule's timezone.
c. Evaluate rules in priority order per Section 15; first match wins.
d. A matched rule supplies the destination and may supply its own UTM set.
e. No match -> the default destination.
9. A/B SPLIT [0.1–0.3 ms]
a. If experiment_id is null, skip.
b. Compute visitor_hash (in memory, from the rotating daily salt at
`salt:visitor:{date}`, cached in process for 60 s).
c. Compute the bucket using the assignment function in Section 16 and select
the variant's destination. Variant identity is never appended to the
outbound URL.
10. BUILD AND DISPATCH [0.3 ms]
a. Compose the final URL: destination + merged UTM parameters (Section 15) +
forwarded query parameters (11.8.4).
b. Validate the composed URL: scheme in the allow-list, length ≤ 2048, host
resolvable and not private (precomputed at write time, re-asserted cheaply).
c. XADD the event to the Redis Stream `clicks:raw`, fire-and-forget: the
promise is NOT awaited, has a 50 ms internal timeout, and its failure
increments a counter and is otherwise ignored. The redirect never waits.
The event carries `fallback_stage = 'active'`.
d. Return:
HTTP/1.1 302 Found
Location: <final URL>
Cache-Control: private, no-store
Referrer-Policy: strict-origin-when-cross-origin
X-Content-Type-Options: nosniff
Content-Length: 0
e. A destination redirect is ALWAYS 302. 301 and 308 are never issued for a
destination on any surface: destinations are editable at any time, and a
permanently cached redirect is a product defect, not an optimisation. The
single 301 in the product is the HTTP-to-HTTPS transport upgrade in
11.12.5, which is a different thing entirely.
11. NOT FOUND (unreserved slugs only) [0.2 ms]
Reachable only after step 2A or step 4c has confirmed the slug is NOT
reserved. Render 11.10.1, HTTP 404, cached 60 s at the edge.
12. QR FALLBACK CHAIN (QR surface only) [0.2 ms]
Rungs 1-4 with the `fallback_stage` values `active`, `paused_fallback`,
`workspace_unavailable`, `generic`. Never 404, never 410, never 5xx, and
there is no rung 5. Defined in Section 14.8.2 and summarised in 11.9.3.Why the reservation guard sits at step 2A rather than after the record lookup. A slug's reservation is permanent and independent of every other record in the system — the domain, the workspace, the account and the link may all be gone. Any ordering that requires an active host or an existing record before consulting the reservation table produces a 404 for exactly the customer whose printed material is still in circulation, which is the one outcome the product promises can never happen. The guard is therefore placed before any not-found response can be produced, on both the host-failure path and the record-miss path, and step 11 is unreachable without having passed it.
11.8.2 Latency budget summary #
| Step | Warm path | Cold path |
|---|---|---|
| 1 Normalise | 0.2 ms | 0.2 ms |
| 2 Host lookup | 0.3 ms | 4 ms |
| 2A Reservation guard | not executed | 0.3 ms warm / 3 ms cold |
| 3–4 Slug lookup | 0.4 ms | 8 ms |
| 5–7 Status, password, safety | 0.2 ms | 0.2 ms |
| 8 Targeting | 0.2–0.8 ms | 0.8 ms |
| 9 A/B | 0.1–0.3 ms | 0.3 ms |
| 10 Build + dispatch | 0.3 ms | 0.3 ms |
| Total server processing | ~1.7 ms | ~14 ms |
| Budget | p50 < 20 ms, p95 < 50 ms, p99 < 120 ms |
Step 2A does not appear in either total because it is mutually exclusive with the rest of the chain: it runs only when the host cannot serve, in which case steps 3–10 do not run at all. Its worst case is ~3.5 ms of total processing, comfortably inside the p50 budget, and it costs nothing on any request that resolves normally.
The headroom between ~1.7 ms and the 20 ms p50 budget is deliberate: it absorbs runtime scheduling, TLS termination, connection pool contention, and the occasional cold container. The budget is the promise; the measured warm path is what makes the promise safe.
11.8.3 Dependency failure behaviour #
Resolution must be the most available thing LinkHub does. Every dependency has a defined failure mode, and none of them is "return 500".
| Dependency unavailable | Behaviour |
|---|---|
| Redis | Circuit breaker opens after 5 consecutive failures (2 s half-open retry). All lookups go straight to Postgres. Latency rises to the cold-path figures (~14 ms) — still inside budget. Analytics XADD is dropped and counted; a per-instance in-memory ring buffer of up to 10,000 events is flushed when Redis returns, with events older than 5 minutes discarded |
| Postgres | Redis-cached payloads (TTL 3600 s) continue to serve every warm slug — in practice the overwhelming majority of traffic. A cold slug during a Postgres outage returns the 11.10.9 temporary-error page with 503 and Retry-After: 30 for links. It never does so for a QR: when the reservation table cannot be read, the resolver assumes the slug is reserved and falls to rung 3 if branding is in the in-process cache, otherwise rung 4, both 200. Assuming reservation under uncertainty is the only safe default, because the cost of a wrong "reserved" is one extra branded page and the cost of a wrong "unreserved" is a dead printed code |
| Both Redis and Postgres | Each edge instance holds an in-process LRU of the 10,000 hottest payloads (60 s TTL, refreshed on every hit). Those continue to resolve. Everything else: 503 for links, rung 4 generic at 200 for QR |
| Reservation table unreadable | Treated as "reserved" per the Postgres row above. This is the one lookup in the resolver that fails closed toward serving, and it is deliberate |
| Geo database | Targeting rules that reference country evaluate to "no match" and the default destination is used. The redirect always happens. Geo dimensions on the event are recorded as unknown per Section 17 |
| Analytics stream | Already fire-and-forget. Redirect is unaffected |
| Safety service | Never on the request path. The cached safe_browsing_status is used; if absent, the value is unchecked and the link resolves, because failing closed here would break every link during a safety-service outage |
| Clock skew across instances | Schedule and expiry comparisons use the database-issued timestamp carried in the payload plus the instance's monotonic clock; instances run NTP and an alert fires above 500 ms drift |
11.8.4 Query-parameter forwarding #
Incoming query parameters on a short link are forwarded to the destination, with these rules:
- Parameters explicitly configured on the link's UTM set (Section 15) win over incoming ones with the same key.
- Incoming parameters are forwarded unless their key appears in the reserved list (
lh_*, which LinkHub uses internally). - The composed URL is capped at 2048 characters; excess incoming parameters are dropped from the right and a counter increments.
- Fragments (
#…) are never sent to the server by browsers and therefore cannot be forwarded; the link's own configured fragment is appended last. - Forwarding can be disabled per link, defaulting to enabled, because dropping a partner's tracking parameters silently is a common and expensive support issue.
11.8.5 HEAD, bots and prefetch #
HEADis handled identically toGETbut emits no analytics event.- Requests classified as bots (UA classification plus the datacenter ASN list, per Section 17) still redirect normally, and their events are recorded with
is_bot = trueand excluded from default views. - Requests carrying
Purpose: prefetch,Sec-Purpose: prefetch, orX-Moz: prefetchredirect normally but are recorded withis_prefetch = trueand excluded from click counts, so a browser's speculative fetch does not inflate a creator's numbers. - Messaging-app link unfurlers (identified by UA) receive the redirect and are recorded as bots; they are never served an interstitial, because that would produce a misleading preview.
11.9 The QR landing path #
11.9.1 How a QR scan differs from a plain short link #
A QR code is printed on physical material. It cannot be corrected after the fact. Every difference below follows from that single fact.
| Aspect | Short link | QR code |
|---|---|---|
| URL shape | https://lnkhb.co/{slug} |
https://lnkhb.co/{slug} (or a custom QR host) — the same bare shape, in the same per-host namespace (11.1.1) |
| Slug lifetime | Released 30 days after deletion, then reusable — unless the slug is reserved, in which case it is never released | Reserved forever. Never recycled, never reassigned, never purged — not on downgrade, non-payment, workspace deletion or account deletion (Section 14). The reservation also blocks the identical short-link slug on that host |
| Not-found response | 404 |
Never. Falls through the rungs in 11.9.3 |
| Expired response | 200 branded expired page |
Never reaches it. Falls through the rungs |
| Plan cap behaviour | Resolves 90 days past the cap, then a branded landing page | Exempt entirely. Always resolves, on every plan state |
| Suspension / past-due | May stop resolving | Always resolves. Branding may change; resolution does not stop |
| Unknown, removed or suspended host | 404 only when the slug is unreserved |
Resolves to rung 3 or rung 4 via the guard at 11.8.1 step 2A |
| Analytics event | click |
scan, with scan_source and the QR version id |
| Default response | 302 to the destination |
302 to the destination, or 200 HTML when a landing page is configured |
| Interstitial | Only for safety | Optionally always, when the code is configured with a landing page |
11.9.2 The QR landing page #
A dynamic QR code may resolve to a landing page instead of a direct redirect. This is a per-code setting, and it exists because a printed code often needs to offer a choice that a single URL cannot express — a restaurant menu in three languages, an app in two stores, a product page plus a warranty registration.
| Property | Decision |
|---|---|
| Response | 200 text/html, Cache-Control: private, no-store. A configured landing page is rung 1 — the code is active and serving its own content — and rungs 1 and 2 are never cached, for the same reason a destination redirect is never cached: the author can change what it points at, and a printed symbol gives them no second chance to correct a stale copy |
| Rendering | Server-rendered by apps/web using the same public template and the same block catalogue as a bio page. A QR landing page is a bio page with a QR-specific entry point; it is not a second templating system |
| Budget | Identical to a bio page (11.2.2). The reference device for QR is, if anything, worse — a phone camera app's in-app browser on a cold network in a shop |
| Blocks | The full catalogue is available. The contact block (10.12) and the group block are the most common |
| Auto-routing | Optional rules evaluated server-side before render: OS (iOS → App Store, Android → Play Store), language (Accept-Language → localised page), country, and time of day. These use the same rule engine as Section 15. When a routing rule matches, the response is a 302 and no HTML is rendered |
| Analytics | A scan event is emitted on the landing render; subsequent link activations emit click events joined to the same visitor_hash, so the scan-to-click funnel is measurable |
| Caching | Not cached at the CDN. The landing page's content is composed exactly as a bio page is, but the response is rung 1 and therefore private, no-store |
11.9.3 The QR fallback chain #
Section 14.8.2 owns this chain. The table below reproduces its rungs so that the resolver's behaviour is readable in one place; where the two could ever disagree, 14.8.2 governs and this table is corrected to match it.
There are exactly four rungs. There is no rung 5. Every rung terminates, and no rung returns 404, 410 or 5xx on any surface, in any state.
| Rung | Name | fallback_stage |
Condition | HTTP | Cache-Control |
|---|---|---|---|---|---|
| 1 | Active destination | active |
The code is active with a destination, or with a configured landing page | 302 to the destination, or 200 for the landing page |
private, no-store |
| 2 | Paused / expiry fallback URL | paused_fallback |
The code is paused or past its expiry and a fallback URL is set | 302 to the fallback URL |
private, no-store |
| 3 | Workspace branded unavailable page | workspace_unavailable |
Paused with no fallback URL, expired with no fallback URL, plan-limited, workspace past-due, workspace suspended, or the backing host is unknown, removed, mid-provisioning or suspended while the slug is reserved (11.8.1 step 2A). Also the pre-erasure memorial: the workspace or account is closed but its personal data has not yet been erased | 200 |
public, max-age=60 |
| 4 | Neutral platform landing page | generic |
No workspace branding is available — the workspace has been erased under Section 23, the account is gone, or branding cannot be read at all. Also the post-erasure memorial | 200 |
public, max-age=60 |
Rules that follow from the table and are binding on the implementation:
- Rung 4 carries no workspace identity of any kind. It does not display the workspace display name, the last known display name, the handle, the logo, the theme, the author's message, or anything else that could identify the former customer. It is neutral platform text plus platform branding, and nothing else. This is not a presentation preference: the "no personal data is retained" limb of the erasure argument in Section 23.15.3 is only true if it is true, and a memorial page that still prints a person's name falsifies it. Rung 3 may carry the display name, logo and theme, because at rung 3 nothing has been erased.
fallback_stageis the single vocabulary for this concept. It is emitted on every scan event (Section 17), it is the enum used in analytics breakdowns, and it is the value asserted by the synthetic checks in 11.13.2. There is no second field describing the same thing.- Rung numbers and enum values are always written together in logs, dashboards and alerts — "rung 3 (
workspace_unavailable)" — so that neither vocabulary can drift away from the other. - The two
no-storerungs and the two cacheable rungs split exactly where mutability does. Rungs 1 and 2 point at author-editable destinations and must never be cached. Rungs 3 and 4 are generated pages whose content changes only when the workspace's state changes, and 60 seconds is short enough that a customer who fixes their billing sees their code working again within a minute rather than an hour.
Availability when the origin itself is down. Rung 4's document is also deployed to the CDN as a static object on every release and configured as the origin-error response for the resolver's host. When the origin returns 5xx or times out, the CDN serves that static copy with 200. This is not a fifth rung — it is rung 4, served from a place that does not depend on LinkHub's application tier being up at all. That distinction matters, because it means the guarantee has one terminal state rather than two, and the terminal state is reachable even during a total origin outage.
11.9.4 Scan-specific analytics #
The scan event carries everything a click carries, plus: qr_id, qr_version_id (which printed design was scanned — critical when a customer has printed three generations of packaging), scan_source (direct when the referrer is absent and the parsed user-agent family suggests a camera app, landing when it came from a QR landing page, unknown otherwise), fallback_stage (the rung that served the scan — active, paused_fallback, workspace_unavailable or generic), and is_first_scan_for_visitor. Column definitions are in Section 6. No additional personal data is collected; the visitor identity rules are unchanged, and the raw user-agent string is parsed in memory and discarded rather than stored.
Because fallback_stage is on every scan event, a customer can see in the dashboard exactly how many scans of a printed run landed on something other than the intended destination, and for how long. A fallback that nobody can measure is indistinguishable from a fallback that never fires.
11.10 Error and edge pages #
Every page below is server-rendered, styled with the same critical-CSS system as a bio page, weighs under 12 KB, works with JavaScript disabled, is accessible, and is available in all supported languages. None of them is a client-side route.
The status-code rule for visitor-facing states, stated once and applied without exception. An expired link, a scheduled-but-not-yet-live link, and a password-protected link all return 200 with a branded page, never a 4xx. The reason is structural rather than aesthetic: any of these URLs may be QR-backed, the QR and short-link namespaces are shared per host (11.1.1), and a QR-backed URL must never return a 4xx. Using 200 uniformly for these three states removes the possibility that a wrong status leaks through the QR path at all — there is no branch to get wrong, no per-surface exception to remember, and no way for a future change to reintroduce one. A 4xx on the public path is reserved for a slug that genuinely resolves to nothing and is not reserved (11.10.1), and for the transport-level and infrastructure conditions in 11.10.7 through 11.10.9.
11.10.1 Not found #
| Property | Value |
|---|---|
| When | Unknown handle, unknown and unreserved slug, unpublished page, soft-deleted page, expired handle alias |
| Status | 404 |
| Precondition | Reachable only after the permanent slug reservation guard has confirmed the slug is not reserved (11.8.1 steps 2A and 4c). A reserved slug can never reach this page from any state |
| Content | Workspace-branded when the host belongs to a known workspace (logo, theme, and a link to the workspace's root page); otherwise LinkHub-branded. Heading "This page isn't available." One sentence of explanation. No suggestion of what used to be there, and no listing of other pages on the domain — that would leak an author's private inventory |
| Caching | public, max-age=0, s-maxage=60, stale-while-revalidate=300. Short, because a page is often published moments after someone tries the URL |
| Robots | noindex, nofollow |
11.10.2 Expired #
| Property | Value |
|---|---|
| When | A short link whose expires_at has passed |
| Status | 200 with a branded expired page. Never 410, never any 4xx — see the status-code rule at the head of 11.10 |
| Content | "This link has expired." Optional author-supplied message and optional fallback link, both configured on the link (Section 15) |
| Caching | public, max-age=0, s-maxage=300, stale-while-revalidate=3600 |
| Robots | noindex, nofollow, and an X-Robots-Tag header carrying the same directives, because a 200 is otherwise indexable and this page must never be |
| Note | A QR-backed code never reaches this page; it takes rung 2 if a fallback URL is set, otherwise rung 3 (11.9.3) |
11.10.3 Scheduled but not yet live #
| Property | Value |
|---|---|
| When | starts_at is in the future for a link, or a page's publish_at has not arrived |
| Status | 200 with a branded "not available yet" page. Never 404 — see the status-code rule at the head of 11.10 |
| Content | "This isn't available yet." No launch time, no destination, no campaign name, and no author-supplied copy unless the author has explicitly enabled a coming-soon message. The default reveals that the URL is reserved and nothing more |
| Pre-launch leakage | Handled by robots directives, not by the status code. The page carries noindex, nofollow in a meta tag and an X-Robots-Tag response header, and it emits no Open Graph tags, no title beyond the workspace name, and no preview image — so a URL shared before launch unfurls as nothing and is not indexed. A 404 was the wrong instrument for this: it suppressed indexing at the cost of breaking every QR-backed scheduled campaign, and robots directives suppress indexing at no cost at all |
| Caching | private, no-store — the state changes at a known instant and must not be cached across it |
| Opt-in | When the author enables a coming-soon page, the same 200 carries their message and an optional countdown block. Only the body changes; the status was already 200 |
11.10.4 Password protected #
The password interstitial — its status code, markup, form, cookie name, cookie attributes, HMAC inputs, lifetime, verification algorithm, lockout and rate limit — is specified once, in Section 12.7, and is not restated here. Two things only are Section 11's concern:
- The resolver decides that the interstitial is required at 11.8.1 step 6 and delegates rendering to it.
- The initial GET returns
200with the form, never401, per the status-code rule at the head of 11.10. Section 12.7 states this and Section 11 does not hold a rival value.
11.10.5 Safety interstitial #
| Property | Value |
|---|---|
| When | A destination's safe_browsing_status is flagged or blocked (Section 23) |
| Status | 200 |
| Content | A neutral, clearly LinkHub-branded warning: what was detected (phishing, malware, deceptive content), the destination host shown in full so the visitor can judge it, and the report source. For flagged: a Continue anyway control that is a form POST, not a link, so it cannot be triggered by a prefetch. For blocked: no continue control at all |
| Extra | An "Is this wrong?" link to the appeal endpoint. The interstitial never renders the destination in an iframe or preloads it |
| Caching | private, no-store — safety state can change in seconds |
| Robots | noindex, nofollow |
11.10.6 Plan-limited fallback #
| Property | Value |
|---|---|
| When | A short link above the plan cap whose 90-day grace period has elapsed (Section 22) |
| Status | 200 |
| Content | A branded page carrying the workspace's name: "This link is no longer active." It does not name the plan, the price, or blame the workspace publicly. A discreet "Are you the owner?" link leads to the dashboard |
| Caching | public, max-age=0, s-maxage=300, stale-while-revalidate=3600 |
| Robots | noindex, nofollow |
| Note | QR codes are exempt entirely and never reach this page |
11.10.7 Unknown or inactive host #
| Property | Value |
|---|---|
| When | A request arrives for a hostname that has no active domain record — a stale DNS entry, a removed domain, or a domain still in pending_dns, verifying, provisioning_tls, dns_failed or tls_failed — and the requested slug is not reserved |
| Precondition | Reachable only after the reservation guard at 11.8.1 step 2A. When the slug is reserved, the request takes rung 3 or rung 4 with 200 instead, and never reaches this page. This is the single most important ordering constraint in the resolver, because a customer whose domain has lapsed is precisely the customer whose printed codes are still in circulation |
| Status | 404 |
| Content | LinkHub-branded, no workspace information. For a domain in setup, a short line stating the domain is not yet configured, with a link to the setup guide — helpful to the person configuring it, meaningless to anyone else |
| Caching | public, max-age=0, s-maxage=60 — short, because a domain can go active at any moment |
| Robots | noindex, nofollow |
11.10.8 Workspace suspended #
| Property | Value |
|---|---|
| When | A workspace suspended for abuse or a terms violation |
| Status | 403 |
| Content | "This content has been suspended." No detail, no accusation, and no workspace branding. A link to the abuse-policy page |
| Caching | public, max-age=0, s-maxage=300 |
| Robots | noindex, nofollow |
| Exception | A reserved slug never reaches this page. QR codes continue to resolve through their rungs (11.9.3): a suspended workspace's QR codes reach rung 3 (workspace_unavailable) or rung 4 (generic) with 200 |
11.10.9 Temporary error #
| Property | Value |
|---|---|
| When | An unhandled render error, or a hard dependency failure with no cached copy |
| Status | 500 for a render error, 503 with Retry-After: 30 for a dependency failure |
| Content | "Something went wrong on our end." The request_id is displayed so support can trace it. No stack trace, no internal hostnames, no version strings |
| Caching | private, no-store |
| Robots | noindex, nofollow |
| Backstop | The CDN's stale-if-error=86400 means most visitors never see this page even during an origin outage — they get the last good copy |
11.10.10 QR permanent fallback #
| Property | Value |
|---|---|
| When | Rungs 3 (workspace_unavailable) and 4 (generic) of the chain in 11.9.3. There is no rung 5 |
| Status | 200, always. Never 404, never 410, never any 4xx, never 5xx |
| Content — rung 3 | Workspace branding: display name, logo, theme, and the author's optional message. Used pre-erasure, including as the memorial page while the workspace's data still exists |
| Content — rung 4 | Neutral platform text and platform branding only. No workspace display name, no last-known name, no handle, no logo, no theme, no author message (11.9.3). Used post-erasure and whenever branding cannot be read |
| Caching | public, max-age=60 for both rungs |
| Robots | noindex, nofollow, in a meta tag and an X-Robots-Tag header |
| Origin down | Rung 4's document is also served as a static CDN object when the origin is unreachable — the same rung, a different delivery path (11.9.3) |
Stated explicitly, because it is the product's most important guarantee: a QR code never reaches a 404. There is no combination of billing state, plan state, deletion, suspension, expiry, workspace closure, domain lapse or infrastructure failure that causes a scan of a previously working LinkHub QR code to return a not-found response. This is asserted by a dedicated test suite that walks every state in the matrix — including workspace deleted, account deleted, personal data erased, plan downgraded, payment failed, custom domain removed, custom domain reverted to pending provisioning, workspace suspended, Redis down, Postgres down, reservation table unreadable, and origin entirely unreachable — and asserts a 200 or a 302 in every single case, together with the expected fallback_stage. The domain-removed and domain-pending cases are called out because they are the ones that route through the host-lookup branch rather than the record-lookup branch, and a resolver that guards only the record lookup passes every other case in this list while still returning 404 for a lapsed domain. Coverage on the QR fallback chain is held at 95% (Section 26).
11.11 SEO and social #
11.11.1 Indexing policy by surface #
| Surface | Policy | Mechanism |
|---|---|---|
Bio page, indexable: true (default) |
Indexable | <meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1"> |
Bio page, indexable: false |
Not indexed | noindex, nofollow meta and an X-Robots-Tag response header, because a meta tag alone does not cover non-HTML responses and some crawlers honour only one |
| Short-link redirect | Never indexed | X-Robots-Tag: noindex, nofollow on every 302. A short link is a redirector, not a document; indexing one splits authority and pollutes results |
| QR redirect | Never indexed | Same header |
| QR landing page | noindex by default, author-toggleable to indexable |
Landing pages usually exist for a printed audience, not a search audience |
| All error and edge pages | Never indexed | noindex, nofollow |
| Expired, not-yet-live, and password-protected pages | Never indexed | noindex, nofollow in a meta tag and an X-Robots-Tag header, plus no Open Graph tags at all |
| Preview | Never indexed | noindex, nofollow header, and the preview host is disallowed wholesale in robots |
| Generated OG images | Not indexed as documents | X-Robots-Tag: noindex |
The middle row carries the weight that a status code used to. Because those three states return 200 (11.10), the robots directives are the only thing standing between an unlaunched campaign URL and a search index, and they are therefore applied twice — in the document and in the header — and asserted in continuous integration rather than left to a template. Suppressing a crawler and breaking a printed QR code are unrelated problems, and the status code is the wrong tool for the first one.
11.11.2 Meta tags #
<title>Acme Studio</title>
<meta name="description" content="Independent design studio. Prints, posters, occasional chaos.">
<link rel="canonical" href="https://linkhub.app/acme">
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1">
<meta name="theme-color" content="#0B0D12">
<meta name="color-scheme" content="dark light"><title> resolves as seo_title → profile display_name → page title, truncated at 60 characters on a word boundary. description resolves as seo_description → the first 160 characters of the profile bio → empty (the tag is omitted rather than emitted empty).
11.11.3 Open Graph and Twitter cards #
<meta property="og:type" content="profile">
<meta property="og:site_name" content="Acme Studio">
<meta property="og:title" content="Acme Studio">
<meta property="og:description" content="Independent design studio…">
<meta property="og:url" content="https://linkhub.app/acme">
<meta property="og:image" content="https://linkhub.app/og/0192f3b2.png?v=44">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="Acme Studio">
<meta property="og:locale" content="en_GB">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Acme Studio">
<meta name="twitter:description" content="Independent design studio…">
<meta name="twitter:image" content="https://linkhub.app/og/0192f3b2.png?v=44">og:image is the uploaded share image or the generated card (9.2.4). It is always an absolute URL on the page's own host, always ≥ 1200 × 630, always PNG or JPEG, and always carries explicit width, height and alt — several crawlers refuse to render a card without dimensions. The ?v={revision} parameter is what makes a re-share pick up a changed image without a manual cache bust at the social platform.
11.11.4 Canonical URLs #
| Situation | Canonical |
|---|---|
| Page on a custom domain | The custom domain URL. The platform-domain URL, if reachable, carries a canonical pointing at the custom domain and is served with noindex |
| Page reached through a handle alias (9.2.2) | The alias 302s, so no canonical question arises |
| Page reached with tracking query parameters | The clean URL without query parameters |
| Workspace root page (a custom domain root mapped to a page) | The root URL, not the /{handle} form |
| A/B variant | The same canonical for every variant. Variants are never distinct URLs, so there is no duplicate-content exposure and no need for rel=alternate |
11.11.5 Structured data #
Emitted as a single application/ld+json block, composed from each block's structuredData() contribution (10.16.1):
| Source | Type |
|---|---|
| Page + profile block | ProfilePage containing a Person or Organization with name, description, image, url |
| Social icons block | sameAs array on that entity — the single highest-value structured-data contribution on a bio page, because it is exactly what entity resolution consumes |
FAQ block with structured_data: true |
FAQPage with mainEntity question/answer pairs |
| Buy/product block | Product with offers (price, priceCurrency, availability), one per product block |
| Contact block | ContactPoint on the entity |
| Video block | VideoObject with name, thumbnailUrl, uploadDate, duration |
Rules: only content actually visible on the page is described (no invisible keyword stuffing); the block is omitted entirely rather than emitted with placeholder values; total JSON-LD is capped at 8 KB and counts against the HTML budget; and the output is validated against the schema vocabulary in a CI test on the reference fixture.
11.11.6 robots.txt and sitemaps #
robots.txt is served per host:
# https://linkhub.app/robots.txt (platform host)
User-agent: *
Disallow: /-/ # tracked click, form post, vCard and reveal endpoints
Disallow: /og/
Disallow: /preview/
Allow: /
Sitemap: https://linkhub.app/sitemap.xml
# https://lnkhb.co/robots.txt (redirect host)
User-agent: *
Disallow: /
# https://go.acme.com/robots.txt (customer redirect domain)
User-agent: *
Disallow: /
# https://acme.link/robots.txt (customer domain serving bio pages)
User-agent: *
Disallow: /-/
Allow: /
Sitemap: https://acme.link/sitemap.xmlRedirect-only hosts disallow everything. A crawler has nothing to gain from a redirector and everything to lose in crawl budget.
Sitemaps:
- One sitemap index per host at
/sitemap.xml, referencing paginated child sitemaps at/sitemap-{n}.xmlwith 10,000 URLs each. - Contains only published, indexable bio pages on that host. Never short links, never QR codes, never landing pages set to
noindex, never error pages. - Each entry carries
<loc>and<lastmod>(the publish timestamp).changefreqandpriorityare omitted — they are ignored by every major crawler and their presence is noise. - Regenerated by a worker job on publish and unpublish, debounced to at most once per 5 minutes per host, cached at the CDN for 1 hour with a surrogate key of
sitemap-{host}. - Free-plan pages are included; indexing is a product feature, not a paid one.
11.12 CDN and edge configuration #
11.12.1 What is cached where #
| Asset class | Edge cached | Notes |
|---|---|---|
| Bio page HTML | Yes | Per 11.3.3, keyed with the Vary set below |
| QR landing HTML | No | Rung 1 — private, no-store (11.9.3) |
| QR fallback pages, rungs 3 and 4 | Yes, 60 s | public, max-age=60 (11.9.3) |
| Error and edge pages | Yes, briefly | Per 11.10 |
Redirect responses (302) |
No — never | private, no-store. A cached redirect is a broken product: destinations are editable at any time |
| Password-gated pages | No | private, no-store |
| Safety interstitials | No | private, no-store |
| Preview | No | private, no-store |
| Media derivatives | Yes, 1 year | Immutable, content-addressed |
| Fonts | Yes, 1 year | Immutable |
| Enhancement JS | Yes, 1 year | Immutable, content-hashed |
| Generated OG images | Yes, 1 day browser / 7 days edge | Revision-keyed |
robots.txt |
Yes, 1 hour | |
| Sitemaps | Yes, 1 hour | |
| vCard | Yes, 5 minutes | |
| Form POST endpoint | No | Method is not cacheable |
| Tracked click endpoint | No | private, no-store |
11.12.2 Cache-control headers by surface #
| Surface | Header |
|---|---|
| Bio page | public, max-age=0, s-maxage=60, stale-while-revalidate=600, stale-if-error=86400 |
| Bio page with a near-term time boundary | public, max-age=0, s-maxage={10..60}, stale-while-revalidate=600, stale-if-error=86400 |
| QR rung 1 — landing page or destination redirect | private, no-store |
| QR rung 2 — fallback-URL redirect | private, no-store |
QR rung 3 — workspace_unavailable |
public, max-age=60 |
QR rung 4 — generic |
public, max-age=60 |
| Short-link redirect | private, no-store |
| Not found | public, max-age=0, s-maxage=60, stale-while-revalidate=300 |
Expired (200) |
public, max-age=0, s-maxage=300, stale-while-revalidate=3600 |
Scheduled (200), password interstitial (200), safety interstitial, error |
private, no-store |
| Media, fonts, JS | public, max-age=31536000, immutable |
| OG image | public, max-age=86400, s-maxage=604800, immutable |
| robots.txt, sitemap | public, max-age=0, s-maxage=3600, stale-while-revalidate=86400 |
Vary is kept minimal, because every value multiplies cache entries:
- HTML:
Vary: Accept-Encodingonly. Content negotiation for images happens on the image URL, not the document. Geo and device variation are handled by the rule key inside the cache key (11.3.2), never byVary, becauseVary: User-Agentwould destroy the hit rate. - Media:
Vary: Acceptwhere a single URL serves AVIF/WebP; in practice variants have distinct URLs, so this applies only to the legacy compatibility route. - Redirects: no
Vary, because they are never cached.
11.12.3 Security headers on the public path #
Section 23.5 is the sole owner of the public-path security header set, including the complete Content-Security-Policy and its frame-src and connect-src allow-lists. Section 11 does not restate the policy, does not hold a second copy of any directive, and does not define which origins are permitted. The continuous-integration gate asserts the header set exactly as Section 23.5 defines it; if this section and Section 23.5 could ever be read as disagreeing, Section 23.5 is correct by construction, because there is nothing here to disagree with it.
What Section 11 owns is delivery — the three mechanical facts about how that policy reaches the visitor:
- Every public response carries the full set, applied at the edge rather than at the origin, so a response served entirely from cache is protected identically to one rendered fresh. There is no code path on the public surface that emits a document without it.
- The per-response nonce is the one value that cannot be cached. It is injected at the edge on cache hit: the cached HTML contains a placeholder token, and an edge function substitutes a freshly generated nonce into both the response header and the document on every single response. This is what lets the policy stay nonce-based — and therefore free of
unsafe-inline— while the HTML remains fully cacheable. A cached nonce would be no nonce at all. - Framing is the one directive with a per-domain override. Bio pages and error pages are never framable. A QR landing page that a customer legitimately embeds in their own site may relax framing to a configured allow-list, per domain, as an explicit opt-in setting. The permitted values and the mechanism are Section 23.5's; the setting surfaces in the custom-domain configuration described in Section 13.
Any origin required by the escalated bot challenge in 10.9.5 is present in both the frame-src and the connect-src allow-lists in Section 23.5. It is added there and nowhere else, which is what keeps a single edit sufficient.
11.12.4 Compression #
| Content | Encoding | Level |
|---|---|---|
| HTML (dynamic) | Brotli | 5 — the point where additional compression costs more CPU-milliseconds than the saved bytes cost network-milliseconds at 1.6 Mbps |
| HTML (cached at edge) | Brotli | 11, computed once on cache fill |
| CSS, JS (static) | Brotli 11 precomputed at build, plus a gzip 9 artefact for the small tail of clients without Brotli | — |
| SVG, JSON, XML | Brotli 5 dynamic / 11 cached | |
| AVIF, WebP, PNG, JPEG, WOFF2, MP4 | None | Already compressed; re-compressing wastes CPU and can increase size |
| Zstandard | Negotiated when the client advertises zstd, level 9 |
Falls back to Brotli otherwise |
The reference page's 40 KB HTML budget is measured after gzip, which is the conservative case; Brotli typically lands the same document 15–20% smaller.
11.12.5 Edge routing rules #
Force HTTPS:
301fromhttptohttps, to the identical URL, plus the HSTS header (with preload) so compliant browsers never make the plaintext request a second time. This is a transport upgrade to the identical URL, not a destination redirect; the never-301 rule in Section 12 governs destinations, whose targets are editable. The upgrade is permanent, cacheable, saves a round trip on the highest-traffic surface in the product, and pairs with HSTS — a302here would force every first-time visitor on every device to repeat the plaintext hop indefinitely, for no benefit.Strip a leading
www.on customer bio-page domains via302, unless the customer's configured canonical host includes it. This one is302because the canonical host is a customer setting they may change.Route every request to the single bare-
/{slug}resolver. There is no/q/prefix and no QR-specific path (11.1.1); the surface is resolved from the record, not the URL. The static rung 4 document is configured as the origin-error response for the resolver's hosts as a whole, so an origin outage produces a200on any slug rather than only on a prefixed subset — which is the whole point, since under the bare-slug shape there is no prefix to scope such a rule to.Block requests whose
Hostheader matches no known domain at the edge, before they reach an origin — except that the request is first passed to the reservation guard (11.8.1 step 2A), because an unknown host is one of the states in which a reserved slug must still resolve. An edge rule that drops unknown hosts unconditionally would defeat the guard from outside the application, which is exactly the kind of defect this ordering exists to prevent.Rate limit at the edge, per IP per host. These are the per-IP limits only; the per-workspace and per-API-key limits are a separate mechanism entirely and are defined in Section 23.9, with the public API's own limits owned by Section 21.7.
Surface Per-IP limit Redirect resolution (link and QR) 6,000 requests / minute HTML surfaces (bio pages, landing pages, error pages) 600 requests / minute Exceeding either returns
429withRetry-After. The redirect limit is ten times the HTML limit for two reasons that both matter: the redirect path is cheap enough that 6,000/minute costs almost nothing to serve, and a single apparent IP is routinely a shared corporate NAT, a campus network, a mobile carrier gateway or a university, any of which can legitimately exceed 600 requests a minute when a post goes viral. A limit that throttles a whole office building is worse than no limit at all.Never cache a response carrying
Set-Cookie, enforced as an edge rule rather than relying on origin discipline.Never cache a
301. The scheme upgrade in rule 1 is cached by the browser, which is where its benefit is realised; caching it at the CDN would add nothing and would create a shared artefact nobody needs to reason about.
11.13 Observability of the public path #
11.13.1 Real-user monitoring, cookie-free #
RUM is collected from real visitors, and it introduces no cookie, no local storage, no device identifier and no cross-site state. It is the same position, for the same reasons, as the first-party analytics posture in Section 23.
| Property | Decision |
|---|---|
| What is collected | LCP, CLS, INP, FCP, TTFB, plus navigation_type, effective_connection_type, device class, country, and the page's revision |
| What is never collected | Any identifier, any cookie, any IP (the country is resolved in memory at the edge and the IP discarded), any URL query string, any referrer path beyond the host |
| Transport | navigator.sendBeacon to /-/rum on visibilitychange → hidden, one beacon per page view, ≤ 400 bytes |
| Sampling | 10% of page views by default, deterministic from visitor_hash so a sampled visitor's session is internally consistent. 100% on pages under a performance investigation, toggled per workspace by an operator |
| Attribution | Each beacon carries the LCP element's block type and the CLS-largest shift source, so a regression points at a block rather than at a page |
| Without JavaScript | No beacon. Server-side TTFB and response size are recorded for every request regardless, so the server-side half of the picture is complete |
| Storage | Aggregated into the same rollup tables as analytics (Section 17), under resource_type = 'rum'. Raw beacons are retained 7 days |
| Exposure | A performance panel in the dashboard shows the workspace's own pages' field metrics against the budgets, so an author who uploads a 4 MB avatar can see the consequence |
11.13.2 Synthetic monitoring #
Run continuously from at least four geographic locations (North America East, Europe West, South America, South-East Asia), independent of the CI gates:
| Check | Frequency | Asserts |
|---|---|---|
| Redirect resolution, canary short link | 30 s | 302, correct Location, total time < 300 ms including network |
| Redirect resolution, canary QR code | 30 s | 302 or 200, never 4xx/5xx |
| QR fallback chain, one canary code held in each of the four rungs | 60 s | Rung 1 302 or 200; rungs 2, 3 and 4 as specified in 11.9.3, with the expected fallback_stage on each and never a 4xx or 5xx. Rung 4's canary is additionally asserted to contain no workspace name. This is the single most important synthetic check in the product |
| QR code on a lapsed host, a canary reserved slug on a domain deliberately left removed | 60 s | 200 at rung 3 or rung 4 — never 404. This check exists specifically because the host-lookup branch is the easiest place for a future change to bypass the reservation guard without any other test noticing |
| Bio page render, canary page | 60 s | 200, HTML < 40 KB, contains the expected sentinel string |
| Bio page Lighthouse run | 15 min | LCP, CLS, FCP, TBT within budget on the reference profile |
| Custom-domain TLS expiry | 1 h | > 14 days remaining on every active domain |
| OG image generation | 5 min | 200, image/png, 1200 × 630, < 400 ms |
| Email capture end-to-end | 5 min | Native POST returns 303, lead persists |
| Cache purge propagation | 15 min | Publish a canary change; assert it is visible from all four locations within 5 s |
| Static rung 4 document | 5 m | Reachable directly from the CDN with the origin bypassed, 200, and containing no workspace-identifying text |
11.13.3 Alert thresholds tied to the budgets #
Alerts are derived from the budgets in 11.2.2, so an alert always means "we are violating a documented promise", never "a number looks high". Routing and escalation are defined in Section 25.
| Signal | Warning | Page (wake someone) |
|---|---|---|
| Redirect p95 server processing | > 50 ms for 5 min | > 120 ms for 5 min, or p99 > 500 ms for 2 min |
| Redirect error rate (5xx) | > 0.05% for 5 min | > 0.5% for 2 min |
| Any QR resolution returning 4xx or 5xx | — | Immediately, on a single occurrence. Zero tolerance; this is the product's core guarantee |
| Bio page origin TTFB p95 | > 250 ms for 10 min | > 1 s for 5 min |
| Bio page 5xx rate | > 0.1% for 10 min | > 1% for 5 min |
| CDN cache hit ratio, HTML | < 85% for 15 min | < 60% for 5 min (indicates a purge storm or a cache-key explosion) |
| Field LCP p75 (RUM) | > 1.2 s for 1 h | > 2.5 s for 30 min |
| Field CLS p75 (RUM) | > 0.02 for 1 h | > 0.1 for 30 min |
| Field INP p75 (RUM) | > 200 ms for 1 h | > 500 ms for 30 min |
| Redis unavailability at the edge | Circuit breaker open > 30 s | Open > 5 min |
Analytics XADD failure rate |
> 1% for 5 min | > 10% for 5 min |
| Cache purge queue depth | > 500 for 10 min | > 5,000, or any purge failing all retries |
| Media CDN 5xx | > 0.1% for 10 min | > 1% for 5 min |
| Synthetic check failure | 1 location | 2+ locations for 2 consecutive runs |
11.13.4 Tracing and logging #
- Every public request carries a
request_id(UUIDv7), returned in the error envelope (Section 21) and surfaced on the temporary-error page (11.10.9). - Distributed tracing is sampled at 1% on the redirect path — the spans are cheap but the volume is not — and at 10% on the bio-page render path, with 100% sampling on any request that errors or exceeds its budget (tail-based).
- Structured JSON logs. Never logged, on any code path: raw IP addresses, full user agents beyond the parsed family, passwords, session tokens, the daily salt, the experiment salt, email addresses submitted through capture forms, or full destination URLs for flagged links. Logged: host, slug, resource id, workspace id, status, duration, cache result, and error class.
- Log retention: 30 days hot, 12 months archived.
11.14 Load and capacity #
11.14.1 The traffic model #
Steady-state design target for a single production deployment:
| Metric | Design target |
|---|---|
| Redirect requests | 5,000 rps sustained, 20,000 rps peak |
| Bio page requests | 1,500 rps sustained, 8,000 rps peak |
| CDN cache hit ratio, HTML | ≥ 92% steady state |
| CDN cache hit ratio, media | ≥ 99% |
| Origin bio-page renders | ≤ 120 rps steady state (the 8% miss plus revalidation) |
| Redis operations | ~12,000 ops/s at 5,000 rps of redirects (2 reads + 1 XADD per request, minus in-process LRU hits) |
| PostgreSQL read QPS from the public path | < 100 qps steady state — only cold-path lookups reach it |
| Analytics events | 6,500/s ingested into the stream, consumed in batches of up to 1,000 per 2-second window |
The ratio that matters: at a 92% HTML hit ratio and a 3,600-second redirect payload TTL, more than 99% of public requests are served without touching PostgreSQL at all. The database's role on the public path is to be the thing that fills the caches, not the thing that serves the traffic.
11.14.2 The viral burst assumption #
The design case is not a smooth ramp. It is a single creator's post going viral: one bio page and its handful of links absorbing 50,000 requests in 60 seconds, arriving from a standing start, concentrated in one or two geographic regions, overwhelmingly on mobile, and overwhelmingly cold-cached at the start of the burst.
The specific failure this must avoid is a cache stampede: 50,000 simultaneous misses for the same key, all of which attempt an origin render.
How each layer absorbs the burst:
| Layer | Behaviour under the burst |
|---|---|
| CDN | Absorbs > 99% after the first fill. Request collapsing (origin shielding) is mandatory and must be verified as enabled: concurrent misses for one key produce exactly one origin request per shield POP, not 50,000. This single setting is the difference between absorbing the burst and amplifying it |
| CDN shield | A designated shield POP sits between all edge POPs and the origin, so a globally distributed burst produces one origin fill rather than one per POP |
stale-while-revalidate |
After the first 60 seconds, the CDN serves stale instantly and refreshes once in the background. Visitors never queue behind an origin render |
| Redis | Absorbs the CDN's misses. A single payload read is sub-millisecond and Redis handles the full 20,000 rps peak on one node with headroom; a replica serves reads if needed |
| Single-flight at the origin | The renderer holds a per-key in-flight promise map. Concurrent requests for the same cache key await one render. A distributed lock held on the render key itself (5 s TTL, SET NX, in the page: namespace of Section 4's Redis catalogue) prevents the same across instances; a request that fails to acquire the lock waits up to 200 ms for the winner's result, then renders independently rather than queuing indefinitely |
| Negative caching | rd:miss:{host}:{slug} and page:{host}:{handle}:miss (60 s each) stop a burst of requests for a non-existent slug — the shape of a typo going viral, or a scraper walking the slug space — from hammering PostgreSQL. A negative marker is written only for a slug already confirmed unreserved (11.8.1), so it can never cache away a QR guarantee |
| PostgreSQL | Sees at most a handful of queries for the burst's keys. Connection pooling caps public-path connections at a fixed ceiling so the public path can never exhaust the pool the dashboard and workers need |
| Analytics | XADD is fire-and-forget with a 50 ms timeout. 50,000 events land in the stream in 60 seconds; the consumer drains at its own pace. The stream is capped (MAXLEN ~ 5,000,000) so an unbounded backlog cannot exhaust Redis memory — under extreme backlog the oldest raw events are dropped, and a counter records exactly how many. Dropping raw events is the correct trade: analytics is not the system of record for anything a customer is paid for, and the alternative is dropping redirects |
| Edge rate limiting | Set at 6,000 requests/minute per IP for redirects — high enough that a genuine viral burst (many distinct IPs) passes untouched, low enough that a single abusive client is stopped |
| Autoscaling | apps/edge scales on request rate with a 15-second evaluation window and a scale-up step of 100%, because a viral burst outruns a conservative scaler. A warm pool of standby instances covers the 20–40 second container start latency. apps/web scales on origin render concurrency |
11.14.3 Load testing #
The capacity claims above are verified, not asserted. The k6 suite in Section 26 runs:
| Scenario | Profile | Pass criteria |
|---|---|---|
| Redirect steady state | 5,000 rps for 5 minutes | p95 < 50 ms, error rate < 0.01% |
| Redirect spike | 500 → 20,000 rps in 10 seconds, hold 60 s | p95 < 120 ms during the spike, zero 5xx, zero dropped redirects |
| Cache stampede | 20,000 concurrent requests for one cold key | Exactly one origin render observed; p95 < 400 ms |
| Bio page steady state | 1,500 rps for 5 minutes | TTFB p95 < 60 ms at the edge |
| Cold origin | Full cache flush, then 1,500 rps | p95 < 800 ms during the first 30 s, recovering to < 60 ms |
| Redis failure | Kill Redis mid-run at 5,000 rps | Zero failed redirects; p95 < 120 ms on the Postgres path |
| Postgres failure | Kill the database mid-run at 5,000 rps | Warm slugs continue; zero QR resolutions fail |
| Analytics backpressure | Stop the consumer, run 5,000 rps for 5 minutes | Zero impact on redirect latency; stream trims as designed |
These run nightly against staging and on every pull request touching apps/edge. A regression against any pass criterion fails the build.
12. Branded Short Links #
A short link is the atomic unit of LinkHub's redirect surface. Bio pages (Sections 9–11) link out through it, dynamic QR codes (Section 14) are backed by it, and every analytics row in Section 17 originates from a resolution of it. This section specifies the entity's behaviour, its lifecycle, creation and validation, bulk operations, organisation, per-link settings, deletion, and the list UI.
Two ownership statements govern how to read this section:
- Section 6 is the sole schema authority. Every column, type, constraint and index for
linksis defined in Section 6.3.32; for targeting rules in Section 6.3.35; for the reserved-word list in Section 6.3.66. Nothing below declares a column or a type. What follows is the behaviour those columns carry. - Section 12.7 owns the password interstitial. Its status code, cookie, signature inputs and rate limits are specified once, in 12.7.3, and every other section references that subsection rather than restating it.
12.1 The Link Entity and Its Lifecycle #
12.1.1 Behaviourally Significant Fields #
The storage definition is Section 6.3.32. The table below states what each field means to the product; it declares no types and adds no column Section 6 does not define.
| Field | Behavioural meaning |
|---|---|
id |
Public identifier returned by the API. |
workspace_id |
Owning workspace. All authorisation resolves through this. |
domain_id |
The host the slug lives on. Section 13 owns domains. |
slug |
Lower-case [a-z0-9-], 1–64 characters, unique per domain_id. The path segment a visitor types or a symbol encodes. |
destination_url |
Where a rung-1 resolution sends the visitor. Absent only while status = 'draft'. Validated per 12.3. |
title |
Internal label, up to 120 characters. Auto-filled from the destination's <title> when fetchable (12.7.1). |
notes |
Internal only, up to 1,000 characters. Never rendered on any public surface. |
folder_id |
Organisation. Absent = workspace root. |
status |
draft, active, scheduled, expired, paused, archived. Semantics in 12.1.2. |
scheduled_at |
Activation instant. Section 15.3. |
expires_at |
Expiry instant. Section 15.3. |
expiry_url |
Where an expired link sends visitors. Section 15.3. |
click_limit |
Count-based expiry. Section 15.4. |
schedule_timezone |
IANA zone name, for authoring and display only. Comparisons are always UTC. |
password_hash |
Presence implies the interstitial in 12.7.3. Argon2id, parameters per Section 7.3. |
| UTM values | The five parameters and the preset binding (Section 15.1), applied at resolve time. |
param_forwarding_mode, param_forwarding_extra |
Section 15.2. |
| Targeting rules | Rows in link_rules (Section 6.3.35), compiled into the redirect payload. Section 15.5. |
deep_link |
Per-platform destinations with a mandatory web fallback, 12.7.4. |
| Social preview | Custom unfurl metadata, 12.7.6. |
safe_browsing_status |
unchecked, safe, flagged, blocked. The only safety vocabulary in the product; there is no second safety column and no second value set. |
safe_browsing_checked_at |
Anchor for the weekly recheck. |
has_qr |
True once any QR code references this link. Never returns to false, because the slug may already be printed. |
qr_dedicated |
True when the link exists solely to back a QR code (14.1). |
parent_link_id |
Set on QR child links minted from an existing link (14.2.3). |
experiment_id |
Set while a running experiment owns the destination. Section 16. |
click_count_cached, last_clicked_at |
Denormalised, reconciled by the ingest worker. Never authoritative for analytics. |
archived_at, archived_reason |
downgrade, user, abuse or domain_removed. See 12.9.1. |
created_by_user_id / created_by_api_key_id |
Provenance. Exactly one is set. |
deleted_at, purge_after |
Soft delete and its 30-day horizon, 12.9. |
Tags are a text[] on the link, GIN-indexed per Section 6.3.32 — not a join table. The consequences for rename and merge are in 12.6.2.
Folders, saved views and import records are behavioural concepts owned by this section; their storage, like everything else, is defined in Section 6.
12.1.2 States #
| State | Resolves? | Meaning | Counts toward the plan link cap? |
|---|---|---|---|
draft |
No | Created without a destination, or created by the API with publish: false. Slug is reserved. |
Yes |
active |
Yes | Normal operation. | Yes |
scheduled |
Not yet | scheduled_at is in the future. |
Yes |
expired |
Fallback only | expires_at has passed, or click_limit reached. |
Yes |
paused |
Fallback only | Manually paused by a member. | Yes |
archived |
Fallback only | Removed from the working list but retained. Set manually or by a plan downgrade (Section 22.5). | No |
Two cap rules, stated here because they are the ones implementations get wrong:
- Archived resources do not count toward any cap. Section 22.2.5 is the canonical statement of what counts; this row conforms to it.
- A link pinned to a QR code never counts toward the link cap and is never archived, in any state, on any plan. See 12.9.6.
status is a materialised view of truth, not the truth itself for the two time-driven states. The edge resolver evaluates scheduled_at / expires_at / click_limit against the request clock on every resolution (Section 15.3.5); the status column is updated by a bookkeeping job so the dashboard, filters and exports agree with what visitors experience. If the column and the clock disagree, the clock wins.
12.1.3 State Diagram #
set destination
┌──────────┐ ─────────────────► ┌──────────┐
│ draft │ │ active │◄────────────┐
└──────────┘ ◄───────────────── └──────────┘ │
│ clear destination │ ▲ │ │
│ │ │ │ pause │ unarchive
│ archive │ │ ▼ │ (capacity
│ activate │ │ ┌──────────┐ │ available)
│ (time hit)│ │ │ paused │ │
│ │ │ └──────────┘ │
│ ┌────────┴───┴──────┐ │ │
│ │ scheduled │ │ resume │
│ └───────────────────┘ │ │
│ │ │ │
│ expires_at passed │ │ │
│ or click_limit met ▼ │ │
│ ┌───────────────┐ ◄────┘ │
│ │ expired │ │
│ └───────────────┘ │
│ │ clear/extend │
│ └──────────────────────┘
│
▼ archive ┌──────────┐
┌──────────┐ ◄──────────────────────────────────── │ archived │
│ archived │ (never reachable for a QR-pinned └──────────┘
└──────────┘ link — see 12.9.6)
│ delete (soft)
▼
┌──────────────────────┐ restore (≤30d)
│ deleted (deleted_at) │ ───────────────────► previous state
└──────────────────────┘
│ 30 days elapsed
▼
hard purge — row removed; slug released UNLESS has_qr = true (12.9.4)12.1.4 Transition Table #
| # | From | To | Trigger | Guard | Side effects |
|---|---|---|---|---|---|
| T1 | draft |
active |
Destination set and saved | Passes 12.3 validation; workspace under link cap | Write-through redirect cache; audit link.status_changed |
| T2 | draft |
scheduled |
Destination set with a future scheduled_at |
As T1 | Cache written with schedule bounds; TTL clamped (15.3.5) |
| T3 | active |
paused |
Member action | Actor has edit rights | Cache rewritten with status=paused; fallback chain engaged |
| T4 | paused |
active |
Member action | Actor has edit rights | Cache rewritten |
| T5 | scheduled |
active |
Request clock passes scheduled_at |
— | Lazy; bookkeeping job updates the column within 60 s |
| T6 | active |
expired |
Request clock passes expires_at, or the click limit is met |
— | Lazy; same bookkeeping job |
| T7 | expired |
active |
expires_at cleared/extended, or click_limit raised |
Passes 12.3 revalidation | Cache invalidated; audit entry |
| T8 | any non-deleted | archived |
Member action, or plan downgrade (Section 22.5) | Actor has edit rights (system actor for downgrade). Blocked when the link is pinned to a QR code (12.9.6) | Removed from default list views; keeps resolving per the fallback chain |
| T9 | archived |
prior state | Unarchive | Workspace under link cap; otherwise plan_limit_reached |
Cache rewritten |
| T10 | any | soft-deleted | Delete | Blocked if has_qr = true (12.9.5) |
deleted_at and purge_after set; cache purged; slug held |
| T11 | soft-deleted | prior state | Restore within 30 days | Slug still held; under cap | Cache rewritten; audit link.status_changed |
| T12 | soft-deleted | purged | purge_after reached |
— | Row deleted; slug released unless QR-touched |
Transitions T1, T3, T4, T7, T8, T9 and T11 write link.status_changed; T10 writes link.deleted. Every event key used anywhere in this section is drawn from the canonical audit catalogue in Section 8.9.1 — no section invents an event key. T5 and T6 write nothing: they are clock events, not actions, and would otherwise flood the log.
12.1.5 Resolution Behaviour by State #
| State | Redirect behaviour | HTTP |
|---|---|---|
active |
302 to the resolved destination | 302 |
scheduled (before activation) |
Branded "not available yet" page, optionally showing scheduled_at |
200 |
expired, expiry_url set |
302 to expiry_url |
302 |
expired, no expiry_url |
Branded expired page | 200 |
paused, paused fallback URL set |
302 to that URL | 302 |
paused, no fallback URL |
Branded unavailable page | 200 |
archived |
Branded unavailable page. QR-backed links additionally follow Section 14.8. | 200 |
draft |
Branded unavailable page (the slug is reserved but has no destination) | 200 |
| Password-protected, no valid unlock cookie | The password form, per 12.7.3 | 200 |
| soft-deleted, non-QR | Branded "this link was removed" page | 410 |
| purged, non-QR | Branded not-found page | 404 |
Any state, has_qr = true |
Section 14.8 fallback chain. Never 404, never 410. | 302 / 200 |
Why the visitor-facing cases are 200 and not 4xx. Any of these URLs may be QR-backed, and a QR-backed URL must never return a 4xx (14.8.3). Answering expired, not yet live and password required with 200 uniformly removes the possibility of a wrong status leaking through the QR path, and it removes an entire class of "which case is this" branching from the resolver. The two remaining 4xx rows apply only to links that never had a QR code, and the runtime guard in 14.8.3 re-checks the reservation table before either is emitted.
Destination redirects are always 302, with Cache-Control: private, no-store. 301 and 308 are never issued for a link or QR destination, under any configuration, because destinations are editable at any moment and a permanently cached redirect is a product defect that cannot be recalled from a visitor's browser. The single permanent redirect in the product is the HTTP→HTTPS scheme upgrade (13.8.5), which is a transport upgrade to the identical URL, not a destination redirect.
12.2 Creating a Link #
12.2.1 Permissions and Entitlements #
| Check | Rule | Failure |
|---|---|---|
| Role | Owner, Admin or Editor may create. Viewer may not. | 403 insufficient_role |
| Per-resource grant | On Business, a scoped member may create only into folders they are granted; creation always grants them the new link. | 403 resource_not_granted |
| Plan cap | Per the entitlement table in Section 22.1.2: Free 25 links; Pro and Business uncapped with a fair-use creation ceiling per billing period. | 403 plan_limit_reached, details[].kind = "count" for the stored cap and "period" for the fair-use ceiling |
| Email verification | The account must be verified before any publicly resolving resource is created. | 403 email_verification_required |
| Domain | domain_id must be an active domain in this workspace, or the system default host. |
422 domain_not_active |
Two entitlement codes exist in the whole product and both are 403: plan_limit_reached for a numeric or period cap, and plan_feature_unavailable for a binary feature gate. The exact refusal payload — a details array whose entries carry field, issue, limit, current, plan and kind — is specified once, in Section 22.2.7, and uses the issue vocabulary of Section 21.3.2.
12.2.2 Request Fields #
POST /v1/links
| Field | Type | Required | Constraint | Default |
|---|---|---|---|---|
destination_url |
string | yes unless publish=false |
12.3 | — |
domain_id |
uuid | no | Active domain in workspace | Workspace default domain |
slug |
string | no | 12.2.4 | Generated per 12.2.3 |
title |
string | no | ≤ 120 chars after trim | Derived from the destination when fetchable, else the destination host |
notes |
string | no | ≤ 1,000 chars | null |
folder_id |
uuid | no | Folder in workspace | null (root) |
tags |
string[] | no | ≤ 20 items, each 1–40 chars | [] |
utm |
object | no | Section 15.1; Pro+ | null |
scheduled_at / expires_at |
string (RFC 3339) | no | Section 15.3; Pro+ | null |
expiry_url |
string | no | Same validation as destination_url |
null |
click_limit |
integer | no | 1 – 10,000,000; Pro+ | null |
password |
string | no | 8–128 chars; Pro+ | null |
rules |
array | no | Section 15.5; Pro+ | null |
deep_link |
object | no | 12.7.4 | null |
social_preview |
object | no | 12.7.6 | null |
param_forwarding_mode |
enum | no | allow_list | all | none |
allow_list |
publish |
boolean | no | false creates a draft |
true |
create_qr |
boolean | no | Section 14.2 | false |
POST is idempotent when an Idempotency-Key header is supplied (Section 21.6). Replaying the same key within 24 hours returns the original 201 response rather than minting a second slug.
Success is 201 with the full link object:
{
"data": {
"id": "0198f3c1-7b2a-7c31-9f0e-2a1c4d5e6f70",
"workspace_id": "0198f3b0-1111-7000-8000-aaaabbbbcccc",
"domain_id": "0198f3b0-2222-7000-8000-ddddeeeeffff",
"short_url": "https://go.acme.com/spring-24",
"slug": "spring-24",
"destination_url": "https://acme.com/collections/spring?utm_source=newsletter",
"title": "Spring Collection",
"status": "active",
"tags": ["spring", "email"],
"has_qr": false,
"safe_browsing_status": "safe",
"created_at": "2026-03-04T09:14:22Z",
"updated_at": "2026-03-04T09:14:22Z"
},
"meta": {}
}Every response in this section uses the canonical envelope: data plus meta on success, a single error object with code, message, details and request_id on failure, snake_case throughout, and cursor pagination on every collection.
12.2.3 Slug Generation #
When no slug is supplied:
- Generate 7 characters from the Crockford base32 alphabet with look-alikes removed (
i,l,o,uexcluded), drawn from a cryptographically secure RNG. This yields 32⁷ ≈ 3.4 × 10¹⁰ candidates per host. - Lower-case the result. Generated slugs never contain a hyphen, which keeps them visually distinct from hand-written slugs.
- Reject and redraw if the candidate matches the blocklist in 12.2.5 (a generated string can accidentally spell a blocked token).
- Attempt the insert. The unique index on
(domain_id, slug)is the arbiter — never a pre-flightSELECT, which races. - On unique-violation, redraw and retry. After 5 collisions, extend the length to 8 characters and retry up to 5 more times. After 10 total attempts, return 503
slug_generation_exhaustedand page the operator; at that collision rate the host's namespace is being enumerated and that is an incident, not a user error.
Slug allocation for a QR code additionally writes the permanent reservation described in 14.2.4, inside the same transaction, and must clear both the printed host and the system host before it commits.
12.2.4 Custom Slug Rules #
| Rule | Detail | Error code |
|---|---|---|
| Charset | [a-z0-9-] only, after the normalisation below |
400 link_slug_invalid_chars |
| Length | 1–64 characters | 400 link_slug_length |
| Hyphen placement | May not start or end with -; may not contain -- |
400 link_slug_invalid_format |
| Not numeric-only beyond 12 digits | Prevents collision with internal numeric routes | 400 link_slug_invalid_format |
| Reserved | See 12.2.5 | 400 link_slug_reserved |
| Blocked | See 12.2.5 | 400 link_slug_blocked |
| Confusable | See 12.2.6 | 400 link_slug_confusable |
| Unique per host | Unique index on (domain_id, slug) |
409 link_slug_taken |
| Held by a recently deleted link | Inside the 30-day restore window (12.9.3) | 409 link_slug_held |
| Permanently reserved by a QR code | The (host, slug) pair exists in the permanent reservation table |
409 qr_slug_reserved |
qr_slug_reserved is the one code for every QR slug rejection, on every surface — link creation, QR creation, CSV import and bulk generation. There is no second name for this condition anywhere in the product.
Normalisation applied before validation, in order:
- Trim leading/trailing whitespace.
- Apply Unicode NFKC normalisation.
- Lower-case using the invariant (root) locale, never the request locale — Turkish dotless-i rules would otherwise produce a different slug for the same input.
- Replace internal whitespace with
-. - Strip a leading
/if the user pasted a path. - Reject anything still outside
[a-z0-9-]. LinkHub does not silently transliterate accented characters, becausecafé→cafeproduces a slug the user did not ask for and did not print.
Uniqueness scope is per host, not global. go.acme.com/spring and acme.link/spring are two different links owned by two different workspaces, and both are legal. There is no global slug registry, with exactly one exception: the system default redirect host maintains a mirror reservation for every QR slug (14.8.4), so that a code can always be resolved on infrastructure LinkHub itself controls.
Short links and QR codes share one slug namespace per host. They are the same path shape — a bare /{slug} — served by the same resolver, arbitrated by the same unique index. A QR reservation therefore blocks the identical short-link slug on that host, and an existing short link blocks a QR code from claiming its slug. That shared namespace is precisely what makes the permanent reservation protective; the reasoning is in 14.1.5.
12.2.5 Reserved and Blocked Words #
The reserved-word list is data, seeded per Section 6.7 and stored per Section 6.3.66, not a constant in code.
Reserved words are blocked on system-owned hosts only (the dashboard host, the API host and the default redirect host). They are permitted on a customer's own custom domain, because a customer's go.acme.com/api collides with nothing.
Reserved on system hosts:
api, app, admin, dashboard, login, logout, signin, signup, register,
auth, oauth, callback, account, settings, billing, pricing, plans,
support, help, docs, status, blog, about, terms, privacy, legal, dpa,
security, abuse, report, robots.txt, sitemap.xml, favicon.ico,
.well-known, static, assets, cdn, img, images, css, js, fonts,
qr, q, r, go, link, links, l, s, e, p, u, v, w, health, healthz,
metrics, internal, edge, worker, webhook, webhooks, stripe,
unsubscribe, preview, embed, oembed, manifest.jsonBlocked words apply on every host, including custom domains:
- The profanity and hate-speech list bundled with the deployment, matched against the slug with hyphens removed and leet-speak folded (
0→o,1→i,3→e,4→a,5→s,7→t). - Brand-impersonation terms: a curated list of well-known financial, government, shipping and platform brands (for example
paypal,hmrc,irs,dhl-tracking,apple-id,microsoft-login). Matching is substring-based on the folded slug. - Phishing-shape tokens combined with a brand term:
verify,secure,update,confirm,unlock,suspended— blocked only when co-occurring with a brand term, sosecure-checkouton your own domain is fine andpaypal-verifyis not.
A blocked custom slug returns 400 link_slug_blocked with a message that names the category but never the matched term, so the response cannot be used to enumerate the list. Workspaces on Business may request an allow-list exception for their own trademark through support; the exception is stored per workspace and audited. The security rationale for the blocklist and the confusable check is owned by Section 23.6.7; the behaviour is here.
12.2.6 Confusable / Homoglyph Check #
Homoglyph abuse on an ASCII-only charset is still possible through digit/letter substitution (rn vs m, 0 vs o, 1 vs l). The check runs after normalisation:
- Compute a skeleton of the candidate by applying the Unicode confusables mapping (TR39) and then folding the ASCII look-alike set:
0→o,1→l,5→s,8→b,rn→m,vv→w,cl→d, and removing all hyphens. - Compare the skeleton against:
- the skeletons of all reserved words (system hosts only),
- the skeletons of all blocked brand terms (all hosts),
- the skeletons of existing slugs on the same host that belong to a different workspace — impossible on a custom domain, where one workspace owns the host, so this arm only fires on shared system hosts.
- On a skeleton collision, reject with 400
link_slug_confusable. The message states that the slug is too similar to an existing or reserved slug and offers three concrete alternatives.
Skeletons are stored in a generated column with its own index (Section 6.3.32) so the check is a single indexed lookup, not a scan.
12.3 Destination URL Validation #
Validation runs identically on create, on edit, on CSV import, on targeting-rule destinations (Section 15.5), on expiry URLs, and on paused fallback URLs. There is one validator, in the shared core package, and it is the only place these rules exist. Section 23.6 owns the security rationale for every rule below and the corresponding webhook-side controls; this subsection owns the product behaviour and the error codes.
12.3.1 Scheme Allow-List #
| Scheme | Allowed | Notes |
|---|---|---|
https |
Yes | Preferred. |
http |
Yes | Permitted, with a non-blocking warning in the editor that the destination is not encrypted. |
mailto |
Yes | Address syntax validated; no headers other than subject and body. |
tel |
Yes | Digits, +, -, (, ), spaces; max 32 chars. |
sms |
Yes | Same as tel plus an optional body parameter. |
| everything else | No | Includes javascript, data, file, ftp, blob, vbscript, intent, market, itms-apps, and all custom app schemes. |
Rejected schemes return 400 link_destination_scheme_not_allowed. The same allow-list is enforced as a database constraint (Section 6.3.32), because a bad destination is a security incident, not a validation slip. Custom app schemes are refused deliberately: a scheme that only resolves on one device class produces a dead end on every other device, and the supported path for app targeting is the deep-link configuration in 12.7.4 combined with the platform rules in Section 15.5.
12.3.2 Length and Shape Limits #
| Limit | Value | Error |
|---|---|---|
| Total URL length | 2,048 characters after normalisation | 400 link_destination_too_long |
| Host label length | 63 characters per label, 253 total | 400 link_destination_invalid |
| Query string length | 1,024 characters | 400 link_destination_too_long |
| Number of query parameters | 64 | 400 link_destination_invalid |
| Redirect chain depth followed during safety checks | 3 hops | 422 link_destination_redirect_loop |
| Control characters, newlines, tabs, raw spaces | Rejected outright | 400 link_destination_invalid |
12.3.3 Normalisation Rules #
Applied in this order, and the normalised form is what gets stored:
- Trim surrounding whitespace; reject if any C0/C1 control character remains anywhere in the string.
- If no scheme is present and the string parses as a hostname with an optional path, prepend
https://. A bareacme.com/xbecomeshttps://acme.com/x. A string with a scheme-like prefix that is not in the allow-list is rejected rather than coerced. - Lower-case the scheme and the host. Never touch the path, query or fragment case — path case is significant on most origins.
- Convert a Unicode host to punycode (IDNA 2008,
UseSTD3ASCIIRules = true). Store the punycode form; display the Unicode form in the UI. - Remove the default port (
:80forhttp,:443forhttps). - Collapse
..and.path segments. - Normalise percent-encoding: upper-case hex digits, decode unreserved characters that were needlessly encoded, and leave everything else byte-identical.
- Preserve the fragment (
#…) verbatim. Fragments are never sent to the server but are meaningful to single-page destinations. - Do not add or remove a trailing slash, do not sort query parameters, and do not strip tracking parameters. Any of these would change a destination the customer deliberately constructed.
12.3.4 SSRF and Private-Range Rejection #
The destination host is resolved at validation time (A and AAAA, 2-second timeout, resolver configured per Section 27.4) and every returned address is checked against the reject list. The check re-runs on the weekly safety recheck, because a hostname that resolved publicly on Monday can be re-pointed to 127.0.0.1 on Tuesday. Section 23.6.3 and 23.6.4 own the rebinding-window analysis behind this.
| Range | Family | Reject |
|---|---|---|
0.0.0.0/8, 127.0.0.0/8 |
IPv4 | Loopback / this-network |
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 |
IPv4 | RFC 1918 private |
100.64.0.0/10 |
IPv4 | CGNAT |
169.254.0.0/16 |
IPv4 | Link-local, including cloud metadata at 169.254.169.254 |
192.0.0.0/24, 192.0.2.0/24, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24 |
IPv4 | Special-use / documentation |
224.0.0.0/4, 240.0.0.0/4, 255.255.255.255/32 |
IPv4 | Multicast / reserved / broadcast |
::1/128, ::/128 |
IPv6 | Loopback / unspecified |
fc00::/7 |
IPv6 | Unique local |
fe80::/10 |
IPv6 | Link-local |
::ffff:0:0/96, 64:ff9b::/96 |
IPv6 | IPv4-mapped and NAT64 — unwrapped and re-checked against the IPv4 table |
fd00:ec2::254/128 |
IPv6 | Cloud metadata |
Additional host-level rejections:
- Hostnames ending in
.local,.internal,.localhost,.home.arpa,.corp,.lan, or any single-label hostname with no dot. - Any hostname that resolves to an address inside the deployment's own VPC CIDR, which is supplied as configuration rather than hard-coded.
- IP literals: a public IP literal is accepted but sets
safe_browsing_status = 'flagged'and shows the interstitial specified in Section 23.6.6, because a bare IP destination is overwhelmingly correlated with abuse. A private or reserved IP literal is rejected outright.
Failures return 422 link_destination_private_address. The message says the destination points to a private or internal address and cannot be used; it does not echo the resolved address, which would turn the endpoint into a DNS-rebinding oracle.
The same resolution result is not reused at redirect time. The edge never performs DNS on the destination; it emits a 302 and the visitor's own client resolves it. This is deliberate — a per-request destination resolution would blow the redirect budget in Section 11.
12.3.5 Open-Redirect Protection #
LinkHub must never become a laundering hop for someone else's redirect. Three protections:
- No destination ever comes from the request. There is no query parameter, header or path segment on the redirect surface that can set or override the destination. Forwarded parameters (Section 15.2) are appended to a destination that was already stored; they can never replace its origin.
- Self-referential loop detection. If the destination host is a LinkHub system host or any custom domain registered in the platform, the validator resolves the target slug and walks the chain. A chain longer than 3 hops, or a chain that revisits a slug, is rejected with 422
link_destination_redirect_loop. A one-hop link-to-link reference is legal and useful (a campaign alias pointing at a canonical link). - Chain inspection on external destinations. During the safety check the validator follows up to 3 HTTP redirects with a 4-second total budget and applies the private-range check to every hop. A destination whose chain terminates inside a private range is rejected even though the first hop was public.
12.3.6 Safety Classification #
Google Safe Browsing is consulted on create, on destination edit, and on a weekly recheck job (link-safety-recheck). Section 23.6.5 owns the integration; the four states below are the product's only safety vocabulary.
| Result | safe_browsing_status |
Behaviour |
|---|---|---|
| Never yet evaluated, or the last evaluation was inconclusive | unchecked |
Resolves normally. A recheck is pending. See 12.3.7. |
| No match | safe |
Normal 302. |
| Social-engineering / malware / unwanted-software match | blocked |
Creation is refused with 422 link_destination_unsafe. An existing link flips to blocked and serves the safety interstitial (Section 23.6.6); it never silently keeps redirecting. |
| Heuristic match, or the link was reported through the abuse endpoint and is awaiting review | flagged |
Serves the safety interstitial in Section 23.6.6 with a "continue anyway" control. |
There is no fifth value, and there is no second column. A lookup that fails, times out or is quota-limited leaves the link unchecked — the honest description of the state — rather than inventing a status that means "we tried".
12.3.7 When Validation Is Inconclusive #
Two distinct inconclusive cases, with two different answers:
| Case | Decision | Rationale |
|---|---|---|
| DNS resolution fails or times out (NXDOMAIN, SERVFAIL, timeout) | Fail closed on create, fail open on edit-of-existing. On create, return 422 link_destination_unresolvable with a "create anyway" affordance that leaves safe_browsing_status = 'unchecked' and schedules a recheck in 15 minutes. On edit, the save is accepted and left unchecked, because the customer may be pre-staging a link for a site that launches tomorrow. |
A brand-new link to a nonexistent host is far more likely a typo than a plan. |
| Safe Browsing unreachable | Fail open. The link is created, safe_browsing_status stays unchecked, and a recheck is enqueued with backoff (1 min, 5 min, 15 min, 1 h, then the weekly cadence). |
A third-party outage must not stop customers creating links. The exposure window is bounded and the interstitial applies retroactively the moment a match is found. |
A link that is unchecked because a check is pending resolves normally and shows an amber badge in the list UI with the tooltip "Safety check pending". After 24 hours still pending, the workspace Owner and Admins receive one digest notification listing the affected links.
12.4 Editing a Destination #
12.4.1 Who May Edit #
| Role | May edit destination |
|---|---|
| Owner | Yes |
| Admin | Yes |
| Editor | Yes, subject to per-resource grants on Business |
| Viewer | No — 403 insufficient_role |
| API key | Yes, if the key carries the links:write scope (the scope catalogue is Section 21's, referenced not restated) |
Editing is additionally blocked when the workspace is in a billing state that blocks writes. That schedule is Section 22.7's: full write access through the first days of past-due, and billing_write_blocked 403 only from day 8. It is never blocked for a QR-backed link on the grounds of billing state alone — see 14.8.8.
12.4.2 What Is Audited #
Every destination change writes one link.destination_changed entry to the audit log defined in Section 8.9:
| Field | Value |
|---|---|
action |
link.destination_changed |
actor_type / actor_id |
user or api_key, with id |
resource_type / resource_id |
link, link id |
before |
{"destination_url": "https://acme.com/old"} |
after |
{"destination_url": "https://acme.com/new"} |
context |
{"has_qr": true, "qr_code_ids": ["…"], "experiment_id": null, "source": "dashboard"} |
ip_country, user_agent_family, created_at |
Per Section 8.9 |
Audit entries are append-only and survive link deletion. Retention follows the audit_log_retention_days entitlement in Section 22.1.2 — with one permanent exception, QR destination changes, specified in 14.3.3.
12.4.3 Cache Invalidation #
Section 4.7 is the authoritative cache-invalidation matrix and Section 4 owns the Redis key catalogue. The sequence this section triggers, on commit and inside the same request:
- The PostgreSQL transaction commits the new row.
- Write-through:
SET rd:{host}:{slug}to the newly rendered redirect payload with the standard TTL, clamped by any schedule boundary (15.3.5). - If the link is QR-backed, repeat step 2 for the mirrored system-host key
rd:{system_host}:{slug}. DEL rd:miss:{host}:{slug}— a negative cache entry may exist from a pre-creation probe.- Publish an invalidation message on the resolver's invalidation channel so every edge process drops the entry from its in-process LRU. The channel is a pub/sub topic, not a cache key, and is catalogued with the keys in Section 4.
The in-process LRU at the edge has a hard 5-second TTL, which bounds propagation even if the pub/sub message is lost. The measured propagation guarantee and its verification are specified in 14.3.5; the same mechanism serves plain short links.
12.4.4 Warning When Attached to a QR Code #
If has_qr = true, the save dialog is interrupted with a modal that must be acknowledged. It is not a toast and it is not dismissible by clicking outside.
This link is printed. 2 QR codes point at this link. Changing the destination changes where every printed, sticker-ed and packaged code sends people — including codes already in the world.
Now:
https://acme.com/collections/springAfter:https://acme.com/collections/summerCodes affected: Spring Catalogue Insert, Store Window Decal Scans in the last 30 days: 4,182
[ Cancel ] [ Change destination ]
Typed confirmation — the user must type the word CHANGE — is additionally required when either condition holds:
- the QR codes attached to this link recorded more than 1,000 scans in the last 30 days, or
- more than one QR code is attached.
Rationale: the friction should scale with blast radius, and a single low-traffic code does not warrant it.
12.4.5 Blocking When in a Running Experiment #
If experiment_id is set and the experiment is running, editing the base destination is blocked, not warned, with 409 link_in_active_experiment. Changing the control destination mid-test invalidates the result and there is no honest way to reconcile the data afterwards. The dialog offers three paths: stop the experiment and keep the current winner, stop and revert to control, or edit the variant destinations inside the experiment editor (Section 16.5). API callers receive the same 409 with details[].experiment_id. The experiment lifecycle states are Section 16.9's — draft, running, paused, concluded, promoted, archived — and only running blocks.
12.4.6 Editing Other Fields #
Slug changes are a different operation from destination changes and carry their own rules:
| Condition | Behaviour |
|---|---|
has_qr = false, no clicks |
Slug may be changed freely. The old slug is released after a 30-day hold. |
has_qr = false, has clicks |
Slug may be changed. The old slug is retained as a 302 alias to the link for 12 months, then released. A warning explains that existing shares of the old URL keep working for 12 months. The alias is a 302 like every other destination redirect. |
has_qr = true |
Slug changes are refused. 409 link_slug_immutable_qr. A printed code encodes the slug; renaming it would break physical media. The message directs the user to create a new QR code instead. |
12.5 Bulk Operations #
12.5.1 CSV Import — Column Contract #
The importer accepts UTF-8 CSV (RFC 4180) with a required header row. A UTF-8 BOM is tolerated and stripped. Delimiter is auto-detected between , and ;. Line endings may be LF or CRLF.
| Column | Required | Type / Constraint | Default when blank |
|---|---|---|---|
destination_url |
Yes | Validated per 12.3 | — (row fails) |
slug |
No | Validated per 12.2.4 | Generated |
domain |
No | Hostname of an active domain in the workspace |
Workspace default domain |
title |
No | ≤ 120 chars | Derived |
notes |
No | ≤ 1,000 chars | Empty |
folder |
No | Folder path, / separated, e.g. Campaigns/Q1 |
Root |
tags |
No | Pipe-separated, e.g. `spring | |
utm_source, utm_medium, utm_campaign, utm_term, utm_content |
No | Section 15.1 validation | None |
scheduled_at, expires_at |
No | RFC 3339, or YYYY-MM-DD HH:mm interpreted in the workspace timezone |
None |
expiry_url |
No | Validated per 12.3 | None |
click_limit |
No | Integer 1 – 10,000,000 | None |
password |
No | 8–128 chars | None |
create_qr |
No | true / false / 1 / 0 / yes / no |
false |
external_id |
No | ≤ 128 chars, unique per workspace. Used for idempotency and for re-import updates. | None |
Unknown columns are reported as warnings and ignored — they do not fail the import. A missing destination_url column (not value) fails the whole file before any row is read, with 400 import_missing_required_column.
A downloadable template with the header row and three example rows is offered on the import screen.
12.5.2 Import Limits #
| Plan | Max rows per import | Max file size | Concurrent imports |
|---|---|---|---|
| Free | 25 (bounded by the workspace's 25-link entitlement in Section 22.1.2) | 2 MB | 1 |
| Pro | 5,000 | 10 MB | 2 |
| Business | 20,000 | 40 MB | 4 |
A file exceeding the row limit is rejected before validation with 403 plan_limit_reached (details[].kind = "count"). A file exceeding the byte limit is rejected at the transport layer with 413 request_too_large. Splitting is the documented remedy; LinkHub does not silently truncate.
12.5.3 Validation Report Before Commit #
Import is a two-phase operation. Nothing is written until the user approves the report.
Phase 1 — dry run (POST /v1/links/imports, returns 202 with an import id):
- The file is stored in object storage under a 24-hour lifecycle rule.
- The
link-importworker parses and validates every row: schema, slug rules, destination validation including DNS and Safe Browsing, plan cap arithmetic, and intra-file duplicate slug detection. - Each row is classified
ok,warning,errororduplicate_of_existing. - The report is persisted and exposed at
GET /v1/links/imports/{id}.
Report shape:
{
"data": {
"id": "0198f4aa-0000-7000-8000-111122223333",
"status": "validated",
"filename": "q1-campaign-links.csv",
"row_count": 1240,
"summary": {
"ok": 1187,
"warning": 41,
"error": 9,
"duplicate_of_existing": 3,
"will_create": 1228,
"will_update": 3,
"will_skip": 9
},
"plan_check": { "current": 812, "adding": 1228, "limit": null, "blocked": false },
"rows": [
{ "row": 14, "status": "error", "code": "link_slug_taken",
"field": "slug", "value": "spring", "message": "Slug already in use on go.acme.com." },
{ "row": 88, "status": "warning", "code": "link_destination_http",
"field": "destination_url", "value": "http://acme.com/x",
"message": "Destination is not encrypted." }
],
"expires_at": "2026-03-05T09:14:22Z"
},
"meta": { "next_cursor": "eyJyIjoxMDB9", "has_more": true }
}The report UI shows the summary counters, a filterable row table, and a downloadable errors-only CSV containing the original row plus error_code and error_message columns, so the user can fix and re-upload only the failures.
Phase 2 — commit (POST /v1/links/imports/{id}/commit). The report expires after 24 hours; committing an expired report returns 410 import_report_expired and the file must be re-uploaded, because the workspace state it was validated against has moved on.
12.5.4 Partial-Failure Semantics #
The commit request carries on_error:
| Mode | Behaviour | Default |
|---|---|---|
skip_invalid |
Valid rows are created; invalid rows are skipped and listed in the result. | Yes |
all_or_nothing |
If any row is error, nothing is written. |
No |
Commit runs in batches of 500 rows, each batch in its own transaction, so a mid-file infrastructure failure leaves whole batches applied rather than a torn batch. The import record tracks committed_rows; a retried commit resumes from that offset and is safe to call repeatedly (the same external_id or the same (domain_id, slug) will not double-create — a duplicate is recorded as skipped_duplicate).
If the workspace crosses its plan cap during commit (because another member was creating links concurrently), the batch that crosses stops, the import ends with status partially_committed, and the response carries plan_limit_reached with the canonical details array. Already-created links are kept.
Auditing uses the canonical catalogue and nothing else: each created link writes one link.created entry carrying context.import_id and context.row, exactly as a link created through the API or the dashboard does. An import is not a special kind of creation and does not get a special event key; the import id is what ties the entries together in the viewer.
12.5.5 Bulk Edit, Tag, Archive #
Available from the list UI on a selection, and through the API.
| Operation | Endpoint | Max ids per request | Notes |
|---|---|---|---|
| Bulk edit | PATCH /v1/links/bulk |
500 | Settable: folder_id, expires_at, expiry_url, scheduled_at, param_forwarding_mode, utm. destination_url and slug are not bulk-editable. |
| Bulk tag | POST /v1/links/bulk/tags |
500 | add and remove arrays. |
| Bulk archive | POST /v1/links/bulk/archive |
500 | `archive: true |
| Bulk delete | POST /v1/links/bulk/delete |
500 | Soft delete. QR-backed ids are rejected individually (12.9.5). |
Destination is excluded from bulk edit on purpose: a bulk destination rewrite is indistinguishable from an account takeover monetising a customer's printed material, and no legitimate workflow needs it. Changing many destinations is done through a re-import with external_id, which produces a per-row diff and a report the user must approve.
All bulk operations are partial-success and return per-item detail inside a 200 envelope:
{
"data": {
"requested": 500,
"succeeded": 494,
"failed": 6,
"failures": [
{ "id": "0198f3c1-…", "code": "link_has_qr", "message": "Blocked: this link backs a QR code." }
]
},
"meta": {}
}The UI's "select all matching this filter" affordance operates on the filter, not on a materialised id list, and is capped at 10,000 matched links per operation; above that the user is asked to narrow the filter. Bulk operations over 100 items are executed by a worker and reported through the notification centre rather than blocking the request.
12.6 Organisation #
12.6.1 Folders #
| Property | Decision |
|---|---|
| Nesting | Up to 3 levels (Clients/Acme/Q1). Deeper creation returns 422 folder_depth_exceeded. |
| Name | 1–60 chars, unique among siblings, case-insensitive comparison. |
| Membership | A link belongs to zero or one folder. |
| Deletion | Deleting a folder moves its links to the parent (or root) and never deletes links. A confirmation states the count that will move. |
| Permissions | On Business, a per-resource grant may be issued on a folder, which cascades to its current and future contents. |
| Counts | Folder rows display a link count computed from a maintained counter, not a live COUNT(*). |
12.6.2 Tags #
Tags are an array on the link (12.1.1), not a join table.
| Property | Decision |
|---|---|
| Charset | [a-z0-9 \-_], 1–40 chars, lower-cased on save, internal whitespace collapsed. |
| Per link | Max 20. |
| Per workspace | Max 500 distinct tags; beyond that, creation returns 422 tag_limit_reached. |
| Colour | Optional, from a fixed 12-colour palette that satisfies the contrast rules in Section 24. Colours are workspace settings, not per-link data. |
| Rename | A single UPDATE … array_replace over the workspace's matching rows, served by the GIN index on the tag array. One statement, no per-link loop. |
| Merge | Selecting two tags offers "merge into", which is the same single statement followed by a de-duplication of the array. |
| Autocomplete | Prefix search over the workspace's distinct tag values, 8 suggestions, keyboard navigable. |
12.6.3 Search #
Search covers slug, title, notes, destination_url and tag values.
- Implementation: a PostgreSQL generated
tsvectorfortitle+notes, plus a trigram index onslugand ondestination_urlfor substring matching, and the GIN index on tags. All are defined in Section 6. - A query that looks like a URL is matched against
destination_urlhost-first, then full string. - A query prefixed with
#searches tags only;/searches slugs only. - Debounce 250 ms; minimum 2 characters; results capped at the page size with cursor pagination.
- Empty result state: "No links match query." plus the three most recently used filters as one-click removals, and a "clear all filters" control.
12.6.4 Filters and Sort #
| Filter | Values |
|---|---|
| Status | draft, active, scheduled, expired, paused, archived |
| Domain | Any workspace domain, plus the system host |
| Folder | Tree picker, with "include subfolders" toggle (default on) |
| Tags | Multi-select, AND / OR toggle (default OR) |
| Has QR | yes / no |
| Created by | Workspace member picker |
| Created date | Preset ranges plus custom |
| Last clicked | Never, last 24 h, 7 d, 30 d, older than 30 d |
| Safety | unchecked, safe, flagged, blocked |
| In experiment | yes / no |
| Sort | Direction | Backing index |
|---|---|---|
| Created (default) | desc | (workspace_id, created_at desc, id desc) |
| Last clicked | desc | (workspace_id, last_clicked_at desc nulls last, id desc) |
| Clicks (7 d / 30 d / all time) | desc | Served from the rollup tables, joined after the page of ids is chosen |
| Slug | asc | (domain_id, slug) |
| Title | asc | Collation-aware, nulls last |
Archived links are excluded from every default view; the status filter is the only way to see them, and selecting it shows a persistent banner explaining why they are archived when archived_reason is downgrade.
12.6.5 Saved Views #
A saved view stores a filter set, a sort, a column layout and a name.
| Field | Constraint |
|---|---|
name |
1–60 chars, unique per workspace per scope |
scope |
private (creator only) or shared (whole workspace). Editors may create either; only Admins and Owners may edit or delete a shared view they did not create. |
is_default |
One private default per member; overrides the built-in default view |
| Limit | 25 per member, 50 shared per workspace |
Saved views are addressable by URL so they can be bookmarked and shared internally. A shared view whose folder is not granted to the viewing member simply returns the subset they may see — a view never widens permissions.
12.7 Link-Level Settings #
12.7.1 Title and Notes #
title is used in the dashboard, in exports, in the share sheet, and as the default og:title when no custom social preview is set. It is auto-derived on create by fetching the destination with a 3-second budget, a 512 KB cap, and a LinkHub-TitleFetch user agent that honours robots.txt. If the fetch fails, the title defaults to the destination host. The fetch never blocks link creation — it runs in the worker and patches the row within seconds.
notes are internal. They are never served on any public surface, never included in the social preview, and are excluded from the public API's unauthenticated surfaces entirely.
12.7.2 Tags #
Per 12.6.2. Tags are workspace-scoped and appear in analytics as a filter dimension, so a campaign can be measured across many links without a folder.
12.7.3 Password Protection — The Canonical Interstitial Specification #
This subsection is the single definition of the password interstitial. Every other section — including the public delivery path in Section 11 — references 12.7.3 and restates none of it. There is exactly one status code, one cookie name, one signature input set and one rate limit, and they are the ones below.
Entitlement: Pro and Business. A Free workspace attempting to set a password receives 403 plan_feature_unavailable; the capability's entitlement value lives in the catalogue in Section 22.1.2.
Hashing. Link passwords use Argon2id with the parameters defined for account passwords in Section 7.3. The hash is stored on the link (Section 6.3.32), never in Redis, never in the redirect payload, and never returned by the API — the API exposes only password_protected: true|false.
Resolution flow.
| Step | Specification |
|---|---|
| 1. Unprotected request | GET https://go.acme.com/spring-24 on a password-protected link returns 200 with the interstitial. Never 401, never 403, never a redirect. The destination appears nowhere in the HTML, in a header, or in a script. |
| 2. The page | Server-rendered, works with JavaScript disabled. Contains the workspace's logo and name (or LinkHub branding on Free), one labelled password field with autocomplete="current-password", a submit button, and an accessible error region. No destination preview and no "powered by" link that leaks the target. The page's Content-Security-Policy is Section 23.5's, unmodified. |
| 3. Submission | POST to the same URL with a CSRF token. |
| 4. Success | 302 to the destination, plus the unlock cookie below. |
| 5. Cookie name | lh_unlock, one cookie per link scoped by path: Path=/{slug}. One name, not a per-link name family — a per-link name family multiplies cookies without bound on a shared host. |
| 6. Cookie attributes | HttpOnly, Secure, SameSite=Lax, Max-Age=43200 (12 hours). |
| 7. Cookie value | An HMAC-SHA256, keyed by a server-held secret, over exactly three inputs: the link id, the timestamp at which the password was last set, and the cookie's own expiry instant. No other input. Because the last-set timestamp is inside the signature, changing the password invalidates every outstanding unlock immediately and without a revocation list. |
| 8. Fast path | A request carrying a valid unlock cookie skips the interstitial and redirects directly, adding roughly 0.2 ms of HMAC verification to the redirect path. Argon2id verification only ever runs on the POST, so the hot path is never slowed by a memory-hard hash. |
| 9. Rate limit | 10 failed attempts per 15 minutes per (link, visitor hash) and 100 failed attempts per hour per link. Exceeding either returns 429 rate_limited with a Retry-After header, and the interstitial displays a countdown. There is no account lockout — a link password is not an account credential. These are per-link limits and are separate from the per-IP redirect limit in Section 23.9. |
| 10. Analytics | A blocked attempt records an event of type password_prompt; a successful unlock records a normal click. Failed attempts never record the attempted password. The count surfaces in the link's analytics as "unlock attempts". |
Why 200 and not 401. A 401 invites the browser's own credential UI, which cannot be styled, cannot be branded, and on a phone camera browser is indistinguishable from a phishing prompt. More decisively, a password-protected link may be QR-backed, and a QR-backed URL must never return a 4xx (14.8.3). The interstitial is a valid rung-1 outcome for the guarantee in 14.8: it is a 200 that leads to the destination.
QR interaction. A QR-backed link may be password protected, but the editor warns that scanning then requires typing a password on a phone, and that this is a common cause of abandoned scans.
12.7.4 Deep-Link Behaviour for Mobile Apps #
LinkHub supports app destinations through platform-native association, not through scheme sniffing.
| Mechanism | Support |
|---|---|
| Apple Universal Links | LinkHub serves /.well-known/apple-app-site-association on any custom domain when the workspace uploads the JSON. Served with Content-Type: application/json, no redirect, no extension, always 200 or 404 with no interstitial. |
| Android App Links | Same, for /.well-known/assetlinks.json. |
| Per-platform destinations | deep_link.ios_url, deep_link.android_url, deep_link.web_fallback_url. When set, the resolver picks by OS. This is implemented as a system-generated targeting rule (Section 15.5) so there is exactly one evaluation engine. |
Custom URL schemes (myapp://) |
Not supported as a destination. See 12.3.1. |
| JavaScript scheme-probe interstitials | Never used. They break on modern iOS and Android, add a full page load to the redirect path, and are indistinguishable from cloaking to a security scanner. |
deep_link.web_fallback_url is mandatory whenever either platform URL is set; without it a desktop visitor has nowhere to go. Validation enforces this with 422 deep_link_fallback_required.
The two .well-known paths are reserved on every custom domain and cannot be claimed as slugs.
12.7.5 Link Cloaking Policy #
LinkHub does not cloak links, and this is not configurable.
Cloaking here means keeping the short URL in the address bar while the destination is rendered inside a frame or proxied through LinkHub's servers. LinkHub will not do it, for four reasons stated plainly in the product UI:
- Security. Framing a third-party site strips its ability to defend itself; the majority of serious destinations send
X-Frame-Optionsor aframe-ancestorsCSP and would break anyway, producing a blank page the customer cannot debug. - Trust. A visitor cannot see where they are. That is the defining property of a phishing page, and every browser and mail filter treats it accordingly.
- Correctness. Forms, OAuth callbacks, payment flows, app handoffs and cookies behave differently inside a frame. Cloaking converts a working destination into a subtly broken one.
- Reputation. Domains associated with cloaking are aggressively blocklisted, which would damage every customer's custom domain on shared infrastructure.
What LinkHub does render on its own hosts is a small set of first-party interstitials: the password prompt (12.7.3), the safety warning (Section 23.6.6), the scheduled, expired and paused pages (Section 15.3), and the QR fallback pages (14.8.2). These are LinkHub pages, clearly branded, that never embed or proxy the destination. They are the opposite of a cloak.
12.7.6 Custom Social Preview Metadata #
A 302 carries no metadata, so a link pasted into a chat app would normally unfurl the destination's preview — usually the desired behaviour, and the default.
When a custom social preview is set, LinkHub serves a metadata document to crawlers:
- Detection is by user-agent match against a maintained allow-list of unfurlers (Slack, Discord, Facebook/Meta, X, LinkedIn, WhatsApp, Telegram, iMessage, Signal, Pinterest, Mastodon, Bluesky, Google, Bing). The list lives in configuration, not in code.
- Matched requests receive 200 with a minimal HTML document containing
og:title,og:description,og:image,og:url(the short URL),twitter:card=summary_large_image, a<link rel="canonical">to the destination, and a<meta http-equiv="refresh">plus a visible<a>to the destination so a human who somehow lands there is not stranded. - No cookies are set, no analytics event is recorded, and
X-Robots-Tag: noindexis sent. - Unmatched requests receive the normal 302. This is the only user-agent-conditional behaviour anywhere in the redirect path, it is documented here, and it is covered by an explicit test asserting that a normal browser UA always receives a 302.
Image constraints: JPEG/PNG/WebP, ≤ 5 MB, ≥ 600 × 315 px, stored in object storage and served through the CDN at 1200 × 630 with server-generated crops.
12.7.7 Expired and Paused Fallback URLs #
expiry_url is the destination for a link that has passed expires_at or exhausted click_limit. It is validated exactly like destination_url. When unset, an expired link renders the branded expired page with 200. Full semantics in Section 15.3.
A parallel paused fallback URL exists for the paused state. Both are rung 2 of the QR guarantee in 14.8.2 and both redirect with 302.
12.8 QR Attachment #
Any link may have a QR code generated from it. Setting create_qr: true at creation, or invoking "Create QR code" from the link detail view, creates a QR code entity whose mechanics — slug reservation, styling, validation, export and permanence — are specified in Section 14.
The consequences on the link side are:
| Effect | Detail |
|---|---|
has_qr set to true |
Permanent. It never returns to false, even if every attached QR code is deleted, because the slug may already be printed. |
| The link becomes pinned | It is never archived and never counts toward the link cap. 12.9.6. |
| Slug becomes immutable | 12.4.6. |
| Domain becomes immutable | 409 link_domain_immutable_qr (Section 13.1.3). |
| Deletion becomes blocked | 12.9.5. |
| Destination edits gain the modal | 12.4.4. |
| Slug is mirrored | The system-host reservation in 14.8.4 is written, and the identical slug is blocked for short links on both hosts (12.2.4). |
| List UI | A QR badge appears in the link row and in the API response. |
A link may back more than one QR code — for example one styled for packaging and one for a shop window — and all of them resolve through the same slug and therefore the same destination. Codes that must be measured separately need separate links; the UI says so at the point of creation.
12.9 Deletion and Archival #
12.9.1 Archive #
Archiving is reversible, non-destructive and does not consume plan capacity: an archived link does not count toward any cap, per Section 22.2.5. An archived link stops appearing in default views, keeps all its analytics, and serves the branded unavailable page (or its paused fallback, if set) with 200.
Archive is also the mechanism a plan downgrade uses (Section 22.5) — the newest links above the new cap are archived with archived_reason = 'downgrade', never deleted, and the user may choose a different set during the guided downgrade flow. Links pinned to a QR code are excluded from that selection entirely (12.9.6).
12.9.2 Soft Delete #
DELETE /v1/links/{id} sets deleted_at = now() and purge_after = now() + 30 days. The link disappears from all views except the Trash view. The redirect cache entry is deleted and a negative cache entry is written.
12.9.3 The Restore Window #
For 30 days:
- The link appears in Trash with its remaining days, and can be restored to its prior state in one action, subject to the plan cap (restoring above the cap returns 403
plan_limit_reachedwith a prompt to archive something else first). - The slug remains held. Nobody — including the same workspace — can create a new link on that
(domain, slug)pair. Attempting to returns 409link_slug_heldwith a message explaining that the slug belongs to a recently deleted link and when it will free up, plus a one-click "restore that link instead" affordance for members who can see the trashed link. - Requests to the slug return 410 Gone with a branded "this link was removed" page. 410 rather than 404 because the resource demonstrably existed, which is both semantically correct and more useful to the person holding the old URL. This applies only to links that never had a QR code: before either 410 or 404 is emitted for any path, the runtime guard in 14.8.3 checks the permanent reservation table and converts a hit into a 200 fallback rung.
- Analytics for the period the link was alive remain queryable and appear under the link's name with a "deleted" marker.
After 30 days the retention worker hard-deletes the row. Click events are retained on their own plan-based schedule (Section 17.10) and remain in aggregate reporting; they are no longer drillable to a live link.
12.9.4 What Happens to the Slug After the Window #
| Case | Outcome |
|---|---|
| Link never had a QR code | The slug returns to the available pool on the same host. A subsequent creation may reuse it. Requests during the gap return 404. |
Link ever had a QR code (has_qr = true at any point) |
The slug is never released. It stays in the permanent reservation table for that host and for the system host, and continues to resolve through the fallback chain in 14.8.2. Creating anything on that slug — a link or a new QR code — returns 409 qr_slug_reserved. |
The second row is the hard rule. It is enforced in three independent places so that no single refactor can remove it: a NOT EXISTS check in the creation path, a database-level unique index on the reservations table with no partial predicate, and an invariant test in the critical suites of Section 26.5, gated by the coverage requirements in Section 26.12.
12.9.5 Deleting a QR-Backed Link #
DELETE on a link with has_qr = true returns 409 link_has_qr:
{
"error": {
"code": "link_has_qr",
"message": "This link backs a QR code and cannot be deleted. Delete the QR code first, or leave it in place — it costs you nothing.",
"details": [ { "field": "id", "issue": "has_qr", "qr_code_ids": ["0198f5b2-…"] } ],
"request_id": "req_01JB7X9C4Q2M8N"
}
}Deleting the QR code itself is possible (14.8.6) but does not release the slug and does not stop resolution — it removes the code from the dashboard while the printed artefact keeps working forever. The UI is explicit about this at the confirmation step.
12.9.6 Links Pinned to a QR Code #
A link that backs a QR code is pinned. A pinned link is never archived and never counts toward the plan's link cap.
| Consequence | Detail |
|---|---|
| Archival | Refused on every path — manual archive, bulk archive, plan downgrade, domain removal — with 409 link_has_qr. Transition T8 is guarded (12.1.4). |
| Cap arithmetic | Excluded from the counter that backs short_links in Section 22.1.2, in every state including paused and expired. A workspace that downgrades to Free with 40 pinned links is not over its cap and is not asked to choose. |
| Downgrade UI | Pinned links never appear in the guided downgrade selection, because there is no outcome in which one of them is dropped. |
| Deletion | Blocked (12.9.5). |
| Why | The previous behaviour — archive the backing link while exempting the QR code — was self-contradictory: archiving the link is what would have taken the printed code off rung 1. Exempting the code but not its link exempts nothing. The pin is the mechanism that makes the guarantee in 14.8.1 true rather than aspirational, and Sections 22.2 and 22.5 carry the same rule from the billing side. |
The link list shows a small pin affordance on these rows with the tooltip "Backs a printed QR code — kept out of your link count and never archived."
12.10 The Link List UI #
12.10.1 Columns #
| Column | Default | Content |
|---|---|---|
| Selection checkbox | On | Shift-click range selection; keyboard accessible |
| Short URL | On | host/slug, monospace, with a copy button and a QR badge when applicable |
| Destination | On | Favicon (from the destination host, cached and proxied), truncated URL with the full value in a tooltip and as title |
| Title | On | Falls back to the destination host |
| Tags | On | Up to 3 chips, then "+n" |
| Clicks (30 d) | On | Number plus a 7-day sparkline |
| Last clicked | Off | Relative time |
| Status | On | Badge; scheduled and expired show their timestamp on hover |
| Created | On | Relative time, absolute in tooltip |
| Created by | Off | Avatar + name |
| Folder | Off | Breadcrumb |
| Domain | Off | Hostname |
| Actions | On | Copy, edit, QR, duplicate, archive, delete |
Column visibility and order are stored per member and captured by saved views (12.6.5).
12.10.2 Inline Metrics #
Click counts and sparklines come from the daily rollup table (Section 6.5), never from the raw event tables, and are fetched in a second request keyed by the page's link ids. The list renders immediately with metric cells in a skeleton state and fills them when the metrics response lands. This keeps the first paint independent of the analytics store and means an analytics slowdown degrades a number, not the page.
Metric requests are batched at up to 100 ids and cached client-side for 60 seconds.
12.10.3 Bulk Selection #
- Checkbox selects the row. Header checkbox selects the visible page.
- When a page is fully selected, a bar offers "Select all N links matching this filter", which switches the selection to a filter reference rather than an id list.
- The action bar shows the operation, the count, and for destructive operations a confirmation naming the count.
- Filter-based selection is capped at 10,000 (12.5.5).
12.10.4 Performance with Very Large Lists #
| Concern | Decision |
|---|---|
| Pagination | Keyset (cursor) only. No OFFSET. The cursor encodes the sort key and the id tiebreaker. |
| Total count | Not computed for the unfiltered list. The UI shows "25 of many" and an exact count only when a filter reduces the estimated set below 5,000, using a count(*) with a statement timeout of 300 ms that degrades to the estimate on timeout. |
| Rendering | Rows are virtualised above 100 visible rows, with a fixed row height so the scrollbar is honest. |
| Search | Debounced 250 ms, request-cancelling, minimum 2 characters. |
| Index coverage | Every sort/filter combination offered by the UI is backed by an index listed in Section 6.9; a combination without an index is not offered. |
| Target | List first byte < 200 ms p95 at 500,000 links in a workspace; this is a load-test scenario in Section 26.7. |
| Export | Any filtered list can be exported to CSV (the csv_export entitlement, Section 22.1.2), which runs as a worker job and emails a signed 24-hour link rather than streaming from the request. |
12.10.5 Empty States #
| Situation | UI |
|---|---|
| No links at all | Illustration, "Create your first link", a paste-a-URL field that creates in one step, and a secondary "Import from CSV". |
| No results for a filter | "No links match these filters", the active filters as removable chips, and "Clear all". |
| No results for a search | "No links match query", plus "Search archived too" if archived is currently excluded. |
| Trash empty | "Nothing in trash. Deleted links stay here for 30 days." |
| Plan cap reached | A persistent banner above the list: "You've used 25 of 25 links on Free", with "Upgrade" and "Archive some links". Creation controls are disabled with an accessible explanation, not silently hidden. |
12.11 Link-Level Error Codes #
Every code below is registered in the generated registry in Section 30.2 with exactly the status shown here.
| HTTP | Code | Meaning | Client remedy |
|---|---|---|---|
| 400 | link_slug_invalid_chars |
Slug contains characters outside [a-z0-9-] |
Correct the slug |
| 400 | link_slug_length |
Slug is empty or over 64 characters | Shorten |
| 400 | link_slug_invalid_format |
Leading/trailing hyphen, --, or a numeric-only slug over 12 digits |
Correct the format |
| 400 | link_slug_reserved |
Slug is a reserved system word on a system host | Use a different slug or a custom domain |
| 400 | link_slug_blocked |
Slug matches the blocklist | Choose another slug |
| 400 | link_slug_confusable |
Slug is confusable with a reserved or existing slug | Use one of the offered alternatives |
| 400 | link_destination_scheme_not_allowed |
Scheme outside the allow-list | Use https, http, mailto, tel or sms |
| 400 | link_destination_too_long |
URL or query string over the limit | Shorten the destination |
| 400 | link_destination_invalid |
Unparseable URL, control characters, or too many parameters | Correct the URL |
| 400 | link_field_too_long |
title over 120 or notes over 1,000 characters |
Shorten |
| 400 | import_missing_required_column |
CSV has no destination_url column |
Fix the header row |
| 400 | import_malformed_csv |
Unparseable CSV, inconsistent column count | Re-export the file |
| 403 | insufficient_role |
Viewer attempted a mutation | Ask an Admin |
| 403 | resource_not_granted |
Scoped member lacks a grant on this link or folder | Request a grant |
| 403 | plan_limit_reached |
Numeric or period cap: link cap, import row cap, fair-use creation ceiling | Upgrade or free capacity |
| 403 | plan_feature_unavailable |
Binary feature gate: password protection, UTM builder, scheduling, CSV export | Upgrade |
| 403 | email_verification_required |
Account not verified | Verify the email address |
| 403 | billing_write_blocked |
Past-due write block, from day 8 only (Section 22.7) | Settle the invoice |
| 404 | not_found |
No such link visible to this actor. Also the cross-workspace response, deliberately identical so the code cannot be used to prove a resource exists elsewhere. | — |
| 409 | link_slug_taken |
Slug already in use on this host | Choose another |
| 409 | link_slug_held |
Slug belongs to a soft-deleted link inside its 30-day window | Restore that link or wait |
| 409 | qr_slug_reserved |
Slug is permanently reserved by a QR code on this host or the system host | Choose another |
| 409 | link_slug_immutable_qr |
Slug change attempted on a QR-backed link | Create a new QR code |
| 409 | link_domain_immutable_qr |
Domain change attempted on a QR-backed link | Create a new QR code |
| 409 | link_has_qr |
Delete or archive attempted on a link pinned to a QR code | Leave it in place |
| 409 | link_in_active_experiment |
Destination edit while an experiment is running |
Stop the experiment first |
| 409 | import_already_committed |
Commit called twice on a completed import | Read the result |
| 410 | import_report_expired |
Validation report older than 24 hours | Re-upload the file |
| 413 | request_too_large |
Import file exceeds the plan's byte limit | Split the file |
| 422 | link_destination_private_address |
Destination resolves to a private, loopback, link-local or reserved address | Use a public destination |
| 422 | link_destination_unresolvable |
DNS lookup failed on create | Check the hostname, or create anyway |
| 422 | link_destination_unsafe |
Safe Browsing match | Use a different destination |
| 422 | link_destination_redirect_loop |
Self-referential or over-deep redirect chain | Point at a final destination |
| 422 | domain_not_active |
Chosen domain is not active |
Complete domain setup (Section 13) |
| 422 | folder_depth_exceeded |
More than 3 folder levels | Flatten the structure |
| 422 | tag_limit_reached |
20 tags on a link or 500 in a workspace | Remove tags first |
| 422 | deep_link_fallback_required |
Platform URL set without a web fallback | Provide web_fallback_url |
| 429 | rate_limited |
Creation or unlock rate limit exceeded | Honour Retry-After |
| 503 | slug_generation_exhausted |
10 consecutive slug collisions | Retry; an operator is paged automatically |
13. Custom Domains & TLS Provisioning #
A custom domain is what makes a short link branded. It is also the highest-support-cost feature in the product, because it depends on DNS configuration LinkHub does not control. This section is written so that a support agent can resolve any domain issue from the tables here alone.
13.1 The Domain Model #
13.1.1 Definition #
A custom domain is a hostname the workspace controls in DNS and delegates to LinkHub for serving. Once active, LinkHub answers HTTPS requests on that hostname and serves short-link redirects, QR resolutions, and — if enabled — public bio pages.
| Property | Decision |
|---|---|
| Ownership scope | A hostname may be active in exactly one workspace at a time, platform-wide. Enforced by a unique index on the normalised hostname. |
| Plan allowance | Per the custom_domains entitlement in Section 22.1.2: Free 0, Pro 1, Business 5. Exceeding returns 403 plan_limit_reached with details[].kind = "count". |
| Domains retained for QR continuity | Do not count against the allowance (13.9.4). |
| Hostname form | Punycode-normalised, lower-cased, no trailing dot, no port, no path, no scheme, no wildcard. Max 253 characters, each label max 63. |
| Public suffix guard | The hostname must be at least one label below a public suffix. co.uk is rejected; acme.co.uk is accepted. The public-suffix list ships with the deployment and is refreshed monthly by a worker job. |
| Reserved | Any hostname under the platform's own registrable domains is refused with 400 domain_reserved. |
13.1.2 Subdomain versus Apex #
| Type | Example | Routing record | Recommended |
|---|---|---|---|
| Subdomain | go.acme.com, links.acme.com |
CNAME to the platform CNAME target |
Yes. CNAME survives an IP change with no customer action. |
| Apex / root | acme.link |
A + AAAA to the published anycast addresses, or ALIAS/ANAME where the provider supports it |
Acceptable, with the caveat below. |
The apex caveat is stated at the point of entry, not buried: an apex domain pinned to A/AAAA records depends on published IP addresses. LinkHub commits to 90 days' notice before changing them, publishes them at a stable documented location, and alerts every affected workspace, but the customer must edit DNS when that happens. If the registrar supports ALIAS/ANAME, LinkHub recommends it and the onboarding flow offers it first for apex domains.
An apex domain is never recommended for a domain that will carry QR codes. The reason is in 13.9 and it is stated during onboarding.
13.1.3 What a Domain Serves #
Each domain carries a usage setting:
usage |
Root path / |
/{slug} |
/{handle} bio pages |
|---|---|---|---|
links (default) |
Per 13.8.4 | Short links and QR codes | Not served |
pages |
The workspace's designated primary bio page, or the root redirect | Not served | Served |
both |
Per 13.8.4 | Resolved first as a link or QR slug; on miss, as a bio page handle | Served |
Short links and QR codes are one namespace on every host: both are served from the bare /{slug} path by the same resolver, so a slug claimed by either blocks the other (12.2.4, 14.1.5). There is no QR-specific path prefix, no separate QR path and no second router.
With usage = 'both', slugs and handles additionally share that namespace. The creation path enforces it: creating a bio page handle that collides with an existing slug on the same domain returns 409 handle_conflicts_with_slug, and vice versa. Resolution order is slug-first because the redirect path is the latency-critical one.
Relationship to content:
- A link's
domain_idis set at creation and may be changed only whilehas_qr = false; moving a QR-backed link between domains would break printed media and returns 409link_domain_immutable_qr. - Deleting a domain never deletes a link or a QR code (13.9).
- Analytics are recorded against the link, not the domain, so a domain change does not fragment a link's history.
domain_idis available as a report filter.
13.1.4 Where the Domain Entity Is Defined #
The custom_domains table — every column, type, constraint, index and foreign key — is defined in Section 6.3.22, and Section 6 is the sole schema authority. The certificate table it references is Section 6.3.24 and the per-attempt verification history is Section 6.3.23. This section declares no columns; it specifies what the domain does.
The one piece of the storage definition that behaviour depends on directly is the state set, and it is fixed:
custom_domains.status ∈ { pending_dns, verifying, provisioning_tls, active, dns_failed, tls_failed, suspended }
Those seven values are the complete set. There is no eighth, no pending, no failed, and no separate boolean shadowing them. Their meanings, entry conditions and exits are the state machine in 13.4. Alongside the status, the row carries a machine-readable status_reason drawn from the code table in 13.10.1 plus a human sentence, and the last observed DNS answers used by the diagnostic in 13.6.2.
13.2 Onboarding Flow #
Permissions: Owner and Admin only. Editors and Viewers see the domain list read-only and receive 403 insufficient_role on any mutation.
13.2.1 Screen 1 — Enter the Domain #
A single input, placeholder go.acme.com. Client-side normalisation strips a pasted scheme, path, port and trailing dot and shows the normalised result beneath the field before submission.
Synchronous validations, in order, each with its own message:
| Check | Failure code |
|---|---|
| Parses as a hostname | 400 domain_invalid_hostname |
| Not a wildcard, not an IP literal | 400 domain_invalid_hostname |
| At least one label below a public suffix | 400 domain_is_public_suffix |
| Not a platform-owned hostname | 400 domain_reserved |
| Not already active in another workspace | 409 domain_already_claimed |
| Not already in this workspace | 409 domain_already_added |
| Workspace under its plan allowance | 403 plan_limit_reached |
domain_already_claimed deliberately does not reveal which workspace holds it. The message explains that the domain is in use elsewhere on LinkHub and directs the user to support with the domain name, where an ownership transfer is handled by a human after a TXT proof from both sides.
13.2.2 Screen 2 — Choose Usage #
Three cards: short links only, bio pages only, both. Explains the shared-namespace consequence of "both" in one sentence. Default is short links only. Changeable later at any time.
13.2.3 Screen 3 — DNS Records #
The screen shows every record the user must create, each as a three-column row (Type / Name / Value) with a per-field copy button and a "copy all as text" control. Values are shown exactly as the majority of registrars want them, with a note for the exceptions.
Case A — subdomain (go.acme.com):
| # | Type | Name | Value | TTL | Purpose |
|---|---|---|---|---|---|
| 1 | TXT |
_linkhub-challenge.go |
lh-verify-7K2M9QX4RBTV8NHC3JW6ZDPF5A |
300 | Ownership |
| 2 | CNAME |
go |
cname.linkhub.app |
300 | Routing |
Case B — apex with ALIAS/ANAME support (acme.link):
| # | Type | Name | Value | TTL | Purpose |
|---|---|---|---|---|---|
| 1 | TXT |
_linkhub-challenge |
lh-verify-… |
300 | Ownership |
| 2 | ALIAS or ANAME |
@ |
cname.linkhub.app |
300 | Routing (preferred) |
Case C — apex without ALIAS/ANAME support (acme.link):
| # | Type | Name | Value | TTL | Purpose |
|---|---|---|---|---|---|
| 1 | TXT |
_linkhub-challenge |
lh-verify-… |
300 | Ownership |
| 2 | A |
@ |
Published anycast IPv4 address 1 | 300 | Routing |
| 3 | A |
@ |
Published anycast IPv4 address 2 | 300 | Routing |
| 4 | AAAA |
@ |
Published anycast IPv6 address 1 | 300 | Routing |
| 5 | AAAA |
@ |
Published anycast IPv6 address 2 | 300 | Routing |
Optional but recommended, all cases:
| Type | Name | Value | Purpose |
|---|---|---|---|
CAA |
@ |
0 issue "letsencrypt.org" |
Permits the ACME CA to issue. Required only if the domain already has CAA records that exclude it. |
The anycast addresses are rendered from configuration at request time and are never hard-coded into the UI, so a change is a config deploy and not a code change.
Notes rendered alongside the table:
- Name-field conventions differ. Some registrars want the relative label (
go), some want the fully-qualified name (go.acme.com). Both forms are shown, with a hint reading "If your provider adds the domain for you, entergo. If it does not, entergo.acme.com." - TTL of 300 seconds is recommended during setup so mistakes are cheap to correct. Raising it after the domain is active is fine and the UI says so.
- A
CNAMEcannot coexist with any other record on the same name. If the subdomain already has anA,TXTorMXrecord, it must be removed first. The diagnostic in 13.6.2 detects this exact case.
13.2.4 Per-Provider Hints #
A collapsible list on the same screen. Selecting a provider swaps the record table's labels and shows the provider's quirk.
| Provider | Hint |
|---|---|
| Cloudflare | Set the proxy status to DNS only (grey cloud). Orange-cloud proxying terminates TLS at Cloudflare, breaks ACME HTTP-01 validation, and hides the real client IP from the country lookup. Cloudflare supports CNAME flattening at the apex, so a CNAME at @ is accepted and behaves like ALIAS. |
| GoDaddy | Enter the relative host in the Name field (go, or @ for the apex). GoDaddy does not support ALIAS/ANAME — use Case C for an apex. Changes can take up to 10 minutes to appear in GoDaddy's own UI. |
| Namecheap | Use Advanced DNS. The Host field takes the relative label. Namecheap's "URL Redirect Record" is not a substitute for a CNAME — do not use it. |
| Squarespace Domains (formerly Google Domains) | Custom records live under DNS → Custom records. Apex requires Case C. |
| Amazon Route 53 | Use an Alias record targeting the CNAME target for the apex; use a normal CNAME for subdomains. Alias records have no TTL field, which is expected. |
| Porkbun | ALIAS is supported at the apex. Enter the relative host. |
| Hover | Enter the relative label. No ALIAS support — use Case C at the apex. |
| DNSimple | Supports ALIAS. Enter the bare @ for the apex. |
| Gandi | Records are edited in the zone file editor; the trailing dot on the CNAME value is required (cname.linkhub.app.). |
| Netlify DNS | Supports NETLIFY/ALIAS-style apex records; a standard CNAME works for subdomains. |
| Vercel DNS | Add a CNAME for subdomains; apex uses A records (Case C). |
| IONOS | The subdomain must exist as a subdomain entry before a CNAME can be attached to it. |
| name.com | Enter the relative label; the apex is the empty host field, not @. |
| Bluehost / HostGator / cPanel Zone Editor | The Name field auto-appends the domain — enter go, not go.acme.com, or you will create go.acme.com.acme.com. This is the single most common support ticket and the diagnostic detects it explicitly. |
| Shopify-managed domains | Third-party DNS must be edited at the registrar, not in Shopify, unless the domain was bought through Shopify — in which case only subdomain CNAMEs are supported. |
Providers not listed get the generic instructions; the list is configuration and is extended without a code change.
13.2.5 Screen 4 — Verification (Live) #
Described in 13.6. The user may leave the page; progress continues server-side and an email is sent on success or on terminal failure.
13.2.6 Screen 5 — Done #
Shows the live hostname, a "create your first link on this domain" action, the option to set it as the workspace default domain, and the settings from 13.8 (HSTS, www redirect, root behaviour).
13.3 Ownership Verification #
13.3.1 The TXT Challenge #
| Property | Value |
|---|---|
| Record name | _linkhub-challenge.<domain> — for go.acme.com, the FQDN is _linkhub-challenge.go.acme.com |
| Record type | TXT |
| Value | lh-verify- followed by 26 characters from the Crockford base32 alphabet (excluding i, l, o, u), from a CSPRNG — roughly 130 bits of entropy |
| Lifetime | 7 days from issue |
| Regeneration | The user may regenerate at any time; the previous token is invalidated immediately. Rate limited to 5 regenerations per domain per hour. |
| Matching | Case-sensitive exact match against any TXT value at that name. Other TXT records at the same name are ignored, not treated as a failure. Surrounding quotes added by the registrar are stripped before comparison. |
| Persistence | The record must remain in place. It is re-checked hourly along with routing; its removal does not by itself deactivate a domain, but it is surfaced as a warning because it will be required again on any re-verification. |
| Single use | A token verifies exactly one hostname in one workspace. |
An expired token puts the domain in dns_failed with status_reason = verification_token_expired and offers a one-click reissue that returns the domain to pending_dns.
13.3.2 Why Ownership Is Verified Separately from Routing #
Routing (the CNAME or A records) proves that traffic currently arrives at LinkHub. Ownership (the TXT record) proves that the person configuring the domain controls the zone. These are different claims and conflating them is a documented class of takeover vulnerability:
- Dangling-record takeover. If a domain's CNAME still points at LinkHub after the customer stops using LinkHub, anyone who can add that hostname to their own workspace would inherit live traffic. Requiring a fresh TXT proof under the customer's zone makes that impossible — the attacker cannot write into a zone they do not control.
- Staged cutover. A customer migrating from another provider needs to prove ownership and provision a certificate before moving production traffic. Separating the checks lets LinkHub verify ownership and complete DNS-01 issuance while the hostname still resolves elsewhere, so the cutover is a single record change with zero downtime.
- Shared infrastructure. The CNAME target is the same for every customer. It carries no identity. The TXT record is the only per-customer secret in the flow.
Consequently: verification is required before provisioning_tls, and routing correctness is required before active.
13.4 The Domain State Machine #
The seven values below are custom_domains.status and nothing else names them.
| Status | Meaning | Serving? | Entered by | Exits to |
|---|---|---|---|---|
pending_dns |
Records issued, nothing observed yet | No | Domain created; token reissued; user retry | verifying, dns_failed |
verifying |
TXT observed and matched; routing being confirmed | No | TXT match | provisioning_tls, dns_failed |
provisioning_tls |
Ownership and routing confirmed; ACME order in flight | No (HTTP-01 challenge path only) | Routing match | active, tls_failed |
active |
Certificate installed, serving traffic | Yes | Certificate installed and loaded at the edge | suspended, tls_failed, dns_failed |
dns_failed |
Records not observed within the window, or an active domain stopped resolving to LinkHub for 24 h | No (or degraded) | Timeout / sustained mismatch | pending_dns on retry |
tls_failed |
Certificate could not be issued or renewed | No (or serving on the previous certificate until it expires) | ACME terminal failure | provisioning_tls on retry |
suspended |
Administratively stopped: plan downgrade below allowance, abuse action, or retained for QR continuity | Depends — see below | Billing, trust & safety, or 13.9.4 | active on resolution |
Transitions:
| # | From → To | Trigger | Timeout / condition |
|---|---|---|---|
| D1 | pending_dns → verifying |
TXT value matches | — |
| D2 | verifying → provisioning_tls |
Routing record matches expectation for the domain type | — |
| D3 | provisioning_tls → active |
Certificate issued, stored, and confirmed loaded by at least two edge nodes | — |
| D4 | pending_dns/verifying → dns_failed |
Check schedule exhausted | 72 hours from creation |
| D5 | provisioning_tls → tls_failed |
ACME order failed after retries | 6 attempts over 6 hours |
| D6 | active → dns_failed |
Routing has not matched for 24 consecutive hourly checks | 24 hours |
| D7 | active → tls_failed |
Renewal failed and fewer than 7 days of validity remain | Paging threshold (13.7.4) |
| D8 | dns_failed/tls_failed → pending_dns/provisioning_tls |
User clicks Retry, or fixes DNS and the hourly probe succeeds | Manual or automatic |
| D9 | active → suspended |
Plan downgrade below allowance, abuse action, or domain removal with QR retention | — |
| D10 | suspended → active |
Upgrade, abuse resolution, or re-add | — |
Serving behaviour in suspended depends on the cause and is the point where the QR rule overrides everything:
| Suspension cause | Links on the domain | QR codes on the domain |
|---|---|---|
| Plan downgrade | Serve for 90 days, then the workspace branded unavailable page (rung 3, workspace_unavailable, 200). Never 404. |
Always resolve. Forever. |
| Abuse / trust & safety | Serve the abuse notice page (200) | Always resolve, through the fallback chain in 14.8.2 |
| Retained for QR continuity (13.9.4) | Branded "moved" page (200) | Always resolve |
Every status change writes an audit entry drawn from the canonical catalogue in Section 8.9.1 — domain.added, domain.verified, domain.tls_issued, domain.tls_failed, domain.removed — and, for the terminal statuses, sends an email to the Owner and all Admins.
13.5 Verification Job Schedule and Backoff #
The domain-verify queue drives all checks.
| Phase | Cadence | Duration | Ends with |
|---|---|---|---|
| Fast | every 30 seconds | first 15 minutes | Advance, or move to Slow |
| Slow | every 5 minutes | up to 72 hours from creation | Advance, or dns_failed |
| Steady (active domains) | every 60 minutes | indefinitely | Detects drift, drives D6 |
| Post-failure | every 6 hours | 7 days after dns_failed |
Auto-recovers a domain the user fixed without clicking Retry |
Job mechanics:
- Jitter. Each scheduled check is offset by a random 0–10% of its interval so 10,000 domains do not stampede the resolvers on the minute.
- Locking. One in-flight check per domain, enforced by a Redis lock with a 30-second TTL. A duplicate job exits immediately.
- Resolver strategy. Every check queries (a) the domain's authoritative nameservers directly, obtained from an
NSlookup on the registrable domain, and (b) three independent public recursive resolvers. Quorum is 2 of 3 public resolvers agreeing with the authoritative answer. Authoritative-only success is recorded as "propagating" and does not advance the status; public-resolver-only success is treated as a match, because that is what real visitors experience. - Transport. UDP first, automatic TCP retry on a truncated response — long TXT record sets are a common truncation cause.
- No caching. Checks bypass any local DNS cache and set the recursion-desired flag appropriately per target. Observed TTLs are recorded to drive the propagation estimate in 13.6.3.
- Backoff on resolver error. A resolver timeout or SERVFAIL is not a mismatch. It increments the attempt counter without changing status; three consecutive resolver-level failures escalate to the operations channel as a resolver-health signal, not as a customer-facing domain failure.
- Idempotency. Every check writes the last-checked timestamp, the observed records and the next-check time in one update, so the schedule is derived from the row and survives a worker restart.
13.6 Live Status UI #
13.6.1 Per-Step Indicators #
Four steps, always all four visible, each with one of five visual states — pending, checking, success, warning, failure:
| Step | Success criterion | Typical duration |
|---|---|---|
| 1. Ownership (TXT) | _linkhub-challenge.<domain> returns the issued token |
1–10 minutes |
| 2. Routing (CNAME / A / AAAA / ALIAS) | Records match the expected values under quorum | 1–30 minutes |
| 3. Certificate | ACME order completed and certificate stored | 20–90 seconds after step 2 |
| 4. Serving | Two or more edge nodes confirm the certificate is loaded and a synthetic HTTPS request to the hostname returns the expected response | < 60 seconds |
The panel polls every 5 seconds while the tab is focused and every 30 seconds when it is not, and stops polling entirely once the domain reaches a terminal status. Each step exposes its last-checked timestamp.
13.6.2 The "What We See vs What We Expect" Diagnostic #
The single most valuable element on the screen. For each expected record it renders a comparison table from the last observed answers:
| Record | Expected | What we currently see | Verdict |
|---|---|---|---|
TXT _linkhub-challenge.go.acme.com |
lh-verify-7K2M9QX4RBTV8NHC3JW6ZDPF5A |
(no record found) | ✗ Not found |
CNAME go.acme.com |
cname.linkhub.app |
acme.hosting-provider.net |
✗ Points elsewhere |
Alongside the table, the diagnostic runs a fixed set of pattern detectors and emits a specific, actionable sentence for each. These are the cases that generate almost all support volume:
| Detected pattern | Message shown |
|---|---|
Record found at go.acme.com.acme.com |
"Your provider appears to have appended the domain automatically. Change the record name from go.acme.com to just go." |
CNAME present alongside A, MX or TXT at the same name |
"A CNAME cannot coexist with other records on the same name. Remove the other records at go.acme.com, or use a different subdomain." |
| Response served by a known reverse proxy signature | "This hostname is being proxied. Set your DNS record to DNS-only so requests reach LinkHub directly — a proxy in front of us breaks certificate issuance." |
A record at a subdomain instead of a CNAME |
"You have an A record where a CNAME is expected. An A record will break when our addresses change. Replace it with a CNAME to cname.linkhub.app." |
| Value has a missing or extra trailing dot | "Some providers require a trailing dot on the value. Try cname.linkhub.app. instead." |
| Authoritative answer matches but public resolvers do not | "Your records are correct at your DNS provider and are still propagating to the rest of the internet. Nothing more to do." |
NXDOMAIN on the whole zone |
"We cannot find DNS for acme.com at all. Check the domain is registered and its nameservers are set." |
| CAA records present without an entry for the CA | "Your CAA records prevent our certificate authority from issuing. Add 0 issue \"letsencrypt.org\"." |
CNAME at an apex where the provider does not flatten |
"A CNAME at the root of a domain is not valid DNS. Use ALIAS/ANAME if your provider offers it, or the A and AAAA records below." |
| DNSSEC validation failure | "Your zone's DNSSEC signatures are not validating, so resolvers are discarding the answer. Contact your DNS provider." |
Every diagnostic string is copyable, and a "copy diagnostic report" button produces a plain-text block (hostname, expected records, observed records, timestamps, resolver used, status history) that a user can paste to their DNS provider or to LinkHub support. The report never contains the verification token in full — it is truncated to its first 8 characters.
13.6.3 Propagation Estimate #
Beneath the diagnostic: "DNS changes usually take 5–30 minutes to reach everyone, but can take up to 48 hours depending on the TTL your provider used. We keep checking automatically — you can close this page."
When an observed TTL is available, the estimate is specific: "Your provider is using a TTL of 3600 seconds, so a change you just made may take up to 1 hour to be visible everywhere." No countdown timer is shown, because DNS propagation is not a countdown and a false timer produces exactly the support ticket it was meant to prevent.
13.6.4 Manual Recheck #
A "Check now" button that enqueues an immediate check at the front of the queue.
| Limit | Value | Response on exceed |
|---|---|---|
| Per domain | 10 per hour | 429 rate_limited, button disabled with a countdown to the next allowance |
| Per workspace | 60 per hour | 429 rate_limited |
| Per workspace across all DNS operations | 200 per hour | 429 rate_limited |
These are resource-specific limits; the consolidated view of every limit in the product is Section 23.9, which reproduces them.
The button is disabled with an accessible explanation for 10 seconds after each press, so a double click does not consume two allowances.
13.7 TLS Provisioning #
13.7.1 Challenge Strategy #
| Order | Method | When used | Requirement |
|---|---|---|---|
| 1 | HTTP-01 | Default for every domain | The hostname must already route to LinkHub, so the edge can answer /.well-known/acme-challenge/<token> |
| 2 | DNS-01 | Fallback when HTTP-01 fails twice, and the required path for pre-cutover issuance and for any wildcard | A CNAME at _acme-challenge.<domain> pointing to <domain>.dns.linkhub.app, which delegates the challenge record to LinkHub |
The DNS-01 delegation CNAME is offered as an optional record during onboarding, described as "add this if you want us to renew certificates even if your domain temporarily stops pointing at us". Delegating the challenge subdomain grants LinkHub no ability to affect any other record in the zone, and the UI says so.
/.well-known/acme-challenge/* is reserved on every domain and cannot be claimed as a slug or a bio-page handle. It is served before slug resolution, returns text/plain, and is never redirected, never rate limited, and never subject to the password interstitial in 12.7.3.
13.7.2 Certificate Parameters #
| Property | Decision |
|---|---|
| CA | Let's Encrypt, with the ACME account key held in the managed secret store (Section 27.5) |
| Key type | ECDSA P-256, with an RSA-2048 companion certificate issued for the same hostname to serve legacy clients via SNI-based selection |
| SAN set | The hostname, plus www.<hostname> when the domain is an apex and a www redirect is configured and www resolves to LinkHub |
| Validity | 90 days (CA-determined) |
| OCSP | Stapling enabled, with a 4-hour refresh and a soft-fail on staple unavailability |
13.7.3 Certificate Storage #
| Aspect | Decision |
|---|---|
| Private keys | Encrypted with envelope encryption: a per-certificate data key wrapped by a KMS master key. Only the wrapped key and ciphertext are in PostgreSQL (Section 6.3.24). |
| Plaintext keys | Exist only in the edge process's memory, decrypted at load, never written to disk, never logged, never included in a crash dump (core dumps disabled on the edge image). |
| Distribution | Edge nodes fetch and cache certificates in memory at startup and on a pub/sub invalidation. A cold node that receives SNI for an unknown hostname performs a single on-demand fetch with a 200 ms budget and a negative cache of 30 seconds. |
| Rotation | Renewal writes a new row; the old certificate is retained for 7 days after replacement, then purged, so a bad rollout can be reverted. |
| Access | The certificate table is readable only by the edge and worker service roles. No dashboard surface exposes a private key, ever. |
13.7.4 Renewal Schedule and Thresholds #
| Days of validity remaining | Action |
|---|---|
| 30 | Renewal attempt begins. Retries at 6-hour intervals. |
| 21 | Retry cadence increases to hourly. |
| 14 | Alert. Email to the workspace Owner and Admins with the specific failure reason and the fix; a warning banner appears on the domain. Operations notified on the low-priority channel. |
| 7 | Page. On-call is paged per the rotation and severity definitions in Section 25.9. The domain moves to tls_failed. The customer email escalates in tone and names the exact record to fix. |
| 0 | The certificate expires. The domain stops serving HTTPS. Links on it fail at the TLS layer, which LinkHub cannot intercept. QR codes remain resolvable on the system-host mirror (14.8.4), which is why that mirror exists. |
Renewals are jittered across a 48-hour window to avoid a synchronised wave, and are rate-limited against the CA's issuance limits with a token bucket held in Redis.
13.7.5 TLS Failure Catalogue #
| Failure | Detection | Customer message | Operator action |
|---|---|---|---|
| CAA record blocks issuance | ACME error caa / rejectedIdentifier |
"Your domain's CAA records don't allow our certificate authority to issue a certificate. Add a CAA record: 0 issue \"letsencrypt.org\"." |
Confirm via CAA lookup; if the customer cannot change it, offer DNS-01 with an alternate CA configured in the ACME client. |
| CA rate limit hit (certificates per registered domain, duplicate certificate, or failed-validation limit) | ACME error rateLimited |
"We've hit a temporary limit with our certificate authority for this domain. We'll retry automatically; no action needed." | Inspect the issuance bucket. If a retry loop caused it, fix the loop before it re-triggers. Never retry manually — that extends the window. |
| Propagation not complete | HTTP-01 challenge 404s, or DNS-01 TXT not visible at quorum | "Your DNS changes haven't reached everywhere yet. We're retrying." | None; the schedule handles it. Escalate only past 6 hours. |
| Domain pointed away mid-renewal | HTTP-01 challenge answered by a foreign server, or routing check mismatch | "Your domain is no longer pointing at LinkHub, so we can't renew its certificate. Restore the CNAME, or add the challenge-delegation record so we can renew regardless." | Switch the order to DNS-01 if the delegation record exists. If it does not, the certificate will lapse — verify the QR mirror is live for every code on the domain and notify the customer explicitly. |
| Proxy in front terminating TLS | Challenge response contains a proxy signature | "This hostname is proxied by another service. Set it to DNS-only so we can complete validation." | Confirm with a direct connection test. |
| Nameserver change mid-order | NS set changed between check and order |
"Your DNS provider changed while we were setting things up. We're starting again." | Automatic: cancel the order, re-run verification from verifying. |
| ACME account issue (key rollover, ToS update, nonce failures) | ACME 400-class on the account | Not shown; internal | Runbook in Section 25.8: refresh nonces, accept updated terms, roll the account key. |
| Certificate loaded but not served | Synthetic HTTPS probe fails after storage | "We're finishing the last step." | Check edge node certificate cache; force an invalidation; if two nodes disagree, roll the edge deployment. |
Every entry above has a matching runbook stanza in Section 25.8 and a synthetic monitor.
13.8 Serving #
13.8.1 SNI #
Certificate selection is by SNI on every TLS handshake. A connection with no SNI, or with an SNI for an unknown hostname, is served the default platform certificate and receives a 421 Misdirected Request with a plain-text explanation — never another customer's certificate and never a fallback into a different workspace's namespace.
Hostname lookup at the edge is an in-memory map refreshed by pub/sub over the canonical host key in Section 4's Redis catalogue, with a 30-second negative cache, so an unknown-hostname flood cannot generate database load.
13.8.2 HSTS Policy #
HSTS is enabled cautiously, because it is one of the few settings a customer can use to make their own domain unreachable for months.
hsts_policy |
Strict-Transport-Security header |
When |
|---|---|---|
off |
Not sent | First 24 hours after activation, and any time the customer disables it |
ramping |
max-age=300 |
Automatically, from 24 hours after activation |
on |
max-age=15768000 (6 months) |
Automatically, after 7 consecutive days in active with no TLS incident |
preload |
max-age=63072000; includeSubDomains; preload |
Opt-in only, never automatic |
includeSubDomains is off in every automatic tier. It is a customer choice with its own confirmation, because it silently forces HTTPS on sibling subdomains LinkHub does not serve and cannot fix.
Preload is deliberately never automatic. The UI states the reason before the toggle: browser preload lists are compiled into browser binaries, removal takes months, and once acme.link is preloaded, every subdomain of it must serve valid HTTPS forever — including ones the customer runs elsewhere. Enabling preload requires typing the hostname to confirm, is restricted to the Owner, writes an audit entry, and shows a permanent notice on the domain page describing how to request removal. LinkHub never submits a domain to a preload list on the customer's behalf; it only emits the header the customer asked for.
13.8.3 Apex and www #
www_redirect is a customer choice with three values:
| Value | Behaviour |
|---|---|
none (default) |
Only the configured hostname is served. |
apex_to_www |
acme.link issues a 302 to https://www.acme.link preserving path and query. Requires www.acme.link to resolve to LinkHub; the UI shows the extra CNAME and verifies it. |
www_to_apex |
The reverse, also 302. |
The redirect is 302, not 301, for the same reason every destination redirect is 302: the setting is changeable, and a cached permanent redirect would outlive the change and could not be recalled from a visitor's browser. Both hostnames are covered by the same certificate when the www variant resolves to LinkHub.
13.8.4 Root-Path Behaviour #
For a domain with usage = 'links', GET / has four configurable behaviours:
| Root mode | Response |
|---|---|
redirect (default) |
302 to the configured URL, which defaults to the workspace's primary bio page if one exists, else https://<registrable domain of the custom domain> — i.e. go.acme.com/ sends to https://acme.com |
page |
Renders a designated bio page at the root (200) |
branded_notice |
A 200 branded page: "This is a link-shortening domain for Acme. There's nothing at this address." |
not_found |
404 with the branded not-found page |
Whatever the mode, the root path never lists links, never exposes a directory, never returns a server default page, and never leaks the workspace name unless the mode explicitly does so.
Additional fixed behaviour on every custom domain:
| Path | Response |
|---|---|
/robots.txt |
200, User-agent: * / Disallow: / for usage = 'links'; a sitemap-referencing policy for pages and both |
/favicon.ico |
The workspace's favicon, or the platform default |
/.well-known/acme-challenge/* |
13.7.1 |
/.well-known/apple-app-site-association, /.well-known/assetlinks.json |
12.7.4 |
/sitemap.xml |
404 for links; generated for pages and both |
/{slug} |
Short-link or QR resolution — one namespace, one resolver, no prefix (13.1.3) |
| Any unmatched path | Slug resolution → bio-page handle resolution (if enabled) → 404 branded page, unless the path matches a permanent QR reservation, in which case 14.8.2 applies and the response is 200 or 302 |
13.8.5 The One Permanent Redirect: HTTP → HTTPS #
A plain-HTTP request to any LinkHub-served hostname is answered with 301 to the identical URL on https, before routing, before slug resolution, and before any workspace lookup.
This is the only 301 in the product. It is permanent because the URL it points at is the same URL — only the transport changes; it is cacheable, which saves a round-trip on the highest-traffic surface in the system; and it pairs correctly with the HSTS policy in 13.8.2, which is what makes the upgrade stick after the first visit. This is a transport upgrade to the identical URL, not a destination redirect; the never-301 rule in Section 12 governs destinations, whose targets are editable. The launch-checklist gate in Section 28.3 asserts the absence of 301 and 308 on destination-resolution paths only, and exempts this upgrade explicitly.
13.9 Removing a Domain #
This subsection is the one where a careless implementation destroys printed material. Read it as a specification, not as guidance.
13.9.1 The Confirmation Flow #
Removal is Owner or Admin only. It is a three-step flow and cannot be triggered from a bulk action or from the API without the explicit body flag below.
Step 1 — Impact summary. Computed live, never cached:
Removing
go.acme.comwill affect: • 1,284 short links — they will keep working atgo.linkhub.app/<slug>but the branded URL will stop working the moment DNS stops pointing at us. • 17 QR codes — read the next screen carefully. • 2 bio pages — they will move tolinkhub.app/<handle>. • The TLS certificate for this domain will not be renewed after removal unless codes require it.
Step 2 — The QR screen. Shown only when at least one QR code exists on the domain, and it cannot be skipped:
17 QR codes are printed with this domain in them.
A printed QR code contains a fixed URL:
https://go.acme.com/mk4t2wq. We cannot change what is already printed.What we guarantee: every one of these codes also resolves at
https://go.linkhub.app/mk4t2wq, and always will. That slug is reserved permanently and can never be reassigned to anyone.What we cannot guarantee: if you delete the DNS record for
go.acme.com, the printed URL will fail before it ever reaches us — the visitor's browser will not find the host at all. That is outside our control.What to do: leave the
CNAMEforgo.acme.comin place. We will keep answering it, keep renewing its certificate, and keep resolving these 17 codes, at no charge and with no effect on your plan's domain allowance.Affected codes: Packaging Insert v3, Trade Show Banner, … [full list, exportable as CSV]
Step 3 — Typed confirmation. The user types the hostname exactly. A checkbox — "I understand that 17 printed QR codes depend on this domain's DNS record remaining in place" — must be ticked. Both are required; neither is pre-filled.
API equivalent: DELETE /v1/domains/{id} requires ?confirm_hostname=go.acme.com. When QR codes are attached it additionally requires ?acknowledge_qr_impact=true; without it the call returns 409 domain_removal_requires_qr_acknowledgement with the code count and ids in details.
13.9.2 Consequences by Resource Type #
| Resource | On the removed domain | Immediately after removal |
|---|---|---|
| Active short link | Continues to resolve on the domain while it is retained (13.9.4) | Also resolves at go.linkhub.app/<slug>; the dashboard shows the system URL as primary and the custom URL as "legacy" |
| Scheduled / paused / expired link | Same, following its own state rules | Same |
| Archived link | Branded unavailable page (200) | Same |
| Link pinned to a QR code | Never archived, never dropped, never counted against the cap (12.9.6) | Same |
| Bio page | Served at its handle on the system host | The custom-domain URL 302s to the system URL while the domain is retained |
| QR code | Resolves. Always. On both hosts. | Resolves. Always. On both hosts. |
| Analytics | Unaffected; history is keyed to the link, not the domain | domain_id remains as a historical filter value |
| Certificate | Retained and renewed while the domain is retained; otherwise allowed to lapse at its natural expiry | — |
| Plan allowance | Freed immediately | Retained domains do not consume allowance |
13.9.3 Slug Collisions on the System Host #
A link's slug is unique per host, so moving 1,284 links onto the shared system host could collide. This is solved by never moving anything at removal time:
Every QR slug is reserved on the system host at creation time, not at removal time. When a link is created on go.acme.com with slug spring-24, a mirror reservation for spring-24 on the system host is written in the same transaction if and only if the link is or becomes QR-backed. For non-QR links, the mirror is allocated lazily at removal time and a collision produces a new system slug, recorded as an alias to the link and surfaced in the removal report as "this link's fallback URL is go.linkhub.app/spring-24-a7k".
For QR-backed links the eager reservation makes a collision impossible by construction, which is the entire reason it is eager. See 14.2.4 and 14.8.4.
13.9.4 The Permanent Redirect-of-Record #
When a removed domain has one or more QR codes attached, the domain is not deleted. It transitions to suspended, flagged as retained for printed codes, with the following properties, which are permanent:
| Property | Value |
|---|---|
| Counts against the plan's domain allowance | No |
| Billable | No — free forever, on every plan including Free |
| Certificate renewal | Continues on the normal schedule for as long as DNS points at LinkHub |
| Editable by the workspace | No. It appears in a "Retained for printed codes" section of the domain list, read-only, with an explanation. |
| New links may be created on it | No — 422 domain_retained_read_only |
| QR slugs on it | Resolve through the 14.8.2 fallback chain, forever |
| Non-QR slugs on it | Serve a branded "this link has moved" page (200) with a link to the system URL, for 90 days; then the branded landing page (200) |
| Re-adding the hostname | Only the original workspace may re-add it, restoring it to active. Another workspace attempting it gets domain_already_claimed and a support path that requires the original workspace's consent. This prevents a printed-code takeover through domain recycling. |
| Fully releasing it | Requires a support request, an explicit acknowledgement that printed codes on that hostname will stop resolving at their printed URL, and a 30-day cooling period. It is never self-service. |
The workspace is emailed once at removal and once again 30 days later, restating that the CNAME should remain in place and that leaving it costs nothing.
If DNS is nonetheless removed by the customer, LinkHub records the routing check going stale and marks every affected QR code with a printed_url_unreachable warning in the dashboard, alongside the system-host URL that still works. The product is honest about the limit of its guarantee: LinkHub guarantees the slug resolves forever on infrastructure it controls; it cannot answer for a hostname whose DNS the customer has pointed elsewhere. This sentence appears verbatim in the UI.
13.9.5 Removal Cooldown #
For 30 days after removal, a retained or released hostname can be restored to active by the original workspace with a single click and no re-verification, provided the TXT record is still present. After 30 days the token has expired and the standard onboarding flow applies.
13.10 Domain Error Codes and Support Troubleshooting #
13.10.1 Error Codes #
| HTTP | Code | Meaning |
|---|---|---|
| 400 | domain_invalid_hostname |
Not a parseable hostname, or contains a scheme, path, port or wildcard |
| 400 | domain_is_public_suffix |
The hostname is a public suffix, not a registrable domain |
| 400 | domain_reserved |
The hostname is under a platform-owned domain |
| 403 | insufficient_role |
Editor or Viewer attempted a domain mutation |
| 403 | plan_limit_reached |
Domain allowance exhausted (details[].kind = "count") |
| 404 | not_found |
No such domain visible to this actor; also the cross-workspace response |
| 409 | domain_already_added |
Already present in this workspace |
| 409 | domain_already_claimed |
Active in another workspace |
| 409 | domain_removal_requires_qr_acknowledgement |
Removal attempted without the QR acknowledgement flag |
| 409 | handle_conflicts_with_slug |
Bio-page handle collides with a slug on a shared-namespace domain |
| 409 | link_domain_immutable_qr |
Attempt to move a QR-backed link between domains |
| 422 | domain_not_active |
Operation requires an active domain |
| 422 | domain_retained_read_only |
Mutation attempted on a domain retained for printed codes |
| 422 | domain_verification_pending |
TLS or serving operation requested before verification completed |
| 422 | verification_token_expired |
The 7-day token lapsed |
| 429 | rate_limited |
Recheck or token-regeneration limit exceeded |
| 502 | dns_resolver_unavailable |
All configured resolvers failed; the check is retried automatically |
| 503 | acme_unavailable |
The CA is unreachable; issuance is retried automatically |
13.10.2 Support Troubleshooting Table #
| Symptom | Most likely cause | First check | Fix |
|---|---|---|---|
Stuck in pending_dns past 30 minutes |
TXT record name has the domain appended twice | Diagnostic panel, row 1 | Change record name to the relative label |
| TXT verified, routing never matches | Record is proxied, or is an A record instead of CNAME |
Diagnostic panel, row 2 | Set DNS-only; replace A with CNAME |
dns_failed after 72 hours |
Records never created, or created in the wrong zone (registrar vs hosting provider) | Which nameservers are authoritative for the zone | Edit DNS where the NS records actually point |
| Certificate stuck, HTTP-01 failing | Another service answers the hostname; or a redirect on /.well-known/ |
Fetch the challenge path directly | Remove the proxy or the catch-all redirect; or add the DNS-01 delegation record |
tls_failed with a CAA error |
CAA excludes the CA | CAA lookup on the registrable domain | Add 0 issue "letsencrypt.org" |
Site works on www but not apex |
ALIAS/ANAME missing; provider does not support CNAME at apex | Record type at @ |
Use A/AAAA (Case C) or move to a subdomain |
| Intermittent failures | Only some resolvers see the record; partial propagation | Quorum result in the diagnostic | Wait; confirm TTL |
| Works in browser, fails in an app | HSTS includeSubDomains from a sibling host, or an old cached certificate |
HSTS header and certificate chain | Adjust HSTS; force certificate reload |
| Links dead after the customer moved DNS away | Domain retained but no longer routed | Staleness of the last successful routing check | Restore the CNAME; meanwhile give the customer the system-host URLs |
| Customer says a printed QR "stopped working" | Almost always the case above | Whether the QR resolves on the system host | It will. Give them the system URL immediately, then fix DNS. Never tell a customer a printed code is lost — it is not. |
14. Dynamic QR Codes #
A dynamic QR code is the only LinkHub artefact that gets printed onto physical matter and then leaves the customer's control. Everything in this section follows from one consequence of that fact: the code must still work in ten years, whatever has happened to the account. Section 14.5 specifies how LinkHub proves a code is scannable before it is exported, and Section 14.8 specifies the permanence guarantee that makes it safe to print.
14.1 The Model #
14.1.1 Every QR Code Is Backed by a Short Link #
A QR code encodes a URL. That URL is a LinkHub short URL. Therefore:
qr_codes ──► the backing link ──► destination
│ │
│ └── slug, domain, schedule, rules, password, UTM,
│ targeting, experiment binding — all shared
│
└── styling, versions, exports, print metadata, validation resultsA scan is a resolution of the backing link, recorded as a click with source = 'qr'. There is no separate scan pipeline, no separate counter and no separate table. Section 17 ingests one event type.
14.1.2 Consequences for Analytics #
| Consequence | Detail |
|---|---|
| One funnel | Scans and clicks share dimensions (country, region, device, OS family, browser family, referrer, UTM, variant) and appear in the same reports, filterable by source. |
| Attribution needs a dedicated slug | A slug can only be attributed to scanning if nothing else publishes it. LinkHub therefore mints a dedicated slug per QR code by default (14.2.3). Reusing an existing published slug is possible but downgrades source to unknown for that link, and the UI says so before the choice is made. |
| Version attribution | Each click event carries the QR version id, so re-styling a code produces a measurable before/after without changing the destination. |
| Fallback attribution | Each click event carries fallback_stage (14.8.2), so a workspace can see that scans are still arriving while a code sits on rung 3 — which is exactly the signal that tells them to fix it. |
| Experiments and rules apply | Because a scan is a click, targeting rules (Section 15.5) and experiments (Section 16) work identically. source = 'qr' is itself a targetable condition. |
| Rollups | source is a rollup dimension, so scan-versus-click splits are available at the same reach as every other chart. |
14.1.3 Consequences for Editing #
| Action on the backing link | Effect on the QR code |
|---|---|
| Change destination | Changes where the printed code sends people, immediately. Gated by 14.3. |
| Change slug | Refused (409 link_slug_immutable_qr). The slug is inside the printed symbol. |
| Change domain | Refused (409 link_domain_immutable_qr). The hostname is inside the printed symbol. |
| Pause | The code resolves to the paused fallback URL (rung 2), or rung 3 if none is set. |
| Schedule / expire | The code resolves to the scheduled or expired page with 200. Never a 404. |
| Add a password | Allowed, with a warning that this is a common cause of abandoned scans. The interstitial is a 200 (12.7.3). |
| Delete the link | Refused (409 link_has_qr). |
| Archive the link | Refused (409 link_has_qr). The link is pinned (12.9.6). |
14.1.4 Entities and Where They Are Defined #
Section 6 is the sole schema authority for all four tables below: qr_codes (6.3.37), qr_code_versions (6.3.38), qr_slug_reservations (6.3.39) and qr_render_artifacts (6.3.40). Nothing here declares a column or a type. What follows is what each entity means.
qr_codes — one row per physical code. It carries the internal name (required: an unnamed code is unmanageable at scale), the pointer to its current version, the print metadata a manager needs (batch label, declared print date, declared quantity) and the paused fallback URL that serves rung 2. Its status never stops resolution. Soft-deleting it removes the code from the dashboard and nothing else.
qr_code_versions — immutable and append-only. One row per rendered design: the exact payload encoded in the symbol, the error-correction level actually used after any auto-escalation, the symbol version and module count, the complete style snapshot, and the validation evidence from 14.5.7. Versions are never edited and never deleted while the parent code exists, because the destination and design history of a printed code is evidence, not convenience.
qr_render_artifacts — one row per generated file (SVG, PNG at 300 or 600 DPI, PDF, EPS) with its checksum, so a re-render is idempotent and a print vendor's copy can be verified byte-for-byte against what was validated.
qr_slug_reservations — the permanence table. It has no soft-delete column, no delete path in the application, a BEFORE DELETE trigger that raises unconditionally, and DELETE revoked from every application role including the retention worker. Its unique index on (host, slug) is not partial and carries no deleted predicate, which is the single mechanism that makes recycling impossible. Each row records the host, the slug, the last known workspace and code, the frozen last-known destination and fallback URL used when no live row remains, the memorial display name, and whether an erasure request has stripped that name. On the system-host mirror row it also records which custom host it mirrors.
14.1.5 The Public URL Shape — Decided Once, Because Printing Is Irreversible #
A QR code's public URL is the bare https://{host}/{slug}. There is no path prefix of any kind and no QR-specific route anywhere in the product. A QR code and a short link are the same URL shape, served by the same resolver, on the same host.
This is decided here, permanently, for two reasons that both matter more after the first print run than before it:
- A shorter payload is a more scannable symbol. Every character in the encoded URL costs modules. Dropping a three-character prefix typically keeps a code at a lower QR symbol version, which means fewer, physically larger modules at the same printed width — and module size is the dominant variable in whether a code decodes at 2 cm on a product label or under supermarket lighting. The 7-character generated slug (12.2.3) exists for the same reason, and the two decisions compound: the payload-length target in 14.5.1 is only achievable without a prefix.
- One namespace is what makes the reservation protective. Because QR slugs and short-link slugs occupy the same
/{slug}namespace on a host, a single unique index and a single permanent reservation table govern both. A QR reservation therefore blocks the identical short-link slug on that host, and a short link blocks a QR code from claiming its slug, in both directions, on the printed host and on the system-host mirror. Had QR codes been given their own prefixed path instead, a short link could legally claim a slug in the bare namespace while a code owned the same slug under the prefix; two independent namespaces would then need two reservation mechanisms, and the guarantee in 14.8.1 would depend on both staying in step forever. They would not.
Consequences, all of them enforced at creation time: qr_slug_reserved (409) is returned to a link creation that hits a QR reservation and to a QR creation that hits either a reservation or a live link on that slug; the reserved-word list in 12.2.5 applies to QR slugs unchanged; and the tokens q, qr and r are reserved on system hosts as ordinary reserved words, not as route prefixes.
14.2 Creating a QR Code #
14.2.1 Permissions and Entitlements #
| Check | Rule | Failure |
|---|---|---|
| Role | Owner, Admin, Editor. Viewer refused. | 403 insufficient_role |
| Grants | On Business, scoped members may create only within granted folders | 403 resource_not_granted |
| Plan cap | The qr_codes entitlement in Section 22.1.2: Free 3, Pro 100, Business uncapped with a fair-use creation ceiling per billing period |
403 plan_limit_reached, details[].kind = "count" (stored cap) or "period" (fair use) |
| Email verified | Required | 403 email_verification_required |
The plan cap counts non-deleted QR codes. Deleting a QR code frees a slot in the cap; it never frees the slug (14.8.6). QR rendering and download are never gated and never blocked — not by plan, not by billing state — because a customer who needs to reprint a damaged sign must be able to; Section 22.6.1 is the canonical statement of that carve-out.
14.2.2 From a New Destination #
POST /v1/qr-codes
| Field | Type | Required | Constraint | Default |
|---|---|---|---|---|
name |
string | yes | 1–120 chars | — |
destination_url |
string | yes (when link_id absent) |
Section 12.3 | — |
domain_id |
uuid | no | Active domain | Workspace default domain |
slug |
string | no | Section 12.2.4, and free on both hosts (14.2.4) | Generated (7 chars) |
style |
object | no | 14.4 | Workspace QR brand defaults |
error_correction |
enum | no | M, Q, H. Auto-escalated per 14.5.2. L is not accepted. |
M |
print_batch_label |
string | no | ≤ 60 chars | null |
estimated_print_quantity |
integer | no | ≥ 1 | null |
folder_id, tags, utm, rules, scheduled_at, expires_at, expiry_url |
— | no | As Sections 12 and 15 | — |
The call creates the backing link (dedicated to the code, and pinned per 12.9.6), the QR code, version 1, and both slug reservations — all in one transaction. Rendering and validation run in the worker; the response is 202 Accepted with the QR object in render_status: "pending", and the client polls or listens for the completion event. A render that fails validation ends in render_status: "rejected" with the diagnosis from 14.5.6.
14.2.3 From an Existing Link #
Two modes, presented as a choice with the consequence stated:
| Mode | Behaviour | Attribution |
|---|---|---|
dedicated_slug (default) |
Mints a new link with a new slug, its parent set to the source link, following the parent's destination. The child inherits destination, UTM, rules, schedule and password from the parent and stays in sync unless explicitly detached. | Clean. Every hit on the child slug is a scan. |
reuse_slug |
Encodes the existing link's slug directly. No new link. | Degraded. source is recorded as unknown for that link and every scan-versus-click chart shows a "cannot be separated" note for it. |
The choice screen states it in one line: "Use a dedicated code URL so we can tell scans apart from clicks — recommended — or reuse this link's URL and accept that scans and clicks will be reported together."
Destination propagation: editing the parent's destination updates the child in the same transaction and fires the same cache invalidation. Detaching is one click, writes an audit entry, and is irreversible only in the sense that re-attaching requires confirming the current destinations differ.
14.2.4 Slug Reservation — Immediate and Permanent #
Inside the creating transaction, before commit, two reservation rows are written:
- One for the printed host, exactly as the hostname will appear inside the symbol — for example
go.acme.comwith slugmk4t2wq. - One for the system-host mirror — the same slug on the default redirect host, marked as mirroring the printed host. This is the rung that survives losing the custom domain.
Rules:
- Both writes are in the same transaction as the QR code. If either unique constraint fires, the whole creation rolls back and the API returns 409
qr_slug_reservedwith a regenerated suggestion. There is no window in which a code exists without its reservation. - Slug generation for QR codes clears both hosts before it commits. The generator draws a candidate and attempts both writes; a collision on either host is a redraw. This is why a mirror collision is impossible later (13.9.3).
- If the user supplies a custom slug that is free on the custom domain but taken on the system host — or taken by an existing short link on either host — creation fails with 409
qr_slug_reservedand the message explains that a QR slug must be available on both the branded host and the fallback host, and in the shared link/QR namespace of each (14.1.5). The suggestion appends a 3-character suffix. - Reservations are never deleted by the application. The database role used by the application has
DELETErevoked on the table and aBEFORE DELETEtrigger raises unconditionally. Removal is a manual DBA operation with a documented, audited runbook (Section 25.8) and exists only for a legal takedown order. - The retention worker's purge queries carry an explicit
NOT EXISTSguard against the reservation table on every link purge, and a test in Section 26.5 asserts that guard exists.
14.3 Editing the Destination After Printing #
This is the reason dynamic QR codes exist. A sticker printed in March can point somewhere else in September without reprinting anything.
14.3.1 The Flow #
- The user opens the QR code and clicks "Change destination", or edits the backing link.
- LinkHub validates the new destination (Section 12.3) synchronously — SSRF check, scheme check, Safe Browsing. A destination that fails validation is refused before any confirmation is shown; a printed code must never be pointed at a blocked destination.
- The confirmation modal in 14.3.2 appears.
- On confirm: the transaction commits, cache invalidation runs (14.3.4), the audit entries are written, and the UI shows a live propagation indicator that turns green when a synthetic probe against the edge returns the new destination.
- The symbol is not re-rendered. The encoded payload is unchanged, so no version row is created and no export is invalidated. This is stated in the modal, because customers routinely and reasonably fear that changing the destination changes the image.
14.3.2 The Confirmation #
The modal from 12.4.4, with QR-specific fields populated:
This code is printed. "Packaging Insert v3" — printed 14 Feb 2026, approx. 25,000 copies.
Now:
https://acme.com/springAfter:https://acme.com/summerScans in the last 30 days: 4,182. Total scans: 61,904. The printed image does not change. Only where it sends people changes.
Type CHANGE to confirm: [______] [ Cancel ] [ Change destination ]
Typed confirmation is required when any of: a print date is declared, the declared print quantity is 100 or more, scans in the last 30 days exceed 1,000, or more than one QR code shares the backing link. Otherwise a single confirm click suffices.
14.3.3 The Audit Entry and Its Permanent Retention #
Two entries are written, both drawn from the canonical catalogue in Section 8.9.1: qr.destination_changed on the code, and link.destination_changed on the backing link.
| Field | Value |
|---|---|
action |
qr.destination_changed |
resource_type / resource_id |
qr_code, id |
before / after |
{"destination_url": …} on both sides |
context |
{"link_id": …, "qr_name": …, "print_batch_label": …, "printed_at": …, "scans_30d": 4182, "scans_total": 61904, "confirmation": "typed"} |
| Actor, IP country, user-agent family, timestamp | Per Section 8.9 |
retention_expires_at |
NULL |
QR destination-change entries are retained indefinitely on every plan, because the question "where did this code point in 2027" can arise long after the audit window closes. Prose alone does not survive a purge job, so the rule is carried by the data and by the query that reads it:
- The writer sets
retention_expires_at = NULLon both entries for a QR destination change. Every other audit entry getsnow() + the plan's audit retention windowfrom Section 22.1.2. - The purge job's condition is exactly
WHERE retention_expires_at IS NOT NULL AND retention_expires_at < now(). A NULL is never less thannow()in SQL and would silently drop out of a naive<comparison, so theIS NOT NULLlimb is written explicitly rather than relied upon — and the same condition is the trigger predicate that lets the retention role delete a row at all (Section 8.9.2). Both are stated in Sections 6 and 8; this subsection is what makes them true for QR codes. - A test in Section 26.5 writes a QR destination change, advances the clock past the longest plan window, runs the purge, and asserts the entry survives.
This is the only permanent exception to audit retention in the product.
14.3.4 Cache Invalidation #
On commit, in order, for both the printed host and the system-host mirror, against the canonical Redis keys catalogued in Section 4 and the invalidation matrix in Section 4.7:
SET rd:{host}:{slug}→ newly rendered payload, TTL 3600 s clamped by any schedule boundary.DEL rd:miss:{host}:{slug}.- Publish on the resolver invalidation channel — every edge process drops the key from its in-process LRU on receipt.
The in-process LRU at the edge has a hard 5-second TTL independent of pub/sub, so a lost message costs at most 5 seconds. Redis is the shared authority for the resolved payload; the write in step 1 is immediately visible to every node that misses its local LRU.
14.3.5 The Propagation Guarantee and Its Measured Bound #
| Guarantee | Value |
|---|---|
| Target | 99.9% of destination changes are visible at every edge node within 5 seconds of the commit |
| Hard ceiling | 10 seconds, being the 5-second in-process LRU TTL plus a 5-second budget for cross-region Redis replication |
| p50 | < 1 second |
| Failure mode if Redis is unavailable | The edge serves from its LRU for up to 5 seconds, then falls back to a direct PostgreSQL read of the link row (a documented degraded path with a 300 ms budget). The new destination is served. Propagation is never blocked by a cache outage. |
How it is measured, continuously. A synthetic canary QR code exists in every region. A worker job flips its destination between two known values once per minute, then polls every edge node's public resolution endpoint until it observes the new value, recording the delta. The metric qr_propagation_seconds is exported as a histogram (Section 25.3), alerts at p99 > 5 s for 3 consecutive minutes, and pages at p99 > 10 s. The canary's results are shown on the public status page. This is the evidence behind the number, not an aspiration.
The propagation indicator shown to the user after a change reads from the same probe, so the customer sees the real observed value: "Live everywhere — 1.2 seconds."
14.4 Styling #
Styling is stored as the style snapshot on a version. Changing style creates a new version, re-renders, and re-runs validation (14.5). It never changes the encoded payload, so restyling never invalidates a printed code — but it does mean existing printed material keeps the old appearance, which the UI states. A style change writes qr.styling_changed and, when artefacts are regenerated, qr.rendered.
14.4.1 Style Document #
{
"module_shape": "square",
"eye_frame_shape": "square",
"eye_ball_shape": "square",
"foreground": { "type": "solid", "color": "#000000" },
"background": { "type": "solid", "color": "#FFFFFF" },
"logo": { "asset_id": null, "size_percent": 18, "shape": "square", "knockout_padding_modules": 1 },
"frame": { "style": "none", "color": "#000000", "text": "", "text_color": "#FFFFFF" },
"quiet_zone_modules": 4,
"margin_style": "clean"
}14.4.2 Options and Their Scannability Impact #
| Option | Values | Scannability impact | Product behaviour |
|---|---|---|---|
| Module shape | square (default), rounded, dot, classy, vertical_bars, horizontal_bars |
square is the reference: no impact. rounded and classy reduce effective module fill by ~8–12%, tolerable. dot reduces it by ~25% and is the single most common cause of a failed low-resolution decode. Bar shapes merge adjacent modules along one axis and fail more often at angle. |
Any non-square value forces error correction to H and triggers the full validation pipeline. dot and the bar shapes show an inline caution before selection. |
| Eye frame shape | square (default), rounded, circle, leaf, cushion |
Finder patterns are how a decoder locates the symbol. Non-square eyes are the most decoder-sensitive choice in the whole style set; some older scanners fail on circle and leaf at small sizes. |
Forces H. Validated. The 1:1:3:1:1 ratio is preserved exactly in every variant — shapes change the outline, never the proportions. |
| Eye ball shape | square (default), rounded, circle, leaf, diamond |
Lower risk than the frame, but diamond reduces the centre mass and hurts at low resolution. |
Forces H. Validated. |
| Foreground colour | Any sRGB colour | Contrast is the dominant variable. Below the floor in 14.5.4, decoding is unreliable regardless of everything else. | Contrast checked before render; hard block below 4.5:1, warning below 7:1. |
| Foreground gradient | linear or radial, 2–3 stops |
Every stop must independently satisfy the contrast floor against the background. Gradients also interact badly with print colour conversion. | Forces H. Worst-case stop pair is used for the contrast test. Validated. |
| Background | Solid colour or transparent | Transparent backgrounds are the second-most common real-world failure: the code is placed on a photograph and the contrast becomes unknowable. | Transparent is permitted only in the SVG and PNG exports, is never permitted in PDF/EPS, and shows a permanent warning: "You are responsible for the contrast of whatever this is placed on." Contrast validation for a transparent background assumes white and states that assumption. |
| Background image | Not supported | — | Refused as a product decision. It makes contrast unverifiable and is the leading cause of unscannable codes in the wild. |
| Logo overlay | Uploaded PNG/SVG/JPEG | Directly destroys codewords. Bounded by 14.5.3. | Forces H. Size ceiling enforced. Knockout padding applied. Geometry check against functional patterns. Validated. |
| Frame | none (default), border, banner_bottom, banner_top, speech_bubble |
No impact on the symbol provided the frame is drawn outside the quiet zone, which the renderer enforces geometrically. | Frames never encroach on the quiet zone. A frame that would is rejected at render with 422 qr_frame_encroaches_quiet_zone. |
| Call-to-action text | ≤ 24 characters | None on the symbol. Improves real-world scan rate measurably — a code with "SCAN ME" outperforms a bare code. | Rendered inside the frame only. Font is embedded as outlines in vector exports so it never depends on a font being installed. Contrast between text and frame colour is checked against the 4.5:1 accessibility floor in Section 24. |
| Quiet zone | 4 modules minimum | Below 4 modules, decoders fail against busy backgrounds. | Not adjustable below 4. The field accepts 4–10. |
| Margin style | clean (default), extended |
Cosmetic. | — |
14.4.3 Workspace QR Brand Defaults #
A workspace may save a default style document. New codes inherit it. Changing the default never restyles existing codes — a restyle is always explicit and always creates a version, because silently changing an asset a customer has already sent to a printer is unacceptable.
14.5 The Scannability Contract #
Every rendered QR code must be proven scannable before it can be exported. Not estimated, not assumed from the error-correction level — decoded, three times, under three deliberately hostile conditions. A render that cannot be decoded is not delivered. The conformance suite that keeps this honest across renderer changes is Section 26.8.
14.5.1 Encoding Baseline #
| Property | Value |
|---|---|
| Symbology | QR Code Model 2, per ISO/IEC 18004 |
| Mode | Byte mode, UTF-8. Alphanumeric mode is used automatically when the payload is entirely uppercase alphanumeric, which produces a smaller symbol. |
| Mask | Chosen by the standard penalty-score evaluation across all 8 patterns; never fixed, never user-selectable |
| Symbol version | Smallest version that fits the payload at the required error-correction level; never padded up for aesthetics |
| Payload | The short URL, https:// included. http:// is never encoded. |
| Payload length target | ≤ 40 characters, which keeps typical codes at version 3–4 (29×29 to 33×33 modules) and therefore scannable at small print sizes. The 7-character generated slug and the absence of any URL prefix (14.1.5) are both in service of this number. |
14.5.2 Error Correction Defaults and Automatic Upgrade #
| Level | Codeword recovery | Use |
|---|---|---|
| L | ~7% | Never used. Not offered, not accepted via API. |
| M | ~15% | Default for a plain, unstyled, logo-free code |
| Q | ~25% | First auto-escalation step |
| H | ~30% | Mandatory whenever a logo, gradient, or non-square module/eye shape is applied; second escalation step |
Rules:
- Default is M.
- Applying any of: a logo overlay, a foreground or background gradient, a non-
squaremodule shape, a non-squareeye frame shape, or a non-squareeye ball shape sets the level to H automatically and immediately, before the first render. The UI shows this as an informational note, not a warning: "Error correction raised to High to protect this design." - The level never falls below M under any configuration or API input. A request specifying
Lis rejected with 400qr_error_correction_too_low. - A user may raise the level manually at any time. They may not lower it below the level the auto-upgrade rule requires; attempting to returns 422
qr_error_correction_lockednaming the styling choice that locked it. - Raising the level increases the symbol version (more modules), which makes each module physically smaller at a fixed print size. The export screen's sizing table (14.6.3) reflects the actual version, so the minimum print size shown always corresponds to the code the user actually has.
14.5.3 The Logo Size Ceiling and Its Arithmetic #
Ceiling: the logo bounding box may not exceed 22% of the symbol width and 22% of the symbol height, measured on the symbol excluding the quiet zone, and it is always centred.
The arithmetic that justifies 22%:
Logo box area = 0.22 × 0.22 = 0.0484 → 4.84% of the symbol area
Knockout padding = 1 module ring around the logo box
For a version-4 symbol (33×33), the logo box is 7.26 modules across.
With a 1-module ring, the obliterated region is ~9.26 modules across
= 9.26² / 33² = 85.8 / 1089 = 7.87% of the symbol area
Level H recovers ~30% of codewords
Damage budget consumed ~7.9% → ~26% of the available budget
Remaining margin ~22 percentage points, reserved for:
• print registration error and ink bleed on packaging (typ. 2–5%)
• substrate wear, folding, scuffing, condensation (typ. 3–8%)
• lighting glare and partial specular reflection (typ. 2–6%)
• camera resampling loss on a low-resolution sensor (typ. 2–5%)The margin is deliberate. A ceiling near the theoretical 30% would decode perfectly in the validator and fail on a folded carton in a shop.
Additional geometric constraints, checked before decoding is attempted:
| Constraint | Rule | Error |
|---|---|---|
| Finder patterns | The logo box must not intersect any of the three 7×7 finder patterns or their 1-module separators | qr_logo_overlaps_finder |
| Timing patterns | Must not intersect row 6 or column 6 | qr_logo_overlaps_timing |
| Format information | Must not intersect the format-information regions adjacent to the finders | qr_logo_overlaps_format_info |
| Version information | For symbols version ≥ 7, must not intersect the version-information blocks | qr_logo_overlaps_version_info |
| Alignment patterns | May cover at most one alignment pattern | qr_logo_overlaps_alignment when two or more would be covered |
| Knockout | A 1-module background-coloured ring is always drawn around the logo, so a logo with a dark edge cannot be read as data | Enforced, not optional |
| Aspect | A non-square logo is fitted inside the 22% × 22% box preserving aspect ratio; it is never stretched | — |
| Transparency | Transparent logo pixels are composited onto the background colour before validation, so a transparent logo cannot smuggle in low contrast | — |
Because a centred box on a small symbol frequently covers the central alignment pattern, that single covering is permitted — the standard tolerates loss of individual alignment patterns when the finders and timing patterns are intact. The decode validation in 14.5.5 is the final arbiter in every case; the geometric rules exist to fail fast and to produce a precise error message, not to replace the decode.
14.5.4 Quiet Zone and Contrast #
Quiet zone. Minimum 4 modules on all four sides, enforced at render time, present in every export format, and non-removable. The API rejects fewer than 4 with 400 qr_quiet_zone_too_small. Frames, captions and call-to-action text are drawn strictly outside it. When a code is exported as SVG, the quiet zone is part of the viewBox, so a designer who places the SVG edge-to-edge still gets the margin.
Contrast. Computed as the WCAG relative-luminance ratio between foreground and background:
L = 0.2126·R + 0.7152·G + 0.0722·B (linearised sRGB channels)
ratio = (L_lighter + 0.05) / (L_darker + 0.05)| Ratio | Behaviour |
|---|---|
| < 4.5:1 | Hard block. The render is refused with 422 qr_contrast_too_low. No export is produced. The editor shows the measured ratio, the required ratio, and a "fix for me" control that darkens the foreground (or lightens the background) by the minimum amount that clears the floor. |
| 4.5:1 – 6.99:1 | Render proceeds. A persistent warning states the measured ratio and recommends 7:1 or better for print. The warning is repeated on the export screen and included in the print checklist. |
| ≥ 7:1 | No warning. |
Gradient handling: the ratio is computed for the worst-case pair — the foreground stop with luminance closest to the background, against the background (or, for a background gradient, against the background stop closest to the foreground). Every stop must independently clear 4.5:1.
Transparent background: contrast is computed against white, and the UI states that assumption explicitly rather than silently passing.
Inversion (light modules on a dark background) is permitted only when the decode validation in 14.5.5 passes at all three conditions. Many scanners assume dark-on-light; the validator uses a decoder configured to attempt both polarities and the render is accepted only if the standard polarity assumption also succeeds at condition 2. An inverted code additionally carries a fixed advisory on the export screen: "Inverted codes scan reliably on modern phones but can fail on older or industrial scanners. Test before a large run."
14.5.5 The Automated Validation Pipeline #
Every render — creation, restyle, error-correction change, logo change, colour change — runs the pipeline before any export asset is published. Nothing reaches object storage until it passes.
Pipeline stages:
render SVG
↓
geometric pre-checks (14.5.3) — fail fast, precise error
↓
contrast check (14.5.4) — hard block below 4.5:1
↓
rasterise to 1024 × 1024 px PNG — deterministic renderer, no AA on module edges
↓
┌──────────────────────────────────────────────────────────────┐
│ CONDITION 1 — clean, full scale │
│ Input: the 1024 px raster, unmodified │
│ Simulates: a good scan in good light │
├──────────────────────────────────────────────────────────────┤
│ CONDITION 2 — downscaled and resampled │
│ Downscale to 50% (512 px) with bilinear resampling, │
│ then upscale back to 1024 px with bilinear resampling │
│ Simulates: a low-resolution camera, or a code printed │
│ smaller than intended │
├──────────────────────────────────────────────────────────────┤
│ CONDITION 3 — degraded and skewed │
│ Reduce contrast by 30% (blend toward mid-grey), │
│ add 3% Gaussian noise (σ = 0.03 × full range, │
│ seeded PRNG, fixed seed per version id), │
│ rotate 2° about the centre with bilinear interpolation, │
│ pad with the background colour │
│ Simulates: poor lighting, print noise, a handheld angle │
└──────────────────────────────────────────────────────────────┘
↓
all three must decode to a byte-identical payload
↓
publish artefacts, store the validation resultPass criterion. For each condition, the decoder must return a payload byte-identical to the version's encoded payload. A decode that succeeds but returns a different string is a failure, not a pass — this catches renderer bugs that produce a valid-but-wrong symbol, which is the most dangerous possible defect here.
Decoder. A ZXing-class decoder running with "try harder" mode disabled — deliberately, so the validator is stricter than a real scanner rather than more forgiving. Both polarities are attempted only for the inversion case described above.
Determinism. The Gaussian noise uses a seeded PRNG keyed on the version id, so re-running validation on the same version produces the same result. There is no wall-clock and no unseeded randomness anywhere in the pipeline; a validation result is reproducible from the stored version, which is what makes the artefact checksums in 14.1.4 meaningful.
Performance. Budget of 1,500 ms p95 for the full three-condition pipeline. It runs in the qr-render worker queue, never in the request path. The live editor preview runs condition 1 only, debounced at 400 ms, so the designer gets instant feedback; the full pipeline runs on save. A preview that passes condition 1 but later fails the full pipeline is surfaced as a save-time rejection, and the editor makes clear that the preview is indicative and the save is authoritative.
Concurrency and cost. Renders are deduplicated by a hash of (payload, style, error correction); an identical render returns the cached validation result rather than re-running. Rate limit: 60 renders per minute per workspace, 600 per hour, reproduced in the consolidated table in Section 23.9.
14.5.6 Auto-Escalation, Rejection and Attribution #
Auto-escalation. If any condition fails:
| Attempt | Action |
|---|---|
| 1 | Original level (M, Q or H) fails → escalate one level (M→Q, Q→H) and re-render, re-validate |
| 2 | Still failing → escalate again if possible (Q→H) and re-validate |
| 3 | Already at H, or two escalations exhausted → reject |
Maximum 2 escalations. Escalation is recorded on the version row and shown to the user as an informational note: "We raised error correction to High to make this design scannable."
Rejection. A rejected render returns 422 qr_unscannable. No artefacts are published. The previous version remains current, so a failed restyle never leaves a customer without a working code.
Attribution — naming the styling choice that caused it. The service does not guess. It runs an ablation loop: each styling attribute is reverted to its default, one at a time, in a fixed priority order, and the pipeline is re-run at the original error-correction level. The first attribute whose removal makes all three conditions pass is named as the cause. The order is fixed so the result is deterministic:
1. logo → remove the logo entirely
2. module_shape → revert to "square"
3. eye_frame_shape → revert to "square"
4. eye_ball_shape → revert to "square"
5. foreground → revert gradient to its darkest stop as a solid
6. background → revert to solid white
7. quiet_zone → restore to 4 modules (if it was larger and a frame encroached)If no single ablation passes, the loop runs the two highest-priority ablations together, and reports both. If that still fails, the message falls back to "this combination of styling choices" and lists every non-default attribute.
The user-facing message names the culprit and the fix, and offers the fix as a button:
This design can't be scanned reliably. We rendered it and tried to read it back under three conditions — clean, low-resolution, and poor lighting at an angle. It failed the low-resolution test.
Cause: the logo. At 22% it covers too much of this particular symbol once error correction is already at High.
Try: reduce the logo to 15%, or remove it. [ Reduce logo to 15% ] [ Remove logo ] [ Keep editing ]
The three conditions are named in plain language in the UI, because a customer who understands what was tested trusts the result.
14.5.7 Where the Validation Result Is Stored #
On the version row (Section 6.3.38):
{
"passed": true,
"validated_at": "2026-03-04T09:15:02.418Z",
"pipeline_version": 3,
"error_correction_final": "H",
"escalations": [ { "from": "M", "to": "Q" }, { "from": "Q", "to": "H" } ],
"symbol_version": 5,
"module_count": 37,
"contrast_ratio": 12.63,
"logo_size_percent": 18,
"logo_damage_estimate_percent": 6.4,
"conditions": [
{ "id": "clean", "passed": true, "decode_ms": 11, "payload_match": true },
{ "id": "downscaled", "passed": true, "decode_ms": 19, "payload_match": true },
{ "id": "degraded", "passed": true, "decode_ms": 34, "payload_match": true,
"noise_seed": "0198f5b2-…" }
],
"ablation": null,
"renderer_version": "2026-03-01"
}Additional properties:
- The result is immutable, like the version that carries it.
- It is exposed on the API as
validationon the QR version object and is included in every export bundle as a machine-readable sidecar, so a print vendor can verify what was tested. pipeline_versionis incremented whenever the conditions or thresholds change. A background job re-validates existing current versions against a new pipeline version at a low rate and flags any that would no longer pass — without altering the published artefacts, because a code already in the world must not be silently deprecated. The flag surfaces as an advisory on the code's page.- The export screen shows a plain-language summary: "Tested and readable: clean ✓ · low resolution ✓ · poor light and angle ✓".
14.6 Export #
14.6.1 Formats #
| Format | Role | Details |
|---|---|---|
| SVG | Canonical. Every other format is derived from it. | Vector paths only, no embedded raster except an embedded logo (base64 when the source is raster, inlined paths when the source is SVG). Quiet zone inside the viewBox. No external font references — text is outlined. Deterministic output for a given version. |
| PNG 300 DPI | Office printing, digital proofs | Sized so the symbol is exactly the requested physical width at 300 DPI. sRGB. Optional transparent background. |
| PNG 600 DPI | High-quality print, small physical sizes | As above at 600 DPI. |
| Print-ready | Vector, CMYK, single page trimmed to the symbol plus quiet zone, with the physical size set in the page box. Black is 100% K only — never a rich black build — because registration error between plates smears module edges. | |
| EPS | Legacy print workflows | Vector, CMYK, same colour rule. |
| JPEG | Not offered | Refused: lossy compression produces ringing at module edges, which is a direct scannability risk. The export UI states this rather than hiding the option. |
Artefacts are generated once per version and stored; downloads are served from object storage through signed URLs valid for 60 minutes. A bulk download produces a ZIP with a manifest CSV (14.9.2). Export and re-download are available on every plan and in every billing state (Section 22.6.1); only the bulk export path carries an entitlement, in 14.9.2.
14.6.2 Export Screen Contents #
- Format picker, physical size input (mm or inches), and a live preview at true scale with a printed ruler graphic.
- The validation summary from 14.5.7.
- The sizing table from 14.6.3, with the row matching the chosen size highlighted.
- The print checklist from 14.7, always visible and always included in the ZIP as a text file.
- A domain-dependency notice when the code is on a custom domain: "This code encodes
https://go.acme.com/mk4t2wq. It will keep working for as long asgo.acme.compoints at LinkHub. It also always resolves athttps://go.linkhub.app/mk4t2wq, which we guarantee permanently. If you want a code that depends only on us, generate it on the LinkHub domain instead." A "generate a system-domain version instead" action is offered inline; it creates a second QR code with its own slug.
14.6.3 Physical Sizing and Scan Distance #
The governing rule is scan distance ≈ 10 × symbol width, the widely used field heuristic for a modern phone camera on a matte, well-lit, flat surface. The table assumes a version 3–5 symbol (29–37 modules); larger symbol versions need proportionally more size, and the export screen computes the row from the code's actual module count rather than showing a static table.
| Symbol width | Max reliable scan distance | Typical use | Minimum export resolution |
|---|---|---|---|
| 2.0 cm (0.79 in) | ~20 cm | Absolute minimum. Product labels, business cards. | 600 DPI |
| 2.5 cm (1.0 in) | ~25 cm | Business cards, small packaging | 600 DPI |
| 3.0 cm (1.2 in) | ~30 cm | Menus, flyers, hang tags | 300 DPI |
| 5.0 cm (2.0 in) | ~50 cm | Posters at reading distance, table talkers | 300 DPI |
| 10 cm (3.9 in) | ~1 m | Shop windows, shelf edge, A4 posters | 300 DPI |
| 20 cm (7.9 in) | ~2 m | A2/A1 posters, exhibition panels | 300 DPI |
| 50 cm (19.7 in) | ~5 m | Vehicle livery, wall graphics | Vector only |
| 1 m+ | ~10 m+ | Billboards, building wraps | Vector only |
Hard rules enforced by the export screen:
- Minimum printable symbol width is 2.0 cm. Requesting less returns 422
qr_size_below_minimumwith the message that below 2 cm the module size falls under the reliable threshold for phone cameras. - Above symbol version 6 (41 modules), the minimum rises proportionally and is computed as
module_count × 0.6 mm, rounded up to the next 0.5 mm. The screen shows the computed minimum, not the generic one. - Raster export is disabled entirely above 30 cm; the screen offers vector only, with the reason stated.
14.7 The Print Checklist #
Presented on the export screen, included as a text file in every ZIP, and printed at the top of the bulk export manifest.
| # | Check | Why |
|---|---|---|
| 1 | Size. At least 2 cm across, and at least one tenth of the distance people will scan from. Measure the real printed piece, not the screen. | Undersized codes are the most common field failure. |
| 2 | Quiet zone. Keep the clear margin around the code. Do not let artwork, a border, a fold or a die-cut enter it. | A busy edge prevents the decoder from locating the symbol. |
| 3 | Contrast on the real stock. Coloured, kraft, recycled and uncoated papers absorb ink and lift the background luminance. A design that measures 7:1 on screen can print at 3:1 on kraft. Print a proof and check it. | Screen contrast is not print contrast. |
| 4 | No inversion on uncertain stock. If the substrate is dark, print the code on a light patch rather than inverting it. | Inverted codes fail on a minority of scanners; a dark substrate compounds it. |
| 5 | Flat, or nearly flat. Curved surfaces distort the symbol. On a bottle or a can, keep the symbol width under about one third of the visible curve and orient it so the curve runs vertically through the code, not horizontally across it. | Horizontal curvature across the finder patterns defeats perspective correction. |
| 6 | No lamination glare on gloss. High-gloss lamination under retail lighting produces specular reflection over part of the symbol. Prefer matte or soft-touch finishes for codes. | Glare removes modules the decoder needs. |
| 7 | Black is 100% K. In CMYK, use flat black, not a rich-black build. | Plate registration error smears module edges. |
| 8 | Vector where possible. Use the PDF or EPS for professional print; use PNG only when the vendor cannot take vector. Never use a screenshot, and never resize a raster upward. | Resampling a raster destroys module edges. |
| 9 | Add a call to action. "Scan for the menu" outperforms a bare code. Include what happens after the scan. | Scan rate is a behaviour problem as much as a technical one. |
| 10 | Test before the run. Print one, at final size, on final stock, with final finish. Scan it with at least three phones — one recent iPhone, one recent Android, and one device more than three years old — from the distance people will actually stand, in the light they will actually be in. Then scan it again after folding, if it will be folded. | This step catches everything the other nine miss. |
| 11 | Keep the DNS record. If the code uses your own domain, that domain's DNS record must stay in place for the printed URL to work. | Section 13.9. |
| 12 | Do not password-protect a printed code unless the audience expects it. | Typing a password on a phone after a scan has a high abandonment rate. |
14.8 The Permanence Guarantee #
14.8.1 The Rule #
A QR slug is reserved forever. It is never recycled. It is never reassigned to a different link, a different code, or a different workspace. It is never purged. Not on plan downgrade. Not on non-payment. Not on cancellation. Not on workspace deletion. Not on account deletion. Not by the retention worker. Not by a support agent. Resolution of a reserved QR slug never returns 404 and never returns 410.
This rule outranks every other rule in this specification, including plan enforcement, soft-delete semantics, and data retention. Where any other section appears to conflict with it, this rule wins. The mechanisms that make it true rather than aspirational are the pinned backing link (12.9.6), the undeletable reservation table (14.1.4), the shared namespace (14.1.5) and the four-rung chain below.
14.8.2 The Resolution Fallback Chain — The One Canonical Table #
Evaluated in order on every request to a reserved QR slug. The first rung that applies is served. Every section that refers to a rung uses these numbers, these names and these fallback_stage values, and no other vocabulary exists for this concept anywhere in the product.
| # | Rung | fallback_stage |
Condition | HTTP | Response | Cache-Control |
|---|---|---|---|---|---|---|
| 1 | Active destination | active |
Backing link is active (or scheduled and within its window), a destination is present, and safe_browsing_status is not blocked |
302 | Location: the resolved destination after targeting rules (Section 15.5), experiment assignment (Section 16), and UTM and parameter merging (Sections 15.1–15.2). A password-protected link resolves through the 200 interstitial in 12.7.3 and then to this same 302. |
private, no-store |
| 2 | Paused / expiry fallback URL | paused_fallback |
Link is paused with a paused fallback URL set, or expired with expiry_url set |
302 | Location: the fallback URL. Validated by the same rules as any destination, when set and on the weekly recheck. |
private, no-store |
| 3 | Workspace branded unavailable page | workspace_unavailable |
No rung above applies, and the workspace still exists — including the pre-erasure memorial case, where the workspace has been deleted but its display name has not been erased | 200 | Server-rendered page carrying the workspace's logo, display name and brand colours. Live workspace: "This code isn't active right now — the code you scanned belongs to Acme. It isn't pointing anywhere at the moment." Pre-erasure memorial: "This code was registered by Acme. The account it belonged to has been closed," plus the reclaim path in 14.8.5. Optional workspace-configured contact link. No JavaScript required. | public, max-age=60 |
| 4 | Neutral platform landing page | generic |
The workspace no longer exists, its branding cannot be resolved, an erasure request has removed the display name, or any dependency failure prevents evaluating rungs 1–3 — this is the post-erasure memorial case | 200 | Neutral page: "This QR code is registered but isn't currently pointing anywhere," a short explanation that the code is permanently registered and may be reactivated by its owner, and an abuse-report link. No workspace name, no display name, no personal data of any kind. | public, max-age=60 |
The memorial page is not a fifth rung and is not a separate page family: it is rung 3's content before erasure and rung 4's content after erasure. That assignment is what preserves the "no personal data is retained" limb of the argument in Section 23.15.3.
There is no rung 5. There is no fall-through. The resolver's final else branch returns rung 4; it is not reachable by any other path, and a test asserts that the function is total. If PostgreSQL is unreachable and no cached payload exists, the answer is rung 4 with 200 and an incident metric — never a 5xx.
Every resolution emits an analytics event carrying fallback_stage, so a workspace can see scans arriving on rungs 2–4 (14.1.2).
14.8.3 Why No Rung Is Ever 404, 410 or 5xx #
A 404 or 410 in a phone's camera browser renders as the browser's own error page. To the person holding the packaging, the product looks broken and the brand looks careless — and there is no recovery path, because the paper cannot be edited. A 200 with an explanation is always more useful than an error, even when there is genuinely nothing to show. Additionally, a 410 invites intermediaries to cache the failure, and some scanning apps suppress non-2xx responses entirely, showing nothing at all.
This is also why the visitor-facing statuses in 12.1.5 are 200 rather than 4xx for the expired, not-yet-live and password cases: any of those links may be QR-backed, and answering uniformly with 200 removes the possibility of a wrong status leaking through the QR path.
Enforcement is threefold:
- Single resolver. One function resolves every short URL, on every host. The QR branch is inside it, not alongside it — a consequence of the shared namespace in 14.1.5.
- Runtime guard. Before any 404 or 410 is emitted by the edge for any path — including a host that is not yet active or has been removed — the resolver checks the reservation table for
(host, slug). A hit converts the response to rung 3 or rung 4. The check is a single indexed lookup against an in-memory bloom filter backed by Redis, costing under 1 ms, and is on the error path only; it never touches the hot path. - Coverage gate and invariant test. The fallback chain carries a 95% line-coverage gate under the coverage requirements in Section 26.12, and the critical suites in Section 26.5 include a property test asserting that for every combination of link state × workspace state × billing state × domain state, the response status is in {200, 302} and never in {404, 410, 5xx}.
14.8.4 The System-Host Mirror #
Every QR slug is reserved on both the printed host and the system redirect host at creation time (14.2.4). The mirror exists so that the guarantee survives events LinkHub does not control:
| Event | Printed URL | Mirror URL |
|---|---|---|
| Custom domain removed from the workspace | Keeps working while DNS points at LinkHub (13.9.4) | Always works |
| Customer deletes their DNS record | Fails at DNS — outside LinkHub's control | Always works |
| Custom domain's certificate lapses | Fails at TLS | Always works |
| Customer's registrar seizes the domain | Fails | Always works |
| Workspace deleted | Follows the chain | Follows the chain |
The mirror URL is shown on every QR code's page, labelled "Always-on fallback URL", and is included in every export manifest. Support's first response to "my printed code stopped working" is to supply the mirror URL, which resolves the incident immediately while DNS is fixed.
14.8.5 Workspace Deletion and the Memorial Page #
When a workspace is deleted (Section 8.9), after its 30-day grace period:
- Every QR reservation belonging to it records the last known workspace display name. The row is untouched in every other respect and is never deleted.
- The backing links and QR code records are purged on the normal schedule. The reservation is not.
- Resolution serves the memorial content at rung 3 (
workspace_unavailable, 200): "This code was registered by Acme. The account it belonged to has been closed, so the code isn't pointing anywhere. If you're the owner, you can reclaim it." — plus a reclaim path that requires proving control of the original account's verified email or the original custom domain, handled by support and audited. - Reclaiming restores the slug to a new or existing workspace with its original slug intact, which is the entire point: a closed account does not cost the customer their printed inventory.
14.8.6 Deleting a QR Code #
Deleting a QR code is a dashboard operation, not a destruction operation. The confirmation says so:
Deleting this code removes it from your dashboard. It does not stop it working.
go.acme.com/mk4t2wqwill keep resolving forever — that's the promise we make about printed codes, and we don't break it because you tidied up a list. After deleting, the code will follow your workspace's fallback: visitors will see your "code not active" page. The slugmk4t2wqcan never be reused by anyone, including you.
Effects: the QR code row is soft-deleted; the code leaves the dashboard and frees a slot in the plan cap; the backing link is neither archived nor deleted — it stays pinned, outside the link cap, exactly as 12.9.6 requires, because archiving it is precisely what would take the printed code off rung 1; the reservation rows are untouched; and resolution moves to rung 3 (workspace_unavailable). A deleted code can be restored within 30 days, after which the code record is purged and only the reservation remains — still resolving at rung 3 or 4.
14.8.7 Erasure and a Permanently Resolving Code #
A GDPR erasure request removes personal data. The reservation is not personal data — it is a routing entry consisting of a hostname and a random string — but the memorial display name may be, when a workspace was named after an individual.
| On erasure | Action |
|---|---|
| Memorial display name | Cleared, and the row is marked as having had erasure applied |
| Resolution | Falls to rung 4 (generic), the neutral platform landing page — still 200, still resolving, and now carrying no workspace name and no personal data |
| Last known workspace id | Retained as an opaque identifier with no join path to any personal record after purge |
| Reservation row | Retained. Never deleted. |
The lawful basis for retaining the routing entry after erasure rests on two limbs, both of which must hold and both of which are documented with the balancing test in Section 23.15.3: a legitimate interest in service continuity for third parties who hold physical media encoding the URL, and the fact that after erasure no personal data is retained — which is true only because rung 4, not rung 3, serves the post-erasure case. Billing treatment, including the rule that QR codes are exempt from every downgrade and non-payment restriction, is specified in Section 22.6. This subsection states the behaviour; those sections own the reasoning and the commercial policy.
14.8.8 Billing States and Resolution #
| Workspace billing state | QR resolution | Editing the destination | Branding on fallback pages |
|---|---|---|---|
| Active, any plan | Rung 1 (active) |
Yes | Workspace branding (LinkHub attribution on Free) |
| Past due, days 0–7 | Rung 1 — unchanged | Yes (Section 22.7 permits writes until day 8) | Workspace branding |
| Past due, day 8 onward | Rung 1 — unchanged | No — 403 billing_write_blocked |
Workspace branding |
| Canceled, or downgraded below the QR cap | Rung 1 — unchanged | Read-only above the cap; the code still resolves | Workspace branding |
| Suspended for abuse | Rung 1 unless the destination itself is the abuse; otherwise rung 3 with the abuse notice | No | Neutral |
| Workspace deleted | Rung 3 memorial, then rung 4 after erasure | No | Memorial, then neutral |
A QR code never stops resolving because of money. Section 22.6 is the canonical statement of that policy; rendering and download stay available throughout (14.2.1).
14.9 Bulk Generation and CSV Export #
14.9.1 Bulk Generation #
POST /v1/qr-codes/bulk and a CSV-driven UI flow, both two-phase (validate, then commit) exactly as the link importer in 12.5.3.
Import columns:
| Column | Required | Notes |
|---|---|---|
name |
Yes | 1–120 chars, unique within the file (a warning, not an error, if duplicated) |
destination_url |
Yes | Section 12.3 validation |
slug |
No | Section 12.2.4; must be free on both the target host and the system host, in the shared link/QR namespace of each |
domain |
No | Defaults to the workspace default domain |
print_batch_label |
No | Groups the run |
estimated_print_quantity |
No | Integer |
style_preset |
No | Name of a saved workspace style preset. Blank uses the workspace default. |
utm_source, utm_medium, utm_campaign, utm_term, utm_content |
No | Section 15.1 |
external_id |
No | Idempotency key for re-import |
Caps come from Section 22.1.2 and nowhere else. The number of codes a workspace may hold is the qr_codes entitlement (Free 3, Pro 100, Business uncapped) and the number it may create per billing period is the fair-use ceiling attached to it; a bulk run is checked against both before any row is rendered, and a run that would cross either is refused with plan_limit_reached carrying details[].kind of count or period respectively. On top of the entitlement, one operational batch limit applies uniformly: 2,000 rows per import file, which exists to bound worker memory and report size, not to price the feature — a Business workspace splits a larger run across files, and the UI says so.
Rendering is queued at a maximum of 20 concurrent renders per workspace so a large batch cannot starve interactive renders. Progress is shown as "412 of 2,000 rendered, 3 rejected", and rejected rows are listed with the ablation diagnosis from 14.5.6 so the user can fix the style and re-run only the failures.
The commit produces a ZIP containing every generated artefact plus the manifest below, delivered as a signed 24-hour download link.
14.9.2 CSV Export of Codes #
GET /v1/qr-codes/export and the "Export" action on the QR list. Runs as a worker job; the result is a signed 24-hour link.
| Column | Content |
|---|---|
qr_code_id |
UUID |
name |
Internal label |
slug |
The reserved slug |
printed_url |
The URL inside the symbol, e.g. https://go.acme.com/mk4t2wq |
fallback_url |
The permanent system-host URL, e.g. https://go.linkhub.app/mk4t2wq |
destination_url |
Current destination |
link_id |
Backing link UUID |
domain |
Hostname |
status |
Backing link status |
error_correction |
Final level after auto-escalation |
symbol_version, module_count |
Symbol geometry |
minimum_print_width_mm |
Computed per 14.6.3 |
validation_passed |
true / false |
validated_at |
Timestamp |
print_batch_label, printed_at, estimated_print_quantity |
Print metadata |
scans_total, scans_30d, last_scanned_at |
From rollups |
created_at, created_by_email |
Provenance |
svg_url, png_300_url, png_600_url, pdf_url, eps_url |
Signed artefact URLs, 24-hour validity, stated in the header comment row |
The same file is the manifest inside every bulk-export ZIP, so a print vendor receives the artefacts and the metadata describing them in one package.
Bulk CSV export is the csv_export entitlement in Section 22.1.2 — Pro and Business. A Free workspace receives 403 plan_feature_unavailable. This gates the bulk manifest, not access to a code: downloading an individual code's artefacts is never gated, on any plan, in any billing state (14.2.1).
14.10 Scan Analytics Specifics #
14.10.1 What a Scan Records That a Click Does Not #
Every field in Section 17's click event, plus:
| Field | Value | Notes |
|---|---|---|
source |
qr |
Set when the resolved slug belongs to a QR-dedicated link |
qr_code_id |
UUID | Which physical code |
qr_version_id |
UUID | Which styling version was current at scan time — enables before/after measurement of a restyle |
print_batch_label |
text | Denormalised at ingest so batch comparison needs no join |
fallback_stage |
enum | active, paused_fallback, workspace_unavailable or generic, per 14.8.2 |
scan_host |
text | printed or mirror, distinguishing a scan of the branded URL from one that arrived via the system-host fallback. A rising mirror share is an early warning that a customer's DNS is broken. |
A scan characteristically records no referrer, because a camera app navigates directly. That absence is itself a weak signal and is recorded as a null referrer host, never guessed.
14.10.2 Known Limits of Scan Attribution #
Stated plainly in the analytics UI, next to the scan chart, because over-claiming here erodes trust in the whole product:
| Limit | Explanation |
|---|---|
| A scan is a URL request, not a camera event. | LinkHub sees an HTTP request. Anyone who types the short URL, copies it from a photo, or shares it in a message produces an identical request. Attribution is therefore "traffic to a code's URL", not "camera scans", and the UI uses that wording. |
| Dedicated slugs are what make attribution work. | A QR whose slug is also published as a clickable link cannot be separated. LinkHub defaults to a dedicated slug for this reason (14.2.3); reused slugs report source = unknown. |
| No referrer. | Camera and in-app browsers send no referrer, so scan traffic cannot be attributed to a physical location by referrer. Physical placement must be modelled with separate codes per location — the product recommends this explicitly and the bulk generator exists to make it cheap. |
| In-app browsers distort device data. | Scans opened inside a social app's embedded browser report that app's user-agent family, not the system browser. Device type and OS family remain reliable; browser family does not. |
| Preview and prefetch. | Some scanning apps and messaging clients fetch the URL to render a preview before the user taps. These arrive with datacenter or known-bot signatures and are flagged is_bot = true by the pipeline in Section 17.6, but a small residual over-count is unavoidable and is disclosed. |
| Repeat scans by the same person. | Unique counting uses the rotating daily visitor hash (Section 17.3). A person scanning the same poster on Monday and Tuesday counts as two uniques, and multi-day unique totals are an upper bound. This is a deliberate privacy trade-off, not a defect, and the tooltip says so. |
| Uniques exist for a code's total, not for a breakdown. | Unique visitors are deduplicated at resource level only. A breakdown by country, device or batch reports scan events, not unique people, and the UI labels those columns accordingly (Section 17.9). |
| Offline scans are invisible. | A scan with no connectivity never reaches LinkHub. There is no way to count it. |
| Geography is country and region only. | Never city, never coordinates. A "which store" question must be answered with a code per store, not with geo data. |
14.10.3 Reports Specific to QR #
| Report | Contents |
|---|---|
| Scans over time | Daily/hourly series with a print-date marker and version-change markers annotated on the axis |
| Scans by code | Ranked table across the workspace, filterable by print_batch_label |
| Batch comparison | Side-by-side series for codes sharing a batch label — the intended way to compare physical placements |
| Version impact | Scans before and after a restyle, on the same code, with the version boundary marked |
| Printed vs fallback host | The scan_host split, with an alert when the mirror share exceeds 5% of a code's scans over 24 hours |
| Fallback stage | The fallback_stage split, which surfaces a code sitting on rung 3 while still being scanned |
| Time-to-first-scan | Days between the declared print date and the first recorded scan, for print-run effectiveness |
14.11 QR Error Codes #
| HTTP | Code | Meaning | Remedy |
|---|---|---|---|
| 400 | qr_name_required |
name missing or empty |
Provide a name |
| 400 | qr_error_correction_too_low |
L requested |
Use M, Q or H |
| 400 | qr_quiet_zone_too_small |
Fewer than 4 modules requested | Use 4 or more |
| 400 | qr_logo_invalid_format |
Logo is not PNG, JPEG or SVG, or exceeds 2 MB | Re-upload |
| 400 | qr_cta_text_too_long |
Call-to-action text over 24 characters | Shorten |
| 403 | insufficient_role |
Viewer attempted a mutation | Ask an Admin |
| 403 | resource_not_granted |
Scoped member without a grant | Request a grant |
| 403 | plan_limit_reached |
QR cap or the fair-use creation ceiling | Upgrade or delete an unused code |
| 403 | plan_feature_unavailable |
Bulk CSV export on a plan without it | Upgrade |
| 403 | billing_write_blocked |
Destination or styling edit from day 8 of past-due (Section 22.7). Never blocks resolution, rendering or download. | Settle the invoice |
| 404 | not_found |
No such code visible to this actor; also the cross-workspace response | — |
| 409 | qr_slug_reserved |
Slug is taken or permanently reserved on the printed host or the system host, by a link or by a code | Use the suggested alternative |
| 409 | link_slug_immutable_qr |
Slug change attempted on a QR-backed link | Create a new code |
| 409 | link_domain_immutable_qr |
Domain change attempted on a QR-backed link | Create a new code |
| 409 | link_has_qr |
Delete or archive attempted on the backing link | Leave it in place |
| 422 | qr_unscannable |
All escalations exhausted and the render still failed validation | Apply the suggested styling fix |
| 422 | qr_contrast_too_low |
Foreground/background ratio below 4.5:1 | Increase contrast, or use "fix for me" |
| 422 | qr_logo_too_large |
Logo exceeds 22% of symbol width or height | Reduce the logo |
| 422 | qr_logo_overlaps_finder |
Logo intersects a finder pattern | Reduce or re-centre the logo |
| 422 | qr_logo_overlaps_timing |
Logo intersects a timing pattern | Reduce the logo |
| 422 | qr_logo_overlaps_format_info |
Logo intersects format information | Reduce the logo |
| 422 | qr_logo_overlaps_version_info |
Logo intersects version information | Reduce the logo |
| 422 | qr_logo_overlaps_alignment |
Logo would cover two or more alignment patterns | Reduce the logo |
| 422 | qr_frame_encroaches_quiet_zone |
Frame geometry enters the quiet zone | Choose another frame |
| 422 | qr_error_correction_locked |
Attempt to lower error correction below the level a styling choice requires | Remove that styling choice first |
| 422 | qr_size_below_minimum |
Requested physical size below the computed minimum | Increase the size |
| 422 | qr_payload_too_long |
Encoded URL exceeds the length that fits at the required level | Use a shorter slug or domain |
| 429 | rate_limited |
Render rate limit exceeded | Honour Retry-After |
| 503 | qr_render_unavailable |
Render service degraded | Retry; the job is queued automatically |
15. UTM Builder, Scheduling, Expiry & Targeting Rules #
Everything in this section runs on the redirect path and is therefore governed by the processing budget in Section 11: p50 < 20 ms, p95 < 50 ms, p99 < 120 ms, server-side. The design rule that follows from that budget is absolute — no feature in this section may perform I/O during resolution. All inputs are derived from the request itself and from the redirect payload already fetched from cache.
15.1 The UTM Builder #
Entitlement: the utm_builder key in Section 22.1.2 — Pro and Business. Free workspaces see the builder disabled with an upgrade prompt; the API returns 403 plan_feature_unavailable.
15.1.1 The Five Parameters #
| Parameter | Required by the builder | Purpose | Typical values |
|---|---|---|---|
utm_source |
Yes | Where the traffic came from | newsletter, instagram, partner-acme |
utm_medium |
Yes | The channel type | email, social, cpc, qr, print |
utm_campaign |
Yes | The campaign | spring-2026, black-friday |
utm_term |
No | Paid keyword | running-shoes |
utm_content |
No | Creative or placement variant | hero-banner, footer-link |
The three required parameters are required by the builder UI only. The API accepts any subset, because a caller may legitimately set only utm_source. A partially filled builder shows an inline validation message rather than blocking the save of the link itself.
15.1.2 Validation and Normalisation #
Applied to every value, in this order:
- Trim leading and trailing whitespace.
- Collapse internal runs of whitespace to a single
_. - Apply Unicode NFKC normalisation.
- Apply the workspace's UTM case policy:
lowercase(default) orpreserve. Lower-casing uses the invariant locale. - Validate against the allowed charset:
[A-Za-z0-9._~+%-]after normalisation. Any other character is percent-encoded rather than rejected, so a legitimate value containing&or a space still produces a correct URL. - Reject if the value is empty after normalisation, or exceeds 200 characters.
| Rule | Value | Error |
|---|---|---|
| Max length per value | 200 characters | 400 utm_value_too_long |
| Max combined UTM query length | 512 characters | 400 utm_too_long |
| Empty after normalisation | Not allowed | 400 utm_value_empty |
| Reserved prefixes | Values may not begin with lh_, which is reserved for LinkHub's own parameters |
400 utm_reserved_prefix |
The case policy defaults to lowercase because case-inconsistent UTM values are the single largest source of fragmented campaign reporting, and lower-casing at the source fixes it permanently. preserve exists for workspaces whose downstream analytics tool is case-sensitive and already standardised on mixed case.
15.1.3 Presets #
Stored per workspace in the presets table defined in Section 6.3.36.
| Field | Constraint |
|---|---|
name |
1–60 chars, unique per workspace |
values |
Any subset of the five parameters |
is_default |
At most one per workspace; pre-fills the builder on every new link |
| Limit | 50 presets per workspace |
| Permissions | Owner, Admin and Editor may create and use. Only Owner and Admin may edit or delete a preset used by more than 10 links; the delete confirmation names the count. |
Applying a preset fills the builder fields; the user may then override individual values. Deleting a preset never alters links already built from it.
15.1.4 Taxonomy-Consistency Warning #
The warning that catches Newsletter versus newsletter, and email versus e-mail.
Algorithm. When a value is entered, it is compared against the distinct values used by the same parameter in this workspace over the last 180 days (maintained as a materialised list of parameter, value, use count and last-used timestamp, refreshed nightly and updated on write):
- Compute a comparison key: lower-cased, with
_,-,.and spaces removed. - If the comparison key matches an existing value but the raw value differs → case/separator collision.
- Otherwise compute the Damerau-Levenshtein distance against every existing value with the same first two characters. A distance of 1 on values of 5+ characters, or a distance of 2 on values of 10+ characters → near-duplicate.
Presentation. A non-blocking inline notice under the field, never a modal, never a save blocker:
You've used newsletter 47 times and Newsletter 0 times. Use
newsletterinstead? [ Use newsletter ] [ Keep Newsletter ]
For a near-duplicate: "Did you mean spring-2026? You've used it 112 times. spring-2026- looks like a typo."
Choosing "Keep" suppresses the warning for that exact value for 30 days, per workspace, so a deliberate new value is not nagged about repeatedly.
An autocomplete dropdown on each field offers the workspace's top 10 existing values for that parameter by use count, which prevents most collisions before they happen.
15.1.5 Live Preview #
Beneath the builder, the final destination URL is rendered continuously, with the added parameters highlighted and a copy button:
https://acme.com/collections/spring?ref=partner
&utm_source=newsletter
&utm_medium=email
&utm_campaign=spring-2026The preview shows the URL after the merge rules in 15.1.6 have been applied, so what the user sees is exactly what a visitor will be sent to. A character count is shown against the 2,048-character destination limit, and a warning appears above 1,800.
15.1.6 Merging with Parameters Already on the Destination #
| Case | Behaviour |
|---|---|
Destination has no utm_* parameters |
Builder values are appended. |
Destination already has a utm_* parameter the builder also sets |
Governed by the conflict policy below. |
Destination has a utm_* parameter the builder does not set |
Left untouched. The builder never removes parameters. |
| Destination has non-UTM parameters | Always preserved, in their original order and position. |
The UTM conflict policy is set per workspace with a per-link override:
| Value | Behaviour | Default |
|---|---|---|
overwrite |
The builder value replaces the destination's value | Yes |
keep_destination |
The destination's existing value wins; the builder value is dropped | No |
overwrite is the default because the builder is the intent of record: a marketer who fills in the builder has said what the campaign is, and a stale parameter pasted into the destination URL is far more often an accident than a decision. The editor shows a one-line notice whenever a conflict is detected, naming the parameter and both values, so the behaviour is never silent.
Parameter ordering in the output: existing destination parameters first, in their original order, then builder parameters in the fixed order source, medium, campaign, term, content. Deterministic ordering matters because it makes the stored destination stable and diffable in the audit log.
15.1.7 Bulk Application #
From the link list, selecting links and choosing "Apply UTM parameters" opens the builder with a preview of the change across the selection.
| Property | Value |
|---|---|
| Selection limit | 500 links per operation |
| Preview | Shows the first 10 resulting URLs plus a count of links whose destination already carries a conflicting parameter |
| Conflict handling | The chosen conflict policy applies to the whole batch and is shown in the confirmation |
| QR-backed links in the selection | Listed separately with a warning that this changes where printed codes send people; a typed confirmation is required if any is present |
| Execution | Runs as a worker job above 100 links, with per-link partial-success reporting in the standard bulk envelope |
| Audit | One link.destination_changed entry per link, each carrying the same bulk operation id in context. No bespoke event key is minted for the batch — every event key in this specification comes from the canonical catalogue in Section 8.9.1. |
| Reversibility | The job records the prior destination for each link, and an "undo" action available for 24 hours restores them |
15.2 Parameter Forwarding #
An incoming request to a short URL may carry query parameters — https://go.acme.com/spring?utm_source=twitter&fbclid=abc. Forwarding decides which of those reach the destination.
15.2.1 The Decision: Allow-List by Default #
param_forwarding_mode is set per link, with a workspace default:
| Mode | Behaviour | Default |
|---|---|---|
allow_list |
Only parameters on the allow-list are forwarded | Yes |
all |
Every incoming parameter is forwarded | No |
none |
No incoming parameters are forwarded | No |
Pass-all is not the default, for three reasons: an arbitrary parameter appended to a destination is a cache-busting and cache-poisoning vector on the destination's CDN; parameters routinely carry personal data (an email address in ?email= is common in email tooling) which would then be forwarded to a third party without the workspace ever deciding to; and unbounded parameters allow a request-size amplification against the destination. all remains available for workspaces that need it and is a deliberate, logged choice.
15.2.2 The Default Allow-List #
utm_source, utm_medium, utm_campaign, utm_term, utm_content, utm_id,
gclid, gbraid, wbraid, dclid, (Google)
fbclid, (Meta)
ttclid, (TikTok)
msclkid, (Microsoft)
twclid, (X)
li_fat_id, (LinkedIn)
epik, (Pinterest)
irclickid, (Impact)
mc_cid, mc_eid, (Mailchimp)
ref, referrer, source, campaign, variant, lang, locale, currency, qparam_forwarding_extra adds up to 20 workspace-defined names. Names are matched case-sensitively, because query parameters are case-sensitive on most origins.
Always stripped, in every mode including all:
| Stripped | Reason |
|---|---|
Any parameter beginning lh_ |
Reserved for LinkHub's own use |
password, pwd, token, access_token, id_token, api_key, secret, session, sid, auth |
Credential-shaped names must never be relayed to a third party |
15.2.3 Limits #
| Limit | Value | On exceed |
|---|---|---|
| Forwarded parameters | 32 | Extras dropped, in the order received |
| Total forwarded query length | 1,024 characters | Truncated at the last complete parameter |
| Final destination URL length | 2,048 characters | Forwarded parameters are dropped from the end until it fits; builder UTM values are never dropped |
| Parameter name length | 64 characters | Dropped |
| Parameter value length | 512 characters | Dropped |
Dropping is silent to the visitor — a redirect must never fail because of a malformed inbound parameter — and is counted in the metric redirect_params_dropped_total.
15.2.4 Merge Precedence #
The final query string is built by applying these layers in order. A later layer overwrites an earlier layer on the same parameter name.
| Order | Layer | Notes |
|---|---|---|
| 1 | Parameters already present on the stored destination URL | The base |
| 2 | Forwarded incoming parameters (per 15.2.1–15.2.3) | Overwrites the destination's value for the same name |
| 3 | UTM builder values | Subject to the conflict policy: with keep_destination, layer 3 does not overwrite a value that came from layer 1 |
| 4 | Parameters attached to the matched targeting rule's destination (15.5) | Highest precedence |
Worked example:
Stored destination : https://acme.com/p?ref=site&utm_source=direct
Incoming request : https://go.acme.com/spring?utm_source=twitter&fbclid=xyz&debug=1
Builder UTM : utm_medium=social, utm_campaign=spring-2026
Conflict policy : overwrite
Forwarding : allow_list (debug is not on the list)
Layer 1 → ref=site, utm_source=direct
Layer 2 → utm_source=twitter (overwrites), fbclid=xyz ; debug dropped (not allowed)
Layer 3 → utm_medium=social, utm_campaign=spring-2026 ; utm_source untouched (builder does not set it)
Result: https://acme.com/p?ref=site&utm_source=twitter&fbclid=xyz
&utm_medium=social&utm_campaign=spring-2026Fragments: an incoming fragment is never forwarded (it never reaches the server). The destination's own fragment is preserved and always placed last.
15.3 Scheduling #
Entitlement: the scheduling_and_expiry key in Section 22.1.2 — Pro and Business. A gated attempt returns 403 plan_feature_unavailable.
15.3.1 Fields #
| Field | Constraint |
|---|---|
scheduled_at |
Must be in the future at the time it is set; must be before expires_at if both are set |
expires_at |
Must be after scheduled_at and after now() when set |
expiry_url |
Validated per Section 12.3 |
schedule_timezone |
IANA zone name, for authoring and display only |
Setting scheduled_at in the past returns 422 schedule_activation_in_past. Setting expires_at before scheduled_at returns 422 schedule_window_invalid. The ordering constraint is also enforced in the database (Section 6.3.32).
15.3.2 Timezone Handling #
- The workspace has a default IANA timezone, set at creation from the creator's browser and editable by Owners and Admins.
- Every link may override it with
schedule_timezone. - The picker accepts a local date and time; the client converts to UTC using the chosen zone's rules for that date, so a schedule set across a daylight-saving boundary lands on the intended wall-clock time.
- Both the UTC instant and the zone name are stored. The zone name is needed to render the value back correctly and to recompute if the user edits the local time later.
- Display always shows the local time with the zone abbreviation and offset, plus the viewer's own local equivalent when it differs: "6 Mar 2026, 09:00 CET (UTC+1) — 08:00 your time".
- Ambiguous local times (the repeated hour when clocks go back) resolve to the first occurrence; non-existent local times (the skipped hour when clocks go forward) resolve forward to the next valid instant. Both are disclosed inline when the picker detects them.
15.3.3 What a Visitor Sees Before Activation #
| Configuration | Response |
|---|---|
| Default | 200 branded page: "This link isn't active yet." Workspace branding, no destination revealed. |
| Activation time disclosure enabled on the link | The same 200 page, including "It goes live on 6 March 2026 at 09:00 CET" |
| Link is QR-backed | The same 200 page. Never a 404. This is rung 3 of 14.8.2, fallback_stage = workspace_unavailable. |
A not-yet-live link is 200 on every host and for every visitor, whether or not a QR code is attached. The status does not depend on how the URL was published, because at resolution time the resolver cannot know how the visitor obtained it.
15.3.4 What a Visitor Sees After Expiry #
| Configuration | Response |
|---|---|
expiry_url set |
302 to that URL. This is rung 2 of 14.8.2, fallback_stage = paused_fallback. |
expiry_url not set |
200 branded page: "This link has expired." Optional workspace contact link. Rung 3, fallback_stage = workspace_unavailable. |
Link is QR-backed, no expiry_url |
The same 200 page. Never a 404, never a 410. |
The expired page never reveals the original destination. An expired link is 200 (or 302 to the fallback) uniformly — there is no configuration under which expiry produces a 4xx.
The paused state behaves identically with the paused fallback URL in place of expiry_url.
15.3.5 Lazy Evaluation, Not a Job #
Decision: the schedule is evaluated at request time. The background job exists only for bookkeeping.
At the edge, the cached redirect payload carries scheduled_at, expires_at, click_limit and status. The resolver compares the request's clock against those bounds before choosing a rung. This is:
| Property | Consequence |
|---|---|
| Exact | A link scheduled for 09:00:00 activates at 09:00:00, not "within a minute of it". A job-driven flip cannot make that promise at any tolerable job frequency. |
| Free | The comparison is two integer comparisons on data already in memory. It adds no I/O and no measurable latency to the budget. |
| Robust | A stalled worker, a queue backlog or a deploy cannot leave a link stuck in the wrong state. There is no failure mode where a campaign misses its launch because a job did not run. |
| Consistent | Every edge node makes the same decision from the same payload, so there is no window in which some regions have flipped and others have not. |
Clock discipline: every edge host runs NTP with a monitored offset; an offset above 500 ms fires an alert. Schedule comparisons use UTC exclusively.
Cache TTL clamping. The redirect payload's TTL is set to min(3600, seconds_until_the_next_schedule_boundary), so a payload can never outlive the boundary it describes. A link activating in 40 seconds is cached for 40 seconds.
The bookkeeping job (schedule-reconcile) runs every 60 seconds and updates the status column for links whose bounds have been crossed. It exists so that the dashboard, list filters, exports and the API's status field agree with what visitors experience. It is explicitly not on the critical path, and a documented invariant states that disabling it entirely would change no visitor's experience.
15.3.6 Editing a Schedule #
- Extending
expires_aton an expired link returns it toactive(transition T7 in 12.1.4) and re-runs destination validation, because the destination may have been flagged since. - Clearing
scheduled_aton a scheduled link activates it immediately. - Both operations invalidate the cache per 12.4.3.
- Scheduling changes on a QR-backed link show the printed-code notice, though not the typed confirmation — a schedule change is recoverable, a destination change is what people print against.
15.4 Click-Limit Expiry #
An alternative or a companion to time expiry. When both are set, whichever is reached first expires the link, and the reason is recorded so the UI can say which.
15.4.1 Semantics #
| Property | Decision |
|---|---|
| Range | 1 – 10,000,000 |
| Counts | Non-bot resolutions that produce a rung-1 redirect. Requests flagged is_bot, password-interstitial views, fallback-page views and rejected requests do not count. |
| Uniqueness | Total clicks, not unique visitors. A unique-visitor limit would require durable per-visitor state on the redirect path and is out of scope; the UI states "total clicks" explicitly on the field label. |
| On reaching the limit | The link becomes expired and follows 15.3.4. |
| Raising the limit | Returns the link to active, re-running destination validation. |
| Lowering below the current count | Accepted; the link expires immediately. The confirmation says so. |
15.4.2 Counting Accurately Without Slowing the Redirect #
The redirect path already performs exactly one Redis round trip to fetch the payload. Click-limit counting adds no additional round trip because it is pipelined into the same one:
PIPELINE
GET rd:{host}:{slug}
INCR <the link's click counter> (only issued when the payload carries a click_limit)
EXECSection 4 owns the Redis key catalogue; the click counter and its once-only limit-hit guard are registered there alongside the other keys, and no key is named in two places.
The INCR returns the post-increment value. The resolver compares it to click_limit from the payload and:
| Comparison | Action |
|---|---|
count <= limit |
Serve rung 1 |
count > limit |
Serve the expired behaviour, and — once only, guarded by a SETNX on the limit-hit key — enqueue a state-flip message so the dashboard updates within seconds |
Correctness properties:
| Property | Detail |
|---|---|
| Atomic | INCR is atomic across every edge node, so concurrent requests cannot both consume the last remaining click. |
| Exact under normal operation | Overshoot is zero: the Nth+1 request sees count = limit + 1 and is refused. |
| Seeding | On a cache miss, the counter is seeded from the link's cached click count inside a Lua script (SET … NX then INCR) so two concurrent misses cannot double-seed. |
| Reconciliation | The ingest worker (Section 17) is the authority for the durable count and writes the cached count on every batch. A nightly job reconciles the Redis counter against it and logs any drift above 0.1%, which would indicate a lost-write bug. |
| Redis unavailable | Fail open. The redirect is served. The limit may overshoot for the duration of the outage, and the overshoot is reported in the link's activity as "N clicks during a counter outage". A closed failure would break printed QR codes, which is forbidden by 14.8.1. |
| Counter TTL | 90 days from last write, refreshed on each INCR, so abandoned counters expire. Any later miss re-seeds from PostgreSQL. |
| Bot exclusion | The edge performs the cheap user-agent classification it already needs for the analytics event; requests it flags as bots skip the INCR entirely. Datacenter-ASN classification happens downstream and is corrected during reconciliation. |
Measured cost of the whole mechanism: under 0.3 ms added to p95, because it rides an existing round trip.
15.5 The Targeting Rule Engine #
15.5.1 The Model #
A link may carry an ordered list of rules, stored as rows in the rules table defined in Section 6.3.35 and compiled into the redirect payload. Each rule is a set of conditions combined with AND, plus a destination. Evaluation is top to bottom, first match wins. If no rule matches, the default destination — the link's own destination_url — is used.
{
"default_destination": "https://acme.com/global",
"rules": [
{
"id": "0198f6c0-0001-7000-8000-000000000001",
"name": "UK visitors",
"enabled": true,
"conditions": [
{ "field": "country", "operator": "in", "value": ["GB"] }
],
"destination": "https://acme.co.uk/",
"append_utm": true
}
]
}| Property | Decision |
|---|---|
| Combination within a rule | AND across conditions. There is no OR within a rule; an OR is expressed as two rules, which keeps the evaluation order explicit and the UI readable. |
| Combination across rules | First match wins. Later rules are not evaluated. |
| Rule destination | Validated exactly like destination_url (Section 12.3), including SSRF and Safe Browsing. |
append_utm |
Whether the link's builder UTM values are appended to this rule's destination. Default true. |
enabled |
A disabled rule is skipped and greyed in the UI, retaining its position. |
| Max rules per link | Pro 10, Business 25. Exceeding returns 403 plan_limit_reached with details[].kind = "count". |
| Max conditions per rule | 8 |
| Storage and evaluation | Rows in the rules table, compiled into the redirect payload at render time; never read from PostgreSQL during resolution. |
15.5.2 Condition Catalogue #
Every value below is derived from the request in memory. Country and region come from the same in-process IP database used for analytics (Section 17.4); device, OS and browser from user-agent parsing (Section 17.5); language from Accept-Language; referrer from the Referer header; time from the server clock. No lookup in this table performs I/O.
| Rule field | Analytics column it reads | Operators | Value format | Notes |
|---|---|---|---|---|
country |
country_code |
in, not_in |
ISO 3166-1 alpha-2, e.g. GB, US |
ZZ is the unknown-country sentinel used throughout the product and is selectable as a value |
region |
region_code |
in, not_in |
ISO 3166-2 subdivision part, e.g. US-CA, GB-SCT |
Subdivision only; never city |
device_type |
device_type |
is, is_not, in, not_in |
mobile, tablet, desktop, tv, bot, unknown |
— |
os |
os_family |
is, is_not, in, not_in |
ios, android, windows, macos, linux, chromeos, other |
Version comparison is deliberately not offered — UA version data is unreliable |
browser |
browser_family |
is, is_not, in, not_in |
chrome, safari, firefox, edge, samsung, opera, in_app, other |
in_app matches embedded webviews |
language |
— | in, not_in |
ISO 639-1, optionally with a region: en, en-GB, fr |
Matches the highest-weighted Accept-Language entry; a bare en matches en-GB and en-US |
referrer |
referrer_host |
is, is_not, contains, not_contains, starts_with, is_empty, is_not_empty |
Registrable domain, compared case-insensitively | Compared on host only, never the full URL, so query strings cannot be targeted |
time_of_day |
— | between, not_between |
HH:mm–HH:mm in the rule's timezone (defaults to the link's schedule_timezone) |
A window that crosses midnight is supported and stated in the UI |
day_of_week |
— | in, not_in |
mon…sun, evaluated in the rule's timezone |
— |
source |
source |
is, is_not |
qr, click, unknown |
Scan-versus-click. unknown occurs on a reused slug (14.2.3) |
The middle column exists so there is exactly one vocabulary in the product: the rule DSL uses short field names for authoring, and each maps to precisely one analytics column, with no third naming.
Cross-cutting rules:
in/not_inaccept 1–50 values. Beyond 50, 400rule_value_list_too_long.- String comparisons are case-insensitive and Unicode-normalised.
- An unresolvable input (no country, unparseable UA) never matches a positive operator and always matches its negative counterpart, so a rule targeting "not GB" correctly catches an unknown visitor.
15.5.3 Evaluation Order and Guarantees #
- Rules are evaluated in stored order, index 0 upward. Order is the user's, never re-optimised, never re-sorted by the engine — a first-match-wins system whose order is opaque is unusable.
- Disabled rules are skipped without consuming a position.
- The first rule all of whose conditions are true supplies the destination. Evaluation stops.
- If no rule matches, the default destination is used.
- The default destination always exists. It is the link's
destination_url, which is non-null for any link in a resolving state. It is not deletable, not clearable while rules exist, and the API rejects an attempt with 422rule_default_destination_required. A request can never fall through to nothing. - The engine is pure: same request, same rules, same result. No randomness, no state, no I/O. (Randomised splitting is A/B testing, and it lives in Section 16 — see 15.6.)
15.5.4 Latency Ceiling #
| Property | Value |
|---|---|
| Engine budget | ≤ 2 ms p99 for the full evaluation of a 25-rule set, measured inside the redirect handler |
| Share of the redirect budget | 4% of the 50 ms p95 allowance in Section 11 |
| Implementation | Rules compile to a flat array of predicate closures at payload-render time. Evaluation is an array walk with early exit. |
| Inputs | Country, region, device, OS family, browser family, language, referrer host and clock are resolved once per request, before rule evaluation, and reused by the analytics event — so rules add no parsing cost of their own |
| Enforcement | A benchmark in the performance suite (Section 26.7) fails the build if a 25-rule evaluation exceeds 2 ms p99 on the reference runner |
| Degradation | If evaluation ever exceeds a 10 ms circuit-breaker, the engine aborts, serves the default destination, and increments rule_engine_timeout_total. A visitor is never made to wait on rule evaluation. |
15.5.5 Worked Examples #
Example 1 — store locale split. Send UK and Ireland to the UK store, Germany and Austria to the German store, everyone else to the global store.
{
"default_destination": "https://acme.com/",
"rules": [
{ "name": "UK & Ireland", "conditions": [ {"field":"country","operator":"in","value":["GB","IE"]} ],
"destination": "https://acme.co.uk/" },
{ "name": "DACH", "conditions": [ {"field":"country","operator":"in","value":["DE","AT"]} ],
"destination": "https://acme.de/" }
]
}Example 2 — app-store split. Send iOS to the App Store, Android to Play, everyone else to the marketing page.
{
"default_destination": "https://acme.com/app",
"rules": [
{ "name": "iOS", "conditions": [ {"field":"os","operator":"is","value":"ios"} ],
"destination": "https://apps.apple.com/app/id123456789" },
{ "name": "Android", "conditions": [ {"field":"os","operator":"is","value":"android"} ],
"destination": "https://play.google.com/store/apps/details?id=com.acme.app" }
]
}This is exactly what the deep-link setting in 12.7.4 compiles down to; there is one engine, not two.
Example 3 — campaign window. During the sale, send everyone to the sale page. Outside it, the default applies — no edit required at either boundary.
{
"default_destination": "https://acme.com/collections/all",
"rules": [
{ "name": "Sale window",
"conditions": [
{ "field": "day_of_week", "operator": "in", "value": ["fri","sat","sun"] },
{ "field": "time_of_day", "operator": "between", "value": "00:00-23:59",
"timezone": "Europe/London" }
],
"destination": "https://acme.com/collections/weekend-sale" }
]
}Example 4 — printed code, different behaviour in store. A QR on shelf-edge labels: scans in Germany during opening hours get the German product page; every other visitor gets the global product page.
{
"default_destination": "https://acme.com/p/widget",
"rules": [
{ "name": "In-store DE",
"conditions": [
{ "field": "source", "operator": "is", "value": "qr" },
{ "field": "country", "operator": "in", "value": ["DE"] },
{ "field": "time_of_day", "operator": "between", "value": "09:00-20:00",
"timezone": "Europe/Berlin" }
],
"destination": "https://acme.de/p/widget?in_store=1" }
]
}Example 5 — language over geography. A tourist in France with a Japanese phone should get Japanese.
{
"default_destination": "https://acme.com/en/",
"rules": [
{ "name": "Japanese speakers", "conditions": [ {"field":"language","operator":"in","value":["ja"]} ],
"destination": "https://acme.com/ja/" },
{ "name": "French speakers", "conditions": [ {"field":"language","operator":"in","value":["fr"]} ],
"destination": "https://acme.com/fr/" }
]
}Language is placed above country deliberately. Order encodes the priority, and the UI shows exactly this example when a user first creates two rules whose conditions could both match.
Example 6 — partner referral. Traffic arriving from a partner's site gets a co-branded landing page; everything else gets the standard one.
{
"default_destination": "https://acme.com/offer",
"rules": [
{ "name": "From partner", "conditions": [ {"field":"referrer","operator":"is","value":"partnersite.com"} ],
"destination": "https://acme.com/offer?partner=partnersite", "append_utm": false }
]
}Example 7 — desktop fallback for a mobile-only experience. A mobile wallet pass has no desktop equivalent.
{
"default_destination": "https://acme.com/wallet-instructions",
"rules": [
{ "name": "Mobile only",
"conditions": [ {"field":"device_type","operator":"in","value":["mobile","tablet"]} ],
"destination": "https://acme.com/wallet/download" }
]
}Example 8 — scan-versus-click on a shared campaign. The same campaign runs on a poster and in an email; the poster audience gets a map, the email audience gets the booking page.
{
"default_destination": "https://acme.com/event/book",
"rules": [
{ "name": "Poster scans",
"conditions": [
{ "field": "source", "operator": "is", "value": "qr" },
{ "field": "device_type", "operator": "in", "value": ["mobile"] }
],
"destination": "https://acme.com/event/directions" }
]
}15.6 Interaction with A/B Testing #
Precedence, stated unambiguously: targeting rules are evaluated first. The experiment split is applied afterwards, and only to the destination that targeting selected.
Resolution order on every request:
1. Resolve state (active / scheduled / expired / paused) → 15.3, 12.1.5
2. Evaluate targeting rules → produces a "selected destination slot"
3. If that slot is attached to a running experiment → apply the split (Section 16)
4. Merge UTM and forwarded parameters → 15.1, 15.2
5. Emit 302Consequences, each stated as a rule:
| # | Rule |
|---|---|
| R1 | An experiment is always attached to a specific slot — either the link's default destination or one named targeting rule. It is never attached to "the link" in the abstract. |
| R2 | If a targeting rule matches and that rule has no experiment attached, the visitor is excluded from any experiment on the default destination. The click event records a null variant and excluded_by_rule_id = <rule id> — a column on the click event table defined in Section 6 — so exclusions are visible and countable in the experiment report (Section 16.12.1). |
| R3 | If a targeting rule matches and that rule does have an experiment attached, the split runs within that rule's variants only. |
| R4 | If no targeting rule matches, the default destination's experiment applies as normal. |
| R5 | A visitor is never in two experiments at once on the same link. |
| R6 | Bucketing uses the assignment function in Section 16; adding, reordering or editing a targeting rule never re-buckets a visitor, because the bucket is derived from the experiment id and the visitor hash, not from the rule path. |
| R7 | Assignment stickiness is bounded at 24 hours, because the visitor hash rotates daily (Section 16.2.5). The rule engine neither extends nor shortens that window, and no surface in this section claims a longer one. |
| R8 | The experiment editor shows the rule set above the split and states which slot the experiment is attached to, along with the estimated share of traffic that will reach it based on the last 7 days of matched-rule statistics. If that share is below the minimum-sample guard's reachable threshold, the editor warns before the experiment starts. |
| R9 | Creating a targeting rule that would shadow a running experiment's slot (that is, a new rule placed above it that would capture most of its traffic) shows a blocking confirmation naming the experiment and the estimated traffic loss. |
| R10 | Section 16's minimum-sample guard is computed on traffic that actually reached the experiment slot, not on total link traffic. |
The reasoning behind this order in one sentence: targeting answers "who is this visitor and where should people like them go", which is a business rule; experiments answer "among the options for this audience, which performs better", which is a measurement. Measuring inside a defined audience is meaningful; splitting before segmenting produces variants whose populations differ, which is not a valid test.
15.7 The Rules UI #
15.7.1 Builder #
- Rules render as an ordered, numbered list of cards. Each card shows its name, a plain-English summary ("If country is GB or IE →
acme.co.uk"), an enable toggle, and reorder controls. - Adding a condition presents field → operator → value, with the operator set filtered to those legal for the chosen field and the value control typed to the field (a country multi-select, a time range picker, a free-text host field).
- Destination fields use the same validated input as the link editor, with the live-preview behaviour from 15.1.5.
- The default destination is pinned to the bottom of the list, visually distinct, labelled "Everyone else", and is not removable.
- The card list is a semantic ordered list with correct heading structure for screen readers.
15.7.2 Reordering #
Drag-and-drop, plus a keyboard and button alternative: each card has "Move up" and "Move down" buttons, and a focused card responds to Alt+↑ / Alt+↓. This satisfies WCAG 2.2 success criterion 2.5.7 (Dragging Movements) as required by Section 24. Reordering announces the new position through a live region ("UK visitors, now rule 2 of 4").
15.7.3 The Live Test Tool #
A panel where the user describes a hypothetical visitor and sees the resolved destination, with the reasoning.
Inputs: country, region, device type, OS, browser, language, referrer, source (scan or click), and date/time (defaulting to now, in the link's timezone).
Output:
Result:
https://acme.co.uk/?utm_source=newsletter&utm_medium=email• Rule 1 "Japanese speakers" — no match (language is
en-GB, notja) • Rule 2 "UK & Ireland" — matched (countryGBis in[GB, IE]) • Rules 3–4 not evaluated (first match wins) • Experiment: none attached to this rule — visitor excluded from "Homepage CTA test" • UTM appended:utm_source,utm_medium• Final URL length: 61 of 2,048 characters
The tool evaluates against the draft rule set, so a user can test before saving, and it uses the same compiled engine as production — not a re-implementation — so a discrepancy is impossible by construction. Presets are offered for common visitors ("iPhone in the US", "Android in Germany", "Desktop in the UK", "QR scan, unknown country").
15.7.4 Validation That Catches Unreachable Rules #
Run on every edit, surfaced as non-blocking warnings on the affected card:
| Check | Detection | Message |
|---|---|---|
| Shadowed rule | Rule B's condition set is a superset of an earlier rule A's on the same fields (A's conditions are implied by B's) | "Rule 4 can never match — rule 2 already catches every visitor rule 4 would. Move rule 4 above rule 2, or narrow rule 2." |
| Contradictory conditions | Two conditions on the same field that cannot both hold — country in [GB] and country not_in [GB]; disjoint in lists; a time_of_day window of zero length |
"This rule can never match: country cannot be both GB and not GB." |
| Catch-all above others | A rule with no conditions, or whose conditions are trivially true, placed anywhere but last | "Rule 1 matches everyone, so rules 2–5 will never run." |
| Duplicate rule | Two enabled rules with identical condition sets | "Rules 2 and 5 are identical. Rule 5 will never run." |
| Destination equals default | A rule whose destination is the default destination | "This rule sends visitors to the same place as everyone else. It has no effect." (Informational, not a warning — it may be a deliberate experiment exclusion, per R2.) |
| Empty value list | in with zero values |
Blocking — 400 rule_condition_invalid. |
| Unreachable by traffic | A rule that matched zero requests in the last 30 days while the link received more than 1,000 | "This rule hasn't matched anyone in 30 days." Shown only after 30 days of data. |
Shadow detection is implemented as an implication check over the condition sets, not as a general SAT solver: for each pair (earlier A, later B), B is shadowed if for every field constrained in A, B's constraint on that field is a subset of A's, and B constrains no field A leaves free in a way that could escape A. This is sound (it never reports a false shadow) and deliberately incomplete (it may miss an exotic shadow), which is the correct trade-off for a warning surface.
15.8 Plan Gating and Downgrade Behaviour #
15.8.1 Gating #
Every value below is the entitlement catalogue's, in Section 22.1.2; this table is a reading of it, not a second source.
| Capability | Free | Pro | Business |
|---|---|---|---|
| UTM builder, presets, taxonomy warnings, bulk apply | — | Yes | Yes |
| Parameter forwarding mode | Fixed at allow_list |
Configurable | Configurable |
| Custom forwarding allow-list additions | — | Yes | Yes |
Scheduling (scheduled_at, expires_at, expiry_url) |
— | Yes | Yes |
| Click-limit expiry | — | Yes | Yes |
| Targeting rules | — | Yes, max 10 per link, conditions limited to country, device_type, os, language, source |
Yes, max 25 per link, full condition catalogue |
| Live test tool | — | Yes | Yes |
| Saved rule templates (apply a rule set across links) | — | — | Yes |
Two codes, and only two, express a refusal here:
- 403
plan_feature_unavailable— the capability is not in the plan at all (a Free workspace opening the UTM builder, setting a schedule, or adding a rule). The refusal payload carries"issue": "feature_unavailable"and no limit or current value. - 403
plan_limit_reached— the capability is in the plan but a numeric ceiling is reached (an 11th rule on Pro). The payload carrieslimit,current,planand"kind": "count".
Both use the exact shape in Section 22.2.7.
15.8.2 Downgrade Behaviour #
Existing rules, schedules, click limits and UTM values continue to function after a downgrade. They become read-only; they are never disabled and never deleted.
| Configuration | On downgrade to a plan that does not include it |
|---|---|
| UTM values already on a link | Keep applying. Editing the UTM block returns 403 plan_feature_unavailable; the link's destination remains editable. |
| Schedule already set | Keeps evaluating. The link still activates and still expires as configured. Editing the schedule returns 403 plan_feature_unavailable; clearing it is permitted, because removing a restriction should never require an upgrade. |
| Click limit already set | Keeps counting and still expires the link. Editing returns 403; clearing is permitted. |
| Targeting rules already set | Keep evaluating, in order, unchanged. Editing, adding and reordering return 403 plan_feature_unavailable. Deleting a rule is permitted. Disabling a rule is permitted. |
| Rules above the new plan's count limit | All keep evaluating. No rule is dropped. |
| Rules using conditions above the new plan's catalogue | Keep evaluating. |
There is no separate "read-only after downgrade" error code. A retained configuration the current plan does not include is, precisely, a feature the plan does not have, so it returns plan_feature_unavailable like every other binary gate — the message explains that the configuration keeps working and that an upgrade restores the ability to edit it.
The rationale is stated once and applies throughout: a downgrade must never silently change where a visitor is sent. A printed QR code, a poster, an email already in inboxes and a partner's embedded link all depend on the resolved destination, and changing that as a side effect of a billing event would be a product defect regardless of what the customer is paying. The commercial mechanism for a downgrade is loss of control, not loss of behaviour — which is exactly the pattern Section 22.5 applies to every other resource, and the same principle that pins a QR code's backing link in 12.9.6.
The affected surfaces carry a persistent, dismissible-per-session banner: "Targeting rules are a Pro feature. Your existing rules keep working — upgrade to edit them."
15.9 Error Codes #
| HTTP | Code | Meaning | Remedy |
|---|---|---|---|
| 400 | utm_value_too_long |
A UTM value exceeds 200 characters | Shorten it |
| 400 | utm_value_empty |
A UTM value is empty after normalisation | Provide a value or remove the parameter |
| 400 | utm_too_long |
Combined UTM query exceeds 512 characters | Shorten the values |
| 400 | utm_reserved_prefix |
A UTM value begins with lh_ |
Use a different value |
| 400 | utm_preset_name_taken |
Preset name already exists in this workspace | Choose another name |
| 400 | param_forwarding_mode_invalid |
Mode is not allow_list, all or none |
Use a valid mode |
| 400 | param_forwarding_list_too_long |
More than 20 custom allow-list entries | Remove entries |
| 400 | rule_condition_invalid |
Unknown field, illegal operator for the field, or an empty in list |
Correct the condition |
| 400 | rule_value_list_too_long |
More than 50 values in an in / not_in list |
Split into multiple rules |
| 400 | rule_too_many_conditions |
More than 8 conditions in one rule | Split the rule |
| 400 | schedule_timezone_invalid |
Not a recognised IANA zone name | Use a valid zone |
| 403 | plan_feature_unavailable |
Binary gate: UTM builder, scheduling, click limits, targeting rules, saved templates — including editing a configuration retained from a higher plan | Upgrade |
| 403 | plan_limit_reached |
Numeric ceiling: rule count above the plan's maximum | Remove a rule or upgrade |
| 403 | insufficient_role |
Viewer attempted a mutation | Ask an Admin |
| 422 | schedule_activation_in_past |
scheduled_at is not in the future |
Choose a future time |
| 422 | schedule_window_invalid |
expires_at is not after scheduled_at, or is in the past |
Correct the window |
| 422 | click_limit_out_of_range |
Outside 1 – 10,000,000 | Use a valid value |
| 422 | rule_default_destination_required |
Attempt to clear the default destination while rules exist | Set a default destination |
| 422 | rule_destination_invalid |
A rule destination failed the checks in Section 12.3 | Correct the destination |
| 422 | rule_experiment_conflict |
Attempt to attach a second experiment to a slot that already has one | Stop the running experiment first |
| 429 | rate_limited |
Bulk UTM application or rule-test rate limit exceeded | Honour Retry-After |
16. A/B Testing #
LinkHub ships two distinct experimentation mechanisms that share exactly one assignment function. This section owns the assignment model; every other section that mentions a variant refers here.
16.1 The Two Mechanisms #
LinkHub does not have "an A/B testing feature". It has two, and conflating them produces a broken product because they differ in what varies, where the decision is made, what the primary metric is, and what happens when something fails.
| Bio page variant testing | Short-link destination splitting | |
|---|---|---|
| What varies | The rendered page: block order, per-block copy, button labels, hero/profile imagery, theme accent, block visibility | The destination URL a click resolves to |
| Where the decision runs | The SSR renderer for the bio page host | The redirect resolver in the edge app |
| When it runs | Once per page render | Once per redirect |
| Latency envelope | Inside the bio page performance budgets of Section 11 | Inside the redirect budget of Section 11 (p95 < 50 ms server-side) |
| Unit of exposure | A page view | A click |
| Primary metric | Click-through rate — unique clickers ÷ unique viewers | Conversion rate where a conversion goal is configured, otherwise unique click volume share |
| Number of arms | 2–4 (control + up to 3) | 2–8 destinations |
| What the visitor sees differ | Visibly different content on the same URL | Nothing — the same short URL, a different landing page |
| Failure mode if the experiment record is unreadable | Render the published control page | Serve the link's primary destination |
| Applies to QR codes? | n/a | Yes — a QR code pointing at an experimented link inherits the split; QR resolution still never fails (Section 14) |
| Caching consequence | Shared CDN caching of that page path is disabled while running (16.12) | None — redirects are already private, no-store |
They are genuinely different because the bio page experiment changes a surface LinkHub renders and therefore fully measures, while the destination split changes a surface LinkHub never renders and can only measure up to the moment of departure. That asymmetry drives every downstream difference: page experiments can measure engagement (which block, how fast, how many clicks per viewer); link experiments can measure only arrival, plus whatever a conversion signal reports back.
They compose. A creator can run a page experiment on their bio page while one of the links on that page is itself running a destination split. Section 16.3 defines exactly how those two assignments stay coherent for one visitor.
16.1.1 What is explicitly not in scope for v1 #
Each of these is a decided exclusion, not an open question. Each is named as roadmap in Section 28 where relevant.
| Excluded | Decided reason |
|---|---|
| Multivariate (factorial) testing | With a 100-visitors-per-arm guard, a 2×2×2 design needs 800 qualifying visitors; the target user does not have that traffic. Arms are whole variants, not factor combinations. |
| Multi-armed bandit / auto-optimising allocation | Bandits make "why did my split change" unanswerable for a non-technical user and interact badly with the 7-day salt window. Allocation is fixed until a human changes it. |
| Experiments nested inside a targeting rule branch | Targeting short-circuits experiments (16.12). Combining them multiplies the arm count and makes the sample guard unreachable. |
| Sequential testing / always-valid p-values | The fixed-horizon two-proportion z-test with an enforced minimum duration is simpler to explain and to audit. The guard in 16.7 is what prevents peeking. |
| Experiments on QR styling | The printed symbol cannot be changed after printing, so there is no way to split traffic between two printed designs on one slug. Two designs require two QR codes (Section 18.6). |
| Server-side holdout groups across a whole workspace | Workspace-wide holdouts require identity that survives the daily hash rotation (17.3). Not available. |
16.2 The Shared Assignment Model — Canonical #
Every variant assignment in LinkHub, for either mechanism, is produced by the function defined here. There is no second implementation. It lives in the shared core package and is imported by the SSR renderer, the edge resolver, and the analytics worker.
16.2.1 The bucketing function #
bucket = crc32( experiment_salt || "|" || experiment_id || "|" || visitor_hash ) % 10000| Element | Definition |
|---|---|
crc32 |
CRC-32 (IEEE 802.3, reversed polynomial 0xEDB88320), the standard zlib/table implementation, result read as an unsigned 32-bit integer. |
experiment_salt |
A 32-byte random value, base64-encoded, rotating every 7 days (16.2.4). |
experiment_id |
The experiment's UUID, canonical lowercase hyphenated form. |
visitor_hash |
The 24-character visitor identity string defined in Section 17.3. Never the raw address, never a cookie value. |
|| |
Byte concatenation of the UTF-8 encodings, with a literal | separator between the three parts to prevent boundary ambiguity. |
% 10000 |
Yields a bucket in [0, 9999] — 10,000 basis points of traffic. |
Assignment maps the bucket onto contiguous, half-open, ordered ranges:
function assignVariant(experimentSalt, experimentId, visitorHash, arms):
# arms is the epoch's arm list, sorted ascending by variant_index,
# with integer weight_bp values summing to exactly 10000
key = experimentSalt + "|" + experimentId + "|" + visitorHash
bucket = crc32(utf8Bytes(key)) % 10000
cumulative = 0
for arm in arms:
cumulative += arm.weight_bp
if bucket < cumulative:
return arm
return arms[arms.length - 1] # defensive only; unreachable when Σ weight_bp = 10000Properties that the implementation must preserve, each covered by a unit test under the 95% coverage gate of Section 26:
| Property | Requirement |
|---|---|
| Purity | No I/O, no clock read, no randomness, no global state. Same inputs → same output, in every process. |
| Determinism across runtimes | The SSR renderer, the edge resolver and the worker must produce byte-identical keys and identical buckets for the same inputs. A shared fixture file of 500 (salt, experiment_id, visitor_hash) → bucket triples is asserted in all three packages. |
| Order stability | Arms are ordered by variant_index, which is assigned at creation and never renumbered. Reordering arms in the editor changes display order only, never variant_index. |
| Range contiguity | Ranges are [lo, hi) in variant_index order with no gaps and no overlap. Control (variant_index = 0) always occupies the lowest range. |
| Uniformity | A CI test buckets 1,000,000 synthetic visitor hashes and asserts a chi-square goodness-of-fit p-value > 0.01 against a uniform distribution over 10,000 buckets, and that no configured 5% arm receives less than 4.5% or more than 5.5% of the sample. |
| Cost | ≤ 0.35 ms at p99 including the arm range scan (16.12). |
CRC-32 is deliberately not a cryptographic hash. It is chosen because the input already contains visitor_hash, which is a SHA-256 prefix and therefore already uniformly distributed; CRC-32 only needs to mix and reduce, and it does so in tens of nanoseconds. The security property that matters — that a visitor cannot predict or select their own bucket — comes from the secret rotating salt, not from the mixing function.
16.2.2 The default path: deterministic, cookie-free #
This is the path taken for every visitor unless the consented path in 16.2.3 applies.
- The surface (SSR renderer or edge resolver) computes
visitor_hashin memory per Section 17.3. - It reads the current
experiment_saltfrom process-local memory (refreshed from Redis every 60 seconds; see 16.2.4). - It calls
assignVariant. - It renders or redirects accordingly.
- It writes nothing to the visitor's device. No cookie is set, no local storage is touched, no identifier is returned in a header.
Consequences, stated plainly:
- The assignment is recomputed from scratch on every single request. There is no lookup and no state to lose.
- Two requests with the same
visitor_hashinside the same salt window always produce the same arm. - Because
visitor_hashitself is derived from a salt that rotates every 24 hours (Section 17.3), the same human on a later UTC day presents a differentvisitor_hashand may be assigned a different arm. - Because no cookie is set, this path requires no consent under the legitimate-interest position documented in Section 23, and it functions identically for a visitor who has refused all cookies.
16.2.3 The consented path: exact stickiness via a first-party cookie #
When, and only when, the visitor has granted the analytics consent category (Section 23), the surface additionally pins the assignment in a first-party cookie.
| Property | Value |
|---|---|
| Cookie name | lh_ab |
| Domain | The surface's own host (the bio page host, or the redirect host). First-party only; never set on a shared apex, never a third-party cookie. |
Max-Age |
7,776,000 seconds (90 days) |
HttpOnly |
Yes |
Secure |
Yes |
SameSite |
Lax |
Path |
/ |
| Size cap | 2,048 bytes. Writing is skipped if the encoded value would exceed it. |
| Entry cap | 24 assignments. On overflow the oldest entry by insertion order is evicted. |
Value format:
lh_ab = <base64url(payload_json)> "." <base64url(hmac_sha256(payload_json, ASSIGNMENT_COOKIE_SECRET)[0..15])>
payload_json = {
"v": 1, # payload schema version
"a": { "<exp12>": [<variant_index>, <epoch>] },
"t": 1755600000 # unix seconds, issued-at
}exp12is the first 12 hex characters of the experiment UUID with hyphens removed. Twelve characters over the experiments of a single workspace makes collision negligible; a collision is detected on read (the storedepochwill not match) and resolved by discarding the entry.epochis the experiment's current epoch number (16.5.3). A pinned entry whose epoch does not match the live experiment is discarded and re-assigned.- The HMAC uses a server-side secret from the managed secret store. A cookie failing HMAC verification is treated as absent and is cleared with a
Max-Age=0response.
Read precedence on every request:
1. If analytics consent is granted AND lh_ab is present AND HMAC valid
AND an entry exists for this experiment AND entry.epoch == experiment.current_epoch
→ use entry.variant_index.
2. Otherwise → compute assignVariant() (16.2.2).
3. If analytics consent is granted, write/refresh the entry for this experiment into lh_ab.Additional rules:
- Consent withdrawn → the cookie is deleted on the next response and the visitor silently returns to the deterministic path. No error, no visible change.
- The cookie contains no visitor identity, no workspace identity and no destination. It is an opaque assignment map and nothing else. It is never read by client-side JavaScript (
HttpOnly). - The cookie is never required. Every code path must behave correctly when it is absent, malformed, truncated or stale.
- Analytics events do not record which path produced the assignment as a per-visitor field; instead each event carries
assignment_source∈{deterministic, pinned}so the results UI can report the share of pinned traffic (16.10).
16.2.4 The rotating experiment salt #
| Property | Decision |
|---|---|
| Length | 32 random bytes, base64-encoded |
| Rotation cadence | Every 7 days, at 00:00 UTC on Monday |
| Generation | A scheduled worker job generates the next salt at 23:50 UTC on Sunday and writes it before the boundary |
| Durable home | The managed secret store, versioned, with the two most recent versions retained |
| Hot path home | The experiment-salt entry in the Redis key catalogue owned by Section 4, keyed by the epoch it applies to so that "current" and "previous" are two epoch values rather than two mutable pointers. No TTL; refreshed into process memory every 60 seconds by every surface |
| Logging | Never logged. The structured-logging redaction list (Section 17.13) includes experiment_salt |
| Boundary grace | For 5 minutes after rotation, a surface that has not yet refreshed may still hold the previous salt. This is accepted: it re-buckets a small slice of traffic 5 minutes early and is dominated by the rotation effect itself |
| Failure | If both Redis and the secret store are unreachable, the surface keeps the last salt it loaded and continues. A salt older than 14 days raises a warning; a salt older than 21 days pages. Assignment never fails |
16.2.5 The honest tradeoff at rotation, and how it is absorbed #
Two independent salts affect an assignment, and the shorter one dominates:
| Salt | Cadence | Effect on assignment |
|---|---|---|
| Visitor identity salt (Section 17.3) | 24 hours, 00:00 UTC | Changes visitor_hash, so the same human is a new input to the bucketing function |
| Experiment salt (16.2.4) | 7 days, Monday 00:00 UTC | Changes the mapping itself, so every visitor_hash may move arm |
Therefore, stated without hedging:
On the default cookie-free path, assignment stickiness is bounded at 24 hours. It is exact within a UTC day and probabilistic across days. The 7-day experiment salt fixes the assignment function for an epoch, which is what makes the aggregate traffic split stable across the minimum run length — but it does not extend stickiness, because the visitor identity it consumes rotates every 24 hours. A returning human is a fresh draw on each new UTC day. Nowhere in this product is stickiness on the deterministic path described as lasting a week; the shorter of the two rotations always governs, and the shorter one is 24 hours. On the consented path, the
lh_abcookie pins the assignment exactly for up to 90 days and neither rotation affects it.
What this costs, quantitatively. For a 50/50 test, a human returning on a later day has a 50% chance of landing in the other arm. Re-bucketing is independent of the outcome being measured, so it does not bias the estimate in either direction — it dilutes it. A visitor exposed to both arms across days contributes clicks to both, which pulls the two observed rates toward each other. The measured lift is therefore an underestimate of the true lift whenever a meaningful share of traffic is returning visitors. A test that reaches significance is trustworthy; a test that does not may be losing a real effect to dilution.
It also means the denominator is visitor-days, not humans: a person visiting on three days counts three times in both the numerator and the denominator of every rate. This is consistent, so rates remain correct; only the absolute "unique visitors" figure is inflated relative to distinct humans. Section 17.3 owns this definition and Section 18.2 restates it in the metric catalogue.
How the product absorbs it — four mechanisms, all mandatory:
| Mechanism | Detail |
|---|---|
| Minimum duration ≥ 7 days | An experiment cannot conclude with a winner before 168 hours have elapsed since the current epoch started. This guarantees the sample spans at least one full experiment-salt window rather than a favourable slice of one. |
| Minimum sample of 100 per arm | With dilution pushing rates together, small samples are the dominant risk. The per-arm floor is enforced before any significance claim (16.7). |
| Pooled two-proportion z-test on visitor-days | The test is applied to the same unit the pipeline actually counts. It is not applied to an estimated human count, because LinkHub does not have one and will not invent one. |
| Explicit UI disclosure | The results panel states, in one line under the sample size, that repeat visitors across days are counted separately and that this makes the measured difference conservative. The consented-traffic share (assignment_source = pinned) is shown so a workspace with high consent rates can see that its own stickiness is exact. |
For a workspace that wants exact stickiness and has a consent banner enabled, the pinned path delivers it. For a workspace that does not, LinkHub does not pretend otherwise — it enforces a run length and a sample floor sized for the noisier measurement, and says so on the screen.
16.3 Cross-Surface Stickiness #
The requirement: a visitor who lands on a bio page and is shown variant B must, when they click any link on that page, be treated as the same visitor by the link's own experiment and by analytics — and no variant identity may ever appear in an outbound URL.
16.3.1 The mechanism #
Both surfaces derive visitor_hash from the same inputs — the current daily salt, the client address held in memory, the user-agent string, and the workspace id (Section 17.3). A visitor who renders a bio page and then clicks a link on it, within the same UTC day, from the same network and the same browser, presents an identical visitor_hash at both surfaces. Nothing is transferred between the two requests to make this true; it falls out of the derivation.
That shared hash is what allows the two independent assignments to join:
- The page experiment's arm is a pure function of (
experiment_salt, pageexperiment_id,visitor_hash). - The link experiment's arm is a pure function of (
experiment_salt, linkexperiment_id,visitor_hash). - Both are recomputed independently and both are stable for the same visitor. Neither needs to know about the other at request time.
To attribute a click back to the page variant that produced it, the assignment is published for the ingest worker to pick up — never for the redirect path to read, because the redirect budget does not permit an extra round trip:
| Step | Actor | Action |
|---|---|---|
| 1 | SSR renderer | After assigning the page variant, issues a fire-and-forget Redis write: HSET ab:seen:{workspace_id}:{visitor_hash} {experiment_id} {variant_id} followed by EXPIRE ab:seen:{workspace_id}:{visitor_hash} 1800. Pipelined into a single round trip, not awaited. |
| 2 | Edge resolver | Does not read this key. It computes the link's own assignment and redirects. |
| 3 | Ingest worker | While enriching a click event, reads ab:seen:{workspace_id}:{visitor_hash} and denormalises the matching page experiment/variant onto the click row as source_page_id, source_page_experiment_id and source_page_variant_id. |
| 4 | Ingest worker | If the key is absent (expired, Redis flushed, or the visitor arrived from somewhere other than an experimented page), the columns are left null and the click is attributed as off-page. This is a degradation, never an error. |
The 1,800-second window is the attribution window: a click more than 30 minutes after the page render is not attributed to that render. This is stated in the UI on the bio page results panel.
16.3.2 The decision: variant identity never appears in a URL #
No experiment id, variant id, arm index, bucket value or assignment token is ever appended to, or encoded in, any outbound destination URL, any bio page URL, or any short link. There is no opt-in and no workspace setting that changes this.
Reasons, all of which are product requirements rather than preferences:
| Reason | Detail |
|---|---|
| Destination integrity | Customers paste destination URLs that already carry their own parameters. Appending our own creates collisions, breaks signed URLs, and breaks destinations that validate their query string. |
| Attribution hygiene | An appended parameter would be forwarded into the customer's own analytics, inflating their landing-page URL cardinality and polluting their reports with LinkHub internals. |
| Shareability | A visitor who copies the URL from their address bar and shares it would propagate a variant assignment to other people, corrupting the experiment. |
| Privacy posture | A durable identifier in a URL is a cross-site identifier in everything but name. Section 23's position depends on not creating one. |
| Cacheability | Query-parameter variance defeats destination-side caching and CDN normalisation. |
The one exception, which is not variant identity: when a short link has a conversion goal configured (16.6.3) and the workspace has explicitly enabled click-level conversion attribution, the redirect appends lh_cid=<id> to the destination, where <id> is the click event's own id — the UUIDv7 primary-key value defined for click_events in Section 6. It identifies a click, never an arm; the arm is recoverable server-side by looking up that click row. The default for this setting is off, it is disclosed in the link editor with a preview of the resulting URL, and it is never applied to destinations whose scheme is not http/https.
16.3.3 Worked trace: one visitor, one page render, two clicks #
Illustrative values. Workspace w-1f2a, bio page p-7c31 running page experiment e-page-9a (control 50% / variant B 50%), and two links on that page: l-alpha running destination experiment e-link-4d (60/40) and l-beta with a single destination.
T+0.000s GET https://acme.link/nova (bio page, SSR)
├─ address held in memory, user-agent read
├─ visitor_hash = "k9Qm2Z1sVb7Xa0Rd8Tn4Lg==" [Section 17.3; the in-memory address and the
│ raw user-agent string are both discarded here]
├─ experiment_salt = "S7:2026-W34"
├─ analytics consent = not granted → deterministic path (16.2.2)
├─ bucket = crc32("S7:2026-W34|e-page-9a|k9Qm2Z1sVb7Xa0Rd8Tn4Lg==") % 10000 = 7412
│ arms: [0]=control [0,5000) [1]=variant_b [5000,10000)
│ 7412 ∈ [5000,10000) → variant_b (variant_id = v-page-b)
├─ renders page with variant_b overrides applied
├─ fire-and-forget: HSET ab:seen:w-1f2a:k9Qm2Z1sVb7Xa0Rd8Tn4Lg== e-page-9a v-page-b ; EXPIRE 1800
├─ fire-and-forget: XADD clicks:raw → page_view event
│ { event_type:"page_view", bio_page_id:"p-7c31", experiment_id:"e-page-9a",
│ variant_id:"v-page-b", visitor_hash:"k9Qm...", assignment_source:"deterministic" }
└─ HTML contains <a href="https://acme.link/go/alpha"> and <a href="https://acme.link/go/beta">
— no variant parameter, no assignment token, no click id
T+11.400s GET https://acme.link/go/alpha (redirect, edge)
├─ visitor_hash recomputed from the same inputs = "k9Qm2Z1sVb7Xa0Rd8Tn4Lg==" ← identical
├─ resolve rd:acme.link:go/alpha from Redis (payload already contains the arm list + weights)
├─ schedule/expiry checks pass (Section 15); no targeting rule matches (16.12)
├─ bucket = crc32("S7:2026-W34|e-link-4d|k9Qm2Z1sVb7Xa0Rd8Tn4Lg==") % 10000 = 2038
│ arms: [0]=dest_a [0,6000) [1]=dest_b [6000,10000)
│ 2038 ∈ [0,6000) → dest_a (https://acme.com/spring)
├─ 302 Found, Location: https://acme.com/spring, Cache-Control: private, no-store
│ — destination byte-identical to what the customer configured; nothing appended
└─ fire-and-forget XADD → click event { resource_id:"l-alpha", experiment_id:"e-link-4d",
variant_id:"v-dest-a", visitor_hash:"k9Qm...", ... }
T+11.406s ingest worker picks up the click event in its next batch
├─ HGETALL ab:seen:w-1f2a:k9Qm2Z1sVb7Xa0Rd8Tn4Lg== → { e-page-9a: v-page-b }
└─ writes click row with source_page_id=p-7c31,
source_page_experiment_id=e-page-9a,
source_page_variant_id=v-page-b
T+46.900s GET https://acme.link/go/beta (redirect, edge)
├─ visitor_hash recomputed = "k9Qm2Z1sVb7Xa0Rd8Tn4Lg==" ← identical again
├─ l-beta has no experiment → single destination served
└─ ingest worker attributes this click to page variant v-page-b as well
(same ab:seen key, still within the 1800s window)
Result recorded for e-page-9a / variant_b : 1 unique viewer, 1 unique clicker → contributes 1/1 to CTR
Result recorded for e-link-4d / dest_a : 1 unique clicker
Outbound URLs emitted : https://acme.com/spring and l-beta's destination — unmodifiedSame visitor on the next UTC day: the daily salt has rotated, so visitor_hash differs, so both assignments are redrawn independently. The trace above holds within a day, which is exactly what 16.2.5 states.
If the visitor had granted analytics consent, step 1 would additionally have set lh_ab pinning e-page-9a → 1, and both the page assignment and — because lh_ab is scoped to acme.link, which serves both the page and the redirects — the link assignment would be pinned for 90 days regardless of salt rotation.
16.4 Bio Page Experiments #
16.4.1 What can vary #
A variant is an override patch over the published page, not a copy of it. The patch is a JSON document keyed by target.
| Target | Overridable fields | Notes |
|---|---|---|
| Page | block_order (a permutation of the published block id list) |
Must contain exactly the published block ids, no additions, no removals |
| Page | theme.accent_color, theme.button_shape, theme.button_fill |
Subject to the contrast gate of Section 24 — a variant that fails 4.5:1 cannot be saved |
| Page | profile.display_name, profile.bio_text, profile.avatar_asset_id, profile.cover_asset_id |
Assets must already exist in the workspace media library |
| Block | visible (boolean) |
Hides a published block in this variant only |
| Block | title, subtitle, cta_label |
Any text field the block type declares as overridable in its catalog entry (Section 10) |
| Block | image_asset_id |
For block types that declare an image slot |
16.4.2 What cannot vary, and why #
| Not overridable | Reason |
|---|---|
| Block destination URLs | That is the short-link mechanism's job (16.5). Splitting a destination inside a page variant would double-count the same decision. |
| Adding or deleting blocks | Variants are overlays over one block set. Allowing structural additions makes promotion a merge conflict rather than a patch application, and makes the render cache key unbounded. Hiding a block covers the realistic case. |
Page handle, custom domain, or any URL |
The URL must be identical across arms or the experiment measures the URL, not the content. |
| SEO metadata, canonical URL, Open Graph tags | Social unfurls must be stable; a varying OG image would make shares inconsistent and would be fetched by preview bots, not humans. |
| Lead capture form field definitions | Changing the fields mid-experiment corrupts the lead schema (Section 20). Button copy above the form is overridable. |
| Any consent-gated embed's presence | Toggling an embed changes which pixels fire, which changes the consent surface. Embeds may be hidden in a variant only if the block is not consent-gated. |
| Verified/published state | An unpublished page cannot run an experiment. |
16.4.3 The variant editor #
| Aspect | Behaviour |
|---|---|
| Control | variant_index = 0, always named "Control", always the published page with an empty patch. It cannot be edited from the experiment editor — editing the control means editing the page. |
| Creating a variant | "Duplicate control" creates an empty patch; the editor then records only the fields the user actually changes. |
| Editing surface | Split view: the live control preview on the left, the variant preview on the right, both at the reference viewport (Section 11's reference device) with a desktop toggle. |
| Change indicator | Every overridden field is badged "changed" with a one-click "reset to control" that removes the key from the patch. |
| Diff view | A textual diff panel lists every override as field: control → variant, and is the same rendering used in the audit log entry on promotion. |
| Naming | Variants are auto-named "Variant B", "Variant C", "Variant D" by index and are renameable to ≤ 40 characters. |
| Validation on save | Contrast gate (Section 24), asset existence, block id existence, block_order permutation validity, and the identity check below. |
| Identity check | If a variant's effective render is identical to control (empty patch, or every override equal to the control value), saving fails with experiment_variants_identical. |
| Preview | Each variant has a shareable preview URL …?lh_preview=<signed_token> valid 24 hours, which forces that variant, sets noindex, and emits no analytics events. |
| Accessibility | Each variant is independently checked by the automated accessibility gate of Section 24 before the experiment can start. |
16.4.4 Traffic allocation and arm count #
| Rule | Value |
|---|---|
| Minimum arms | 2 (control + 1) |
| Maximum arms | 4 (control + 3) |
| Weight unit stored | Integer basis points, weight_bp, summing to exactly 10000 |
| Weight unit shown | Percent with one decimal place |
| Minimum per-arm weight | 500 bp (5%) |
| Maximum per-arm weight | 9500 bp (95%) |
| Default | Even split, remainder in basis points assigned to control (e.g. 3 arms → 3334/3333/3333) |
| Editing weights while running | Permitted; starts a new epoch (16.5.3) |
Four arms is the ceiling because the guard requires 100 visitors per arm and a Holm–Bonferroni correction over k−1 comparisons (16.7.4); at five or more arms the corrected threshold plus the sample floor puts a realistic creator months away from a conclusion, and a product that cannot conclude is worse than one that refuses the configuration.
16.4.5 What happens when the underlying page is edited during a running experiment #
Page editing is never blocked by a running experiment. Every case below is a decided behaviour, and every one writes a timestamped annotation to the experiment timeline which is rendered on the results time series (16.10.3).
| Edit | Effect on the experiment |
|---|---|
| A field changed that no variant overrides | Applies to every arm simultaneously. Experiment continues. Annotation page_edited with the field list. The results panel shows a warning that a mid-flight change affects all arms equally but may shift absolute rates. |
| A field changed that some variant overrides | Control changes; the overriding variant keeps its override. Experiment continues. Annotation page_edited_conflicting naming the field and the affected variants. The editor shows an inline warning at the moment of saving, before the change is committed, with the option to also update the variant. |
| A block is added | Appears in every arm at the same position; each variant's block_order patch is extended at the same index. Annotation block_added. |
| A block is deleted | Any variant override targeting it is dropped and block_order patches are rewritten. Annotation block_deleted. If dropping the override leaves a variant identical to control, the experiment auto-pauses with reason variants_identical and notifies the workspace. |
| A block is reordered on the page | Control order changes; variants that override block_order keep theirs; variants that do not inherit the new order. Annotation page_reordered. |
| Structural churn threshold — blocks added plus deleted ≥ 30% of the published block count since the epoch started | The results panel raises a blocking interstitial on next view: "Keep running" or "Restart with a new epoch". If the user dismisses without choosing, the default is keep running, and the annotation remains permanently visible on the chart. |
| Page unpublished | Experiment auto-pauses with reason page_unpublished. Republishing does not auto-resume; the user must resume, which continues the same epoch if the arm set is unchanged. |
| Page deleted (soft) | Experiment moves to archived. Results remain readable for the plan's dashboard reach. Restoring the page within the 30-day window restores the experiment to paused. |
| Theme changed at the workspace brand level | Applies to all arms; annotation brand_theme_changed. |
| A variant's overridden asset is deleted from the media library | Deletion is blocked with asset_in_use_by_experiment naming the experiment and variant. |
16.5 Short-Link Experiments #
16.5.1 Configuration #
| Field | Type | Rules |
|---|---|---|
destinations[] |
array | 2–8 entries |
destinations[].url |
string | ≤ 2048 chars; must pass the full destination safety pipeline of Section 23 (scheme allow-list, private-range DNS rejection, Safe Browsing lookup) at creation and on every edit |
destinations[].weight_bp |
integer | 100–9900, all weights sum to exactly 10000 |
destinations[].label |
string | Optional, ≤ 40 chars, for the results table; defaults to the destination host + path prefix |
destinations[].variant_index |
integer | Assigned on insert, never renumbered; index 0 is the control and is pre-filled with the link's existing destination |
conversion_goal |
object | null | See 16.6.3 |
Eight destinations is the ceiling for the same statistical reason as 16.4.4, relaxed by one power of two because link experiments accumulate clicks faster than page experiments accumulate viewers.
16.5.2 Weight validation #
| Check | Failure code | HTTP |
|---|---|---|
| At least 2 destinations | experiment_arm_count_invalid |
422 |
| At most 8 destinations | experiment_arm_count_invalid |
422 |
Every weight_bp is an integer |
experiment_weights_invalid |
422 |
Σ weight_bp = 10000 exactly |
experiment_weights_invalid (with details.sum) |
422 |
Every weight_bp ≥ 100 |
experiment_weight_below_minimum |
422 |
| No two destinations have the same normalised URL | experiment_variants_identical |
422 |
| Every URL passes the safety pipeline | experiment_destination_rejected (with details.url, details.reason) |
422 |
The editor accepts percentages with two decimal places and converts to basis points. A "Normalise" control scales the entered weights proportionally so they sum to 10000 and shows the resulting values before saving; it never silently adjusts on submit. Rounding on normalise assigns the residual basis points to the arm with the largest weight, deterministically.
16.5.3 Epochs: changing the arm set or the weights mid-experiment #
An epoch is a period during which the arm set and weights are constant. Every experiment has at least one. experiment_epochs records epoch_number, started_at, ended_at, arms_snapshot (the full arm list with weights) and change_reason.
Any of the following ends the current epoch and starts the next:
- adding a destination
- removing a destination
- changing any
weight_bp - changing a destination URL (a change of URL is a change of arm)
- changing the arm set of a bio page experiment (adding, removing, or materially editing a variant patch)
What that does to the data, stated honestly:
Changing weights or the arm set shifts the bucket ranges, so visitors who were previously in one arm move to another. Data from before and after the change is therefore not poolable for a significance test. LinkHub does not pool it. The minimum-sample guard counters reset to the new epoch, the significance test runs only on the current epoch, and the results table shows the current epoch by default with an epoch selector to inspect earlier ones. Nothing is deleted: every epoch's data remains queryable and the time series draws the whole history with a vertical boundary marker at each epoch change.
Specific cases:
| Action | Behaviour |
|---|---|
| Add a destination | New arm receives the next variant_index. The user must supply the full new weight set summing to 10000; an "even split" helper is offered. New epoch. The results UI shows the new arm with zero data and the guard restarts. |
| Remove a destination | The arm is marked removed_at and stops receiving traffic. Its weight is redistributed proportionally across the surviving arms, with residual basis points to the largest, and the proposed result is shown for confirmation before saving. New epoch. The removed arm remains in the results table for prior epochs, greyed, labelled "removed". |
| Remove the control | Permitted. The lowest surviving variant_index becomes the comparison baseline for the significance test, and the results table labels it "baseline (control removed)". |
| Change a weight | New epoch, even for a 1-point change. There is no "minor change" exemption: an exemption would require a threshold, and any threshold would be arbitrary and would let a user reshape traffic without resetting the guard. |
| Edit a destination URL in place | Treated as removing that arm and adding a new one with the same label; new variant_index; new epoch; the old arm's data is retained under the old index. This prevents a URL swap from silently inheriting another URL's performance history. |
| Pause and resume without changes | Same epoch continues. Elapsed-time for the guard excludes paused intervals (16.7.2). |
The interface states the consequence before the change is committed, in a confirmation dialog: "This resets the significance test. You'll need 100 clicks on each destination and 7 days from now before LinkHub will call a winner. Data collected so far stays visible."
16.6 Metrics #
All metrics below exclude events where is_bot = true unless the viewer explicitly toggles bots on (Section 17.6). All uniqueness is distinct (visitor_hash, event_date) per Section 17.3 — the visitor-day, not the human. Every formula states its numerator and denominator explicitly.
One structural note that governs every formula here. Uniqueness is maintained exactly, at the resource grain only, by the daily unique-visitor ledger described in 17.8.3; a variant is a dimension, not a resource, so per-arm unique counts are computed from raw events with COUNT(DISTINCT …) and are therefore bounded by the plan's raw retention (17.10). Experiment results are a raw-backed surface by design (17.9.6). Once an experiment's events pass out of raw retention, the results panel serves the frozen result snapshot of 16.8.3 rather than recomputing, and says so on screen. Multi-day unique figures are sums of daily uniques and are an upper bound on distinct humans, never an exact count (18.2.2).
16.6.1 Bio page experiments — primary metric #
Click-through rate (CTR).
CTR(variant V, range R)
numerator = COUNT(DISTINCT (visitor_hash, event_date))
FROM click_events
WHERE source_page_experiment_id = E
AND source_page_variant_id = V
AND is_bot = false
AND occurred_at ∈ R
denominator = COUNT(DISTINCT (visitor_hash, event_date))
FROM page_view_events
WHERE experiment_id = E
AND variant_id = V
AND is_bot = false
AND occurred_at ∈ R
CTR = numerator / denominator (undefined, rendered "—", when denominator = 0)Reading in words: of the visitor-days that saw this variant, the share that clicked at least one thing on it within the 30-minute attribution window. A visitor-day that clicks six blocks contributes 1 to the numerator, not 6. A visitor-day that sees the page on Monday and clicks on Tuesday contributes 1 view on Monday and, because the attribution key includes the date, is not counted as a Monday clicker.
R is the intersection of the requested range and the current epoch (16.5.3).
16.6.2 Bio page experiments — secondary metrics #
| Metric | Numerator | Denominator |
|---|---|---|
| Total views | COUNT(*) page_view_events for the variant |
— |
| Unique viewers | COUNT(DISTINCT (visitor_hash, event_date)) page_view_events for the variant |
— |
| Total clicks | COUNT(*) click_events attributed to the variant |
— |
| Clicks per viewer | Total clicks | Unique viewers |
| Clicks per clicker | Total clicks | Unique clickers |
| Non-clicking viewer share | Unique viewers − unique clickers | Unique viewers |
| Per-block CTR | COUNT(DISTINCT (visitor_hash, event_date)) clicks on block B attributed to the variant |
Unique viewers of the variant |
| Median time to first click | Median over clicker visitor-days of first_click.occurred_at − page_view.occurred_at, capped at the 1800 s attribution window |
— |
| Lead submissions (where a lead block exists) | COUNT(DISTINCT (visitor_hash, event_date)) lead submissions attributed to the variant (Section 20) |
Unique viewers |
| Assignment pinning rate | Events with assignment_source = 'pinned' |
All events for the variant |
16.6.3 Short-link experiments — primary metric #
Two cases, decided by whether a conversion goal is configured.
Case A — conversion goal configured. Primary metric is conversion rate.
ConversionRate(arm V, range R)
numerator = COUNT(DISTINCT click_events.id)
FROM conversions
JOIN click_events ON conversions.click_event_id = click_events.id
WHERE click_events.experiment_id = E
AND click_events.variant_id = V
AND click_events.is_bot = false
AND click_events.occurred_at ∈ R
AND conversions.occurred_at ≤ click_events.occurred_at + conversion_window
denominator = COUNT(DISTINCT (visitor_hash, event_date))
FROM click_events
WHERE experiment_id = E AND variant_id = V
AND is_bot = false AND occurred_at ∈ R| Conversion goal field | Definition |
|---|---|
source |
pixel (a configured GA4/Meta/TikTok conversion event relayed per Section 19) or webhook (a signed postback to the workspace webhook endpoint) |
event_name |
The event name to count, ≤ 64 chars |
conversion_window |
1, 7 or 30 days; default 7 days |
attribution |
Last click within the window. A click event may produce at most one conversion; a second postback carrying the same lh_cid is idempotently ignored, because conversions.click_event_id is unique |
| Requirement | Click-level attribution (lh_cid, 16.3.2) must be enabled, otherwise the goal cannot be saved: experiment_conversion_requires_click_id, 422 |
Case B — no conversion goal. Primary metric is unique click share.
ClickShare(arm V, range R) = uniqueClickers(V, R) / Σ over all arms A in epoch of uniqueClickers(A, R)Stated honestly in the UI, because this is the important caveat of the whole feature: without a conversion goal, a destination split measures how traffic divided, not which destination performed better. Since traffic division is set by the weights, the only real signal in Case B is a deviation from the configured split — which is what sample-ratio-mismatch detection (16.11) measures. Therefore:
In Case B, LinkHub does not declare a winner. The significance test is not offered, the "Promote winner" action is replaced by "Promote a destination" (a manual choice with no statistical claim attached), and the results panel states in one sentence: "This test has no conversion goal, so LinkHub can only show you how traffic split — not which destination worked better. Add a conversion goal to measure outcomes."
This is a deliberate refusal to imply a result the data cannot support. The canonical rule that short links fall back to "unique click volume" when no pixel is configured is implemented as the reported metric, not as a basis for a winner claim.
16.6.4 Short-link experiments — secondary metrics #
| Metric | Numerator | Denominator |
|---|---|---|
| Total clicks | COUNT(*) click_events for the arm |
— |
| Unique clickers | COUNT(DISTINCT (visitor_hash, event_date)) for the arm |
— |
| Repeat click rate | Total clicks − unique clickers | Total clicks |
| Observed traffic share | Unique clickers for the arm | Unique clickers across all arms in the epoch |
| Allocation deviation | Observed traffic share − configured weight_bp/10000 |
— |
| Conversions | Count of attributed conversions | — |
| Bot share | Events with is_bot = true for the arm |
All events for the arm |
| Page-sourced share | Clicks with a non-null source_page_variant_id |
All clicks for the arm |
| Geo/device/channel breakdown per arm | Standard dimension breakdowns (Section 18.4) filtered to the arm | — |
16.6.5 Metric integrity rules #
| Rule | Consequence |
|---|---|
| Numerator and denominator are always drawn from the same epoch and the same range | A rate is never computed across an epoch boundary |
| Numerator and denominator always apply the same bot filter | Toggling bots on recomputes both, never one |
A rate whose denominator is 0 renders —, never 0% |
Section 18.13 |
| A rate whose denominator is < 30 renders as a fraction ("7 of 24"), not a percentage | Section 18.13 |
| A rate is never shown without its denominator adjacent | Section 18.13 |
| Conversion counts lag clicks by up to the conversion window | The results panel shows "conversions still arriving until " while any click in range is inside its window |
| Metric definitions in the dashboard (Section 18.2) and in the experiment panel are the same code path | A single metric-definition module in the shared core package; a contract test asserts both surfaces return identical values for a fixture dataset |
16.7 The Minimum-Sample Guard #
The guard exists because the single most common failure of a self-serve experimentation feature is a user promoting a "winner" from 40 visitors and a 3-point difference that is noise. LinkHub makes that impossible by default and difficult on purpose.
16.7.1 The three conditions #
All three must hold before a winner can be declared or promoted through the normal path.
| # | Condition | Default | Configurable |
|---|---|---|---|
| G1 | Sample per arm ≥ min_sample_per_arm |
100 | Yes, per experiment, 50–10,000. Values below 100 require the same typed confirmation as force-promote and are audited |
| G2 | Elapsed running time in the current epoch ≥ min_duration_hours |
168 (7 days) | Yes, per experiment, 168–2,160 hours. The floor of 168 is not lowerable — the field rejects values below it with experiment_duration_below_minimum, 422 |
| G3 | Two-proportion z-test p-value < 0.05 (two-sided), after multiple-comparison correction where k > 2 |
α = 0.05 | Confidence level selectable from 90% / 95% / 99%; default 95% |
The unit counted for G1:
| Mechanism | Unit |
|---|---|
| Bio page | Unique viewers (denominator of 16.6.1) |
| Short link, Case A | Unique clickers (denominator of 16.6.3) |
| Short link, Case B | Not applicable — no winner is declared (16.6.3) |
G2's elapsed time excludes paused intervals: elapsed = Σ (running interval durations within the current epoch). An experiment paused for 3 days in the middle of a 10-day run has 7 days of elapsed time, not 10. The results panel shows both wall-clock age and running time when they differ.
16.7.2 Why 100 and why 7 days #
| Threshold | Reason |
|---|---|
| 100 per arm | At a plausible baseline rate of 20%, 100 per arm gives a 95% confidence interval on each rate of roughly ±8 percentage points. That is wide, which is the point: it means only large, real differences clear the bar at the minimum. It is also low enough that a creator with a few hundred weekly visitors can finish a test in a month rather than never. |
| 7 days | Day-of-week traffic composition is the largest confounder in this product's traffic. A test that runs Thursday to Sunday measures weekend audience, not the variant. Seven days also guarantees the sample spans at least one full experiment-salt epoch (16.2.4), so no arm is advantaged by a favourable slice of the salt window. |
16.7.3 The two-proportion z-test, written out #
For the control arm (subscript 1) and a challenger arm (subscript 2):
x₁ = successes in control n₁ = sample in control
x₂ = successes in challenger n₂ = sample in challenger
p̂₁ = x₁ / n₁
p̂₂ = x₂ / n₂
pooled proportion:
p̄ = (x₁ + x₂) / (n₁ + n₂)
pooled standard error:
SE = sqrt( p̄ · (1 − p̄) · ( 1/n₁ + 1/n₂ ) )
test statistic:
z = ( p̂₂ − p̂₁ ) / SE
two-sided p-value:
p = 2 · ( 1 − Φ(|z|) ) where Φ is the standard normal CDF
significant ⟺ p < α (α = 0.05 at the default 95% confidence level, |z| ≥ 1.959964)The reported confidence interval on the difference uses the unpooled standard error, because the pooled form is correct for the null hypothesis test but not for interval estimation:
SE_diff = sqrt( p̂₁(1 − p̂₁)/n₁ + p̂₂(1 − p̂₂)/n₂ )
CI = ( p̂₂ − p̂₁ ) ± z_{α/2} · SE_diffPer-arm intervals use the Wilson score interval rather than the normal approximation, because at n = 100 and low rates the normal interval produces bounds below zero:
Wilson( x, n, z ) :
centre = ( x + z²/2 ) / ( n + z² )
half = ( z / ( n + z² ) ) · sqrt( x(n − x)/n + z²/4 )
CI = [ centre − half , centre + half ]Validity conditions, checked before the test is reported. If any fails, the panel reports "not enough data yet" regardless of G1 and G2:
| Condition | Check |
|---|---|
| Normal approximation applicable | n₁p̄ ≥ 5, n₁(1−p̄) ≥ 5, n₂p̄ ≥ 5, n₂(1−p̄) ≥ 5 |
| Non-degenerate | n₁ > 0, n₂ > 0, SE > 0 |
| Rates in range | 0 ≤ p̂ᵢ ≤ 1 (a rate above 1 indicates an attribution defect and raises an internal alert) |
16.7.4 More than two arms #
With k arms there are k − 1 comparisons, each challenger against control. Running them all at α = 0.05 inflates the family-wise error rate to roughly 14% at k = 4. LinkHub applies Holm–Bonferroni:
1. Compute raw p-values p₍₁₎ … p₍ₖ₋₁₎ for the k−1 challenger-vs-control comparisons.
2. Sort ascending: q₁ ≤ q₂ ≤ … ≤ q_{k−1}.
3. For i = 1 … k−1:
if q_i < α / (k − i) → reject the null for that comparison, continue
else → stop; that comparison and all larger ones are not significant.
4. A winner is declared only if exactly one arm's null is rejected, or if several are rejected
and one of them has the highest point estimate AND its interval does not overlap
the intervals of the other rejected arms.
5. If two or more rejected arms have overlapping intervals, the panel reports
"two variants beat the control, but they are not distinguishable from each other"
and offers promotion of either, without labelling one the winner.Holm is chosen over plain Bonferroni because it is uniformly more powerful at no cost in assumptions, and over false-discovery-rate control because with k ≤ 3 comparisons the difference is negligible and Holm is far easier to explain in a tooltip.
16.7.5 What the interface shows before the guard passes #
The winner region of the results panel never shows a winner, a "leading" badge, an arrow, a green cell, or a lift figure without a confidence interval. It shows one of four states:
| State | Trigger | What is displayed |
|---|---|---|
collecting |
G1 not met on at least one arm | "Collecting data — more visitors needed on ". A progress bar per arm toward min_sample_per_arm. Observed rates shown with Wilson intervals and raw counts. No comparison, no lift. |
waiting_duration |
G1 met, G2 not met | "Enough visitors — waiting until so the test covers a full week." Countdown in days and hours. Rates and intervals shown; lift shown with its interval; no significance verdict. |
inconclusive |
G1 and G2 met, G3 not met | "No clear difference yet." Rates, intervals, lift with interval, the p-value, and an estimate of the additional sample needed to detect the currently observed difference (16.7.6). An explicit "you can keep running, or stop and keep the control" recommendation. |
winner |
G1, G2 and G3 met, and SRM is not critical (16.11) | The winner is named, with rate, lift, interval, p-value, sample, and the plain-language summary (16.10.4). "Promote winner" becomes enabled. |
In collecting and waiting_duration, the arms are displayed in configured order, never sorted by performance, so the layout itself does not imply a ranking.
16.7.6 Remaining-sample estimate #
Shown in collecting and inconclusive. Standard two-proportion sample size for 80% power:
n_per_arm = ( z_{α/2}·sqrt( 2·p̄·(1 − p̄) ) + z_β·sqrt( p₁(1−p₁) + p₂(1−p₂) ) )² / (p₂ − p₁)²
where z_{α/2} = 1.959964 (95% two-sided)
z_β = 0.841621 (80% power)
p₁ = observed control rate
p₂ = observed challenger rate, floored at a minimum detectable effect of
max( |observed lift| , 0.20 × p₁ ) applied to p₁
p̄ = (p₁ + p₂) / 2The 20% relative minimum detectable effect floor prevents the estimate from exploding to "4,300,000 more visitors" when the observed difference is near zero; instead the panel says "at least more per variant to detect a 20% improvement", which is an actionable statement. Estimates above 100,000 per arm are rendered as "more traffic than this resource is likely to get — consider testing a bigger change".
Remaining days = ceil( (n_per_arm − current_n) / observed_daily_rate_per_arm ), using the trailing 7-day average, capped at 365 and rendered as "more than a year" beyond that.
16.7.7 Worked calculation — guard passes #
A bio page experiment, min_sample_per_arm = 100, 95% confidence, 2 arms, 9 days elapsed, one epoch.
| Arm | Unique viewers (n) | Unique clickers (x) | Rate (p̂) |
|---|---|---|---|
| Control | 1,240 | 289 | 0.233065 |
| Variant B | 1,255 | 351 | 0.279681 |
G1: 1240 ≥ 100 ✓ 1255 ≥ 100 ✓
G2: 9 days ≥ 7 days ✓
p̄ = (289 + 351) / (1240 + 1255) = 640 / 2495 = 0.256513
1 − p̄ = 0.743487
p̄(1 − p̄) = 0.190712
1/n₁ + 1/n₂ = 1/1240 + 1/1255 = 0.00080645 + 0.00079681 = 0.00160326
SE = sqrt( 0.190712 × 0.00160326 ) = sqrt( 0.00030574 ) = 0.0174853
p̂₂ − p̂₁ = 0.279681 − 0.233065 = 0.046617
z = 0.046617 / 0.0174853 = 2.6661
|z| = 2.6661 ≥ 1.959964 → G3 candidate
Φ(2.6661) = 0.996163
p = 2 × (1 − 0.996163) = 0.00767 → p < 0.05 ✓
Validity: n₁p̄ = 318.1 ≥ 5, n₁(1−p̄) = 921.9 ≥ 5,
n₂p̄ = 321.9 ≥ 5, n₂(1−p̄) = 933.1 ≥ 5 ✓
Relative lift = 0.046617 / 0.233065 = 0.2000 → +20.0%
Unpooled SE for the interval:
p̂₁(1−p̂₁)/n₁ = 0.2330645 × 0.7669355 / 1240 = 0.178743 / 1240 = 0.00014415
p̂₂(1−p̂₂)/n₂ = 0.2796813 × 0.7203187 / 1255 = 0.201466 / 1255 = 0.00016053
SE_diff = sqrt( 0.00030468 ) = 0.017455
95% CI on the difference = 0.046617 ± 1.959964 × 0.017455
= 0.046617 ± 0.034211
= [ 0.012406 , 0.080828 ]
= [ +1.24 pp , +8.08 pp ]
Wilson 95% intervals on each rate:
Control : [ 0.2100 , 0.2578 ]
Variant B : [ 0.2554 , 0.3053 ]
VERDICT: winner = Variant B.Rendered summary: "Variant B is the winner. 28.0% of visitors clicked something, against 23.3% for the control — a 20% improvement. With 2,495 visitors over 9 days, there is about a 0.8% chance a difference this large is coincidence. The real improvement is most likely between 1.2 and 8.1 percentage points."
16.7.8 Worked calculation — guard blocks #
Same experiment on day 3.
| Arm | Unique viewers (n) | Unique clickers (x) | Rate |
|---|---|---|---|
| Control | 42 | 9 | 0.2143 |
| Variant B | 39 | 15 | 0.3846 |
G1: 42 ≥ 100 ✗ (58 short) 39 ≥ 100 ✗ (61 short)
G2: 3 days ≥ 7 days ✗ (4 days short)
G3: not evaluated — the guard short-circuits and the test is not run at all.
Observed lift would be +79.5% relative. It is not displayed as a lift.
Wilson 95% intervals:
Control : [ 0.1170 , 0.3563 ]
Variant B : [ 0.2467 , 0.5423 ]
→ the intervals overlap across 0.2467–0.3563; the data is consistent with no difference.
Remaining-sample estimate:
p₁ = 0.2143, observed lift 0.1703 absolute > 0.20 × 0.2143 = 0.0429, so p₂ = 0.3846
p̄ = 0.29945
numerator = ( 1.959964·sqrt(2 × 0.29945 × 0.70055)
+ 0.841621·sqrt(0.2143×0.7857 + 0.3846×0.6154) )²
= ( 1.959964 × sqrt(0.419549) + 0.841621 × sqrt(0.168372 + 0.236683) )²
= ( 1.959964 × 0.647726 + 0.841621 × 0.636440 )²
= ( 1.269501 + 0.535648 )² = ( 1.805149 )² = 3.258563
denominator = (0.3846 − 0.2143)² = 0.1703² = 0.029002
n_per_arm = 3.258563 / 0.029002 ≈ 113
Current n₁ = 42 → 71 more; n₂ = 39 → 74 more.
Observed daily rate ≈ 14 per arm per day → ceil(74 / 14) = 6 more days.
Binding constraint is G2 (4 days) vs sample (6 days) → the panel shows 6 more days.
DISPLAYED STATE: collecting
"Collecting data — about 71 more visitors on Control and 74 on Variant B,
roughly 6 more days at the current rate."
Progress bars: Control 42/100, Variant B 39/100.
Rates shown as "9 of 42" and "15 of 39" — not as percentages (denominator < 30 rule
does not apply here, but the fraction is shown alongside the percentage in all
low-data states per Section 18.13).
"Promote winner" is disabled with tooltip: "Not enough data yet."16.7.9 Force-promote #
An escape hatch exists because real users have real deadlines, and a locked door with no key produces workarounds worse than the thing it prevents. It is deliberately uncomfortable.
| Aspect | Rule |
|---|---|
| Who | Workspace Owner or Admin only. An Editor who can start and promote a guarded experiment cannot force-promote one. Enforced server-side; the API returns 403 forbidden for an Editor. |
| Entry point | Not a button. A text link "Promote anyway" beneath the disabled "Promote winner" control, styled as a secondary destructive action. |
| Dialog content | Which guard conditions fail, with the actual numbers ("Control has 42 of the 100 visitors needed"; "3 of 7 days elapsed"); the observed rates with Wilson intervals; the p-value only if the validity conditions of 16.7.3 hold, otherwise the words "cannot be calculated from this sample"; and one sentence: "Promoting now means you are choosing this variant on judgement, not on evidence." |
| Confirmation | The user must type the experiment's exact name into a field. Case-sensitive, whitespace-trimmed. Mismatch → the action stays disabled; a submitted mismatch returns experiment_force_promote_confirmation_invalid, 422. |
| Audit | Writes experiment.force_promoted to the append-only audit log (Section 8) with: actor, workspace, experiment id and name, promoted variant id, every failing guard condition, per-arm n and x, the p-value or the reason it is unavailable, the SRM state, the epoch number, and the typed confirmation timestamp. This entry is never editable and is retained per the plan's audit retention. |
| Marking | The experiment record is permanently flagged force_promoted = true. The results panel, the experiment list, the exported experiment results (Section 18.10) and the audit entry all carry the label "Promoted without sufficient data". The flag is not clearable. |
| Notification | Every Owner and Admin of the workspace receives an in-app notification and an email naming the actor and the experiment. |
| Rate limit | 5 force-promotions per workspace per rolling 30 days. Beyond that, 429 with code rate_limited and a message pointing at the guard settings. |
16.8 Winner Promotion #
16.8.1 What promotion does #
| Mechanism | Effect |
|---|---|
| Bio page | The winning variant's override patch is applied to the published page as an ordinary edit, producing a new page version through the normal versioning path of Section 9. The page's block order, copy, imagery and theme now match the winning variant for everyone. The experiment stops splitting traffic immediately. |
| Short link | The link's single destination_url is set to the winning arm's URL, through the ordinary destination-change path so that all of its safety checks, cache write-through (rd:{host}:{slug}) and audit logging apply. The split is removed; the link resolves to one destination for everyone. |
In both cases, atomically within one database transaction:
1. Validate: experiment is in `running` or `concluded`; guard passed OR force-promote confirmed;
the target resource still exists and is not soft-deleted; the winning arm was not removed.
2. Apply the change to the resource (new page version, or new link destination).
3. Set experiment.status = 'promoted', promoted_variant_id, promoted_at, promoted_by,
force_promoted flag, and freeze the epoch (ended_at = now).
4. Write the audit entries: `experiment.promoted` (or `experiment.force_promoted`), plus the
resource-level entry the underlying change would normally produce
(`bio_page.published` / `link.destination_changed`) — both, not one.
5. Invalidate caches: `rd:{host}:{slug}` write-through for links; the page HTML cache keys
the page cache keys for the page and each of its variants; the public page CDN path
purge. The key names and the invalidation matrix are Section 4.7's.
6. Commit, then emit the workspace webhook event (Section 19) and the in-app notification.Promotion is idempotent at the API layer via the standard idempotency-key mechanism of Section 21. A second promotion attempt on an already-promoted experiment returns 409 experiment_already_promoted.
16.8.2 What happens to the losing variants #
Nothing is deleted. Ever.
| Artefact | Fate |
|---|---|
| Losing variant records | Retained on the experiment, frozen read-only, with their full override patches (pages) or destination URLs (links) |
| Their event data | Retained under the plan's normal analytics retention (Section 17.10). It does not get shorter because the arm lost |
| Their results | Remain in the results table, labelled "not promoted", with all metrics intact |
| Re-use | A "Run again" action clones the whole experiment — every arm, its patches, its weights — into a new draft experiment on the same resource. Useful for re-testing a losing idea after other changes |
| Assets referenced only by a losing variant | Remain in the media library and remain protected from deletion while the experiment record exists |
16.8.3 Does the experiment record survive #
Yes, permanently.
- The experiment metadata row — configuration, arms, patches, epochs, timeline annotations, the final result snapshot including per-arm
n,x, rates, intervals, z, p, SRM state and guard state at promotion — is retained indefinitely and is not touched by the analytics retention worker. It is small, it is the record of a decision, and users need to answer "why is the page like this?" years later. - Only the event-level data behind it is subject to plan retention. Once raw events age out, the results panel serves the frozen result snapshot and labels it "final result, recorded " rather than recomputing from missing data.
- The snapshot is written at the moment of promotion and at the moment of conclusion, whichever occur, and is immutable thereafter.
- Deleting the underlying page or link soft-deletes the experiment alongside it; hard purge after the 30-day window removes it. Experiments are never independently deletable — there is no "delete experiment" action, only archive.
16.8.4 Rollback #
| Window | Path |
|---|---|
| 0–30 days after promotion | "Undo promotion" in the experiment panel. Restores the exact pre-promotion state: for a page, reverts to the page version captured immediately before promotion; for a link, restores the previous destination_url. Sets the experiment to concluded (not running — traffic splitting does not resume automatically, because resuming a split silently would be a surprising side effect of an undo). Writes experiment.promotion_reverted to the audit log with before/after values. Caches invalidated as in 16.8.1 step 5. |
| After 30 days | The experiment-level undo is gone. Rollback is performed through the resource's own permanent history: the page version history (Section 9) or the link destination history (Section 12), both of which retain every prior value indefinitely. The experiment panel links directly to the relevant history entry so the path is one click away, it is simply not framed as an experiment action. |
| Rollback of a rollback | Not offered as a distinct action. Re-promoting requires a new promotion from the (now concluded) experiment, which is permitted and audited normally. |
Rollback does not delete or alter any recorded metric. The result snapshot stands; only the live resource changes back.
16.9 Experiment Lifecycle #
16.9.1 States #
| State | Meaning | Traffic behaviour |
|---|---|---|
draft |
Being configured. Never served. | 100% control (the experiment is invisible to visitors) |
running |
Actively splitting traffic and collecting data | Per configured weights |
paused |
Configured and populated with data, temporarily not splitting | 100% control arm |
concluded |
Finished collecting. No winner applied to the resource | 100% control arm |
promoted |
A winner has been applied to the resource | 100% the promoted variant's content/destination — the split no longer exists |
archived |
Terminal. Hidden from the default list, results still readable | 100% control arm |
16.9.2 State diagram #
┌──────────────────────────────────────────────┐
│ │
▼ │
┌─────────┐ start ┌─────────┐ pause ┌─────────┐ conclude ┌───┴───────┐
│ draft ├─────────►│ running │◄─────────►│ paused ├───────────►│ concluded │
└────┬────┘ └────┬────┘ resume └────┬────┘ └─────┬─────┘
│ │ │ │
│ archive │ conclude │ archive │ promote
│ ▼ │ │
│ ┌───────────┐ │ │
│ │ concluded │◄──────────────┘ │
│ └───────────┘ │
│ ▲ ▼
│ │ undo promotion (≤30d) ┌──────────┐
│ └───────────────────────────────────────┤ promoted │
│ └────┬─────┘
│ ┌───────────────────────────┘
▼ │ archive
┌──────────┐ ▼
│ archived │◄──────────────────────────────┴───────── (from concluded / promoted)
└────┬─────┘
│ duplicate
▼
┌─────────┐
│ draft │ (a NEW experiment record; the archived one is unchanged)
└─────────┘
running ──promote──► promoted (direct promotion without stopping first)16.9.3 Every transition #
| From | To | Trigger | Preconditions | Side effects |
|---|---|---|---|---|
draft |
running |
User clicks Start; or start_at reached for a scheduled experiment |
Resource exists, is published (pages) / active (links); arms valid (16.4.4 / 16.5.2); variants not identical; every destination passes safety checks; workspace plan includes A/B testing; concurrent-experiment entitlement not exceeded; no other running experiment on the same resource | started_at set; epoch 1 created; cache write-through so the resolver/renderer picks up the arms; audit experiment.started |
draft |
archived |
User archives an unstarted experiment | — | Audit experiment.archived |
running |
paused |
User clicks Pause | — | Epoch continues; paused_at recorded on a experiment_pause_intervals row; cache write-through; audit experiment.paused |
running |
paused |
Automatic: page unpublished; link soft-deleted or archived; a destination fails the weekly safety recheck; plan downgraded below A/B entitlement; variants became identical after a page edit; the resource's owning workspace enters a suspended state | — | Same as manual, plus pause_reason and a workspace notification naming the cause and the remedy |
paused |
running |
User clicks Resume | Same preconditions as draft → running |
Same epoch if the arm set and weights are unchanged; otherwise a new epoch (16.5.3); pause interval closed; audit experiment.resumed |
running |
concluded |
User clicks Stop; or end_at reached |
— | Epoch closed; result snapshot written; audit experiment.concluded |
paused |
concluded |
User clicks Stop | — | As above |
running |
promoted |
User promotes a winner (or force-promotes) | 16.8.1 validation | 16.8.1 side effects |
concluded |
promoted |
User promotes a winner after stopping | 16.8.1 validation; the guard is evaluated against the frozen snapshot, not live data | 16.8.1 side effects |
promoted |
concluded |
User undoes promotion within 30 days | now − promoted_at ≤ 30 days |
16.8.4; audit experiment.promotion_reverted |
concluded |
archived |
User archives; or automatically 180 days after conclusion | — | Audit experiment.archived |
promoted |
archived |
User archives; or automatically 180 days after promotion | — | Audit experiment.archived |
archived |
— | Terminal | — | "Duplicate" creates a new draft; the archived record is never revived |
Forbidden transitions, each returning 409 experiment_invalid_state with details.from and details.to:
- Any state →
draft. Once started, an experiment can never return to draft. Restarting means duplicating. draft→paused,draft→concluded,draft→promoted.archived→ anything.promoted→running.- Two
runningexperiments on the same resource simultaneously (experiment_resource_busy, 409).
16.9.4 Scheduling #
| Field | Rules |
|---|---|
start_at |
Optional; if set, must be ≥ 5 minutes in the future and ≤ 90 days out. A scheduler job transitions draft → running within 60 seconds of the time. If preconditions fail at that moment, the experiment stays draft and the workspace is notified with the specific failure |
end_at |
Optional; if set, must be ≥ start_at + min_duration_hours. Transitions running → concluded. Never auto-promotes — a human always decides |
| Timezone | Both are stored as timestamptz and entered in the workspace's analytics timezone (Section 18.3) |
16.10 The Results UI #
16.10.1 Header #
| Element | Content |
|---|---|
| Status pill | Draft / Running / Paused / Concluded / Promoted / Archived, with the pause reason as a tooltip where applicable |
| Timing | "Running 9 days" (running time, excluding pauses) plus wall-clock age when they differ, plus "started " |
| Epoch selector | Hidden when there is one epoch. Otherwise a dropdown: "Epoch 2 (current) — since 14 Aug" with the change reason. Defaults to current |
| Guard chip | Collecting / Waiting for day 7 / Inconclusive / Winner found, colour-coded grey / grey / amber / green |
| Bot chip | "Bots excluded" with a toggle |
| Assignment chip | "Stickiness: 34% pinned" — the share of events with assignment_source = 'pinned', with a tooltip explaining the consented-cookie path |
| Actions | Pause/Resume, Stop, Promote winner (enabled only per 16.7.5), Edit, Duplicate, Export results (Section 18.10) |
16.10.2 The results table #
Columns, in order. Rows are the arms in variant_index order — never sorted by performance, so the table itself never implies a ranking before the guard passes.
| Column | Content | Empty / low-data behaviour |
|---|---|---|
| Variant | Name, plus a "Control" badge on index 0, plus a "Removed" badge and grey styling where applicable | — |
| Allocation | Configured weight as a percentage | — |
| Observed | Actual share of exposures. Shown in red with an SRM icon when 16.11 flags it | — |
| Exposures | Unique viewers (pages) or unique clickers (links), with total events beneath in a smaller weight | — |
| Conversions | Unique clickers (pages) or attributed conversions (links) | "—" when no conversion goal (Case B) |
| Rate | The primary metric as a percentage, with the raw fraction beneath ("289 of 1,240") | Fraction only when denominator < 30; "—" when denominator = 0 |
| 95% CI | Wilson interval on the rate, rendered as text and as a horizontal interval bar aligned across rows | Hidden when denominator < 10 |
| Lift vs control | Relative lift with its 95% interval; blank on the control row | Hidden entirely until G1 and G2 pass |
| p-value | Raw p, and the Holm-adjusted threshold when k > 2 |
"—" until G1 and G2 pass; "cannot be calculated" when validity conditions fail |
| Verdict | Winner / No difference / — |
Never populated before the guard passes |
Beneath the table: a footnote line stating the exact denominator definition in use ("Exposures are unique visitors per day; a returning visitor on a different day is counted again — see how this is measured"), linking to an in-product help panel that restates 16.2.5.
16.10.3 The time series #
| Aspect | Behaviour |
|---|---|
| Granularity | Hourly when the epoch is ≤ 48 hours old, daily otherwise. Never finer than hourly |
| Series | One line per arm, showing the primary metric per bucket (not cumulative), with a shaded 95% Wilson ribbon per arm |
| Secondary chart | A stacked bar of exposures per bucket per arm, so a user can see whether a rate change was driven by the numerator or the denominator |
| Cumulative toggle | A second mode showing the cumulative rate to date per arm, which is what the significance test actually operates on. Default is per-bucket; the toggle is labelled "Show running total" |
| Annotations | Vertical markers with hover detail for: epoch boundaries, pauses and resumes, page edits (16.4.5), brand theme changes, destination safety flags, SRM alerts, and the promotion event |
| Incomplete bucket | The current in-progress bucket is drawn hatched and excluded from any trend statement (Section 18.3) |
| Empty | When an arm has no data in a bucket, the line breaks rather than drawing zero — a gap is honest, a zero is not |
16.10.4 The plain-language summary #
Generated from a fixed template set, never free-form. One sentence of result, one of magnitude, one of certainty, one of caveat. The templates:
| Guard state | Template |
|---|---|
collecting |
"Still collecting. {arm} needs about {n} more visitors — roughly {days} more days at the current rate. LinkHub won't call a winner before then." |
waiting_duration |
"You have enough visitors, but the test has run {elapsed} of the {required} days LinkHub requires. Traffic on a Tuesday doesn't behave like traffic on a Saturday, so a full week matters." |
inconclusive |
"No clear difference. {winner_candidate} is ahead by {lift}, but with {n_total} visitors that could easily be chance ({p}). You can keep running, or stop and keep the control." |
winner |
"{winner} is the winner. {rate_w} of visitors {action}, against {rate_c} for the control — a {lift} improvement. With {n_total} visitors over {days} days, there is about a {p_pct} chance a difference this large is coincidence. The real improvement is most likely between {ci_low} and {ci_high} percentage points." |
winner + SRM warning |
Above, plus: "One caution: traffic didn't divide the way you configured it ({observed} vs {configured}), which can distort the comparison. Check for bot traffic or caching before acting on this." |
| Case B (no conversion goal) | "This test has no conversion goal, so LinkHub can only show you how traffic split — not which destination worked better. Add a conversion goal to measure outcomes." |
force_promoted |
"This experiment was promoted before it had enough data. {failing_conditions}. Treat the result as a judgement call, not a measurement." |
Copy rules, enforced by a review checklist and by string-lint tests in CI:
- Never "significant" without a number beside it.
- Never "clear winner", "obviously better", "crushing it", or any comparative superlative.
- Never a percentage without its underlying counts within the same visual block.
- Never a lift without an interval.
- Never a future-tense promise about what the change will do to the business.
16.10.5 Per-arm detail #
Expanding a row reveals, for that arm only: the variant diff (pages) or the destination URL (links); the standard dimension breakdowns of Section 18.4 filtered to the arm; the per-block CTR table (pages, 16.6.2); the bot share; and the geographic and device distribution, so a user can check that the arms received comparable audiences rather than comparable volumes.
16.11 Sample-Ratio Mismatch Detection #
16.11.1 The test #
An hourly worker job evaluates, per running experiment and per current epoch, once total exposures ≥ 200:
k arms, configured weights w₁ … w_k (basis points, Σ = 10000)
N = total exposures in the epoch (bots excluded)
Eᵢ = N × wᵢ / 10000 expected exposures for arm i
Oᵢ = observed exposures for arm i
χ² = Σᵢ ( Oᵢ − Eᵢ )² / Eᵢ with df = k − 1
p = 1 − CDF_χ²( χ² , df )The minimum of 200 total exposures exists because below it the expected count for a 5% arm falls under 10 and the chi-square approximation stops being trustworthy. Additionally, if any Eᵢ < 5, the test is skipped entirely and the SRM state is not_evaluated.
16.11.2 Thresholds and product behaviour #
| p-value | State | Behaviour |
|---|---|---|
p ≥ 0.001 |
ok |
Nothing shown |
0.0001 ≤ p < 0.001 |
warning |
An amber banner on the results panel: "Traffic didn't divide quite as configured." Results still shown in full. Promotion still allowed. Recorded on the timeline |
p < 0.0001 |
critical |
A red banner: "Traffic did not divide the way you configured it. These results may be misleading." "Promote winner" is disabled; force-promote remains available with the SRM state named in its dialog and recorded in the audit entry. The verdict column shows "—" regardless of the z-test. An in-app notification and, if configured, a Slack alert (Section 18.12) are sent once per epoch |
The threshold is deliberately strict (0.001, not 0.05) because an SRM check runs hourly on every experiment; at α = 0.05 a healthy experiment would raise a false alarm roughly once every 20 hours, and an alert that cries wolf is an alert nobody reads.
16.11.3 What the product does — and does not do #
| Action | Decision |
|---|---|
| Auto-stop the experiment | No. Stopping loses data and destroys the ability to diagnose. The experiment keeps running and keeps collecting |
| Auto-rebalance weights | No. Silently changing allocation to hit the target would mask the cause and would start a new epoch behind the user's back |
| Block promotion | Yes, at critical, through the normal path |
| Discard the skewed data | No. Nothing is deleted; the epoch selector lets a user compare before and after they fix the cause |
| Offer a diagnosis | Yes — a checklist with live values, below |
16.11.4 The diagnostic checklist shown with an SRM alert #
| Cause | What the panel shows | Suggested action |
|---|---|---|
| Bot skew | Bot share per arm; if one arm's bot share differs from another's by more than 10 percentage points, this row is highlighted | "Bots are already excluded from these numbers, but a bot storm can still distort them. Review the bot breakdown." |
| Epoch boundary inside the window | The epoch start time relative to the evaluation window | "You changed the split recently. Wait for a full window in the new epoch." |
| An arm was paused or removed | The arm's removed_at / weight history |
Automatic — the check excludes removed arms from the current epoch, so this appears only for prior epochs |
| Caching of a page variant | Whether the page's CDN cache-control was overridden by a workspace setting | "An experimented page must not be cached publicly. Check your CDN settings." |
| Very low traffic | N and the smallest Eᵢ |
"With this little traffic, the split is naturally lumpy. This will usually resolve as volume grows." |
| Redirect errors on one destination | Count of destination_rejected and interstitial-served events per arm |
"One destination was flagged and served an interstitial. Fix or remove it." |
| A visitor-hash collision hotspot | Share of exposures from the single most common visitor_hash in the epoch, if > 5% |
"A large share of traffic looks like one visitor — often a shared corporate network. Consider a longer run." |
16.11.5 Worked example #
Configured 50/50 on a link experiment. Current epoch: 5,400 exposures on arm A, 4,600 on arm B.
N = 10,000 E_A = E_B = 10,000 × 5000/10000 = 5,000
χ² = (5400 − 5000)² / 5000 + (4600 − 5000)² / 5000
= 160,000 / 5000 + 160,000 / 5000
= 32 + 32
= 64 df = 1
p = 1 − CDF_χ²(64, 1) ≈ 1.2 × 10⁻¹⁵
p < 0.0001 → STATE: criticalDisplayed: "Traffic did not divide the way you configured it. You set a 50/50 split, but LinkHub served 54.0% / 46.0% across 10,000 clicks. A gap this large is essentially never chance. Promotion is disabled until you check the causes below."
A benign contrast, same configuration, 10,000 exposures at 5,090 / 4,910:
χ² = (90²/5000) × 2 = (8100/5000) × 2 = 1.62 × 2 = 3.24 , df = 1
p = 1 − CDF_χ²(3.24, 1) ≈ 0.0718 → p ≥ 0.001 → STATE: ok, nothing shown16.12 Interaction with Targeting Rules, Caching and the Redirect Budget #
16.12.1 Evaluation order on the redirect path #
Strict, single-pass, first-match-wins. Implemented as an ordered pipeline in the edge resolver with no back-tracking.
1. Resolve rd:{host}:{slug} from Redis (or Postgres on miss, then write-through).
The cached payload already contains: destination, schedule, expiry,
targeting rules, and — if an experiment is running — the full arm list
with weights and the epoch number. No additional lookup is ever needed.
2. Status gate Link archived / over-cap / workspace suspended → branded landing page
(never 404 for QR; Section 14).
3. Schedule/expiry Section 15. If outside the active window → the configured expiry
destination or branded expired page. STOP.
4. Targeting Section 15. Evaluate rules in priority order. FIRST MATCH WINS and
SHORT-CIRCUITS THE EXPERIMENT — the rule's destination is served.
The click is recorded with targeting_rule_id = the matched rule and
experiment_id / variant_id NULL. If an experiment was `running` on
this resource, excluded_by_rule_id is ALSO set to that same rule id.
5. Experiment Only if no targeting rule matched AND an experiment is `running`:
compute assignVariant() (16.2.1) and serve that arm's destination.
targeting_rule_id and excluded_by_rule_id are both NULL.
6. Default Serve the link's primary destination.
7. Emit 302 Found, Cache-Control: private, no-store. Fire-and-forget the event.The precedence rule, stated once and only once in this document: targeting is evaluated before experiments, and a matched targeting rule pre-empts the experiment entirely for that request. Everything else in this section is a consequence of that sentence.
Two columns record the outcome, and they are not the same column, which is what makes the exclusion measurable rather than merely asserted. Both are defined on click_events in Section 6:
| Column | Set when | Means |
|---|---|---|
targeting_rule_id |
A targeting rule matched and its destination was served | "This is the rule that decided the destination." Set whether or not an experiment exists |
excluded_by_rule_id |
A targeting rule matched while an experiment was running on the resource |
"This request would have entered the experiment and was taken out of it by this rule." Always equal to targeting_rule_id when set, and always NULL when no experiment was running |
Because excluded_by_rule_id is written only in the pre-emption case, the "Excluded by targeting" figure in 16.12.2 is an exact count — COUNT(*) WHERE excluded_by_rule_id IS NOT NULL scoped to the epoch — rather than a difference between two totals that could disagree. It also makes the diagnosis specific: the results panel names the individual rule responsible for the largest share of exclusions, not merely "a targeting rule".
16.12.2 Why targeting short-circuits the experiment #
A targeting rule is an explicit human instruction ("everyone in Germany goes here"). Running a split inside a targeted branch would mean the instruction is honoured 60% of the time, which is not what "targeting" means to any user. It would also multiply the effective arm count by the number of matching rules, putting the sample guard permanently out of reach for the target user's traffic volumes.
Consequences that the product must state and handle:
| Situation | Behaviour |
|---|---|
| A targeting rule matches 100% of traffic (e.g. no conditions, or a catch-all) while an experiment is running | Starting the experiment is blocked with 409 experiment_target_conflict and details.rule_id. The editor explains that the rule would consume all traffic |
| A targeting rule is added after the experiment starts and matches a large share | The results panel shows an "Excluded by targeting" row above the arm table, counted exactly from excluded_by_rule_id and broken down by rule, with its exposure count and share; a timeline annotation is written. The excluded traffic is not part of any arm and not part of the significance test |
| Excluded share exceeds 50% of exposures in the epoch | An amber banner naming the rule with the largest excluded share: "More than half your traffic is being handled by a targeting rule and never reaches this test." The experiment continues |
| A user wants to test within a country | Documented answer: create a second link with the targeting rule pointing at it, and run the experiment on that second link. The help panel says exactly this. Nested targeting-plus-experiment is named as roadmap in Section 28 |
16.12.3 Caching #
| Surface | Rule |
|---|---|
| Short link / QR redirect | Unchanged by experiments. Redirects are already 302 with Cache-Control: private, no-store (canonical redirect semantics), so no intermediary can pin an arm. The arm list travels inside the Redis payload, so bucketing costs zero extra I/O |
| Redis payload invalidation | Write-through on every experiment mutation: start, pause, resume, conclude, promote, arm add/remove, weight change. The cache keys and the authoritative invalidation matrix are owned by Section 4.7; this section adds no rival matrix and no rival key names. The payload carries experiment_epoch; a resolver holding a stale payload would serve stale weights until its TTL expires, so mutations always write through rather than waiting for expiry |
| Bio page, no experiment running | Normal shared caching per Section 11 |
| Bio page, experiment running | The SSR response for that page's path is served with Cache-Control: private, no-store and the shared CDN cache for that path is purged at experiment start. This is a real cost and is disclosed in the start dialog: "While this test runs, this page can't be cached at the edge, so it will load slightly slower for visitors far from the server." |
| Bio page variant HTML cache | To keep the Section 11 budgets met without shared caching, each rendered variant's HTML is cached server-side in Redis under the page cache key owned by Section 4 — page:{host}:{handle} — extended with the variant id and the theme hash as further key parts, at a 300 s TTL, invalidated write-through on any page or variant edit per the matrix in Section 4.7. The render cost is therefore paid once per variant per 5 minutes, not once per request |
| Bio page cache after promotion | Shared CDN caching is restored automatically when the experiment leaves running, with an explicit purge of the path |
16.12.4 Latency cost #
| Step | Measured cost | Notes |
|---|---|---|
visitor_hash derivation |
Already paid for analytics on every request (Section 17.3); the experiment adds nothing | — |
| Key construction (string concat, ≤ 128 bytes) | ~0.01 ms | Pre-allocated buffer, no intermediate allocations |
| CRC-32 over ≤ 128 bytes | ~0.002 ms | Table-driven |
| Modulo + range scan over ≤ 8 arms | ~0.001 ms | Linear scan; at 8 arms a binary search is slower than the scan |
lh_ab cookie parse + HMAC verify (consented path only) |
~0.05 ms | Skipped entirely when the cookie is absent |
| Budgeted total | ≤ 0.35 ms at p99 | Against the 50 ms p95 redirect budget, this is under 1% |
Enforced: a CI performance test in the load-test suite (Section 26) runs the redirect path with and without an active experiment on identical hardware and fails the build if the p99 delta exceeds 0.5 ms or the p95 redirect budget is breached. The bio page render path is likewise asserted against the Section 11 budgets with an experiment active.
16.13 Plan Gating and Downgrade Behaviour #
16.13.1 Gating #
A/B testing is a paid entitlement. Section 22.1.3 owns the entitlement key registry and Section 22.1.2 owns every per-plan value; neither is restated here. This section names only the keys it depends on and how each is enforced:
| Entitlement key | What it gates |
|---|---|
experiments_enabled |
Whether the workspace may create or start any experiment at all — a binary feature gate |
experiments_concurrent |
How many experiments may be in running at once in the workspace — a numeric cap |
experiment_arms_max |
The maximum arm count, separately for page experiments and link experiments (16.4.4, 16.5.1) |
experiment_conversion_goals |
Whether a conversion goal may be attached to a link experiment (16.6.3) |
experiment_results_export |
Whether the experiment_results export of 18.10.2 is available |
Enforcement, using the two canonical entitlement error codes:
| Surface | Behaviour when the entitlement is absent |
|---|---|
| Dashboard | The Experiments area is visible but every creation entry point opens an upgrade panel explaining what A/B testing does, with one example. Nothing is hidden, because hiding a feature makes it unfindable and unsellable |
| API, feature not in plan | POST /v1/experiments → 403 plan_feature_unavailable, with details[0].field = "experiments_enabled", details[0].issue = "feature_unavailable" and the workspace's current plan. This is a binary gate, so no limit or current value is carried |
| API, concurrency cap reached | POST /v1/experiments/{id}/start → 403 plan_limit_reached, with details[0].field = "experiments_concurrent", issue = "limit_reached", kind = "count", the limit, the current value and the plan; the message names which experiments are currently running |
| API, arm count above the plan's ceiling | 422 experiment_arm_count_invalid when the request is structurally invalid; 403 plan_limit_reached with field = "experiment_arms_max" when the shape is valid but the plan does not allow that many arms |
16.13.2 Downgrade with experiments running #
The governing principle, consistent with Section 22: resources above a cap become read-only, never deleted, and nothing stops resolving.
| Step | Behaviour |
|---|---|
| At the moment the plan drops below the entitlement | Every running experiment in the workspace transitions to paused with pause_reason = 'plan_downgraded'. This is a single transactional operation; no experiment is left half-transitioned |
| Traffic | 100% of traffic goes to the control arm (variant_index = 0) — for a page, the published page as it stands; for a link, the control destination. Never to the highest-weighted arm, never to the leading arm, because "control" is the only choice that requires no judgement and matches the pre-experiment state |
| A link whose control arm was removed mid-experiment | Falls back to the lowest surviving variant_index. A link never resolves to nothing |
| QR codes pointing at an experimented link | Entirely unaffected. They continue to resolve, forever, per the QR rules in Section 14. Downgrade never touches QR resolution |
| Data already collected | Retained, subject to the new plan's analytics retention (Section 17.10). On Free that means the results become readable for 30 days of history |
| Promotion | Disabled while the entitlement is absent. The button explains why and links to billing |
| Editing | Disabled. Experiments become read-only |
| Results viewing | Allowed, within the new plan's dashboard reach. Beyond it, the frozen result snapshot (16.8.3) is still shown, because it is metadata, not event data |
| Guided downgrade flow | Section 22's downgrade flow lists running experiments as an affected-resource category with counts, so the user sees the consequence before confirming |
| Re-upgrade | Experiments return to paused, not running. The user must explicitly resume. Resuming after any gap longer than 24 hours starts a new epoch, because the traffic composition either side of a multi-day pause is not comparable, and the guard restarts. This is stated in the resume dialog |
| Notification | On downgrade, one email plus one in-app notification per workspace listing every paused experiment, not one per experiment |
16.13.3 Non-payment and suspension #
| State | Experiment behaviour |
|---|---|
past_due |
Experiments keep running and serving for the whole dunning period, without exception. Analytics keep collecting. This is deliberate: interrupting a measurement over a failed card charge produces a corrupted experiment and an angry customer. Write access to experiment configuration follows the past-due schedule owned by Section 22.7 — unrestricted early in the period, then blocked with 403 billing_write_blocked from the day that section names. Serving is never blocked by billing state |
canceled / suspended |
Experiments pause as in 16.13.2. Links and pages follow their own Section 22 rules; QR codes always resolve |
| Workspace deletion | Experiments are soft-deleted with the workspace and hard-purged after the 30-day window. QR slugs survive per Section 14 regardless |
16.14 Error Codes #
All responses use the canonical error envelope of Section 21.3, and every code below is registered in the generated error-code registry of Section 30.2. Codes are snake_case and stable. Two envelope rules apply without exception: meta is present on every response, and error.details is always present and is always an array of issue objects — an empty array when there is nothing further to say, never a bare object and never omitted. Each issue object carries at minimum field and issue; the "details" column below names the additional members that issue carries.
| Code | HTTP | When | details[] members beyond field and issue |
|---|---|---|---|
not_found |
404 | Unknown experiment id, an experiment belonging to another workspace, a soft-deleted experiment, or a request for an epoch number that does not exist. The cross-workspace case returns exactly this, with no hint that the resource exists elsewhere (Section 3.5) | field is experiment_id or epoch |
experiment_invalid_state |
409 | A transition not permitted by 16.9.3 | from, to |
asset_in_use_by_experiment |
409 | Deletion of a media asset referenced by a variant patch (16.4.5) | experiment_id, variant_index, asset_id |
experiment_resource_busy |
409 | Another experiment is already running on this resource |
running_experiment_id |
experiment_already_promoted |
409 | Promotion attempted on an experiment already in promoted |
promoted_variant_id, promoted_at |
experiment_promotion_window_expired |
409 | Undo attempted more than 30 days after promotion | promoted_at, window_days |
experiment_guard_not_met |
409 | Normal promotion attempted while the guard blocks | failing_conditions[], min_sample_per_arm, arms[].n, elapsed_hours, required_hours |
experiment_srm_blocked |
409 | Normal promotion attempted while SRM is critical |
chi_square, p_value, observed[], expected[] |
experiment_target_conflict |
409 | Start attempted while a targeting rule would consume all traffic | rule_id, rule_name |
experiment_page_unpublished |
409 | Start or resume attempted on an unpublished bio page | bio_page_id |
experiment_arm_count_invalid |
422 | Fewer than 2 or more than the mechanism's maximum | min, max, provided |
experiment_weights_invalid |
422 | Non-integer weights, or Σ ≠ 10000 | sum, expected_sum |
experiment_weight_below_minimum |
422 | An arm below its minimum weight | variant_index, weight_bp, min_weight_bp |
experiment_variants_identical |
422 | Two arms render or resolve identically, or a variant patch is empty | variant_indexes[] |
experiment_block_missing |
422 | A variant patch targets a block id not present on the page | block_id, variant_index |
experiment_asset_missing |
422 | A variant patch references an asset not in the workspace library | asset_id, variant_index |
experiment_block_order_invalid |
422 | A block_order patch is not a permutation of the published block ids |
missing[], unexpected[] |
experiment_destination_rejected |
422 | A destination failed the safety pipeline of Section 23 | url, reason (scheme_not_allowed / private_address / safe_browsing_flagged / dns_unresolvable) |
experiment_contrast_failed |
422 | A variant's theme override fails the 4.5:1 gate of Section 24 | variant_index, ratio, required_ratio |
experiment_duration_below_minimum |
422 | min_duration_hours set below 168 |
provided, minimum |
experiment_conversion_requires_click_id |
422 | A conversion goal was configured without click-level attribution enabled | link_id |
experiment_force_promote_confirmation_invalid |
422 | The typed confirmation did not match the experiment name | — |
experiment_schedule_invalid |
422 | start_at in the past or > 90 days out; end_at earlier than start_at + min_duration_hours |
field, provided, constraint |
experiment_variant_removed |
422 | Promotion of an arm that was removed from the current epoch | variant_index, removed_at |
plan_feature_unavailable |
403 | A/B testing, conversion goals or results export is not included in the plan — a binary feature gate | plan; issue is feature_unavailable; no limit or current |
plan_limit_reached |
403 | The concurrent-experiment cap or the arm-count cap is reached | limit, current, plan, kind (count) |
billing_write_blocked |
403 | An experiment write attempted during the blocked window of the past-due schedule owned by Section 22.7 | plan, blocked_since |
forbidden |
403 | Force-promote attempted by a role below Admin; or a scoped member acting on a resource outside their grants (Section 3) | required_role |
rate_limited |
429 | Force-promote cap exceeded (5 per workspace per 30 days) | retry_after_seconds, limit, window |
Example error response, showing the canonical envelope in full — details as an array of issue objects, request_id inside error, and meta present:
{
"error": {
"code": "experiment_guard_not_met",
"message": "This experiment does not have enough data to declare a winner yet.",
"details": [
{ "field": "arms[0].n", "issue": "below_minimum", "value": 42, "minimum": 100 },
{ "field": "arms[1].n", "issue": "below_minimum", "value": 39, "minimum": 100 },
{ "field": "elapsed_hours", "issue": "below_minimum", "value": 72, "minimum": 168 }
],
"request_id": "req_01JAV7QX3M8K2ND4YB6TZ9WPFR"
},
"meta": {}
}Example entitlement error, showing the canonical plan_limit_reached payload shape used everywhere in the product:
{
"error": {
"code": "plan_limit_reached",
"message": "You already have the maximum number of experiments running.",
"details": [
{ "field": "experiments_concurrent", "issue": "limit_reached",
"limit": 3, "current": 3, "plan": "pro", "kind": "count" }
],
"request_id": "req_01JAV7QX3M8K2ND4YB6TZ9WPFS"
},
"meta": {}
}17. Analytics Ingestion Pipeline #
This section specifies the path an event takes from the moment a visitor's request arrives to the moment a number appears on a chart. It owns the raw event tables, the rollup strategy and the retention policy. Section 18 owns what is done with the result.
17.1 Design Goals Stated as Invariants #
These are not aspirations. Each is a property the implementation must hold, with a stated enforcement mechanism and a stated test. Section 26 sets a 95% line-coverage gate on analytics ingest specifically so these can be asserted.
| # | Invariant | Enforcement | Test |
|---|---|---|---|
| I1 | A click is never blocked on writing a row. No analytics operation is awaited before the redirect response or the page HTML is produced. | The dispatch is issued after the response object is constructed and is deliberately detached from the request promise; a lint rule forbids await on the analytics client inside a request handler. |
A unit test injects a Redis client whose every method hangs for 30 s and asserts the redirect still completes within the budget. A load test asserts the p95 redirect budget with Redis artificially latency-injected at 200 ms. |
| I2 | No raw IP address is ever persisted. Not in a table, not in a log line, not in a trace attribute, not in an error report, not in a queue payload. | The address is read from the trusted proxy header into a local variable, consumed by the hash derivation, the geo lookup and the ASN lookup, and never assigned to any object that is serialised. The event payload type has no address field — it is a compile-time impossibility, not a convention. The structured-logging redaction list covers every header and field name that could carry one. | A test asserts the payload type rejects an address field. An integration test drives 1,000 requests with a distinctive address and greps the entire log output, every table's textual columns, and the stream contents for it, asserting zero occurrences. |
| I3 | The pipeline is at-least-once with idempotent writes. Every event is delivered one or more times and produces exactly one stored row and exactly one contribution to every rollup. | Event ids are generated once at capture and never regenerated. Raw inserts use ON CONFLICT DO NOTHING. Rollup increments are derived from the RETURNING set of the raw insert inside the same transaction, so a redelivered event contributes zero. Acknowledgement happens strictly after commit. |
A test replays an identical batch 5 times and asserts row counts and every rollup counter are unchanged after the first. A test kills the worker between commit and acknowledgement and asserts no double-count after restart. |
| I4 | A pipeline failure degrades analytics, never the redirect. Every dependency of the pipeline is optional from the redirect's point of view. | The capture call has no return value the handler inspects, a hard 50 ms timeout, and a catch that only increments a counter. The resolver's own dependencies (Redis payload cache, PostgreSQL fallback) are separate from the analytics path and are the only things that can fail a redirect. | Chaos tests take down the stream, the worker, and the analytics database in turn and assert redirect success rate stays at 100% and the budget holds. |
| I5 | PostgreSQL is the system of record; Redis is a buffer and a cache. No number a user can see exists only in Redis, except the sub-minute real-time window, which is explicitly labelled as such. | Rollups and raw events live only in PostgreSQL. The real-time counters have a TTL and are never read for any historical range. | A test flushes Redis entirely and asserts every dashboard figure outside the real-time widget is unchanged. |
| I6 | Every stored event is attributable to exactly one workspace. There is no cross-workspace event, no global event, and no event with a null workspace. | workspace_id is NOT NULL on every event and rollup table. It is resolved at capture from the already-loaded resolution payload, never looked up later. |
A schema test asserts the constraint. A repository-layer type makes it impossible to construct an analytics query without a workspace id (18.14). |
| I7 | Bot traffic is stored and flagged, never silently discarded. | is_bot is a stored boolean with a stored reason, and a dimension on every rollup. Default views exclude it; nothing deletes it. |
A test asserts that a request with a known bot user-agent produces a row with is_bot = true and that the default dashboard query excludes it while the bots-included query returns it. |
| I8 | Retention is enforced by a job, not by hope. Every table holding event-derived data has a named purge owner and a scheduled job that provably removes data past its plan boundary. | 17.10. Every such table appears in a registry that the retention job iterates; a schema test fails if a new table matching the analytics naming pattern is added without a registry entry. | An integration test seeds data at retention boundaries for each plan and asserts exactly the expected rows survive a purge run. |
| I9 | The raw user-agent string is never persisted. Not in an event table, not in a rollup, not in a log line, not in a trace attribute, not in a spill file, not in a dead-letter entry, not in an export. | The string exists only in memory: at the edge for the visitor_hash and ua_hash derivations, and in the worker for parsing. The event tables defined in Section 6 have no raw user-agent column — it is a compile-time impossibility, not a convention. What is stored instead is user_agent_family (a bounded, human-readable reduction such as Chrome on Android) and ua_hash (the daily-salted digest used only for bot-signature clustering). The spill writer and the dead-letter writer both redact the field before anything reaches durable storage (17.7.5, 17.11.2). The one place a raw string is written is ua_parse_corpus, which by construction carries no visitor key, no workspace key and no timestamp finer than a day, and is therefore not linkable to a visitor. |
A test asserts the event payload type accepted by the writer has no raw user-agent field. An integration test drives 1,000 requests carrying a distinctive user-agent string and greps all log output, all trace exports, every spill object, the dead-letter stream and every textual column of every event and rollup table for it, asserting zero occurrences — with the deliberate exception of ua_parse_corpus, which the same test asserts contains it exactly once and with no other column populated. |
17.2 The Capture Path #
17.2.1 Where capture happens #
| Event | Captured by | Trigger |
|---|---|---|
| Short-link click | The edge resolver | A resolved redirect, at the moment the 302 is constructed |
| QR scan | The edge resolver | A resolved redirect on a QR slug. Same code path; distinguished by resource_type = 'qr' |
| Bio page view | The SSR renderer | A successful server-rendered page response for a published page |
| Bio page block click, tracked | The edge resolver | The block's destination is a LinkHub short link, so the click is an ordinary redirect. Exact, server-side, works with JavaScript disabled |
| Bio page block click, untracked | The browser | The block's destination is a raw external URL, so there is no server hop. A progressive-enhancement beacon fires on pointerdown and on keyboard activation |
The untracked case is the one honest compromise in the capture path, and it exists because Section 11 requires that core links work with JavaScript disabled and are rendered as plain <a href> elements pointing at the real destination. Routing every block click through an interstitial hop would break that requirement and add a network round trip to every outbound click. The decided behaviour:
- Raw-URL blocks are rendered as direct anchors. No hop, no JavaScript required for navigation.
- A 1.1 KB deferred script sends
navigator.sendBeacononpointerdown/keydown[Enter|Space]for those anchors, with akeepalive: truefetchfallback for browsers withoutsendBeacon. It never delays or cancels the navigation. - Events from this path are stored with
is_estimated = true. - The block-level analytics table (Section 18.5) labels those rows "estimated" with a tooltip explaining that clicks from visitors with JavaScript disabled or blocked are not counted.
- The bio page builder offers one-click "Track this link", which converts the raw URL into a workspace short link, after which capture is exact. The block analytics table shows this as an inline call to action on every estimated row.
Everything else is captured server-side and requires no client JavaScript at all.
17.2.2 Exactly what is captured #
Captured in memory at the edge or the renderer, used, and — for the address and the raw user-agent string — discarded rather than persisted:
| Input | Source | Used for | Persisted? |
|---|---|---|---|
| Client IP address | Trusted proxy header chain (the rightmost trusted hop; the trusted-proxy list is deployment configuration per Section 27) | visitor_hash derivation, country/region lookup, datacenter-ASN lookup |
No. Never leaves the process |
User-Agent header |
Request | visitor_hash derivation; ua_hash derivation; parsing in the worker |
No. See below — only user_agent_family and ua_hash survive |
Referer header |
Request | Host extraction and channel classification in the worker | Host only |
Accept-Language header |
Request | Primary language tag | Yes, first tag, truncated to 16 chars |
Client hints (Sec-CH-UA, Sec-CH-UA-Platform, Sec-CH-UA-Mobile, Sec-CH-UA-Platform-Version) |
Request | Device/OS classification, preferred over user-agent regex where present | Only as the parsed values they produce |
Request-shape signals: presence of Accept, Accept-Language, Accept-Encoding, and the protocol version |
Request | Bot rule 5, the implausible-header-shape signal (17.6.1) | As a single boolean, headers_implausible |
Prefetch signals (Sec-Purpose, Purpose, X-Moz, X-Purpose) |
Request | Bot/prefetch flagging | As a boolean, is_prefetch |
| Query string | Request URL | UTM parameter extraction | The five UTM values only |
| Resolution payload | Already loaded from the redirect payload cache for the redirect itself | workspace, resource, domain, destination, experiment arms, targeting outcome, fallback stage | Yes |
| Wall clock | Process | occurred_at |
Yes |
The address is used for three things and then the variable goes out of scope. Geo and ASN resolution happen at the edge, not in the worker, precisely so the address never enters the stream. This is the single most important structural decision in the capture path.
The raw user-agent string is the second such decision, and it is enforced just as hard (invariant I9). The string is read into a local variable at the edge, where it feeds two derivations and is then dropped from the edge's own state:
| Derivation | Where | Result | Notes |
|---|---|---|---|
visitor_hash |
Edge | The 24-character visitor identity of 17.3 | The string is an input to a one-way function and is not recoverable from the output |
ua_hash |
Edge | 16 bytes: the daily-salted SHA-256 of the raw string, truncated, using the same salt and the same 24-hour rotation as visitor_hash (17.3.2) |
Stored on the event row. Its only purpose is clustering rows that shared a user-agent string, which is what makes bot-signature review possible without keeping the string. Because the salt rotates daily and is then destroyed, the clustering is bounded to a day and the value is not a durable identifier |
| Parsed device fields | Worker | device_type, os_family, os_version_major, browser_family, browser_version_major, user_agent_family (17.5) |
The raw string travels in the stream payload as transit-only data, is parsed in the worker's memory, and is discarded there. Nothing in the write path accepts it |
The stream is a transient buffer, not a system of record (invariant I5): it is memory-resident, capacity-trimmed within hours (17.7.1), and both paths that could turn a stream entry into a durable object — the spill (17.11.2) and the dead-letter stream (17.7.5) — parse and redact the field before writing. There is therefore no point in the pipeline at which a raw user-agent string comes to rest.
Parser maintenance still needs to see real strings, and it gets them from exactly one place: ua_parse_corpus, defined in Section 6, which the worker upserts when it meets a string whose parse produces an unknown family. It holds the distinct raw string, a day-granular first_seen_at and an occurrence count — no visitor key, no workspace key, no timestamp finer than a day, and no join path back to an event row. It is the deliberate exception to I9, and it is shaped so that being an exception costs nothing.
17.2.3 The payload schema #
One JSON document per event, pushed as a single stream field. Field order is fixed for compressibility. schema_version is the second field so a consumer can dispatch on it after a partial parse.
Two identifiers used by the exactly-once machinery are not in this payload, because neither is known at capture: stream_message_id is assigned by Redis when the entry is appended, and ingest_batch_id is generated by the worker when it opens a batch. Both are defined on the event tables in Section 6 and both are written on every row. 17.7.3 specifies how all three identifiers are used together.
| Field | Type | Req | Max | Notes |
|---|---|---|---|---|
id |
string (UUIDv7) | ✔ | 36 | Generated once here. The end-to-end idempotency key for the whole pipeline, and the primary-key value the row is stored under. Never regenerated, in any retry, buffer flush, spill replay or dead-letter re-injection |
schema_version |
integer | ✔ | — | Currently 1. A consumer receiving an unknown higher version routes the message to the dead-letter stream rather than guessing |
event_type |
enum | ✔ | — | click | scan | page_view | block_click |
occurred_at |
string (RFC 3339, UTC, ms precision) | ✔ | 24 | Edge wall clock |
workspace_id |
string (uuid) | ✔ | 36 | From the resolution payload |
resource_type |
enum | ✔ | — | link | qr | block | bio_page |
resource_id |
string (uuid) | ✔ | 36 | Link id, QR id, block id, or bio page id |
bio_page_id |
string (uuid) | — | 36 | Set for page_view and block_click |
page_version_id |
string (uuid) | — | 36 | Set for page_view |
domain_id |
string (uuid) | — | 36 | Null on the default short domain |
host |
string | ✔ | 253 | Lower-cased request host |
slug |
string | — | 64 | Null for page_view |
destination_id |
string (uuid) | — | 36 | The arm or destination actually served |
destination_url |
string | — | 2048 | The URL actually served, before any lh_cid parameter is appended |
fallback_stage |
enum | — | — | active | paused_fallback | workspace_unavailable | generic — the QR fallback chain position (Section 14). Always active for non-QR |
targeting_rule_id |
string (uuid) | — | 36 | Set when a targeting rule short-circuited the experiment (16.12.1) |
experiment_id |
string (uuid) | — | 36 | — |
variant_id |
string (uuid) | — | 36 | — |
experiment_epoch |
integer | — | — | — |
assignment_source |
enum | — | — | deterministic | pinned |
visitor_hash |
string (base64) | ✔ | 24 | Section 17.3 |
country_code |
string | ✔ | 2 | ISO 3166-1 alpha-2, or ZZ |
region_code |
string | — | 6 | ISO 3166-2 subdivision part, without the country prefix |
geo_resolved |
boolean | ✔ | — | False when the lookup failed or returned nothing |
is_datacenter_asn |
boolean | ✔ | — | From the edge ASN lookup |
is_prefetch |
boolean | ✔ | — | From the prefetch headers |
headers_implausible |
boolean | ✔ | — | Edge-evaluated bot signal 5 (17.6.1). Computed at the edge because the worker never sees the request headers |
ua_hash |
string (base64) | ✔ | 24 | The daily-salted digest of 17.2.2, derived at the edge. Stored |
user_agent |
string | — | 512 | The raw string, truncated. Transit only. It exists in this payload so the worker can parse it, is discarded by the worker after parsing, is redacted by the spill and dead-letter writers, and has no column in any table (invariant I9) |
client_hints |
string | — | 256 | Compact concatenation of the Sec-CH-UA* headers. Transit only; consumed by the parser |
referrer |
string | — | 2048 | Raw header value. Reduced to host only in the worker; the full value is never stored |
language |
string | — | 16 | First Accept-Language tag |
utm_source / utm_medium / utm_campaign / utm_term / utm_content |
string | — | 255 each | Verbatim from the query string, trimmed |
is_estimated |
boolean | ✔ | — | True only for beacon-captured block clicks |
sample_rate |
integer | ✔ | — | 1 normally; N when adaptive sampling is active (17.14.4) |
capture_source |
enum | ✔ | — | edge | ssr | beacon |
Encoded size in the stream: p50 ≈ 330 bytes, p95 ≈ 540 bytes, hard cap 4 KB. The stored row is smaller than the payload, because the payload's two largest transit-only fields never reach a table. A payload exceeding the cap is truncated field-by-field in a fixed order (referrer, destination_url, user_agent, client_hints) and flagged truncated: true; if it still exceeds the cap it is dropped and counted. Truncating user_agent degrades parsing for that one event and nothing else, because the field is never stored.
Validation happens in the worker, not at the edge. The edge constructs the payload from already-validated internal state and does not spend request time on schema validation. The worker validates with the shared schema library and routes failures to the dead-letter stream.
17.2.4 Fire-and-forget dispatch #
// inside the redirect handler, conceptually
const response = buildRedirectResponse(resolved); // 302, Location, Cache-Control
enqueueAnalytics(payload); // NOT awaited — returns void
return response;enqueueAnalytics behaviour:
| Aspect | Decision |
|---|---|
| Transport | XADD clicks:raw NOMKSTREAM MAXLEN ~ 5000000 * d <json> v 1 |
| Awaiting | The returned promise is registered with a process-level pending-work registry so the runtime does not exit mid-flight during a graceful shutdown, but the request handler never awaits it |
| Pipelining | Up to 20 pending XADD commands are coalesced into one Redis pipeline flushed every 5 ms or at 20 commands, whichever comes first. This cuts syscalls by an order of magnitude at high rps without adding meaningful delay |
| Timeout | 50 ms per pipeline flush. On timeout the commands are treated as failed |
| Error handling | A catch that increments analytics_capture_dropped_total{reason} and logs at warn with 1-in-100 sampling. It never rethrows and never surfaces to the handler |
| Backpressure | If the in-process pending queue exceeds 2,000 payloads (Redis is slow or down), new payloads go to the fallback buffer below instead of the pipeline |
17.2.5 What happens when the dispatch fails #
A three-tier response, in order:
| Tier | Condition | Behaviour |
|---|---|---|
| 1 — Retry | A single XADD fails with a transient error (timeout, connection reset) |
The payload is placed in the in-process fallback buffer and retried on the next successful flush |
| 2 — Buffer | Redis is unreachable | The fallback buffer is a bounded FIFO ring of 10,000 payloads (≈ 3–5 MB). A background timer attempts a flush every 2 seconds with jittered exponential backoff to 30 s. On reconnection the buffer drains oldest-first at up to 1,000 per flush |
| 3 — Drop | The buffer is full | The oldest payload is evicted and analytics_capture_dropped_total{reason="buffer_overflow"} is incremented. Oldest-first eviction is chosen over newest-first because recent data is more valuable to a user watching a live dashboard |
Stated plainly, and this is a deliberate trade: the fallback buffer is in memory and is lost if the process restarts. Persisting it to local disk would introduce a write to the request path's host, an unbounded disk consumer, and a data-at-rest surface containing analytics payloads on ephemeral compute. Invariants I1 and I4 outrank completeness of analytics. The loss is bounded (10,000 events per instance), counted, alerted on, and visible in the reconciliation delta metric (17.13).
Failure counters, all labelled by reason: redis_timeout, redis_error, redis_unreachable, buffer_overflow, payload_oversize, shutdown_discard.
17.2.6 Measured cost against the redirect budget #
| Component | p50 | p95 | p99 |
|---|---|---|---|
visitor_hash derivation (SHA-256 over ~120 bytes) |
0.004 ms | 0.009 ms | 0.02 ms |
ua_hash derivation (SHA-256 over ~130 bytes) |
0.004 ms | 0.009 ms | 0.02 ms |
| Geo lookup (in-memory mmap tree) | 0.008 ms | 0.02 ms | 0.05 ms |
| ASN lookup (in-memory) | 0.006 ms | 0.015 ms | 0.04 ms |
| Payload construction + JSON serialise | 0.03 ms | 0.06 ms | 0.14 ms |
XADD enqueue into the pipeline (no I/O on the request path) |
0.01 ms | 0.02 ms | 0.05 ms |
| Total on-request cost | 0.06 ms | 0.13 ms | 0.32 ms |
Against the canonical redirect budget of p95 < 50 ms server-side, capture consumes 0.26% of p95. The budget line item allocated to analytics capture is 1.0 ms at p99, giving more than 3× headroom.
Enforcement: the load-test suite (Section 26) runs the redirect path with capture enabled and with capture stubbed to a no-op, on identical hardware, and fails the build if the p99 delta exceeds 1.0 ms or the absolute p95 budget is breached. The analytics_capture_overhead_ms histogram (17.13) carries the same assertion in production.
17.3 Visitor Identity #
17.3.1 The derivation #
visitor_hash = base64( sha256( daily_salt || client_ip || user_agent || workspace_id )[0..15] )| Element | Detail |
|---|---|
daily_salt |
32 random bytes, base64-encoded, rotating every 24 hours at 00:00 UTC |
client_ip |
The address string as read from the trusted proxy chain, normalised: IPv6 lower-cased and fully expanded, IPv4-mapped IPv6 reduced to its IPv4 form. Normalisation matters — without it the same visitor produces two hashes depending on which edge node handled the request |
user_agent |
The raw User-Agent header, or the empty string when absent. Read from the request into a local variable, consumed here and by the ua_hash derivation, and never persisted (invariant I9) |
workspace_id |
The workspace UUID, canonical lowercase hyphenated form |
|| |
Byte concatenation of the UTF-8 encodings with a 0x1F unit separator between parts, preventing boundary ambiguity between a long address and a short user-agent |
[0..15] |
The first 16 bytes of the 32-byte digest |
base64 |
Standard base64 with padding, producing a fixed 24-character string |
Including workspace_id means the same person visiting two different workspaces' pages produces two unrelated hashes. There is no cross-workspace visitor identity and no way to construct one. This is a privacy property, not an implementation detail, and it is why the identity model can operate without consent under the position documented in Section 23.
Sixteen bytes gives 2¹²⁸ of space; the birthday bound for accidental collision within a single workspace-day is far beyond any plausible traffic volume.
17.3.2 The daily salt rotation mechanism #
| Property | Decision |
|---|---|
| Cadence | Every 24 hours, boundary at 00:00 UTC |
| Generation | A scheduled worker job runs at 23:55 UTC and generates the next day's salt from a cryptographically secure random source |
| Durable home | The managed secret store, under a versioned key, with the current and previous two versions retained |
| Hot path home | The visitor-salt Redis key owned by Section 4's key catalogue, salt:visitor:{date}, keyed by the UTC date it applies to, with no TTL. "Current" and "previous" are simply today's and yesterday's keys; there is no separate pointer key to fall out of step |
| Distribution | Every edge and renderer process refreshes today's and tomorrow's values into process memory every 60 seconds. A refresh failure keeps the last known values |
| Second consumer | The same salt value, on the same rotation, produces ua_hash (17.2.2). One salt, one rotation, one destruction event — a second salt with a second lifecycle would be a second thing to get wrong |
| Boundary handling | For 5 minutes either side of the boundary, a process that has not refreshed may still hold the previous salt. This is accepted: it splits a small slice of one minute's traffic into two identities. The alternative — coordinating an exact cutover across every edge instance — is complexity bought for nothing |
| Logging | Never logged, never traced, never returned by any API. The redaction list in 17.13 includes daily_salt, salt, the Redis key names, and the raw user-agent field |
| Purpose of the previous salt | It is retained only so the rotation job can verify a successful handover. It is never used to link a visitor across days — no code path derives a hash with the previous salt after the boundary grace expires, and no query joins on hashes from different days |
| Failure | If neither Redis nor the secret store is reachable at process start, the process generates an ephemeral local salt, logs an error, and raises analytics_salt_unavailable. Events captured under an ephemeral salt are still valid within that process; they simply do not join with other processes' events for that period. Assignment and capture never fail |
17.3.3 The address is used in memory only #
| Guarantee | Mechanism |
|---|---|
| Not persisted | The event payload type has no field for it (I2) |
| Not logged | The pino redaction configuration removes req.ip, req.socket.remoteAddress, x-forwarded-for, x-real-ip, cf-connecting-ip, forwarded, and true-client-ip at every log level, including error serialisation |
| Not traced | The OpenTelemetry span processor strips net.peer.ip, client.address and http.client_ip before export |
| Not in error reports | The error-reporting integration is configured with the same scrub list and additionally strips any string matching an IPv4 or IPv6 literal pattern from exception messages and stack frames |
| Not in the queue | Geo and ASN resolution happen at the edge so the address has no reason to travel |
| Scoped lifetime | The address is a const inside the handler, passed only to pure functions that return primitives. It is never attached to the request context object, which is what gets serialised in diagnostics |
| Verified | An integration test drives traffic from a distinctive address through the full pipeline and asserts zero occurrences across all log output, all trace exports, every stream entry, and every text column of every table |
17.3.4 The honest limits of this identity model #
These are stated in the product's own help documentation, not only here, because a user comparing LinkHub's numbers to another tool's needs to know why they differ.
| Limit | Consequence |
|---|---|
| A visitor changing network is a new visitor. Mobile data to home wifi, office to café, a VPN toggling on — each changes the address and therefore the hash | Unique-visitor counts are inflated relative to distinct humans. Mobile-heavy audiences are inflated more |
| The salt rotates daily, so identity does not survive midnight UTC. | "Unique visitors" over a multi-day range is the sum of daily uniques, not a distinct count of humans. This is the canonical definition used everywhere in the product: a unique is a distinct (visitor_hash, event_date) pair — a visitor-day. A person visiting on five days counts as five. The dashboard labels the metric accordingly and Section 18.2 restates the definition in the metric catalogue |
| Shared networks collide. An office, a school, a carrier-grade NAT, or a large mobile operator's egress pool puts many people behind one address. Identical device models on the same network produce identical user-agent strings | Several humans can hash to one visitor. Unique counts are deflated for such audiences, and per-visitor metrics (clicks per visitor, repeat rate) are distorted upward. The SRM diagnostic in 16.11.4 surfaces the extreme case |
| User-agent reduction and randomisation. Browsers freezing or randomising the user-agent string reduce its entropy, increasing collisions on shared networks | Accepted. Client hints are used where available to recover device classification but are deliberately not added to the hash input, because a higher-entropy fingerprint is exactly what this model is designed not to build |
| No cross-device identity, ever. Phone and laptop are two visitors | Stated as a permanent product position, not a limitation to be fixed. Building cross-device identity would require a durable identifier and would forfeit the consent-free basis in Section 23 |
| No cross-surface identity beyond the day. The cross-surface join in 16.3 works because both requests happen within one salt window | Attribution windows are bounded at 30 minutes anyway, well inside the window |
The hash is not reversible and not lookupable. Given a visitor_hash there is no way to recover the address, and given an address there is no way to find yesterday's hash once the salt is gone. The same holds for ua_hash: it is the same one-way function under the same destroyed salt |
This is why a per-visitor GDPR erasure request cannot be fulfilled against analytics data — there is nothing to identify. Section 23 documents this as the reason the data is not personal data in the operative sense |
The user-agent string itself is not retained. Only user_agent_family — a bounded reduction such as Chrome on Android — and the daily-salted ua_hash survive |
LinkHub cannot build or reconstruct a device fingerprint from its own stored data, and neither can anyone who obtains a copy of it. The price is stated honestly in 17.5.1: derived device fields cannot be recomputed from history when the parser improves |
17.4 Geo Resolution #
17.4.1 What is resolved, and what is not #
| Field | Resolved? | Stored? |
|---|---|---|
| Country | Yes | As country_code — ISO 3166-1 alpha-2, upper case, ZZ when unresolved. Section 6 carries the declaration |
| Region / first-level subdivision | Yes | As region_code — the ISO 3166-2 subdivision part without the country prefix (BE for DE-BE), null when unresolved. Section 6 carries the declaration |
| City | Never resolved, never stored | — |
| Latitude / longitude | Never resolved, never stored | — |
| Postal code | Never resolved, never stored | — |
| Time zone | Never resolved, never stored | — |
| Accuracy radius | Never resolved, never stored | — |
| ISP / organisation name | Never resolved, never stored | — |
| ASN | Resolved | Only as the boolean is_datacenter_asn. The number itself is not stored |
This is enforced structurally, not by convention. The geo module exposes exactly one function with exactly one return type:
interface GeoResult { countryCode: string; regionCode: string | null; resolved: boolean }
resolveGeo(address: string): GeoResultThe underlying database reader is a private module-scoped value. No other module can import it. A lint rule bans importing the vendor package outside that module, a unit test asserts the returned object has exactly three keys, and a code-review checklist item covers changes to that file. The fields simply cannot leak, because nothing outside the module can reach them.
17.4.2 The database and its update cadence #
| Property | Decision |
|---|---|
| Source | A MaxMind GeoLite2-class country-and-subdivision database in the standard binary format |
| Why this one | Free-tier licensing appropriate to the plan structure, well-supported readers in the Node ecosystem, mmap-based reads with no network dependency, and a documented accuracy characteristic at the country level (>99%) that matches the only granularity the product exposes |
| Swappable | The GeoResolver interface has one method. A commercial provider can be substituted by implementing it. Provider choice is deployment configuration (Section 27), and the decided default is the GeoLite2-class database |
| Distribution | The database file is stored in object storage, not baked into the container image, so it can be refreshed without a deploy |
| Update job | A worker job runs weekly, Tuesday 03:00 UTC: downloads the current release, verifies its published checksum, loads it into a second reader, runs a 500-address fixture assertion against known expected countries, and only then hot-swaps the active reader. The old reader is released after in-flight requests drain |
| Edge propagation | Each edge instance polls the object-storage version marker every 15 minutes and performs the same verified hot-swap. A rolling deploy is not required |
| Version tracking | Each swap records a row in geo_db_versions (version, checksum, activated_at, record_count, fixture_pass), and the active version is exposed on the internal health endpoint |
| Rollback | If the fixture assertion fails, the swap is abandoned, the previous reader stays active, and an alert fires. A stale-but-working database is always preferred to an unverified one |
| Staleness alerting | analytics_geo_db_age_days warns above 14 and pages above 21 (17.13) |
17.4.3 Behaviour when the lookup fails #
There are four failure shapes and one behaviour.
| Case | Handling |
|---|---|
| Address is private, loopback, link-local, or otherwise not globally routable | country_code = 'ZZ', region_code = null, geo_resolved = false |
| Address is absent (no trusted proxy header) | Same |
| Address is present but not in the database | Same |
| Reader throws, or no reader is loaded | Same, plus analytics_geo_lookup_error_total increments. The request is unaffected |
ZZ is a user-assigned ISO 3166-1 code, so it can never collide with a real country. The dashboard renders it as "Unknown" in tables and leaves it off the map, with the count shown beneath the map as "Unknown: n" so the totals reconcile visibly (Section 18.4). A geo lookup never throws to the caller; the function catches internally and returns the unresolved result.
Country resolution is never inferred from any other signal — not from language headers, not from the top-level domain of the referrer, not from currency. An unknown country stays unknown.
17.5 Device, OS, Browser and Referrer Parsing #
17.5.1 Where parsing happens, and why #
All parsing happens in the worker, never at the edge. The edge ships the raw user_agent, the client-hints string and the raw referrer in the payload.
| Reason | Detail |
|---|---|
| Latency | User-agent parsing is regex-heavy. Moving tens of microseconds of regex off a path with a 50 ms p95 budget and 5,000 rps target is free throughput |
| Consistency | One parser, one version, one place. Edge instances deploy independently of workers; parsing at both would let them drift. ua_parser_version is stamped on every row so a value can always be attributed to the parser that produced it |
| Containment of the raw string | Parsing in one process, in one function, makes "hold it in memory and discard it" a property of a few lines of code rather than a property spread across every edge instance. The worker parses, writes user_agent_family, and drops the string (invariant I9) |
What this costs, stated rather than glossed. Because the raw string is not retained, derived device fields cannot be recomputed from history when the parsing library improves. A browser that was unknown in August is still unknown in that August data after a parser update in September. This is a deliberate trade against the privacy position of I9, and it is absorbed three ways:
user_agent_familyis retained, so classifications that operate at family granularity — in particular the link-preview and bot signature lists of 17.6 — can be re-applied to history within raw retention (17.11.3).ua_parse_corpusgives the maintenance loop the raw strings it needs to fix the parser, without those strings being attached to any event.- A parser improvement therefore takes effect forward only, and the affected charts carry a "device and browser detection improved on " annotation so a step change in the
unknownshare is explained rather than mysterious.
17.5.2 The library approach #
| Aspect | Decision |
|---|---|
| Library | A maintained user-agent parsing library with a regex-database approach, pinned at the major line in Section 4 and updated on the regular dependency cadence |
| Client hints preference | When Sec-CH-UA-Mobile is present, it determines mobile vs not mobile and overrides the UA regex result. When Sec-CH-UA-Platform is present, it determines the OS family. When Sec-CH-UA is present, it determines the browser brand. Rationale: user-agent strings are frozen in modern Chromium browsers and increasingly lie; client hints are the browser's own authoritative statement |
| Fallback | Where hints are absent (Safari, Firefox, most in-app browsers, all bots), the UA regex result is used |
| Caching | Parsed results are memoised in an in-process LRU of 20,000 entries keyed by the exact user-agent + hints string. Real traffic is heavily concentrated: measured hit rate exceeds 97%, so the effective per-event parse cost is negligible. The cache holds raw strings as keys, in memory, for the process lifetime; it is never serialised, never dumped in a heap snapshot that leaves the host, and is cleared on shutdown |
| Corpus write | When a parse yields unknown for the browser or OS family, the worker upserts the raw string into ua_parse_corpus (Section 6) once per batch, incrementing its count and keeping the earliest day-granular first_seen_at. This is the only durable write of a raw string anywhere in the product, and the table carries nothing that could tie it to a visitor, a workspace or a moment (17.2.2) |
| Failure | Any exception in parsing yields the unknown value for every field, increments analytics_ua_parse_error_total, and never fails the batch |
17.5.3 Normalisation rules #
Every parsed value is normalised into a closed vocabulary. An unrecognised value becomes other, never a free-form string. This keeps rollup dimension cardinality bounded, which is what makes the rollup tables small enough to be fast.
device_type — exactly one of:
| Value | Rule |
|---|---|
mobile |
Sec-CH-UA-Mobile: ?1, or the parser reports a phone |
tablet |
The parser reports a tablet, or an Android device without Mobile in the UA |
desktop |
The parser reports a desktop or no device class and the OS is a desktop OS |
tv |
Smart TV, console, or set-top box |
bot |
The bot classification of 17.6 returned true on a user-agent signal |
unknown |
No user-agent, or parsing failed |
os_family — exactly one of windows, macos, ios, ipados, android, linux, chromeos, other, unknown. The version is stored separately in os_version_major, null when not determinable. Only the major version is kept: minor versions triple the dimension cardinality and no user has ever made a decision from one.
browser_family — exactly one of:
chrome, safari, firefox, edge, samsung, opera, brave, duckduckgo, in_app_facebook, in_app_instagram, in_app_tiktok, in_app_linkedin, in_app_snapchat, in_app_pinterest, in_app_x, in_app_other, other, unknown. The version is stored in browser_version_major.
user_agent_family — the human-readable composite the parser emits for display and for family-level bot review, formed as <browser display name> on <OS display name> — for example Chrome on Android, Safari on iOS, Instagram in-app on iOS, Unknown browser on Unknown OS. It is bounded at 100 characters, is derived only from the two closed vocabularies above plus their display names, and therefore carries no free text from the request. It is not a dimension in the rollup: it exists so a human reviewing traffic or a support agent reading a row sees something meaningful without the raw string being available, and so the bot signature lists can be re-applied at family granularity (17.5.1).
In-app browsers get their own values rather than being folded into their engine because they are the single most consequential distinction for this product's users: an in-app browser often blocks third-party cookies, restricts downloads, breaks OAuth flows and renders differently, and a creator whose traffic is 70% in-app needs to see that on the first screen rather than seeing "Chrome, 70%".
Normalisation is case-folded to lower snake_case, trimmed, and length-capped at 32 characters. A value not in the vocabulary is mapped to other and the raw string is counted in analytics_unmapped_value_total{field} so the vocabulary can be extended deliberately (17.6.4 describes the same review loop).
17.5.4 Referrer host extraction #
1. Take the raw Referer header. If absent, empty, or not a valid absolute URL → referrer_host = NULL.
2. Parse as a URL. If the scheme is not http or https → referrer_host = NULL.
3. Take the host component. Lower-case it. Strip a trailing dot.
4. Strip a leading "www." (and only "www." — not "m.", not "amp.", which are meaningful).
5. Keep the punycode (ASCII) form. Do not decode to Unicode: decoding creates homograph
ambiguity in a stored dimension value, and the display layer can render it.
6. Truncate to 255 characters.
7. DISCARD the path, the query string and the fragment. They are never stored, never logged,
never shipped past the worker. The raw referrer string exists only inside the parse function.Step 7 is a privacy decision, not an optimisation: a referrer path can carry a search query, a private document title, or a session identifier from someone else's site. LinkHub stores the host and nothing else, and therefore cannot offer path-level referrer drill-down. Section 18.4 states this to the user where they would expect that drill-down to exist.
In practice the browser has usually already reduced the referrer for us: the canonical Referrer-Policy: strict-origin-when-cross-origin (Section 23) means cross-origin requests carry only the origin.
17.5.5 Channel classification #
channel is a closed vocabulary computed by an ordered rule set. First match wins. The rules run in the worker after referrer host extraction.
| Order | Rule | Result |
|---|---|---|
| 1 | resource_type = 'qr' |
qr |
| 2 | utm_medium matches cpc, ppc, paid*, display, banner, retargeting |
ads |
| 3 | utm_medium matches email, newsletter, e-mail, mail |
email |
| 4 | utm_medium matches social, social-network, social-media, sm, social_post |
social |
| 5 | utm_medium matches affiliate, partner |
referral |
| 6 | utm_source or utm_medium present but unmatched above |
campaign |
| 7 | referrer_host is one of the workspace's own hosts (its custom domains, its bio page hosts, the default short domain, and the dashboard host) |
internal |
| 8 | referrer_host matches the bundled host→channel map |
The mapped channel (social, search, messaging, email, ads, video, ai) |
| 9 | referrer_host is non-null and unmatched |
referral |
| 10 | referrer_host is null |
direct |
The bundled host→channel map is a versioned JSON asset in the shared core package covering roughly 400 hosts across seven categories, including search engines, the major social networks, messaging apps (which are the dominant referrer class for this product's users), video platforms, webmail providers, and AI assistants. It ships with releases; its version is recorded so a reparse can be attributed to a map change.
messaging is a first-class channel rather than being folded into social because for a creator, "someone shared my link in a group chat" and "someone clicked from a feed" are different events with different implications, and messaging apps are the largest single source of "direct-looking" traffic.
17.5.6 Empty referrers and self-referrals #
| Case | Handling |
|---|---|
| No referrer at all | referrer_host = NULL, channel direct unless a UTM rule matched first. The dashboard labels this bucket "Direct / unknown", never just "Direct" |
| Honest caveat, shown as a tooltip on that bucket | "Direct includes typed-in and bookmarked visits, but also clicks from apps that strip the referrer, links opened from documents and QR scans on some devices, and visits where the browser's privacy settings removed the source. It is not a measure of people who typed your URL" |
| Referrer is the same host as the request | internal, and by default excluded from the referrer breakdown with a toggle to include it. Without this, a bio page's own outbound clicks dominate its own referrer report |
| Referrer is another host owned by the same workspace | Also internal — the workspace host set is loaded once per batch from a cached workspace-hosts lookup |
| Referrer is a LinkHub system host (the default short domain, the dashboard) | internal |
| Referrer host is an IP literal | Stored as the literal, channel referral |
Referrer host is localhost or a .local/.test name |
Stored, channel referral, and additionally flagged as a development-traffic signal contributing to bot classification (17.6) |
17.6 Bot Filtering #
17.6.1 Detection signals #
Bot classification is a small ordered rule set with stated reasons, not a model. It is auditable, explainable in a tooltip, and reversible by reparse.
| # | Signal | Where evaluated | Sets bot_reason |
|---|---|---|---|
| 1 | The user-agent string matches the bundled bot signature list (crawlers, monitors, scrapers, headless runtimes, HTTP libraries with default agents) | Worker, against the in-memory raw string before it is discarded | ua_signature |
| 2 | The user-agent string matches the link-preview / unfurl agent list — the chat and social clients that fetch a URL to build a preview card | Worker, same in-memory string | link_preview |
| 3 | Source address belongs to a known datacenter or hosting ASN | Edge (as the boolean is_datacenter_asn; the ASN itself is never stored) |
datacenter |
| 4 | Prefetch headers present (Sec-Purpose: prefetch, Purpose: prefetch, X-Moz: prefetch, X-Purpose: preview) |
Edge (as is_prefetch) |
prefetch |
| 5 | Implausible header combination: no Accept, no Accept-Language, and no Accept-Encoding simultaneously, on an HTTP/1.x request |
Edge (as headers_implausible), because the worker never sees request headers |
header_shape |
| 6 | Velocity: more than 60 events from the same visitor_hash against the same resource_id within 60 seconds |
Worker, using the abuse counter key registered in Section 4's key catalogue, at a 60 s TTL | velocity |
| 7 | Missing user-agent entirely | Worker | no_user_agent |
| 8 | Referrer host is a local development name (localhost, *.local, *.test) |
Worker | development |
is_bot is true when any signal fires. bot_reason records the first signal in the order above, so the reason is deterministic. bot_signals records every signal that fired as an array, which is what makes the reconciliation and review loop possible. All three columns are defined on both event tables in Section 6.
Signals 1, 2 and 7 read the raw user-agent string from the batch's in-memory payloads and never write it (invariant I9). What survives the batch for later review is user_agent_family and ua_hash: the family lets a signature list be re-applied at family granularity, and the hash lets rows that shared one string be clustered without the string. Because ua_hash is daily-salted, that clustering is bounded to a single UTC day — which is exactly the window a bot-storm investigation needs, and no longer.
Signal 2 deserves its own reason value because it is the most consequential for this product: pasting a link into a group chat produces one unfurl fetch per participant's client in some apps. Without this rule, a creator sharing a link to a 200-person channel would see 200 phantom clicks and would rightly conclude the analytics are broken.
17.6.2 The storage decision: store and flag, never discard #
| Reason | Detail |
|---|---|
| Counts must reconcile | A user comparing LinkHub's click count to their destination's server logs needs to see where the difference came from. The reconciliation panel (Section 18.13) shows total events, bot events by reason, estimated events, and the resulting filtered figure. Discarding bots makes that panel impossible |
| False positives are recoverable | A signature list will occasionally misclassify a real browser. If the events were discarded, the mistake is permanent. Stored and flagged, it is corrected by a reparse within raw retention (17.11.3) |
| Abuse investigation | Support and abuse handling need the traffic that was filtered, not only the traffic that survived |
| Cost is bounded | Bot traffic in this product's measured mix is 8–14% of raw events. Storing it costs roughly a tenth of the analytics footprint and buys all of the above |
is_bot is a first-class dimension on every rollup row (17.9), so excluding bots at query time costs nothing — it is a predicate on an indexed column, not a scan.
17.6.3 Default exclusion from views #
| Surface | Default |
|---|---|
| Every dashboard chart, table, tile and breakdown | Bots excluded |
| Experiment results | Bots excluded; the toggle is present but changing it is recorded in the results panel so a screenshot is unambiguous |
| Real-time widget | Bots excluded |
| CSV and PDF exports | Bots excluded, with a column and a footer line stating the filter that was applied |
| Raw event export | Bots included, with the is_bot and bot_reason columns present, because a raw export is a raw export |
| API analytics endpoints | Bots excluded unless include_bots=true |
| Webhooks and milestone alerts | Bots excluded, always. There is no toggle: a bot storm must never fire a "you hit 10,000 clicks" celebration |
Workspace setting include_bots_in_reports (default false) flips the dashboard default; a per-query toggle overrides it for one view. Whenever bots are excluded, a persistent chip reads "Bots excluded" and reveals the excluded count on hover. Whenever they are included, the chip reads "Bots included" in a warning colour. A user is never left guessing which mode they are looking at.
17.6.4 How the signature list is maintained #
| Aspect | Decision |
|---|---|
| Form | Two versioned JSON assets in the shared core package: a bot signature list and a link-preview agent list, each a list of { pattern, label, category } with anchored, non-backtracking regular expressions |
| Datacenter ASN list | A third versioned asset, a sorted array of ASN ranges, refreshed monthly from published sources during the dependency-update cadence |
| Version stamping | Every event row stores bot_list_version, so a reclassification can be attributed and scoped |
| Update cadence | With the regular dependency update cycle, and out-of-band when the review loop below surfaces something material |
| Review loop | A weekly job assembles its candidate list from two sources, neither of which requires a stored raw string. From ua_parse_corpus: the highest-count strings first seen in the last 30 days whose parse produced an unknown family. From the event tables: the top 50 user_agent_family values by volume among events classified is_bot = false that also carry a non-firing suspicious characteristic — headers_implausible set, zero clicks across every visitor with that family, or a single ua_hash accounting for over 30% of that family's events on a day. The report goes to the operations channel. A human decides. No automatic classification, ever — an automatic rule that starts silently discarding a real browser's traffic is the worst possible failure for this feature |
| Regression protection | A fixture suite of 400 user-agent strings with expected classifications runs in CI. Adding a signature that reclassifies any fixture fails the build until the fixture is updated deliberately |
| Retroactivity | A signature list update applies to new events only, plus any day inside the nightly reconciliation window (17.9.4). It does not rewrite history beyond that window. Reclassifying six months of history would silently change numbers a user has already reported to their client. A wider re-application is possible as an explicit operator action (17.11.3), is scoped to a stated day range, operates at user_agent_family granularity because that is what is retained, and writes an annotation visible on the affected charts |
17.7 The Stream and the Consumer #
17.7.1 Stream structure #
| Property | Decision |
|---|---|
| Key | clicks:raw — one stream for every event type and every workspace |
| Why one stream | Ordering across events is not required, so partitioning buys nothing. A stream per workspace would create tens of thousands of keys with wildly uneven depth, forcing the consumer to poll many keys and starving small workspaces behind large ones. One stream with a consumer group scales by adding consumers |
| Entry format | Two fields: d = the JSON payload (17.2.3), v = the schema version as a string. Two fields rather than one field per attribute, because field-per-attribute triples the entry overhead and gains nothing — the consumer parses the JSON anyway |
| Write command | XADD clicks:raw NOMKSTREAM MAXLEN ~ 5000000 * d <json> v 1 |
| Trimming | Approximate (~) so trimming happens at radix-tree node boundaries and costs nothing on the write path |
| Capacity | 5,000,000 entries at ~340 bytes average ≈ 1.7 GB. Holding time: ≈ 4.6 hours at the 300 events/s steady rate, ≈ 42 minutes at a 2,000 events/s spike (17.14) |
| Overflow protection | When lag exceeds 2,000,000 entries, the spill job of 17.11.2 begins draining the oldest entries to object storage so that a long consumer outage does not lose data to trimming |
| Redis configuration requirement | The Redis instance or logical database holding clicks:raw must be configured maxmemory-policy noeviction. An LRU or random eviction policy can evict the stream key itself under memory pressure, silently destroying buffered events. The redirect payload cache (rd:*) may use allkeys-lru and should live on a separate instance or database. This is a deployment requirement (Section 27) and is asserted by a startup check that reads CONFIG GET maxmemory-policy and refuses to start the worker if it is not noeviction |
| Key ownership | clicks:raw and its two sibling streams are entries in the Redis key catalogue owned by Section 4. This section describes how they are used; it does not define a rival naming scheme |
| Replay stream | clicks:replay, same structure, consumed by the same consumer group logic through a second reader. Used only by 17.11 |
| Dead-letter stream | clicks:dlq, same structure plus err and attempts fields. No trimming; drained manually. The dead-letter writer redacts the raw user-agent field before writing, substituting the parsed user_agent_family where parsing succeeded and the literal (redacted) where it did not — because a dead-letter entry is durable by design and invariant I9 admits no durable copy |
17.7.2 The consumer group #
| Property | Value |
|---|---|
| Group | ingest, created with XGROUP CREATE clicks:raw ingest $ MKSTREAM on first boot, idempotently |
| Consumer name | ingest-{hostname}-{pid} — stable for the process lifetime so its pending entries are reclaimable by name |
| Baseline concurrency | 2 worker processes, each with 1 consumer. Scales to 6 |
| Read command | XREADGROUP GROUP ingest {consumer} COUNT 1000 BLOCK 2000 STREAMS clicks:raw > |
| Batch trigger | Whichever comes first: 1,000 entries read, or the 2,000 ms block elapsing with a non-empty partial batch |
| Idle behaviour | An empty read returns after 2 s and loops. No busy-polling |
| Graceful shutdown | On SIGTERM: stop reading, finish the in-flight batch (commit and acknowledge), then exit. A 25 s shutdown grace is configured; entries not acknowledged in time remain pending and are reclaimed by the reaper |
17.7.3 Batch processing and the exact insert pattern #
Everything below happens in one database transaction per batch. This is the core of invariant I3.
Three identifiers do the work, and they are deliberately different things. All three are columns on the event tables in Section 6:
| Identifier | Assigned by | Scope | What it is for |
|---|---|---|---|
id |
The edge, once, at capture (17.2.3) | The event, for its whole life | The end-to-end idempotency key. It survives every retry, buffer flush, spill replay and dead-letter re-injection, so the same real-world event can only ever produce one row no matter how many times it is delivered or through which stream |
stream_message_id |
Redis, when XADD appends the entry |
One delivery attempt of one entry on one stream | The transport-level key. It makes the "crashed between COMMIT and XACK" case answerable with a single cheap query instead of an insert attempt, and it lets an operator prove that a specific stream range was consumed |
ingest_batch_id |
The worker, when it opens a batch | Every row one batch wrote, across both event tables and the rollup-only ledger | The unit of work. It makes a batch's entire contribution identifiable by one indexed predicate, which is what turns "a batch was applied twice by an operator error" from an archaeology problem into a one-statement diagnosis |
STEP 1 READ
entries = XREADGROUP ... COUNT 1000 BLOCK 2000
batch_id = uuidv7() -- the ingest_batch_id for everything below
Each entry carries its Redis-assigned stream_message_id alongside its payload.
STEP 2 PARSE AND VALIDATE (no database access)
for each entry:
- JSON.parse the `d` field
- dispatch on `v`; an unknown version → dead-letter, do not guess
- validate against the shared payload schema
- invalid → dead-letter with the validation error, do not fail the batch
- attach stream_message_id and batch_id to the parsed record
Valid payloads are grouped: clicks/scans/block-clicks → one set, page views → another.
STEP 2b REDELIVERY SHORT-CIRCUIT (one read, no transaction)
SELECT stream_message_id
FROM click_events
WHERE occurred_at >= now() - interval '2 days'
AND stream_message_id = ANY($1) -- the batch's stream message ids
UNION ALL
SELECT stream_message_id FROM page_view_events WHERE ... ;
Any entry whose stream_message_id comes back is already committed — the signature of a
crash between COMMIT and XACK. Those entries are acknowledged immediately and dropped
from the batch without opening a transaction at all. This is an OPTIMISATION, not the
correctness mechanism: skipping it changes nothing, because STEP 4a would absorb the
same rows. It exists because after a crash the ENTIRE pending set is redelivered at
once, and paying one indexed read instead of a thousand no-op inserts and a transaction
is the difference between a two-second recovery and a stalled consumer.
STEP 3 ENRICH (no database access; one Redis pipeline)
- parse the in-memory raw user_agent + client hints → device_type, os_family,
os_version_major, browser_family, browser_version_major, user_agent_family
(17.5, memoised)
then DISCARD the raw string. ua_hash arrived already derived from the edge (17.2.2).
- extract referrer host, classify channel (17.5.4, 17.5.5)
- apply bot rules 1, 2, 7, 8 to the in-memory string;
rules 3, 4, 5 arrived as booleans from the edge (17.6)
- ONE Redis pipeline for the whole batch, against the keys registered in Section 4:
· the ab:seen hash for {workspace_id}:{visitor_hash} → page-variant attribution (16.3.1)
· the per-visitor abuse counter → velocity signal (bot rule 6)
· workspace host set lookups (cached in-process, 60 s TTL)
A pipeline failure here degrades enrichment (attribution null, velocity signal skipped)
but never fails the batch.
- clamp occurred_at: if occurred_at > now + 5 min → occurred_at = ingested_at,
clock_skewed = true
- compute event_date = date(occurred_at AT TIME ZONE 'UTC')
STEP 4 TRANSACTION BEGIN
STEP 4a RAW INSERT, DEDUPLICATING
INSERT INTO click_events (id, occurred_at, ingested_at, event_date,
stream_message_id, ingest_batch_id, ...)
SELECT * FROM UNNEST($1::uuid[], $2::timestamptz[], ...)
ON CONFLICT (id, occurred_at) DO NOTHING
RETURNING id, occurred_at, workspace_id, resource_type, resource_id,
visitor_hash, event_date, is_bot, country_code, region_code,
device_type, os_family, browser_family, referrer_host, channel,
language, utm_source, utm_medium, utm_campaign, utm_term, utm_content,
variant_id, fallback_stage;
-- The RETURNING set contains ONLY rows that were actually inserted.
-- Redelivered events return nothing and therefore contribute nothing downstream.
-- This single fact is what makes the whole pipeline exactly-once with respect to
-- an at-least-once transport.
-- The conflict target is (id, occurred_at) — the event's own identity, NOT the
-- transport's. That is deliberate: a payload re-injected through clicks:replay after
-- a spill carries a NEW stream_message_id but the SAME id, and must still deduplicate.
(The same statement shape runs against page_view_events for the page-view group.)
STEP 4b UNIQUE VISITOR-DAYS
INSERT INTO analytics_unique_visitor_days
(workspace_id, resource_type, resource_id, event_date, visitor_hash)
SELECT DISTINCT workspace_id, resource_type, resource_id, event_date, visitor_hash
FROM <the RETURNING set>
WHERE is_bot = false -- bot traffic never creates a visitor-day
ON CONFLICT DO NOTHING
RETURNING workspace_id, resource_type, resource_id, event_date;
-- The RETURNING set here is exactly the set of NEW visitor-days.
-- Counting it gives an exact, additive, idempotent increment for unique_visitors.
-- The grain is the RESOURCE, with no dimension column and no bot column. 17.8.3
-- states what that buys and what it costs.
STEP 4c ROLLUP UPSERT — HOURLY
Aggregate the STEP 4a RETURNING set in memory into rollup rows
(one 'total' row plus one row per dimension value present), then:
INSERT INTO analytics_rollup_hourly AS r
(workspace_id, resource_type, resource_id, bucket_start,
dimension_type, dimension_value,
events, unique_visitors, conversions, ingest_batch_id, updated_at)
SELECT * FROM UNNEST(...)
ON CONFLICT (workspace_id, resource_type, resource_id, bucket_start,
dimension_type, dimension_value)
DO UPDATE SET events = r.events + EXCLUDED.events,
unique_visitors = r.unique_visitors + EXCLUDED.unique_visitors,
conversions = r.conversions + EXCLUDED.conversions,
ingest_batch_id = EXCLUDED.ingest_batch_id, -- last writer, for diagnosis
updated_at = now();
-- is_bot is NOT part of this key. It is a dimension like any other: bot volume is
-- carried by rows with dimension_type = 'is_bot'. 17.9.1 states the consequence.
STEP 4d ROLLUP UPSERT — DAILY
Identical, against analytics_rollup_daily with bucket_start truncated to the UTC day.
STEP 4e REAL-TIME COUNTERS (best effort, outside the transaction's correctness guarantee)
A Redis pipeline of HINCRBY plus EXPIRE against the real-time counter keys registered
in Section 4, derived from the same STEP 4a RETURNING set as everything else.
A failure here is logged and ignored; the real-time widget is explicitly ephemeral (18.7).
STEP 5 COMMIT
STEP 6 ACKNOWLEDGE
XACK clicks:raw ingest <all entry ids in the batch>
The acknowledged stream_message_id set and the batch_id are logged together, so the
join between "what Redis was told" and "what PostgreSQL holds" is always reconstructable.17.7.4 Acknowledgement semantics #
| Timing | Rule |
|---|---|
| Acknowledge after commit, never before | The only correct ordering. Acknowledging first turns a crash into permanent data loss |
Crash between commit and XACK |
The entries stay pending and are redelivered. STEP 2b recognises them by stream_message_id, acknowledges them without opening a transaction, and the batch proceeds with whatever is genuinely new. If STEP 2b were removed, STEP 4a's ON CONFLICT DO NOTHING would return an empty set and STEP 4b–4d would contribute nothing, so the outcome is identical either way — this is the designed-for case, not an edge case |
| Crash before commit | The transaction rolls back entirely. Entries stay pending, are redelivered, and are processed correctly |
| Partial batch failure | There is no such thing. The transaction is all-or-nothing. A single malformed payload was already routed to the dead-letter stream in STEP 2, before the transaction opened |
| Acknowledgement failure after a successful commit | Logged; the entries remain pending and are reclaimed and redelivered, which is harmless |
17.7.5 Pending entries and poison messages #
| Mechanism | Detail |
|---|---|
| Reaper | A job in every worker runs every 60 seconds: XAUTOCLAIM clicks:raw ingest reaper-{pid} 60000 0 COUNT 500, reclaiming entries pending for more than 60 seconds — the signature of a crashed or partitioned consumer |
| Delivery counting | XAUTOCLAIM reports each entry's delivery count. An entry delivered 5 or more times is treated as poison |
| Poison handling | The entry is copied to clicks:dlq — with the raw user-agent field redacted (17.7.1) and with err (the last error), attempts and the originating stream_message_id attached — then XACKed on the main stream so it stops circulating. analytics_dlq_total{reason} increments |
| Dead-letter visibility | analytics_dlq_depth is a gauge; any non-zero value for 15 minutes raises a warning alert. The dead-letter stream is never trimmed automatically |
| Dead-letter drain | An operator command inspects, corrects and re-injects entries into clicks:replay. Because each payload's id is preserved, re-injection is idempotent even though the replayed entry receives a new stream_message_id. The runbook is in Section 25 |
| Schema-version poison | An entry whose v is higher than the consumer understands goes straight to the dead-letter stream on first delivery, without retries. This is the expected behaviour during a rolling deploy where new edges write a newer version before all workers are updated; the drain then replays them once the workers are current. Section 27's expand/contract rule makes this window short and safe |
17.7.6 Why duplicates are harmless — the complete argument #
| Stage | Duplicate behaviour |
|---|---|
id |
Generated exactly once, at capture, and carried unchanged through every retry, buffer replay, stream replay and dead-letter re-injection. It is never regenerated, anywhere. This is the foundation |
stream_message_id |
Recorded on every row, and checked before the transaction opens (STEP 2b). It absorbs the specific duplicate class that arrives in bulk — a whole pending set redelivered after a crash — cheaply. It is a second line, never the first: it cannot catch a replayed payload, which arrives with a new stream id, and it is not relied on to |
ingest_batch_id |
Recorded on every row and on every rollup row's last write. It does not prevent duplication; it makes duplication diagnosable, which is what an operator needs at 03:00 when a metric looks wrong |
| Raw insert | ON CONFLICT (id, occurred_at) DO NOTHING. A duplicate inserts nothing |
The RETURNING set |
Contains only actually-inserted rows. A duplicate contributes an empty set |
| Unique visitor-days | Derived from the RETURNING set, and additionally ON CONFLICT DO NOTHING on its own key. Doubly protected |
| Rollup increments | Derived from the RETURNING set. A duplicate increments by zero |
| Real-time counters | Also derived from the RETURNING set, so duplicates do not inflate the live widget |
| Same transaction | Raw insert, unique-day insert and both rollup upserts commit together, all stamped with one ingest_batch_id. There is no window in which raw and rollup disagree because of a partial write |
| Conversions | Keyed by click_event_id with a unique constraint; a duplicate postback is a no-op (16.6.3) |
| Backstop | The nightly reconciliation (17.9.4) recomputes recent rollups from raw with absolute assignment, so even a rollup corrupted by an operator action self-heals within the window |
The one thing that is not idempotent by construction is the sequence "raw partition already dropped by retention, so the raw insert fails, but the rollup increment still needs to happen". That case is handled explicitly in 17.9.5 with a separate rollup-only write path that carries its own idempotency key.
17.8 The Raw Event Tables #
Two raw tables. Both are append-only, both are range-partitioned by day, neither is ever updated in place except by a reparse operator action (17.11.3).
17.8.1 click_events — what it holds and why it is one table #
click_events holds short-link clicks, QR scans and bio page block clicks. One table rather than three, because the columns are 90% identical, the queries are identical, and three tables would triple the partition management burden for no benefit. The three cases are told apart by resource_type, which is link, qr or block.
Section 6 is the sole schema authority. It carries the column list, the types, the constraints and the indexes for this table, and nothing here restates them. What this section owns is the algorithm: which of those columns the ingest path fills, from what, and what each one is load-bearing for.
| Column group | Columns Section 6 defines | What the ingest path does with them |
|---|---|---|
| Identity | id, occurred_at, ingested_at, event_date, schema_version |
id is the capture-time UUIDv7 and, with occurred_at, the conflict target that makes redelivery a no-op (17.7.3). event_date is the UTC date of occurred_at, denormalised because it appears in the unique-visitor-day key and in most grouping clauses; Section 6 constrains it to stay consistent with occurred_at so it cannot silently drift |
| Exactly-once | stream_message_id, ingest_batch_id |
Written on every row. stream_message_id powers the redelivery short-circuit of STEP 2b; ingest_batch_id makes one batch's whole contribution addressable. Neither is a substitute for id (17.7.6) |
| Tenancy and resource | workspace_id, resource_type, resource_id, domain_id, host, slug |
Taken verbatim from the resolution payload that served the redirect, never looked up afterwards. workspace_id is not nullable, which is invariant I6 expressed as a constraint |
| Resolution outcome | destination_id, destination_url, fallback_stage |
fallback_stage records which rung of the four-rung QR fallback chain served the request — active, paused_fallback, workspace_unavailable or generic (Section 14) — and is active for everything that is not a QR fallback. It is what makes the resolution-health tile of 18.6.1 possible |
| Targeting and experiment | targeting_rule_id, excluded_by_rule_id, experiment_id, variant_id, experiment_epoch, assignment_source, source_page_id, source_page_experiment_id, source_page_variant_id |
Filled by the precedence rule of 16.12.1. targeting_rule_id names the rule that decided the destination; excluded_by_rule_id is set only when that rule pre-empted a running experiment. The three source_page_* columns are denormalised by the worker from the cross-surface attribution key (16.3.1) and are null when attribution is unavailable, which is a degradation and never an error |
| Visitor | visitor_hash |
The 24-character salted digest of 17.3. It is the only visitor-shaped value stored anywhere, and it is not reversible |
| Bot and traffic quality | is_bot, bot_reason, bot_signals, bot_list_version, is_prefetch, is_datacenter_asn, is_estimated, clock_skewed, sample_rate |
is_bot is a dimension column: it is never part of a primary key anywhere in this design, on this table or on any rollup. bot_signals is an array so that adding a signal is not a migration against a table with billions of rows. bot_list_version makes any classification attributable to the list that produced it |
| Geography | country_code, region_code, geo_resolved, geo_db_version |
Resolved at the edge (17.4) so the address never enters the stream. country_code is ZZ and region_code null when unresolved, with geo_resolved false — three columns that agree rather than one that has to be interpreted |
| Device | device_type, os_family, os_version_major, browser_family, browser_version_major, user_agent_family, ua_hash, ua_parser_version |
Produced by the worker's parse of a string it then discards (17.5, invariant I9). There is no raw user-agent column on this table and there never will be. user_agent_family is the bounded human-readable reduction; ua_hash is the daily-salted digest used only for same-day bot clustering |
| Source | referrer_host, channel, language |
referrer_host is host-only — path, query and fragment are discarded in the worker and never stored (17.5.4). channel is the closed vocabulary of 17.5.5 |
| Campaign | utm_source, utm_medium, utm_campaign, utm_term, utm_content |
Verbatim from the query string, trimmed and length-capped. Never inferred, never normalised beyond trimming, because a campaign name is the customer's own label |
| Capture provenance | capture_source |
edge, ssr or beacon. It is what makes the tracked-versus-estimated distinction of 17.2.1 auditable rather than asserted |
Two structural choices carry over from the ingest design and are worth stating because they look like omissions:
| Choice | Reason |
|---|---|
| No foreign keys | Foreign keys on a table taking thousands of inserts per second, against tables whose rows may be soft-deleted, cost write throughput and create lock contention with ordinary product operations. Referential integrity is enforced at write time by the fact that the resolution payload the event came from was itself loaded from those tables. Orphan rows are tolerable in analytics and are surfaced by joining with a left outer join and rendering "(deleted resource)" |
No updated_at |
The table is append-only. The only writes after insert are the bounded re-application described in 17.11.3, which bumps bot_list_version and leaves that as the audit trail. A column nothing maintains is worse than no column |
17.8.2 page_view_events — what differs #
Bio page views live in their own table, again defined in Section 6, because they carry a different resource shape and two columns that only make sense for a rendered page.
Difference from click_events |
Detail |
|---|---|
| Resource identity | bio_page_id, page_version_id and handle replace resource_id, slug and the destination columns. There is no destination: a page view is an arrival, not a departure |
| No targeting columns | A page is not targeted or split by destination, so targeting_rule_id, excluded_by_rule_id and the source_page_* attribution columns are absent. experiment_id, variant_id, experiment_epoch and assignment_source are present, because a page view is the exposure event for a bio page experiment (16.6.1) |
| Two extra columns | render_ms and cache_hit. They are stored because they make the Section 11 performance budgets observable against real traffic on real devices, per page and per variant, rather than only in synthetic tests |
| Everything else | Identical in name and meaning to the corresponding click_events column, deliberately, so that one query-builder and one export formatter serve both tables. In particular the identity columns, the exactly-once columns, the device columns and the same absolute prohibition on a raw user-agent column all apply unchanged |
17.8.3 The unique visitor-day ledger, and the honest consequence of its grain #
analytics_unique_visitor_days exists for one reason: COUNT(DISTINCT …) is not additive, so a rollup counter cannot be maintained by incrementing. Section 6 defines the table. The algorithm is in STEP 4b of 17.7.3, and it is three lines long: insert the batch's distinct visitor-days, let the conflict clause discard the ones already seen, and count what the RETURNING clause hands back. That count is additive and is idempotent, so it can be added into a rollup counter like any other number.
Its grain is (workspace_id, resource_type, resource_id, visitor_hash, event_date) — the resource and the day, and nothing else. Not per country. Not per device. Not per variant. Not split by bot flag: rows are written only for events with is_bot = false, because a unique visitor is a claim about a person and a bot is not one.
That grain is a deliberate trade, and it has a consequence that must be stated loudly rather than discovered:
Unique-visitor counts are available for resource TOTALS only.
A dimension breakdown reports EVENTS, not uniques. "Unique visitors from Germany" is not a number this product maintains, and the interface must never present a breakdown column in a way that implies it does. Every breakdown table, every map hover, every export of a breakdown counts events, and says "events" in its column header. 18.4.1 carries the interface rule and 18.2.2 carries the metric definition.
Multi-day unique figures are a SUM of daily uniques, and are therefore an UPPER BOUND on distinct humans, never an exact count. A person visiting on five days contributes five. The number is exact as a count of visitor-days — which is what it is labelled — and is an over-count of people, always in the same direction, by an amount the product cannot measure.
The alternative was maintaining this ledger per dimension value. It was rejected on arithmetic: the table would be multiplied by the dimension cardinality of the traffic — a resource seeing 15 countries, 5 operating systems and 6 browsers would write 26 ledger rows per visitor-day instead of one — turning a table that runs at roughly 15–20% of the raw event row count into one several times larger than the raw events it summarises, in order to serve a column most users never read. The narrow case is still answerable, and 17.9.6 routes it: within raw retention a specific "uniques where country = DE" question can be answered from raw with COUNT(DISTINCT …) on demand, as the experiment surfaces do (16.6). What is refused is putting that number in a routine breakdown table where it would be silently wrong beyond raw retention and silently expensive inside it.
Retention follows the raw event tables rather than the rollups, per plan, in 17.10.1, and is enforced by partition drop.
17.8.4 Partitioning scheme #
| Aspect | Decision |
|---|---|
| Strategy | PostgreSQL declarative RANGE partitioning on occurred_at (event_date for the unique-day table) |
| Granularity | One partition per UTC day, [day 00:00:00Z, day+1 00:00:00Z) |
| Naming | click_events_p20260819, page_view_events_p20260819, analytics_unique_visitor_days_p20260819 — the p prefix keeps the name a valid identifier and sorts correctly |
| Why daily | It aligns exactly with the retention boundaries (which are expressed in days), it makes the common "last 7 / 28 days" query prune to a handful of partitions, and it keeps any single partition small enough to vacuum, reindex or move in minutes. Monthly partitions would make a 30-day Free retention require row-level deletes every night instead of a partition drop |
| Why not sub-partition by workspace or plan | Plans change. A workspace that sub-partitions as Free today and upgrades tomorrow would need its history physically relocated. Retention handles the mixed-plan case with the two-phase approach in 17.10 |
| Default partition | click_events_default and its siblings exist to catch events whose occurred_at falls outside every created partition — clock-skewed events from a badly-synchronised edge node, or replayed events from a day whose partition has been dropped. A default partition is a safety net, not a destination |
| Default partition monitoring | analytics_default_partition_rows is a gauge; any non-zero value raises a warning. A nightly job attempts to move its rows into the correct partition (creating it if it is within the retention window) and reports rows it could not place |
| Attach/detach concurrency | Partition creation uses CREATE TABLE ... PARTITION OF; removal uses ALTER TABLE ... DETACH PARTITION CONCURRENTLY followed by DROP TABLE, so no long ACCESS EXCLUSIVE lock is taken on the parent during retention runs |
17.8.5 Partition pre-creation and drop jobs #
| Job | Schedule | Behaviour |
|---|---|---|
analytics-partition-create |
Daily 02:00 UTC, and on every worker boot | Ensures partitions exist for today through today + 14 days on all three partitioned tables. Idempotent (IF NOT EXISTS semantics via a catalogue check). Creates the day's indexes with the partition. Emits analytics_partition_headroom_days |
| Headroom alert | Continuous | analytics_partition_headroom_days warns below 7 and pages below 3. Running out of partitions means every event lands in the default partition, which still works but degrades pruning badly — hence a page, not a warning, at 3 days |
analytics-partition-freeze |
Daily 05:00 UTC | For each partition whose day ended more than 24 hours ago and which has not yet been frozen: VACUUM (FREEZE, ANALYZE, INDEX_CLEANUP ON). Once frozen, an append-only partition never needs vacuuming again, which removes it from autovacuum's workload permanently. Records frozen_at in a partition registry table |
analytics-partition-compact |
Weekly, Sunday 06:00 UTC | Merges daily partitions older than 90 days into monthly partitions using PostgreSQL's native partition merge, reducing partition count from ~730 to ~24 monthly plus ~90 daily on a Business workspace's 24-month horizon. Runs one month per execution, verifies row counts before and after, and aborts on mismatch |
analytics-retention |
Daily 04:00 UTC | 17.10 |
Storage and autovacuum tuning is applied to the parent tables and inherited by every partition. The values are set as part of the schema Section 6 defines; the reasoning for each belongs here because each one follows from how this pipeline writes:
| Setting | Value | Why this value follows from the write pattern |
|---|---|---|
| Fill factor | 100% | The tables are append-only. Leaving free space on a page for in-place updates that never happen is pure waste |
| Insert-driven vacuum scale factor | 0.05 | Insert-only tables need vacuuming for freeze and visibility-map maintenance, not for dead tuples. A scale factor tuned for update-heavy tables would either never fire or fire constantly on a partition growing by millions of rows a day |
| Insert-driven vacuum threshold | 50,000 rows | Pairs with the scale factor so a young partition is visited early and an old one is left alone |
| Analyze scale factor | 0.02 | Dimension distributions inside a single day's partition shift quickly at the start of the day; the planner needs to see that |
| TOAST compression | LZ4 | Cheaper in CPU than the default for the handful of text columns on these rows, and the rows are written far more often than they are read |
17.8.6 The access paths, and the budget that keeps them few #
Section 6 declares the indexes. This section states the budget they are drawn against and what each one is for, because an index on a table taking thousands of inserts per second is a decision about the write path as much as the read path.
The budget: each index costs roughly 7–9% of batch insert throughput at a 1,000-row batch size. The rule that follows is no more than four access paths per raw event table, and every one must be justified by a named query in the dashboard or the export path. Four is not a coincidence — it is the point at which the ingest throughput of 17.14.1 still leaves the design headroom it claims.
The four on click_events, in the order they earn their place:
- The primary key, on
(id, occurred_at). Mandatory for invariant I3: it is the probe theON CONFLICTclause makes on every insert. Leading with a time-ordered UUIDv7 gives append-ordered inserts within a partition, so the index stays dense with minimal page splits — the dedup guarantee is close to free rather than a tax. - Workspace, resource type, resource, time descending. The single most common analytics query shape in the product: a resource detail page, a per-resource export, an experiment result query scoped to its resource. The descending time component matches both the default sort and the range predicate, so one scan direction serves both.
- Workspace, time descending. Serves the workspace-wide raw queries — the overview when it falls through to raw, workspace-scoped exports, and the retention deletes of 17.10.3. The second index cannot serve this, because a composite index cannot skip its middle columns to range-scan a later one. Keeping both is a deliberate, measured cost.
- Experiment, variant, time descending — partial, over rows that belong to an experiment. Serves the results queries of 16.10. Being partial is what makes it affordable: on a workspace running no experiments the index is empty and costs nothing on insert; on one running several it covers a small fraction of rows.
page_view_events mirrors the same four, substituting bio_page_id for the resource triple in the second.
analytics_unique_visitor_days carries its primary key and nothing else. It is written with a conflict-do-nothing insert and read with equality-and-range predicates on its leading columns, so a second index would be pure insert cost against a table written once per visitor-day.
What is deliberately absent, and why each absence is correct rather than an oversight:
| Not indexed | Reason |
|---|---|
country_code, device_type, os_family, browser_family, channel |
These are answered from rollups, which is the entire point of having rollups. Indexing low-cardinality columns on a billion-row table to serve questions a 200-row rollup scan already answers is backwards |
(workspace_id, visitor_hash, event_date) |
Uniques are answered by analytics_unique_visitor_days, which is that index — stored once per visitor-day instead of once per event |
A block-range index on occurred_at |
Partition pruning already eliminates every partition outside the range, and within a partition the range is at most one day. It would add maintenance for no gain |
An inverted index on bot_signals |
The array is diagnostic, queried only by operators over a bounded day range, where scanning one partition is acceptable |
stream_message_id |
It is looked up only by the redelivery short-circuit of STEP 2b, over the last two days, where partition pruning plus a bitmap scan is fast enough. A dedicated index would tax every insert to accelerate a query that runs only after a crash. Section 6 may add it later on evidence; it is not there on speculation |
Anything including destination_url |
A wide text column. The queries that need it are exports, which scan by time anyway |
| Anything on a raw user-agent string | There is no such column to index (invariant I9) |
17.9 The Rollup Strategy #
17.9.1 Grain #
Two rollup tables, identical in shape, differing only in bucket width: analytics_rollup_hourly buckets occurred_at to the UTC hour, analytics_rollup_daily to the UTC day. Both use the column bucket_start for that bucket, on both tables, with no second name for the same idea anywhere in the product. Section 6 defines both tables; the retention of each is in 17.10.1.
The grain — the tuple that identifies exactly one rollup row — is:
(workspace_id, resource_type, resource_id, bucket_start, dimension_type, dimension_value)and each row carries three counters, events, unique_visitors and conversions, plus the ingest_batch_id of the batch that last touched it.
Three properties of that grain do the real work, and each is a decision:
1. dimension_type names the raw column the row marginalises, and dimension_value holds one value of it. A row is one dimension's one value, never a combination. The total row uses the reserved sentinel '*' as its dimension_value — the single canonical spelling for "all values", used on both rollup tables and in every export and API response. There is no second sentinel and no empty-string variant.
2. is_bot is NOT part of the grain. It is a dimension column on the raw events, and in the rollup it appears the way every other dimension appears: as dimension_type = 'is_bot' with dimension_value of 'true' or 'false'. It is never part of a primary key, here or anywhere else in this design. What follows from that, stated plainly because it shapes the dashboard:
| Consequence | Detail |
|---|---|
Every non-is_bot rollup row counts non-bot events only |
The default on every surface (17.6.3) is bots excluded, so the cheap path is the default path. A country row means "human events from that country", with no arithmetic needed at read time |
The is_bot dimension carries the bot volume, at the total grain |
dimension_type = 'is_bot', dimension_value = 'true' gives bot events for the resource and bucket; 'false' restates the human total. This is what the bot-share metric and the reconciliation panel of 18.13.3 read |
| Bots-included figures are exact at the resource total, and are raw-backed for any breakdown | "Show me bots included" on a headline tile is total plus is_bot = true — exact, from rollups, at full dashboard reach. "Show me bots included, by country" needs the cross of two dimensions, which the rollup does not store, so it is served from raw within raw retention and the toggle is disabled beyond it with a stated reason (18.8.3). This is the same limitation as any other two-dimension query, and it is handled by the same routing rule rather than by a special case |
| The rollup halves | Carrying is_bot in the key duplicated every dimension row for a traffic class that 8–14% of events belong to and that almost no query looks at. Removing it removes roughly a third of the rollup rows outright (17.9.3) |
3. unique_visitors is populated only on the total row. On every other row it is null, and Section 6 constrains it so. This is the rollup-side expression of the ledger grain in 17.8.3: uniques are maintained at the resource grain, so a dimension row has no honest value to carry and carries none rather than carrying a plausible wrong one. Rendering rules for that null are in 18.4.1.
Access paths: two per table. One leads with the resource and the dimension type and ranges over bucket_start descending — the shape of a time series and of a breakdown. The other leads with the workspace and bucket_start descending — the shape of the overview and of the retention purge. Neither is partial any more; with bot rows no longer duplicating the table, there is nothing to filter out and a partial index would only complicate planning. analytics_rollup_hourly is range-partitioned on bucket_start by month, because its row count runs several times the daily table's and monthly partitions keep retention a partition drop rather than a delete (17.9.7).
17.9.2 The dimension list #
Each dimension_type is named for the raw event column it marginalises, so there is exactly one spelling of each concept across the raw tables, the rollups, the API and the exports. Section 6 carries the permitted set; this is what each one means and what it costs.
dimension_type |
dimension_value |
Cardinality per resource | Source |
|---|---|---|---|
total |
The sentinel '*' |
1 | Every non-bot event |
is_bot |
'true' or 'false' |
2 | 17.6 |
country_code |
ISO 3166-1 alpha-2, or ZZ |
≤ 250 | 17.4 |
region_code |
The ISO 3166-2 subdivision part, or '(unknown)' |
≤ 4,000 realistic | 17.4 |
device_type |
Closed vocabulary | 6 | 17.5.3 |
os_family |
Closed vocabulary | 9 | 17.5.3 |
browser_family |
Closed vocabulary | 18 | 17.5.3 |
referrer_host |
Host string, or '(none)' |
Unbounded — capped, see below | 17.5.4 |
channel |
Closed vocabulary | 13 | 17.5.5 |
utm_source / utm_medium / utm_campaign / utm_term / utm_content |
Verbatim value, or '(none)' |
Unbounded — capped | Query string |
variant_id |
Variant UUID | ≤ 8 | Section 16 |
block_id |
Block UUID | ≤ page block count | Bio page block clicks |
fallback_stage |
Closed vocabulary of the four rungs | 4 | Section 14 |
language |
Primary tag | ≤ 200 realistic | Accept-Language |
Two dimensions in that list are stored only at the total grain, not crossed with anything: is_bot, for the reason in 17.9.1, and unique_visitors is likewise a total-only counter rather than a dimension. region_code holds the bare subdivision part exactly as the raw column does — the country is already available on the same row's sibling country_code rows, and concatenating them here would create a second spelling of a value the raw table stores once.
Unbounded-cardinality protection. referrer_host and the five UTM dimensions can be attacked or accidentally exploded (a UTM campaign carrying a session id would create a new dimension value per visitor). Protection, applied in the worker before the upsert:
| Rule | Behaviour |
|---|---|
| Per (workspace, resource, dimension_type, bucket) value cap | 500 distinct values per hourly bucket, 2,000 per daily bucket. Values beyond the cap are folded into the reserved value '(other)', whose counts remain correct in aggregate |
| Cap tracking | A Redis set per (workspace, resource, dimension type, bucket) — the dimension-cap entry in Section 4's key catalogue — with a TTL of twice the bucket width, whose cardinality is read before a new value is admitted |
| Value normalisation | Trimmed, lower-cased for referrer_host (UTM values keep their case, because campaign names are user-facing), truncated to 255 characters |
| Alerting | Exceeding a cap increments analytics_dimension_cap_hit_total{dimension_type} and, on the third occurrence for a resource within a day, raises a workspace-visible notice: "Some campaign values are being grouped as 'Other' because this link is receiving an unusually large number of distinct values. Check that your UTM parameters do not contain unique ids" |
| Reserved values | '*' (total), '(none)' (absent), '(other)' (capped), '(unknown)' (unresolvable). These four are never produced by user data because they are rejected at the normalisation step |
17.9.3 Row multiplication and how it is contained #
A single event touches one total row plus one row per applicable dimension, in two grains:
Dimensions present on a typical non-bot short-link click:
total, country_code, region_code, device_type, os_family, browser_family,
referrer_host, channel, language, fallback_stage
= 10 rows (utm_* absent, variant_id absent, block_id absent)
Per grain: 10 rows. Two grains: 20 upsert rows per event, naively.
A bot event touches 2 rows per grain instead — total is not incremented for it, and the
is_bot dimension is maintained at the total grain only.Twenty upserts per event at 160 events/s would be 3,200 upserts/s, which is wasteful. It is contained by in-memory pre-aggregation within the batch, which is why batching exists at all:
A 1,000-event batch drawn from realistic traffic:
· typically spans 1–2 hourly buckets and 1 daily bucket
· touches on the order of 40–120 distinct resources
· each resource has few distinct dimension values within a two-second window
Measured collapse ratio on representative traffic: 1,000 events → ~340 hourly rollup rows
→ ~240 daily rollup rows
≈ 0.58 upsert rows per event, against 20 without pre-aggregation — a 34× reduction.
(Taking is_bot out of the rollup grain is worth roughly a fifth of that on its own:
bot events, 8–14% of the stream, no longer fan out across every dimension.)
The aggregation is a plain in-memory map keyed by the full grain tuple, built during
STEP 4c of 17.7.3 from the RETURNING set. It costs microseconds and no allocation
beyond the map itself. Rows are emitted sorted by the grain tuple, which is what makes
concurrent consumers take locks in a consistent order and deadlocks rare by construction.17.9.4 The nightly reconciliation #
| Property | Decision |
|---|---|
| Job | analytics-reconcile |
| Schedule | Daily 03:15 UTC |
| Scope | The last 3 UTC days, both grains, all workspaces |
| Method | For each (day, workspace) chunk: recompute every rollup row from raw with a full aggregate query into a temporary result, then upsert with absolute assignment (SET events = EXCLUDED.events), and delete any stored rollup row for that scope that the recomputation did not produce. Not an increment — a replacement |
| Chunking | One (day, workspace) pair per transaction, ordered by workspace size ascending so small workspaces complete early. A 250 ms pause every 50 chunks keeps replication lag bounded |
| Duration budget | 20 minutes for the full run at the capacity model of 17.14. Exceeding 45 minutes raises an alert |
| Output | analytics_reconcile_delta_ratio{grain,day} = Σ |
| Authority | After reconciliation, rollups for days inside the window are exactly derivable from raw. Outside the window, rollups are authoritative and immutable — they are never recomputed again, which is what allows raw to be purged on a shorter horizon than rollups (17.10) |
Why it exists — four concrete reasons, none of them "in case something goes wrong":
| # | Reason | Without reconciliation |
|---|---|---|
| 1 | Bot reclassification. A signature list update, or a datacenter-ASN list refresh, changes how yesterday's events classify at user_agent_family granularity (17.6.4) |
Rollups would keep yesterday's stale classification forever while raw showed the new one, and the two would disagree permanently. Note the bound: device and browser fields cannot be recomputed at all, because the raw string is not retained (17.5.1), so reconciliation reflects bot reclassification and not parser improvements |
| 2 | Privacy-driven raw deletions. A workspace deletion, a GDPR erasure, or an abuse takedown removes raw rows (Section 23) | Rollups would keep counting data that no longer exists, and the deletion would be incomplete in the only place a user actually looks |
| 3 | Clock-skewed events relocated. The default-partition sweeper (17.8.4) moves events into their correct day after the fact | Those events would be rolled up under the wrong bucket and never corrected |
| 4 | Operator intervention. A replay, a dead-letter drain, a manual correction, or a partially-applied migration | Any of these can leave rollups and raw disagreeing, with no mechanism to notice or fix it |
The delta ratio is the health signal for all four: a healthy day reconciles to a delta below 0.1%. Persistent deltas above 0.5% mean something in the list above is happening more than expected.
17.9.5 Late-arriving events #
An event is "late" when occurred_at is more than 3 days before ingested_at — typically a spill replay (17.11) or a dead-letter drain.
| Case | Handling |
|---|---|
| Late, and the raw partition still exists | Inserted into its correct historical partition normally. The rollup upsert targets its own historical bucket, so the increment is correct without any special handling. Reconciliation never needs to see it. analytics_late_events_total increments |
| Late, and the raw partition has been dropped by retention | The raw insert would fail. It is not attempted. The event takes the rollup-only write path: the rollup upsert proceeds, because rollup retention outlives raw retention on Pro and Business (17.10.1). Idempotency cannot come from the RETURNING set of an insert that never happens, so it comes from a dedicated ledger, analytics_rollup_only_events, defined in Section 6 and keyed on the event's id. The worker writes the ledger row and the rollup increment in the same transaction, conflict-do-nothing, and applies the increment only for the ids the ledger's RETURNING set produced — the identical pattern as the main path, with the ledger standing in for the raw table. The row carries the same ingest_batch_id as everything else the batch wrote. analytics_events_dropped_total{reason="partition_missing"} increments and the event is counted as raw-lost but rollup-counted |
| Late, and both raw and rollup retention have expired for that workspace | The event is discarded. analytics_events_dropped_total{reason="beyond_retention"} increments. Storing data the user cannot see, and is not entitled to see, serves nobody |
| Clock skew in the future | occurred_at > now + 5 minutes is clamped to ingested_at and clock_skewed = true is set at enrichment (17.7.3, step 3). Such events are visible to operators via the flag and are counted in analytics_clock_skew_total |
| Attribution staleness | A late event's ab:seen key will have expired, so source_page_variant_id is null. This is correct: attribution has a 30-minute window and a replayed event cannot manufacture one |
analytics_rollup_only_events is purged on the same schedule as the rollups it protects.
17.9.6 Which queries read rollups and which read raw #
This is the routing rule the query layer implements. It is not a heuristic; it is a decision table, and the chosen source is reported in meta.source on every analytics API response so the behaviour is verifiable.
| Query shape | Source | Reason |
|---|---|---|
| Time series, any range, no dimension filter | Rollup — hourly if the range ≤ 7 days, daily otherwise | The rollup is exactly this shape |
| Time series with one dimension filter (e.g. country = DE) | Rollup, reading rows of that dimension_type |
Single-dimension marginals are precisely what the rollup stores |
| Time series with two or more dimension filters (country = DE and device = mobile) | Raw | The rollup stores marginals, not the cross-product. country=DE rows and device=mobile rows cannot be intersected — doing so would be arithmetically wrong. This is the single most important limitation of the rollup design and is surfaced to the user in Section 18.8 |
| Single-dimension breakdown table (top countries, top browsers) | Rollup | — |
| Cross-tab breakdown (campaign × source) | Raw | Same reason as above |
| Total events for a resource and range | Rollup total rows |
— |
| Unique visitors at the resource grain | Rollup total rows (unique_visitors, maintained exactly by the ledger of 17.8.3) |
— |
| Unique visitors within a dimension (unique visitors from Germany) | Not offered as a routine figure. Breakdowns report events (17.8.3, 18.4.1). The narrow case is answerable on demand from raw with COUNT(DISTINCT …) within raw retention — which is exactly how the experiment surfaces get per-variant uniques (16.6) — and is never presented in a table that would silently empty out beyond raw retention |
17.8.3 — uniques are maintained only at the total grain |
| Bot-included totals for a resource | Rollup: total plus the is_bot = 'true' row |
17.9.1 |
| Bot-included breakdown by any dimension | Raw, within raw retention; the toggle is disabled beyond it with a stated reason | It is a two-dimension query, and is routed as one |
| Any query returning individual events | Raw | Only raw has individual events |
| Event-level CSV export | Raw | — |
| Summary and breakdown CSV export | Rollup | Makes a 24-month export fast and small |
| Experiment results, experiment within raw retention | Raw | Needs the visitor-level join between page views and clicks (16.3.1), which only raw supports |
| Experiment results, experiment beyond raw retention | The frozen result snapshot (16.8.3), plus rollup variant_id dimension rows for the time series. Per-arm event counts survive in the rollup indefinitely; per-arm unique counts do not, which is precisely why the snapshot is written at conclusion and promotion |
— |
| Real-time, last 30 minutes | Redis counters plus the current hourly rollup row | 18.7 |
| Milestone alert evaluation | Rollup | Alerts must not scan raw |
Three enforcement mechanisms:
- The query layer exposes two repository types,
RollupQueryandRawQuery, and a single router that chooses between them from the parsed filter set. No feature code constructs either directly. - Every raw-backed query carries a mandatory bounded time range and a workspace id in its constructor (18.14), so partition pruning always applies.
meta.sourceon the response is"rollup_daily" | "rollup_hourly" | "raw" | "snapshot" | "realtime" | "mixed", and a contract test asserts the routing table above for 40 representative filter combinations.
17.9.7 Hourly rollup partitions and their retention #
Hourly rollup retention is per plan: 30 days on Free, 365 days on Pro, and indefinite on Business (17.10.1). It is not a uniform horizon, and nothing in this pipeline may assume one.
That matters beyond storage, because the hourly table is what makes timezone-correct day boundaries possible: a daily figure for a workspace in a non-UTC zone is assembled by grouping hourly buckets under that zone's offset (18.3.2). Giving Pro and Business long hourly retention is what lets those workspaces see their own calendar days correctly across their whole history rather than only across a recent window. A Free workspace gets exact local days for 30 days and UTC days before that, with the chart captioned accordingly.
The mechanics:
| Aspect | Decision |
|---|---|
| Partitioning | Monthly range partitions on bucket_start |
| Purge for Free-only months | Whole-partition drop by the retention job (17.10.2), taking the cheap path of 17.10.3 whenever no surviving workspace has rows in the partition |
| Purge for mixed months | The same two-phase strategy as the raw tables: cheap drop when possible, chunked paced delete by workspace when not |
| Verification before any drop | The job asserts that the corresponding analytics_rollup_daily rows exist for every day in the partition and that their events sums match the hourly sums, and refuses to drop a partition that fails the check, logging the month instead. Losing the hourly rows is acceptable at their retention boundary; losing them while the daily table is wrong is not |
| Job | analytics-rollup-compact, weekly on Sunday at 06:30 UTC, after partition compaction |
The storage consequence is real and is not hidden: with Business retention indefinite, the hourly rollup becomes the largest single analytics object in the system, larger than the raw events it summarises. 17.14.2 does that arithmetic and 17.14.3 lists the levers, in order, for when it needs managing.
17.10 Retention Per Plan #
17.10.1 The matrix #
Three independent horizons, and the distinction between them is the whole point: raw powers drill-downs and event-level exports, rollups power charts, and dashboard reach is what the user is actually entitled to see.
| Free | Pro | Business | |
|---|---|---|---|
Raw click_events |
30 days | 90 days | 24 months |
Raw page_view_events |
30 days | 90 days | 24 months |
analytics_unique_visitor_days |
30 days | 90 days | 24 months |
analytics_rollup_hourly |
30 days | 365 days | Indefinite |
analytics_rollup_daily |
30 days | 365 days | Indefinite |
| Dashboard reach | 30 days | 365 days | Unlimited |
| Event-level drill-down reach | 30 days | 90 days | 24 months |
| Cross-dimension filter reach (two or more filters) | 30 days | 90 days | 24 months |
| Exact local-timezone day boundaries (17.9.7, 18.3.2) | 30 days | 365 days | Unlimited |
| Event-level CSV export reach | Not available (no CSV export on Free) | 90 days | 24 months |
| Summary / breakdown CSV export reach | Not available | 365 days | Unlimited |
| Experiment result snapshots | Indefinite | Indefinite | Indefinite |
| Audit log | 30 days | 365 days | Unlimited |
The hourly rollup tracks the daily rollup rather than being capped separately, and it has a reader at every horizon: it is the source of timezone-correct day boundaries (18.3.2), so truncating it would silently degrade a Business workspace's older charts from local days to UTC days. That costs storage — 17.14.2 says how much, without softening it.
Read this table as: on Pro, a chart can go back a year; a drill-down cannot go back past three months. The dashboard makes that boundary visible rather than silently degrading (17.10.5). Note also what is absent from the table by design: there is no "unique visitors within a dimension" row, because that figure is not maintained at any horizon on any plan (17.8.3).
17.10.2 The purge job #
| Property | Value |
|---|---|
| Job | analytics-retention |
| Schedule | Daily 04:00 UTC, after reconciliation (03:15) and before the freeze job (05:00) |
| Ordering | Rollup-only-event ledger → hourly rollups → daily rollups → unique visitor-days → raw page views → raw clicks. Coarsest-retention tables last so that a mid-run failure never leaves rollups referencing purged raw in a way that reconciliation would then "correct" to zero |
| Registry | Iterates a static registry of analytics tables, each with its retention resolver. A schema test fails the build if a table matching the analytics naming pattern exists without a registry entry (invariant I8) |
| Grace | A 7-day grace is added to every nominal retention before physical deletion. A workspace that downgrades and re-upgrades within a week loses nothing, and support can honour a "we deleted it by accident" request. Data inside the grace window is not queryable — the API and dashboard enforce the plan boundary strictly, so grace is invisible to users and exists only for recoverability |
| Idempotency | Every step is a bounded delete or a partition drop; re-running the job is safe |
| Observability | analytics_retention_rows_purged_total{table,plan}, analytics_retention_partitions_dropped_total{table}, analytics_retention_duration_seconds |
| Failure | A failure on one workspace or one partition is logged and skipped; the run continues. Two consecutive failed runs raise an alert |
17.10.3 Combining partition drop with per-workspace delete #
This is the hard part. Workspaces on different plans share a partition, because partitions are by day and plans are per workspace. A single day's partition can hold Free rows that must go at 30 days and Business rows that must stay for 24 months.
The job runs a two-phase strategy per partition, choosing the cheap path whenever it can:
For each raw partition P, with age A = today − P.day:
PHASE 0 — CHEAP-PATH CHECK
max_retention_in_partition = MAX(raw retention of every workspace with rows in P)
(read from a small maintained summary table analytics_partition_workspaces,
written by the ingest worker on first insert of a (partition, workspace) pair —
so this check never scans the partition itself)
IF A > max_retention_in_partition + grace:
ALTER TABLE click_events DETACH PARTITION P CONCURRENTLY;
DROP TABLE P;
-- O(1). No row scan, no vacuum, no bloat, no WAL proportional to row count.
CONTINUE to next partition.
PHASE 1 — PER-WORKSPACE DELETE
expired = every workspace W with rows in P where A > retention(W.plan) + grace
IF expired is empty: CONTINUE.
-- Deleted in chunks so no single statement holds locks or generates WAL spikes.
LOOP:
DELETE FROM <P> WHERE ctid IN (
SELECT ctid FROM <P>
WHERE workspace_id = ANY($expired)
LIMIT 10000
);
IF rows_deleted = 0: BREAK;
SLEEP 50 ms; -- keeps replication lag under the 5 s budget
IF elapsed > 45 min: record progress and yield to the next run;
END LOOP;
VACUUM (ANALYZE) <P>; -- reclaim space for reuse within the partition
Remove the deleted workspaces from analytics_partition_workspaces for P.
PHASE 2 — RE-CHECK
IF analytics_partition_workspaces for P is now empty:
DETACH CONCURRENTLY + DROP. -- the partition emptied itself; take the cheap pathWhy this shape:
| Aspect | Reasoning |
|---|---|
| Cheap path first | On a realistic plan mix, the overwhelming majority of partitions older than 24 months contain no surviving workspace and can simply be dropped. Partition drop is constant-time and produces no bloat; row deletion is linear and produces plenty |
| The summary table | analytics_partition_workspaces (partition_name, workspace_id, first_seen_at) is written once per (partition, workspace) pair by the ingest worker via ON CONFLICT DO NOTHING. It turns "which workspaces are in this partition" from a full scan into an index lookup. It is small: at 10,000 workspaces and 730 partitions the absolute worst case is 7.3 M tiny rows, and in practice each partition holds rows for only the workspaces active that day |
ctid-based chunking |
Avoids re-scanning the index from the start on each iteration, which a naive DELETE ... LIMIT on an indexed column would do |
| 50 ms pause | Purging is a background maintenance task competing with live ingest and live dashboard reads on a replica. The pause caps WAL generation and keeps replica lag inside its budget |
| Time-boxing at 45 minutes | Ensures the job never overruns into the freeze job's window. Progress is recorded so the next run resumes rather than restarting |
VACUUM after delete, not VACUUM FULL |
Plain vacuum makes the space reusable within the partition, which is enough since the partition is being progressively emptied and will eventually be dropped whole. VACUUM FULL would take an exclusive lock for no lasting benefit |
Rollup tables use the same two-phase idea. analytics_rollup_hourly is monthly-partitioned, so it takes the cheap partition-drop path whenever a month contains no surviving workspace and the chunked per-workspace delete otherwise, with the daily-sum verification of 17.9.7 as a precondition on any drop. analytics_rollup_daily is not partitioned — its row count is an order of magnitude smaller — and is purged with the same chunked, paced DELETE … WHERE workspace_id = ANY(…) AND bucket_start < … pattern. Lever 6 in 17.14.3 converts it to monthly partitions if that ever stops being cheap enough.
17.10.4 Plan changes and what they do to retention #
| Change | Effect |
|---|---|
| Upgrade (e.g. Free → Pro) | The longer retention applies from the upgrade date forward. Data already purged is gone and does not come back. Stated plainly in the upgrade flow: "Your analytics history starts building from today at the new retention. Data older than your previous 30-day window has already been deleted and cannot be recovered." Anything inside the 7-day grace window survives incidentally, but the product never promises this |
| Downgrade (e.g. Business → Pro) | The shorter retention applies from the next purge run, plus grace. The guided downgrade flow (Section 22) states the exact volume that will become unavailable, with a date, and offers a full export before confirming |
| Downgrade to Free | Same, and additionally CSV export becomes unavailable — so the export offer is made before the plan change commits, not after |
| Workspace soft-deleted | Analytics retention pauses. Nothing is purged during the 30-day restore window beyond what the normal schedule would have purged anyway |
| Workspace hard-purged after 30 days | All analytics rows for that workspace are deleted regardless of plan, in the same chunked fashion, and the workspace is removed from analytics_partition_workspaces, which usually lets several partitions take the cheap drop path immediately |
| Per-visitor erasure request | Not possible, and this is stated rather than fudged: there is no identifier to erase. visitor_hash is a one-way function of a salt that is destroyed daily and an address that was never stored. Section 23 documents this as the reason the data does not constitute identifiable personal data in the operative sense, and the DPIA summary carries the reasoning |
17.10.5 What a user sees when they ask for data beyond their retention #
Four surfaces, one consistent principle: clamp and disclose. Never silently return zeros, never fail a whole dashboard.
| Surface | Behaviour |
|---|---|
| Date picker | Dates before the reach boundary are visually disabled, not hidden. Hovering shows "Your plan keeps analytics for 90 days. Upgrade to see further back." A permanent boundary marker is drawn on the calendar. Preset ranges that would cross the boundary (e.g. "Last 12 months" on Pro) are shown but marked with an upgrade badge |
| Chart with a partially out-of-reach range | The range is clamped to the boundary, the chart renders the reachable part, and a labelled boundary line marks where data ends with the caption "History starts here on your plan" |
| Drill-down or second filter beyond raw reach | The filter control stays enabled but shows an inline note; applying it clamps the range to raw reach and shows "Detailed filtering is available for the last 90 days. Showing 1 Jun – 30 Aug." with an "extend by upgrading" link. If the user instead insists on the wider range, the second filter is removed and the reason is stated |
| Unique visitors within a dimension, at any reach | The question does not arise, because breakdown tables have no uniques column to empty out (17.8.3, 18.4.1). Where a user might expect one, the table states "Breakdowns count events. Unique visitors are shown for the resource as a whole." |
| Analytics API, range partially out of reach | 200 OK with the range clamped, plus meta.range_clamped: true, meta.requested_start, meta.effective_start, and a meta.notices array. A 403 would break every dashboard that requests a default range; returning zeros would be a lie. Clamping with disclosure is the only honest option that keeps clients working |
| Analytics API, range entirely out of reach | 200 OK with an empty data array and the same meta fields, so a client can distinguish "no traffic" (range_clamped: false, empty data) from "not entitled" (range_clamped: true, empty data) |
Export request explicitly demanding an out-of-reach range with strict=true |
403 plan_limit_reached, with a single issue object in error.details: field naming the reach that was exceeded, issue: "limit_reached", kind: "period", limit (retention days), current (requested days) and plan. strict exists precisely so an integration can insist on an error rather than silently receiving less than it asked for |
Export without strict |
Clamped, with the effective range recorded in the export job record and encoded in the generated filename |
Example clamped response. Note the envelope: data and meta are both always present, meta always carries source, the effective range and the pagination members, and notices is an array that is empty rather than absent when there is nothing to say.
{
"data": [
{ "bucket_start": "2026-06-01T00:00:00Z", "events": 1284, "unique_visitors": 903 },
{ "bucket_start": "2026-06-02T00:00:00Z", "events": 1571, "unique_visitors": 1104 }
],
"meta": {
"source": "rollup_daily",
"granularity": "day",
"timezone": "Europe/Berlin",
"requested_start": "2025-09-01T00:00:00Z",
"effective_start": "2026-06-01T00:00:00Z",
"effective_end": "2026-08-19T00:00:00Z",
"range_clamped": true,
"notices": [
{
"code": "retention_boundary",
"message": "Your plan keeps analytics for 90 days. The range was adjusted to start on 2026-06-01.",
"plan": "pro",
"retention_days": 90
}
],
"next_cursor": null,
"has_more": false
}
}17.11 Backfill and Replay #
17.11.1 What makes replay possible #
| Layer | Recovery window | Mechanism |
|---|---|---|
| Consumer group offset | Unlimited while entries remain in the stream | XREADGROUP ... > resumes from the group's last-delivered id after any restart. Nothing is lost to a restart |
| Pending entries | 60 seconds to reclaim | XAUTOCLAIM by the reaper (17.7.5) |
| Stream retention | ≈ 4.6 hours at the steady rate, ≈ 42 minutes at a 2,000 events/s spike | MAXLEN ~ 5000000 |
| Spill to object storage | 30 days | 17.11.2 |
| Raw table | Per plan | Reparse source (17.11.3) |
The stream alone gives a comfortable window for the ordinary failures — a deploy, a crash loop, a brief database outage. The spill exists for the failure the stream cannot cover: a multi-hour outage under load.
17.11.2 The spill #
| Property | Decision |
|---|---|
| Trigger | Consumer lag exceeds 2,000,000 entries (40% of stream capacity), evaluated every 30 seconds |
| Job | analytics-spill, a separate consumer using XRANGE from the group's last-delivered id forward. It does not join the ingest group and does not acknowledge anything — it only copies |
| Destination | Object storage, analytics-spill/{YYYY-MM-DD}/{unix_ms}-{shard}.ndjson.zst |
| Format | Newline-delimited JSON, one payload per line, Zstandard-compressed. Measured ratio ≈ 8:1, so an hour of spill at 2,000 events/s is roughly 300 MB |
| Redaction before writing | The spill writer runs the same memoised parse the ingest worker runs, replaces the payload's raw user_agent field with the derived user_agent_family, and drops client_hints. A spill object is durable storage, so invariant I9 applies to it in full. Replayed spill payloads therefore arrive already-parsed and the consumer uses the derived fields as given, which is also why a replay reproduces the original classification rather than silently reclassifying it |
| Retention of spill files | 30 days, then deleted by the retention job. Long enough for any realistic incident response |
| Stop condition | Lag falls below 500,000 for 5 consecutive minutes |
| Idempotency | Spill files may overlap with entries the consumer eventually processed anyway. That is fine — replaying them is a no-op by 17.7.6. A replayed payload receives a new stream_message_id when it is re-appended, and is absorbed on its unchanged id, which is exactly why id and not the transport key is the conflict target |
| Alerting | Spill activation raises a warning immediately and is recorded as an incident marker on analytics charts for the affected period, so a later "why does this hour look odd" question has an answer on screen |
17.11.3 Recovery procedures #
A. Consumer outage, entries still in the stream. No operator action.
1. Alert fires on analytics_stream_lag_entries.
2. Workers restart (automatically, by the platform's health check).
3. XREADGROUP resumes from the group's last-delivered id. Backlog drains at
~8,300 events/s per worker; scale the worker count to shorten the drain.
4. XAUTOCLAIM reclaims anything left pending by the dead consumers.
5. Verify: lag returns to baseline; analytics_events_ingested_total resumes its normal rate.B. Outage long enough that entries were trimmed. Operator action, runbook in Section 25.
1. Confirm from the spill marker which period is affected.
2. Restore the consumer (procedure A) so live traffic is being handled first.
3. Replay:
pnpm analytics:replay --from "2026-08-19T04:00:00Z" --to "2026-08-19T07:30:00Z"
Reads the spill objects for the range and re-XADDs each payload into `clicks:replay`,
rate-limited to 5,000 entries/s so replay never starves live ingest.
4. The replay consumer group runs the identical consumer code. Each payload's `id` is
unchanged, so every duplicate is absorbed by ON CONFLICT DO NOTHING (17.7.6), even
though every replayed entry carries a fresh stream_message_id.
5. Rebuild rollups for the affected days — the normal reconciliation covers 3 days, so a
wider window must be requested explicitly:
pnpm analytics:reconcile --days 5
6. Verify: analytics_reconcile_delta_ratio for those days returns below 0.1%.C. Dead-letter drain.
1. Inspect: pnpm analytics:dlq --inspect --limit 50 → groups entries by error class.
2. Fix the cause (usually: deploy the worker version that understands the payload schema).
3. Re-inject: pnpm analytics:dlq --replay --error-class schema_version_unknown
Entries are XADDed to `clicks:replay` and XDELed from `clicks:dlq` only after
the replay consumer has acknowledged them.
4. Reconcile the affected days.D. Re-classification — recomputing derived columns from what is actually retained. Used when the bot signature list, the link-preview agent list or the datacenter-ASN list is updated and history should reflect it.
pnpm analytics:reclassify --from 2026-08-01 --to 2026-08-19 --fields bot,channel [--workspace <id>]
· Reads raw rows in batches of 5,000 ordered by (occurred_at, id).
· Recomputes is_bot, bot_reason, bot_signals and channel from the RETAINED columns:
user_agent_family, ua_hash, is_datacenter_asn, is_prefetch, headers_implausible,
referrer_host.
· UPDATEs only rows whose values actually change, bumping bot_list_version so the
change is attributable to the list that made it.
· Paced with a 100 ms pause per batch.
· Then runs reconciliation over the same day range so rollups follow.
· Writes an annotation visible on every affected chart: "Bot classification for
1–19 Aug was updated on 19 Aug."Two bounds, both stated rather than worked around:
- It is bounded by raw retention. Beyond it there is no source to re-read, and rollups outside the reconciliation window are immutable by design (17.9.4). A bot-classification improvement cannot retroactively fix a Business workspace's 18-month-old chart.
- It cannot recompute device, OS or browser at all, at any horizon. Those come from a raw user-agent string that was never persisted (invariant I9), and
user_agent_familyis a product of the old parser, not a substitute for its input. A parser improvement is forward-only (17.5.1). Signature lists work at family granularity, which is why bot re-classification survives and device re-parsing does not.
E. Backfilling a newly added dimension. Adding a dimension (for example, a new channel category) follows the same path as re-classification, with an added rollup rebuild for the affected days. It applies only within raw retention, and only to dimensions derivable from retained columns. New dimensions therefore always start with a visible "data available from " marker rather than a partially-populated history that looks like a drop in traffic.
The operator runbook containing the exact commands, the preconditions, the verification steps and the rollback for each of A–E lives in Section 25.
17.12 Failure Modes and Their User-Visible Effect #
One row per dependency. "User-visible effect" means what the customer experiences, not what the operator sees.
| Dependency | Failure | Detection | Automatic behaviour | User-visible effect | Alert |
|---|---|---|---|---|---|
| Redis (stream) | Unreachable from the edge | XADD timeout or connection error |
Payloads go to the in-process ring buffer and retry with backoff; after 10,000 buffered, oldest are dropped and counted | Redirects and pages are entirely unaffected. Analytics stop updating; after the buffer overflows, some events are permanently lost. The dashboard shows a "data delayed" indicator once the real-time widget's lag exceeds 60 s | Page on analytics_capture_dropped_total rate > 1% of redirects over 5 min |
| Redis (stream) | Memory pressure / maxmemory reached |
XADD returns OOM |
Same as above | Same as above | Page. The noeviction startup check (17.7.1) is what prevents the far worse silent-eviction variant |
| Redis (stream) | Misconfigured with an eviction policy | Worker startup check reads CONFIG GET maxmemory-policy |
The worker refuses to start | None initially — the edge keeps buffering — but analytics stop | Page immediately on worker start failure |
| Redis (cache/counters) | Unreachable from the worker | Pipeline error in enrichment step | Enrichment degrades: page-variant attribution becomes null, velocity bot signal is skipped, real-time counters are not written | Real-time widget shows "Live view unavailable"; historical charts are unaffected; cross-surface experiment attribution is missing for the affected period, and the experiment panel annotates it | Warn |
| PostgreSQL (primary) | Unreachable | Transaction failure in the consumer | The batch is not acknowledged; the consumer retries with backoff; the stream accumulates | Redirects unaffected. Analytics freeze at the last committed batch. Recovery is automatic when the database returns, provided the stream has not trimmed | Page |
| PostgreSQL (primary) | Disk pressure | Insert failure, or a monitored free-space threshold | Consumer backs off; the retention job is triggered out of band; the partition-compact job is triggered | Analytics freeze. Redirects unaffected | Page below 15% free |
| PostgreSQL (primary) | Target partition missing | Insert routing error | Event lands in the default partition; the sweeper relocates it that night; if the partition was dropped by retention, the rollup-only path applies (17.9.5) | Usually invisible. In the dropped-partition case, event-level drill-down for that period is unavailable while charts are correct | Warn on non-zero default-partition rows |
| PostgreSQL (read replica) | Unreachable or lagging beyond 30 s | Health check on replication lag | Analytics reads fail over to the primary with a reduced statement timeout of 2 s | Dashboards slightly slower; no visible error | Warn at 10 s lag, page at 60 s |
| Geo database | Missing at process start | Reader load failure | Every lookup returns ZZ / geo_resolved = false |
Geography breakdowns show "Unknown" for the affected period. Everything else is normal | Page |
| Geo database | Stale | analytics_geo_db_age_days |
Continues using the stale database | Slightly less accurate country attribution. No visible error | Warn at 14 days, page at 21 |
| Geo database | Update verification fails | Fixture assertion in the update job | Swap abandoned; previous reader stays active | None | Warn |
| User-agent parser | Throws on a pathological string | Exception caught per event | Every device field set to unknown, user_agent_family set to the unknown composite, the raw string still discarded, the event still stored |
A small number of events show "Unknown" device/browser. Because the raw string is not retained, these events stay unknown even after the parser is fixed (17.5.1) — which is why the error rate is alerted at a low threshold rather than tolerated | Warn if the error rate exceeds 0.5% of events |
| Worker | Crash loop | Platform health check + zero ingest rate | Platform restarts; stream accumulates; spill activates past the lag threshold | Analytics freeze; a "data delayed" indicator appears. No data loss while the spill holds | Page |
| Worker | Too few consumers for the load | analytics_stream_lag_entries climbing steadily |
None automatic — scaling is an operator or autoscaler decision | Growing delay between an event and its appearance on a chart; the widget reports the actual lag | Warn at 100k, page at 1M |
| Object storage | Unreachable | Spill write failure, export write failure | Spill retries with backoff and, failing that, stops (the stream still holds the recent window); export jobs move to failed with a retry |
Spill: no user-visible effect unless the outage coincides with a long consumer outage. Exports: the user sees "Export failed — retry" with a reason | Warn; page if it coincides with active spill |
| Object storage | Geo database object unreadable | Update job download failure | Previous reader stays active | None | Warn |
| Edge node | Clock skew | occurred_at more than 5 minutes ahead of ingest time |
Timestamp clamped, clock_skewed = true |
Events appear at their ingest time rather than their true time. Sub-minute effect for small skews | Warn on analytics_clock_skew_total above 0.1% of events |
| Payload | Malformed or unknown schema version | Validation failure in the consumer | Routed to the dead-letter stream; the batch continues | Those events are missing from analytics until the dead-letter stream is drained | Warn on any dead-letter depth for 15 min |
| Rollup upsert | Deadlock between concurrent consumers | PostgreSQL deadlock error | The transaction retries up to 3 times with jitter; batches are pre-sorted by the full rollup key so lock ordering is consistent and deadlocks are rare by construction | None | Warn above 5 deadlocks/hour |
| Reconciliation | Job overruns its window | Duration metric | Aborts cleanly at 45 minutes and resumes from where it stopped on the next run | Rollups for the most recent days may briefly retain small drift | Warn at 45 min |
| Retention purge | Job fails or overruns | Job status | Skips the failing scope, continues, resumes next run | Data slightly beyond its retention remains present but is not queryable (the API enforces the boundary independently of the purge job) | Warn after two consecutive failures |
| Conversion postback source | Unreachable or misconfigured | Missing conversions relative to clicks | None automatic | Conversion-rate experiments show fewer conversions; the results panel notes that conversions are still arriving until the window closes | Warn if a link with a conversion goal records zero conversions across 500 clicks |
17.13 Pipeline Observability #
All metrics are OpenTelemetry instruments exported to the platform's metrics backend (Section 25 owns dashboards and routing). Names use the analytics_ prefix.
17.13.1 The four signals that matter most #
| Metric | Type | Definition | Warn | Page |
|---|---|---|---|---|
analytics_stream_lag_entries |
Gauge | Entries added to clicks:raw but not yet delivered to the ingest group, read from the group's reported lag every 15 s |
> 100,000 for 5 min | > 1,000,000 for 5 min |
analytics_capture_dropped_total{reason} |
Counter | Events the capture path failed to enqueue. Alerting is on the ratio to redirects_total plus page_renders_total, not the raw count, so it scales with traffic |
ratio > 0.1% over 15 min | ratio > 1% over 5 min |
analytics_reconcile_delta_ratio{grain,day} |
Gauge | Σ|recomputed − stored| ÷ Σ recomputed, emitted per grain per day by the nightly reconciliation | > 0.5% | > 2% |
analytics_ingest_lag_seconds |
Histogram | ingested_at − occurred_at per event, sampled 1-in-100 |
p95 > 60 s for 10 min | p95 > 300 s for 5 min |
The lag metric is the one a user can feel: it is what the real-time widget's freshness statement (18.7) is derived from, and the widget displays the measured value whenever it exceeds 60 seconds rather than silently showing stale numbers.
The dropped-event ratio is the one that is silently corrosive: a slow leak of 0.05% is invisible in any chart but makes every number quietly wrong, which is why it is alerted on as a ratio at a threshold well below anything a human would notice.
The reconciliation delta is the integrity check: it is the only signal that proves rollups and raw still agree, and it is what would catch a subtle idempotency regression that every unit test missed.
17.13.2 The full metric set #
| Metric | Type | Labels | Purpose |
|---|---|---|---|
analytics_capture_overhead_ms |
Histogram | capture_source |
On-request cost of capture (17.2.6). CI gate: p99 ≤ 1.0 ms |
analytics_capture_buffer_size |
Gauge | instance |
Fallback ring buffer depth. Non-zero means Redis is struggling |
analytics_events_ingested_total |
Counter | event_type, is_bot, capture_source |
Throughput and bot share |
analytics_events_deduplicated_total |
Counter | — | Rows that hit ON CONFLICT DO NOTHING. A sustained non-trivial rate means redelivery is happening more than expected |
analytics_events_dropped_total |
Counter | reason |
partition_missing, beyond_retention, payload_invalid, schema_version_unknown |
analytics_stream_pending_entries |
Gauge | — | Delivered but unacknowledged. Warn > 50,000 for 10 min |
analytics_stream_length |
Gauge | — | XLEN. Approaching MAXLEN means trimming is imminent |
analytics_dlq_depth |
Gauge | — | Dead-letter depth. Warn > 0 for 15 min |
analytics_dlq_total |
Counter | reason |
Dead-letter arrivals |
analytics_ingest_batch_size |
Histogram | — | Consistently at 1,000 means the consumer is saturated and should scale out |
analytics_ingest_batch_duration_ms |
Histogram | phase (parse, enrich, insert, rollup, total) |
Warn p95 total > 800 ms. Phase labels make the bottleneck obvious without profiling |
analytics_late_events_total |
Counter | bucket (under_3d, over_3d) |
Replay and drain activity |
analytics_clock_skew_total |
Counter | — | Warn above 0.1% of events |
analytics_spill_active |
Gauge (0/1) | — | Spill running |
analytics_spill_bytes_total |
Counter | — | Spill volume |
analytics_reconcile_duration_seconds |
Histogram | grain |
Warn > 45 min |
analytics_reconcile_rows_corrected_total |
Counter | grain, direction |
Which way the drift went |
analytics_partition_headroom_days |
Gauge | table |
Warn < 7, page < 3 |
analytics_default_partition_rows |
Gauge | table |
Warn on any non-zero value |
analytics_retention_rows_purged_total |
Counter | table, plan |
Proof that I8 holds |
analytics_retention_partitions_dropped_total |
Counter | table |
Cheap-path hit rate |
analytics_retention_duration_seconds |
Histogram | — | Warn > 45 min |
analytics_geo_db_age_days |
Gauge | — | Warn > 14, page > 21 |
analytics_geo_lookup_error_total |
Counter | — | Reader health |
analytics_ua_parse_error_total |
Counter | — | Warn above 0.5% of events |
analytics_ua_cache_hit_ratio |
Gauge | — | Below 0.9 suggests unusual traffic or a cache sized wrongly |
analytics_ua_corpus_rows_added_total |
Counter | — | Distinct unparsed user-agent strings newly seen. A step change means a new client is in the wild and the parser has not caught up (17.5.2) |
analytics_redelivery_short_circuit_total |
Counter | — | Entries acknowledged by the stream_message_id check of STEP 2b without opening a transaction. A sustained non-zero rate means the worker is crashing between commit and acknowledgement |
analytics_dimension_cap_hit_total |
Counter | dimension_type |
Cardinality attack or a misconfigured UTM |
analytics_rollup_upsert_rows_total |
Counter | grain |
Divided by analytics_events_ingested_total, gives the live collapse ratio of 17.9.3 |
analytics_query_duration_ms |
Histogram | source, endpoint |
Dashboard budget (18.14) |
17.13.3 Tracing and logging #
| Aspect | Decision |
|---|---|
| Tracing | Capture emits no span on the request path — a span per redirect at 5,000 rps would cost more than the capture itself. Instead, 1% of payloads carry a trace_id sampled at capture, and the consumer creates a span linked to it, giving end-to-end visibility on a statistically useful sample at 1% of the cost |
| Consumer spans | One span per batch with attributes for batch size, phase durations, rows inserted, rows deduplicated and rollup rows upserted. Never one span per event |
| Log level | info for job lifecycle, warn for degradations, error for anything requiring attention. Per-event logging is forbidden; the closest thing is 1-in-100 sampled warnings on capture failure |
| Structured logging | Every log line carries workspace_id where known, batch_id, and component |
| Redaction | The logger is configured with a redaction path list applied at every level, covering: ip, remoteAddress, remote_addr, x-forwarded-for, x-real-ip, cf-connecting-ip, true-client-ip, forwarded, client_ip, user_agent, user-agent, client_hints, salt, daily_salt, experiment_salt, cookie, set-cookie, authorization, lh_session, lh_ab, token, api_key, signature. A unit test asserts each path is redacted in a serialised log line, and integration tests grep full log output for both a probe address (17.3.3) and a probe user-agent string (invariant I9) |
| Retention of logs | Per Section 25 |
17.14 Capacity #
17.14.1 The event volume model #
Assumptions, stated so the arithmetic can be re-run with different ones:
| Assumption | Value |
|---|---|
| Workspaces at steady state | 10,000 |
| Plan mix | 80% Free (8,000), 17% Pro (1,700), 3% Business (300) |
| Events per workspace per month, Free | p50 200, mean 400, p95 3,000 |
| Events per workspace per month, Pro | p50 8,000, mean 15,000, p95 60,000 |
| Events per workspace per month, Business | p50 120,000, mean 250,000, p95 1,200,000 |
| Diurnal peak factor | 4× the daily mean rate |
| Campaign spike factor | 5× on top of diurnal, for a single resource |
MONTHLY EVENT VOLUME
Free 8,000 × 400 = 3,200,000
Pro 1,700 × 15,000 = 25,500,000
Business 300 × 250,000 = 75,000,000
────────────
Total 103,700,000 events / month
DERIVED RATES
Per day 103,700,000 / 30 = 3,456,667 events/day
Mean rate 3,456,667 / 86,400 = 40.0 events/s
Diurnal peak 40.0 × 4 = 160 events/s
Steady design point (headroom ×2) = 320 events/s ← the "300 ev/s" figure in 17.7.1
Campaign spike on top = 1,600 events/s
Design burst ceiling 2,000 events/s
Redirect load-test target (Section 26) 5,000 requests/s
The capture path must survive 5,000 events/s bursts. It does, because capture is a
pipelined XADD with no awaited I/O on the request path (17.2.6); the stream absorbs
the burst and the consumer drains it afterwards.
CONSUMER THROUGHPUT
Measured target: a 1,000-row batch completes parse+enrich+insert+rollup in ~120 ms
→ 8,333 events/s per worker process.
Baseline 2 workers = 16,666 events/s (52× the steady design point)
Scaled 6 workers = 50,000 events/s
At the 2,000 events/s burst ceiling, 2 workers drain a 1-hour burst backlog
(7.2 M events) in 7.2M / (16,666 − 2,000) ≈ 8.2 minutes once the burst ends.
STREAM SIZING
MAXLEN ~ 5,000,000 entries × ~340 bytes ≈ 1.7 GB
Holding time at 320 ev/s = 5,000,000 / 320 ≈ 4.3 hours
Holding time at 2,000 ev/s = 5,000,000 / 2,000 ≈ 42 minutes
Spill threshold 2,000,000 entries — reached after ~17 minutes of total consumer
outage at the burst rate, which is comfortably inside any realistic detection and
response time.17.14.2 Storage growth #
RAW ROW SIZE (click_events)
Fixed-width columns (uuids, timestamps, booleans, smallints, the 2-char country
code, and the 16-byte ua_hash) ~166 bytes
Variable text, measured averages:
user_agent_family ~22 destination_url ~60 host ~18
referrer_host ~14 utm_* combined ~20 stream_message_id ~16
other ~15 ≈ 165 bytes
Heap tuple header + null bitmap + line pointer ~28 bytes
────────────────────────────────────────────────────────────────────────────────
Stored row (with lz4 TOAST compression on the text columns) ~330 bytes
Index overhead across the 4 access paths ~180 bytes
────────────────────────────────────────────────────────────────────────────────
ALL-IN PER RAW EVENT ~510 bytes
Not storing the raw user-agent string is worth ~90 bytes per row all-in — about 15%
of the raw footprint. The privacy decision of invariant I9 is also, incidentally,
the single largest storage saving in this design.
MONTHLY RAW GROWTH
103,700,000 × 510 bytes ≈ 53 GB / month (all plans combined, before retention)
RESIDENT RAW AT STEADY STATE (retention-weighted)
Free 3.2 M/mo × 1 month = 3.2 M events × 510 B ≈ 1.6 GB
Pro 25.5 M/mo × 3 months = 76.5 M events × 510 B ≈ 39.0 GB
Business 75.0 M/mo × 24 months = 1,800.0 M events × 510 B ≈ 918.0 GB
──────────────────────────────────────────────────────────────────────
Resident raw total ≈ 959 GB (~0.96 TB)
UNIQUE VISITOR-DAY LEDGER
~18% of raw event count (measured ratio of distinct non-bot visitor-days to events),
at ~110 bytes all-in including its primary key index:
Free 0.6 M, Pro 13.8 M, Business 324 M rows ≈ 37 GB resident.
Note what its resource grain buys here: maintaining it per dimension value instead
would multiply this by the ~26 dimension values a typical resource sees, giving a
~960 GB table to serve a column the breakdowns no longer show (17.8.3).
ROLLUP STORAGE
Rollup row: ~120 bytes stored + ~80 bytes across the two indexes ≈ 200 bytes.
Rows per active resource per day, typical traffic spread across
15 countries / 25 regions / 3 device types / 5 OS families / 6 browser families /
8 referrer hosts / 4 channels / 6 UTM values / 1 total / 1 is_bot:
1 + 1 + 15 + 25 + 3 + 5 + 6 + 8 + 4 + 6 = 74 rows/day at the spread extreme.
That is the worst case. The median resource sees traffic from 2 countries and
2 devices, producing ~11 rows/day. Measured mean: 17 daily rows per resource per
day, and 60 hourly rows — hourly is not 24× daily, because a small resource's
traffic concentrates in a handful of hours.
Active resources at steady state ≈ 180,500, split by plan roughly as
Free 64,000 Pro 59,500 Business 57,000
(measured as resources with ≥1 event on a given day).
DAILY ROLLUP, at 17 rows/resource/day and per-plan retention
Free 64,000 × 17 × 200 B × 30 d ≈ 6.5 GB
Pro 59,500 × 17 × 200 B × 365 d ≈ 73.8 GB
Business 57,000 × 17 × 200 B × 730 d ≈ 141.5 GB (24 months modelled)
──────────────────────────────────────────────────────
Resident daily rollup ≈ 222 GB
HOURLY ROLLUP, at 60 rows/resource/day and the SAME per-plan retention (17.9.7)
Free 64,000 × 60 × 200 B × 30 d ≈ 23.0 GB
Pro 59,500 × 60 × 200 B × 365 d ≈ 260.6 GB
Business 57,000 × 60 × 200 B × 730 d ≈ 499.3 GB (24 months modelled)
──────────────────────────────────────────────────────
Resident hourly rollup ≈ 783 GB
──────────────────────────────────────────────────────────────────────
TOTAL ANALYTICS FOOTPRINT AT STEADY STATE ≈ 0.96 TB raw
+ 0.04 TB visitor-days
+ 0.22 TB daily rollup
+ 0.78 TB hourly rollup
≈ 2.0 TBTwo honest observations about where the money goes.
Business's 24-month raw retention is 96% of the raw footprint and roughly 46% of the total. That is a direct consequence of the plan table's promise and is not negotiable without changing the promise.
The hourly rollup is now the second-largest object, larger than the daily rollup by more than 3×, because its retention matches the daily rollup's per plan rather than being capped at a uniform horizon. What that buys is timezone-correct day boundaries across a workspace's whole history (17.9.7, 18.3.2) instead of only a recent window — which for a customer outside UTC is the difference between their charts being right and their charts being approximately right. It is the most expensive line item in this table relative to what a casual reader would expect it to cost, so it is called out here rather than left to be discovered during a capacity review. Levers 3, 6 and 7 of 17.14.3 all act on it.
17.14.3 Scaling levers, in the order they should be pulled #
| # | Lever | Effect | Cost |
|---|---|---|---|
| 1 | Add consumers to the ingest group |
Linear throughput increase up to the database's write ceiling | Compute only. No code change, no correctness change |
| 2 | Increase batch size from 1,000 to 5,000 with a 5 s window | Roughly 2.2× per-worker throughput (fewer round trips, better rollup collapse) | Ingest lag rises by up to 3 s. Acceptable against the 60 s freshness statement |
| 3 | Enable partition compaction earlier (90 days → 30 days) | Fewer partitions, faster planning, better compression ratios on merged partitions | Slightly slower queries on the 30–90 day window |
| 4 | Move all dashboard reads to a read replica | Removes analytics read load from the primary entirely | Replica lag budget of 5 s; the real-time widget already reads Redis so recent data is unaffected |
| 5 | Add a second read replica and shard dashboard reads by workspace | Doubles read capacity | Infrastructure cost |
| 6 | Convert analytics_rollup_daily to monthly range partitions |
Makes rollup retention purges partition drops instead of chunked deletes | A migration on a large table; done with expand/contract per Section 27 |
| 6a | Move analytics_rollup_hourly partitions older than 90 days to a cheaper tablespace |
Cuts the cost of the largest object in 17.14.2 without shortening any retention or degrading any local-timezone boundary | Regrouping an old range into local days gets slower. Acceptable — it happens on chart load for old ranges, not on the hot path |
| 7 | Split raw event storage by moving partitions older than 90 days to a slower, cheaper tablespace | Cuts the cost of the Business retention tail substantially | Drill-downs into old data get slower. Acceptable — they are rare |
| 8 | Decouple rollup writes into a second consumer group | Raw insert and rollup upsert scale independently | Forfeits the exactly-once property. The rollup consumer would no longer derive its increments from the raw insert's RETURNING set, so it would need its own idempotency ledger keyed on each event's id — a table as large as the event stream, which is exactly the cost the current design avoids by reusing the raw insert's own conflict resolution. This is the last lever precisely because it trades a correctness guarantee for throughput |
| 9 | Adaptive sampling | 17.14.4 | Counts become estimates for the affected resource |
17.14.4 Adaptive sampling — the decided last resort #
Sampling is not enabled by default and is not a plan feature. It is an automatic protection that engages only under extreme single-resource load.
| Aspect | Decision |
|---|---|
| Trigger | A single resource_id exceeds 20,000 events/s sustained for 60 seconds, measured from the real-time counters |
| Behaviour | Capture for that resource switches to 1-in-N with N chosen so the effective rate lands near 20,000 events/s, rounded to a power of two, capped at N = 64. The sampling decision is deterministic on crc32(id) % N == 0 — the event's own capture-time identifier — so it is unbiased, needs no state, and gives the same verdict on every retry of the same event |
| Storage | sample_rate = N is stored on every affected row |
| Counting | Every count derived from a sampled row is multiplied by its sample_rate. Unique-visitor counts are not scaled — a sampled unique count is a lower bound and is labelled as such rather than being extrapolated, because extrapolating a distinct count is not statistically valid |
| Exit | Sampling disengages after 5 minutes below 60% of the trigger rate, returning to sample_rate = 1 |
| Disclosure | Every affected number in the dashboard carries a "sampled" badge naming the rate and the period. The export includes the sample_rate column. Milestone alerts are suppressed for sampled resources rather than firing on estimates |
| Audit | Engagement and disengagement are recorded as timeline annotations on the resource's charts, so a later reader can see exactly which period was sampled |
18. Analytics Dashboards, Reporting & Export #
Section 17 produces the data. This section specifies every surface that displays it, every way it leaves the product, and the rules that keep it honest.
18.1 Dashboard Information Architecture #
18.1.1 The screens #
| Screen | Route | Default range | Minimum role |
|---|---|---|---|
| Workspace overview | /w/{workspace_slug}/analytics |
Last 28 days | Viewer |
| Bio page detail | /w/{workspace_slug}/analytics/pages/{bio_page_id} |
Last 28 days | Viewer (scoped: must be granted the page) |
| Short link detail | /w/{workspace_slug}/analytics/links/{link_id} |
Last 28 days | Viewer (scoped: must be granted the link) |
| QR code detail | /w/{workspace_slug}/analytics/qr/{qr_id} |
Last 28 days | Viewer (scoped: must be granted the code) |
| Campaign (UTM) view | /w/{workspace_slug}/analytics/campaigns |
Last 28 days | Viewer |
| Comparison view | /w/{workspace_slug}/analytics/compare |
Last 28 days | Viewer |
| Export centre | /w/{workspace_slug}/analytics/exports |
— | Editor (create), Viewer (see own) |
| Shared read-only view | /s/{token} |
Fixed at share creation | None — public with a token |
18.1.2 Workspace overview #
Modules, top to bottom:
| Module | Content | Empty state |
|---|---|---|
| Headline tiles | Total clicks, total scans, total page views, unique visitors (daily; 18.2.2), click-through rate (workspace-wide), each with the period-over-period delta | "No activity yet" with the create-a-link call to action |
| Real-time strip | Last 30 minutes, events per minute sparkline, top 5 active resources (18.7) | "Quiet right now" |
| Time series | Events over time, stacked by resource type (link / qr / page), with the granularity rule of 18.3 | Flat empty chart with the range and a "nothing in this period" caption |
| Top resources | Top 10 by events, with type icon, name, events, unique visitors, trend sparkline; expandable to a cursor-paginated full list | "No resources with activity in this period" |
| Channel mix | Donut plus table of the channel dimension |
"Not enough data to show channels" below 10 events |
| Geography | Map plus top-10 country table (18.4.1) | Map with no shading and "Unknown: n" |
| Device mix | Device type, OS, browser — three compact bar lists | — |
| Recent milestones | Last 5 fired milestone alerts (18.12) | "No milestones yet" |
Scoped members (Business per-resource grants, Section 3) see a filtered overview containing only their granted resources, with a persistent line above the tiles: "Showing 3 of 41 resources — you have access to a subset of this workspace." Aggregates are computed over the granted subset only; there is no partial-total leakage.
18.1.3 Per-resource detail #
Common to all three resource types:
| Module | Content |
|---|---|
| Header | Resource name, its public URL with a copy control, status chips (active / scheduled / expired / archived / experiment running), quick actions (edit, duplicate, QR, share analytics) |
| Headline tiles | Type-specific (below), each with period-over-period delta |
| Time series | Primary metric over time with the comparison overlay |
| Breakdowns | Geography, device, OS, browser, referrer, channel, UTM — as tabbed tables with the map on the geography tab (18.4) |
| Experiment panel | Present only when an experiment exists on this resource; renders the results UI of Section 16.10 inline |
| Recent events | Last 50 raw events, within raw retention only, with every stored dimension as a column and a link to export the full set |
| Settings | Bot inclusion toggle, timezone note, retention note |
Type-specific headline tiles:
| Type | Tiles |
|---|---|
| Bio page | Views, unique visitors, total block clicks, click-through rate, clicks per visitor, lead submissions (when a lead block exists) |
| Short link | Clicks, unique clickers, repeat click rate, conversion rate (when a conversion goal exists), top country, top referrer |
| QR code | Scans, unique scanners, repeat scan rate, first-scan share, top country, resolution health (share of scans served by each fallback stage, Section 14) |
18.1.4 Comparison view #
Reached from a "Compare" action on any detail page or directly. Accepts 2–5 resources of any mix of types, or one resource across two time periods. Specified in 18.9.
18.1.5 Navigation and state #
| Aspect | Behaviour |
|---|---|
| URL state | Range, granularity, filters, segment, bot toggle and comparison mode are all encoded in the query string, so any view is shareable with a colleague who has access and is restorable by the browser's back button |
| Range persistence | The last-used range is remembered per member per workspace and reapplied on the next visit, except that a range extending beyond the plan's reach is clamped on load with the notice of 17.10.5 |
| Deep links | Every breakdown row links to the same view filtered to that value, preserving range and other filters |
| Keyboard | Full keyboard operation per Section 24, including range presets, tab navigation between breakdown tabs, and an accessible data table alternative for every chart |
18.2 The Metric Catalogue #
Every metric in the product is defined here once. Two engineers implementing from this table must produce identical numbers. Formulas use the tables and columns of Section 17.
Standing conditions applied to every metric unless explicitly stated otherwise: is_bot = false; workspace_id equals the current workspace; occurred_at is inside the selected range after retention clamping; resource scope is the selected resource or the member's granted subset.
18.2.1 Volume metrics #
| Metric | Definition | Formula | Source |
|---|---|---|---|
| Clicks | Number of short-link redirect events served | COUNT(*) over click_events where resource_type = 'link' |
Rollup total rows, events |
| Scans | Number of QR redirect events served | COUNT(*) over click_events where resource_type = 'qr' |
Rollup total rows |
| Block clicks | Number of bio page block activation events, tracked and estimated combined | COUNT(*) over click_events where resource_type = 'block' |
Rollup total rows |
| Tracked block clicks | Block clicks captured server-side | As above, is_estimated = false |
Raw |
| Estimated block clicks | Block clicks captured by browser beacon | As above, is_estimated = true |
Raw |
| Page views | Number of server-rendered bio page responses | COUNT(*) over page_view_events |
Rollup total rows |
| Total events | All of the above for the scope | Sum of the applicable rollup total rows |
Rollup |
A reload of a bio page is a second page view. A second click on the same link by the same visitor is a second click. Volume metrics count events, full stop.
18.2.2 Uniqueness metrics #
The canonical rule, restated from Section 17.3.4 because it is the single most misread number in any analytics product:
A "unique" is a distinct
(visitor_hash, event_date)pair — a visitor-day. Over a multi-day range, unique visitors is the sum of each day's distinct visitor count, not a distinct count across the whole range. A person who visits on five days counts as five unique visitors. This is a direct consequence of the daily rotation of the visitor identity salt, which is what allows LinkHub to measure without cookies and without storing addresses.
Two further consequences follow, and both are stated on screen rather than left in a specification:
Over any range longer than one day, "unique visitors" is an UPPER BOUND on distinct people, not an exact count. The error is always in one direction — the figure can only over-count, never under-count, repeat visitors — and its size is not measurable, because measuring it would require the cross-day identity the product deliberately does not have. The number is exact as what it is labelled: a count of visitor-days.
Unique counts exist for resource TOTALS only. They are maintained by the ledger of 17.8.3, whose grain is the resource and the day. There is no unique count per country, per device, per browser, per campaign or per referrer, at any retention, on any plan. Every dimension breakdown in this product counts EVENTS, and every breakdown column says so (18.4.1).
Every surface that displays a uniqueness metric labels it "Unique visitors (daily)" on first appearance and carries a tooltip containing the first sentence above in plain language, with the upper-bound sentence beneath it.
| Metric | Definition | Formula | Source |
|---|---|---|---|
| Unique visitors | Distinct visitor-days that produced any non-bot event for the scope | SUM of the unique_visitors counter on rollup total rows across buckets in range |
Rollup, maintained exactly by analytics_unique_visitor_days (17.8.3) |
| Unique clickers | Distinct visitor-days that produced at least one click | Same, restricted to resource_type IN ('link','qr','block') |
Rollup |
| Unique viewers | Distinct visitor-days that produced at least one page view | Same, over page_view_events |
Rollup |
| Unique scanners | Distinct visitor-days that produced at least one scan | Same, resource_type = 'qr' |
Rollup |
| Unique visitors within a dimension | Not a metric this product offers. A breakdown reports events. Where a user asks the question of one specific dimension value, the interface applies it as a filter — which scopes the resource-total unique count, is served from raw as a two-dimension query, and carries the raw-retention bound and the "Detailed view" chip of 18.8.3 | — | — |
| Unique visitors including bots | Not maintained. Bot events never create a visitor-day row (17.7.3), so the bots-included toggle changes event counts and leaves unique counts unchanged, with the tooltip "Unique visitors always exclude bots" | — | — |
18.2.3 Rate metrics #
| Metric | Numerator | Denominator | Notes |
|---|---|---|---|
| Click-through rate (page) | Unique clickers attributed to the page | Unique viewers of the page | The primary bio page metric. Identical definition to 16.6.1 |
| Block click-through rate | Unique clickers of that block | Unique viewers of the page | Not of the block — a block has no impressions, only the page does |
| Clicks per visitor | Total clicks | Unique visitors | Includes non-clickers in the denominator |
| Clicks per clicker | Total clicks | Unique clickers | Excludes non-clickers |
| Repeat rate | Total events − unique visitors | Total events | The share of events that were not a visitor-day's first |
| First-time share | Unique visitors | Total events | The complement of repeat rate |
| Conversion rate | Attributed conversions | Unique clickers | Requires a conversion goal (16.6.3) |
| Bot share | Events with is_bot = true |
All events including bots | The one metric that deliberately ignores the standing bot filter |
| Estimated share | Events with is_estimated = true |
All events | Shown on bio page block tables (17.2.1) |
| Non-clicking viewer share | Unique viewers − unique clickers | Unique viewers | — |
18.2.4 Derived and descriptive metrics #
| Metric | Definition |
|---|---|
| Average events per day | Total events ÷ number of complete days in range (18.3.5) |
| Peak day | The complete day with the highest event count, with its value |
| Peak hour | The complete hour with the highest event count, in workspace timezone, only offered for ranges ≤ 7 days |
| Busiest day of week | The weekday with the highest mean events per occurrence across the range; requires ≥ 14 complete days, otherwise hidden |
| Top country / referrer / device | The dimension value with the highest event count, with its share |
| Period-over-period delta | (current − previous) ÷ previous, suppressed when previous < 10 (18.3.4) |
| Time to first event | For a resource, first_event.occurred_at − resource.created_at |
| Days since last event | now − last_event.occurred_at, shown on inactive resources |
18.2.5 Total versus unique, worked #
Five raw click events on one link, one UTC day, one visitor hash repeated:
| # | occurred_at |
visitor_hash |
event_date |
country_code |
is_bot |
|---|---|---|---|---|---|
| 1 | 2026-08-18 09:14Z | AAA… |
2026-08-18 | DE | false |
| 2 | 2026-08-18 09:16Z | AAA… |
2026-08-18 | DE | false |
| 3 | 2026-08-18 11:02Z | BBB… |
2026-08-18 | FR | false |
| 4 | 2026-08-19 08:40Z | CCC… |
2026-08-19 | DE | false |
| 5 | 2026-08-19 08:41Z | ZZZ… |
2026-08-19 | US | true |
Event 4 is the same human as events 1 and 2 on the next day; the salt rotated, so the hash differs. There is no way for the product to know they are the same person, and it does not pretend to.
| Metric, range 18–19 Aug, bots excluded | Value | Working |
|---|---|---|
| Clicks | 4 | Events 1–4; event 5 excluded as bot |
| Unique visitors | 3 | 18 Aug: {AAA, BBB} = 2. 19 Aug: {CCC} = 1. Sum = 3 |
| Unique visitors, 18 Aug only | 2 | {AAA, BBB} |
| Unique visitors, 19 Aug only | 1 | {CCC} |
| Clicks per visitor | 1.33 | 4 ÷ 3 |
| Repeat rate | 25% | (4 − 3) ÷ 4 |
| Bot share | 20% | 1 ÷ 5 |
| Clicks from DE | 3 | Events 1, 2, 4. The country breakdown row reads "3 events" — it is a count of events, and the column header says Events |
| Unique visitors from DE | not shown in the breakdown | Uniques are maintained at the resource grain only (17.8.3). Applying country_code = DE as a filter rather than reading a breakdown row scopes the resource-total unique count and answers it from raw within raw retention — here it would be 2, from {(AAA, 18th), (CCC, 19th)} — with the "Detailed view" chip shown (18.8.3) |
| Unique visitors, as a count of people | 3 is an upper bound, not a count | AAA and CCC are the same human on two days. The true number of people is 2, and the product has no way to know that. It never claims otherwise, and never displays a metric named "people" or "users" |
| Distinct humans | not measurable | Stated as a permanent property, not a gap to be closed |
18.3 The Time-Series View #
18.3.1 Granularity by range #
Chosen automatically from the range length; the user may narrow but never widen beyond the rule, because a finer granularity over a long range is both unreadable and expensive.
| Range length | Granularity | Source (17.9.6) | Manual override offered |
|---|---|---|---|
| ≤ 48 hours | Hour | analytics_rollup_hourly |
Day |
| > 48 hours, ≤ 7 days | Day | analytics_rollup_hourly, summed to display days |
Hour |
| > 7 days, ≤ 92 days | Day | analytics_rollup_daily, regrouped from hourly where the workspace timezone is not UTC (18.3.2) |
Week |
| > 92 days, ≤ 400 days | Week (ISO, Monday start) | analytics_rollup_daily, summed |
Month |
| > 400 days | Month | analytics_rollup_daily, summed |
— |
Hourly granularity is offered wherever the range is short enough for it to be readable and the hourly rollup still covers the period. Hourly retention is per plan (17.9.7), so on Free the hour option disappears for ranges beginning more than 30 days ago; the control is disabled rather than hidden, with "Hour-by-hour detail is available for the last 30 days on your plan."
Maximum selectable span is 400 days. "All time" is deliberately not offered as a range option: on a Business workspace with indefinite rollup retention it would be an unbounded query with an unbounded chart, and no user makes a decision from a 4-year daily line. The date picker offers presets (Today, Yesterday, Last 7 / 28 / 90 days, This month, Last month, Last 12 months) plus a custom range bounded at 400 days, all clamped to plan reach.
18.3.2 Timezone handling #
| Aspect | Decision |
|---|---|
| Storage | Every bucket is stored in UTC. Nothing in the pipeline is timezone-aware |
| Workspace setting | analytics_timezone, an IANA identifier, default UTC. At workspace creation the browser's detected zone is offered as a one-click confirmation, never applied silently |
| Who can change it | Owner or Admin. Changing it rewrites nothing; it changes only how stored UTC buckets are grouped for display |
| Display grouping, within the plan's hourly rollup reach | Daily display buckets are computed from hourly rollups: date_trunc('day', bucket_start AT TIME ZONE $tz). This is exact for any whole-hour offset. The reach is 30 days on Free, 365 days on Pro and unlimited on Business (17.9.7), so for a paying workspace this is the normal case rather than a recent-window special case — which is the point of giving the hourly rollup a per-plan retention rather than a uniform cap |
| Display grouping, beyond the plan's hourly rollup reach | Hourly rows no longer exist for that period, so daily UTC buckets are used directly. The chart carries a caption naming the exact date the change happens: "Dates before 21 May are shown in UTC." This is stated rather than silently approximated, and on Business it never occurs |
| Half-hour and 45-minute offsets | For zones such as Asia/Kolkata (+05:30) or Asia/Kathmandu (+05:45), grouping from hourly buckets snaps the day boundary to the nearest whole hour. The tooltip states: "Day boundaries are rounded to the nearest hour for your timezone." Within hourly reach the error is at most 30 minutes of one day's traffic at the boundary; beyond it the UTC caption applies |
| Daylight saving transitions | Handled correctly by the database's timezone conversion. The spring-forward day has 23 hourly buckets and the autumn-back day has 25; the hourly chart draws exactly the buckets that exist and labels them in local time, and the daily total is the sum of whatever hours the day actually had |
| Exports | Always contain both bucket_start_utc and bucket_start_local plus a timezone column, so a spreadsheet is never ambiguous (18.10.3) |
| API | timezone is a query parameter defaulting to the workspace setting; meta.timezone echoes the effective value |
18.3.3 Comparison to a previous period #
| Aspect | Behaviour |
|---|---|
| Default comparison | The immediately preceding window of identical length. A 7-day range compares to the 7 days before it, which is weekday-aligned by construction |
| Alternative | "Same period last year" for ranges of 28 days or more, subject to plan reach |
| Rendering | The previous period is drawn as a muted dashed line on the same axes, and each headline tile shows the absolute and percentage delta with a direction indicator |
| Alignment | Compared by position in the window, not by calendar date, so day 1 compares to day 1. For a month-length range spanning months of different lengths, the shorter window is padded with null (drawn as a gap, never as zero) |
| Suppression | The percentage delta is hidden and replaced by "—" whenever the previous-period value is below 10, with the tooltip "Too few events in the previous period to compute a meaningful change." A 200% increase from 1 to 3 is noise dressed as insight |
| Clamping | If the comparison window falls partly outside plan reach, the comparison is disabled entirely rather than computed on a truncated window, with the notice "The previous period is outside your plan's history" |
| Incomplete buckets | The current in-progress bucket is excluded from both sides of every comparison (18.3.5) |
18.3.4 Incomplete current bucket #
The rule, applied everywhere without exception:
A bucket is complete when
bucket_end ≤ now − 60 seconds. The 60-second allowance covers normal ingest lag (17.13.1). Every other bucket in the range is complete; at most one — the current one — is not.
| Surface | Treatment of the incomplete bucket |
|---|---|
| Line chart | Drawn, but with a dashed segment and a hollow end point |
| Bar chart | Drawn hatched |
| Tooltip | Appends "(in progress)" and the elapsed share of the bucket |
| Headline tiles | Included in the total, because a user looking at "today" expects today's clicks so far to be counted |
| Period-over-period delta | Excluded from both sides. Comparing 4 hours of today to 24 hours of yesterday manufactures a fake decline |
| Average per day | Excluded from both numerator and denominator |
| Peak day / peak hour | Excluded, so a partial day is never crowned the peak |
| Trend arrows and plain-language summaries | Excluded |
| Milestone alert evaluation | Included — a threshold crossing should fire when it happens, not an hour later (18.12) |
| CSV export | Included, with a boolean is_complete column so the consumer decides |
| API | Included, with is_complete on each bucket and meta.incomplete_buckets listing which are partial |
18.4 Dimension Breakdowns #
18.4.1 Common rules for every breakdown #
| Rule | Decision |
|---|---|
| Default sort | Events descending, then dimension value ascending as a stable tiebreak |
| Rows shown | Top 20 by events |
| "Other" bucketing | Everything beyond the top 20 is aggregated into a single Other row labelled with the count of collapsed values — "Other (137 values)". Its events are the exact sum, never an estimate |
| Expanding Other | Clicking it opens a full cursor-paginated list (25 per page, per Section 21's pagination), sorted the same way |
| Columns | Value, Events, Share of total, Trend sparkline over the range, and the period-over-period delta |
| Breakdowns count events, and say so | There is no unique-visitors column on any breakdown table, on any dimension, at any retention, on any plan. The header of the count column reads "Events", never "Visitors", never "Uniques", never an ambiguous "Count". Beneath every breakdown table sits one line of standing copy: "Breakdowns count events. A visitor who clicked three times from Germany is three events here. Unique visitors are shown for this <link/page/code> as a whole." This follows directly from the ledger grain of 17.8.3 |
| Why not compute it from raw anyway | Because a column that is populated inside raw retention and empty outside it teaches users that the product's numbers come and go. A breakdown must mean the same thing at every point in the range the plan offers. The question is still answerable — apply the value as a filter rather than reading it as a row (18.8.3) — and that path is explicit about being event-level, bounded and slower |
| Share denominator | Total events in the breakdown, which equals total events in scope. Shares sum to 100% including Other and Unknown, and the interface guarantees this — a breakdown whose shares do not sum to 100% is a bug, not a rounding artefact. Rounding residue is assigned to the largest row |
| Drill-down | Clicking any row applies that value as a filter to the whole view (18.8), preserving range, other filters and the bot toggle |
| Unknown handling | Unknown is always its own row, never folded into Other, and is always shown even when it would fall outside the top 20. Hiding unknowns makes shares lie |
| Empty | "No data in this period" with the applied filter set listed, and a control to clear filters |
| Export | Every breakdown has a per-breakdown CSV export (18.10.2, type breakdown_<dimension>) |
| Accessibility | Every chart has an equivalent data table reachable by keyboard and exposed to assistive technology, per Section 24 |
18.4.2 Geography #
| Aspect | Behaviour |
|---|---|
| Map | A choropleth world map shaded by event count, with a sequential scale and a legend. Countries with zero events are unshaded, not grey-as-zero-ambiguous |
| Map interaction | Hover shows country, events and share. Not unique visitors — 18.4.1. Click drills into that country's regions |
| Unknown on the map | ZZ cannot be drawn. It is displayed as a labelled figure directly beneath the map — "Unknown location: 1,284 events (3.1%)" — so the map and the table reconcile visibly |
| Table | Country name, ISO code, flag, events, share, delta |
| Country → region drill-down | Expanding a country row shows its region_code dimension rows (ISO 3166-2 subdivision parts, rendered with their names) with the same columns. Regions with no subdivision data appear as "Unknown region" |
| Depth limit | Two levels: country, then region. There is no third level. City, coordinates and postal code are never resolved and never stored (17.4.1). Where a user would expect to drill further, the interface says so explicitly: "LinkHub resolves location to country and region only. City-level location is never collected — see how visitor privacy works." linking to the privacy explanation of Section 23 |
| Region availability | Region data is unavailable for some countries and for some address ranges; those events roll into "Unknown region" within their country rather than being dropped |
18.4.3 Device, operating system and browser #
Three separate breakdowns sharing one tab, rendered as compact horizontal bar lists with the standard table available beneath.
| Breakdown | Dimension | Values | Notes |
|---|---|---|---|
| Device | device_type |
desktop, mobile, tablet, tv, unknown (bot never appears with the default filter) |
Closed vocabulary, so no Other row is ever produced |
| Operating system | os_family |
Closed vocabulary of 9 values (17.5.3) | Version shown as a nested expansion, major version only, top 5 plus Other |
| Browser | browser_family |
Closed vocabulary of 18 values | In-app browsers are surfaced as first-class rows with their own icons, and an "In-app browsers" summary chip above the list gives the combined share, because that number is the actionable one for a creator |
Each has a fixed, meaningful ordering option: by events (default) or by the closed vocabulary's canonical order, so a user tracking a shift over time sees rows stay in place.
18.4.4 Referrer #
| Aspect | Behaviour |
|---|---|
| Values | referrer_host — host only, www. stripped, punycode preserved and rendered decoded with the ASCII form in a tooltip |
| Direct bucket | (none) is rendered as "Direct / unknown", always the first row regardless of sort, with the honest tooltip of 17.5.6 |
| Self-referrals | channel = 'internal' rows are excluded by default with a toggle "Include traffic from my own pages". When excluded, a line beneath the table states the excluded count so totals reconcile |
| Path drill-down | Does not exist. Referrer paths are never stored (17.5.4). Where a user would expect to expand a host, the interface states: "LinkHub stores the referring website, never the specific page — the full referring address can contain private information." |
| Favicons | Rendered from a first-party proxy with a fallback letter avatar, never by hotlinking a third party, so no request leaks a visitor's referrer set to an external service |
| Channel column | Each row shows its classified channel (17.5.5), and a control reclassifies the view to group by channel instead of host |
18.4.5 Channel #
| Aspect | Behaviour |
|---|---|
| Values | The closed 13-value vocabulary of 17.5.5 |
| Rendering | Donut plus table; the donut is capped at 8 slices with the remainder as Other, while the table lists all present values |
| Drill-down | Expanding a channel lists the referrer hosts and UTM values that produced it, so "why is 40% of my traffic messaging?" is answerable in one click |
| Explanation | Each channel row carries a one-line definition on hover, taken verbatim from the classification rules, so the classification is never a black box |
18.4.6 UTM campaign performance #
| Aspect | Behaviour |
|---|---|
| Primary table | Grouped by utm_campaign, with columns: campaign, events, share, conversions (where goals exist), conversion rate, first seen, last seen. No unique-visitors column, per 18.4.1 — and conversion rate here is conversions ÷ campaign events, labelled as such, not conversions ÷ unique clickers |
| Expansion | Expanding a campaign row shows its utm_source, utm_medium, utm_content and utm_term breakdowns as four sub-tables |
| Missing campaign | Events with no utm_campaign appear as (none), always shown, never collapsed into Other |
| Cross-tab | A campaign × source cross-tab is offered as a matrix view. It requires raw (17.9.6), so it is available only within the plan's raw retention. Beyond that horizon the matrix control is disabled with: "Campaign-by-source detail is available for the last on your plan. The campaign and source lists below cover the full period." — the marginals remain available, only the intersection does not |
| Cardinality notice | When the dimension cap has been hit (17.9.2), a notice appears above the table: "Some campaign values are grouped as 'Other' because this resource received an unusually large number of distinct values. Check that your UTM parameters do not contain unique ids." |
| Link to the builder | Every campaign row links to the UTM builder of Section 15 pre-filled with that campaign's values |
18.5 Bio Page Analytics Specifics #
18.5.1 Page-level metrics #
| Tile | Definition |
|---|---|
| Views | Page view events (18.2.1) |
| Unique visitors | Visitor-days with at least one page view (18.2.2) |
| Total block clicks | All block activation events on the page, tracked plus estimated |
| Click-through rate | Unique clickers ÷ unique viewers (18.2.3) |
| Clicks per visitor | Total block clicks ÷ unique visitors |
| Lead submissions | Where a lead capture block exists; field definitions and the lead list are owned by Section 20 |
| Median time to first click | Over clicker visitor-days, capped at the 30-minute attribution window |
18.5.2 Block-level click-through table #
One row per block, in the page's rendered order (not sorted by performance, so the table reads as the page reads).
| Column | Definition |
|---|---|
| Position | The block's index in the rendered order, 1-based |
| Block | Type icon, title, and destination host |
| Clicks | Total activation events for the block |
| Unique clickers | Distinct visitor-days that activated the block. This is a genuine unique count rather than an event count, because a block is a resource in its own right — resource_type = 'block' — so the ledger of 17.8.3 maintains it at exactly this grain. It is one of the few places a per-item unique figure is honestly available, and the reason is structural, not an exception |
| CTR | Unique clickers of this block ÷ unique viewers of the page. The denominator is the page, because a block has no impression event of its own |
| Share of clicks | This block's clicks ÷ total block clicks on the page |
| Measurement | Tracked or Estimated (17.2.1) |
| Trend | Sparkline over the range |
Rules:
- A block that received zero clicks is shown with zeros, never omitted. A missing row would read as "this block does not exist".
- Blocks that cannot be clicked (text, image, divider, embed without a link) are shown in a collapsed "Non-clickable blocks" section with their positions, so the position numbering stays honest.
- Every
Estimatedrow carries an inline "Make this exact" action that converts the raw destination into a workspace short link, after which the block is measured server-side. - A block deleted during the range is shown greyed with "(removed)" and its historical data intact.
- Variant-scoped view: when an experiment is or was running, a variant selector filters the table to one arm, and a comparison mode shows per-block CTR side by side across arms.
18.5.3 The click map #
| Aspect | Decision |
|---|---|
| What it is | The page rendered at reduced scale with a translucent heat overlay on each clickable block, shaded by that block's share of total clicks, with the count and CTR labelled on hover |
| What it is not | It is not a pointer heatmap. Pointer coordinates, scroll positions, mouse movement and tap coordinates are never collected. Collecting them would require a continuous event listener on every public page, would breach the zero-blocking-JS and 40 KB HTML budgets of Section 11, and would create a behavioural dataset far more intrusive than anything else in the product. The interface states this where a user would expect a pointer heatmap |
| Fold indicator | A dashed line marks the approximate fold for the reference viewport of Section 11, with the caption "Approximate — actual fold varies by device". A device selector redraws it for mobile, tablet and desktop proportions |
| Position insight | A secondary view plots CTR against position, which is the actual question ("do my lower blocks get ignored?") answered with data the product genuinely has |
| Accessibility | The map has a complete data-table equivalent, and the overlay never conveys information by colour alone — every block carries its numeric label |
18.5.4 Scroll depth — decided: not collected #
Scroll depth is not collected, not stored, and not displayed in v1. This is a decision, not an omission.
| Reason | Detail |
|---|---|
| Performance budget | It requires a scroll listener and a beacon on every public page render. Section 11 mandates zero blocking JavaScript and a 40 KB HTML budget, and the only JavaScript on a bio page is a 1.1 KB deferred block-click beacon. A scroll observer plus its reporting logic materially changes that posture on the product's highest-traffic surface |
| Reliability | Bio pages are short. On a page that fits within one viewport on desktop, "100% scrolled" fires immediately and means nothing; on mobile the same page may need two swipes. The metric would not be comparable between two of the same customer's own pages |
| Redundancy | The question scroll depth is used to answer — "are my lower blocks being ignored?" — is answered directly and reliably by the CTR-by-position view in 18.5.3, using click data that is already collected exactly |
| Honesty | Displaying an unreliable metric next to reliable ones teaches users to distrust all of them |
The interface does not show an empty scroll widget or a "coming soon" placeholder. It shows nothing, and the help documentation explains why, pointing at CTR-by-position. Scroll and viewport-based engagement measurement is named as roadmap in Section 28.
18.6 QR Analytics Specifics #
18.6.1 Metrics #
| Tile / view | Definition |
|---|---|
| Scans over time | click_events where resource_type = 'qr', time series per 18.3 |
| Unique scanners | Visitor-days with at least one scan |
| First-scan share | Unique scanners ÷ total scans, expressed as a percentage. See the honest note below |
| Repeat scan rate | (Total scans − unique scanners) ÷ total scans |
| Scan location | Country and region breakdown per 18.4.2 |
| Device and OS | Per 18.4.3. Overwhelmingly mobile, which is itself a useful sanity signal — a QR code with 40% desktop scans is being clicked from a screenshot, not scanned from print |
| Resolution health | Share of scans served by each rung of the four-rung fallback chain, read from the fallback_stage dimension: rung 1 active, rung 2 paused_fallback, rung 3 workspace_unavailable, rung 4 generic (Section 14). Any non-active share above zero is surfaced as a warning, because it means printed material is currently sending people somewhere other than the intended destination. No rung is ever an error status — a QR-backed URL never returns 4xx — so this tile is the only place a resolution problem becomes visible |
| Time-of-day pattern | Hour-of-day histogram in workspace timezone, for ranges ≥ 7 days. Physical signage produces a strong and interpretable pattern |
18.6.2 First scan versus repeat #
| Aspect | Decision |
|---|---|
| Definition | Within a single UTC day, the first scan by a given visitor_hash is a "first scan"; subsequent scans by the same hash that day are "repeat scans" |
| The honest limitation, shown as a persistent note on the tile | "A person who scans on Monday and again on Wednesday counts as two first scans. LinkHub identifies visitors for one day at a time, without cookies, which means it cannot tell that two scans on different days came from the same phone." |
| Why it is still useful | Within a day it distinguishes a poster scanned once by many people from a poster scanned repeatedly by a few — which is exactly the question at an event or on packaging |
| What it is never labelled | Never "new visitors" and never "returning customers". Those words imply a cross-day identity the product does not have |
18.6.3 What scan data can and cannot tell you #
Rendered as a permanent, collapsible explainer panel on every QR analytics page. Not buried in help documentation, because a customer printing 10,000 flyers deserves to know what they will learn before they print.
What scan data can tell you:
- How many times the code was resolved, and when — to the hour, in your timezone.
- Roughly where, at country and region level.
- What kind of device scanned it, and which operating system.
- Whether scans are growing or falling over time, and how a print run's launch shows up as a step change.
- Whether the code is currently resolving to your intended destination or to a fallback.
- Where a scan led, if you configured a conversion goal on the destination.
What scan data cannot tell you:
| Cannot | Why |
|---|---|
| Who scanned it | No identity is collected. There is no name, no address, no device identifier, no account |
| Exactly where they were | Location resolves to country and region only. Never city, never coordinates (17.4.1) |
| Which printed item was scanned | A QR code's slug is the same on every copy. Ten thousand identical flyers produce one indistinguishable stream of scans. To measure two print runs, two posters, or two venues separately, create a separate QR code for each — the interface says exactly this where a "by print run" breakdown would otherwise be expected |
| Which design version was scanned | Styling versions (Section 14) share the slug. A code restyled in March cannot be distinguished from the same code printed in January |
| Whether the same person scanned twice on different days | 18.6.2 |
| Whether a scan led to a sale | Only if a conversion goal is configured on the destination (16.6.3), and then only for conversions the destination reports back |
| Whether someone saw the code and did not scan | There is no impression event for physical material |
18.7 Real-Time View #
18.7.1 What it shows #
| Element | Content |
|---|---|
| Window | The last 30 minutes, always, as a rolling window |
| Headline | Events in the last 30 minutes, and events in the last minute |
| Sparkline | Events per minute across the 30 buckets |
| Active resources | Top 25 resources by events in the window, with type, name, count and a mini sparkline |
| Breakdowns | Country and device only, as compact lists. Full breakdowns are not offered in real time — they belong in the historical view |
| Bot handling | Bots excluded, always, with no toggle. A bot burst is exactly the thing that would make a live view useless |
18.7.2 How it is served without straining the pipeline #
Real-time reads never touch PostgreSQL.
| Aspect | Mechanism |
|---|---|
| Write | The ingest consumer, in step 4e of 17.7.3, writes Redis counters derived from the same deduplicated RETURNING set as everything else — so the live view is consistent with the stored data and immune to duplicates |
| Keys | The real-time counter entry in Section 4's Redis key catalogue: one key per workspace per minute, holding a hash of resource_type:resource_id → count plus country and device sub-fields, expiring after 40 minutes. Section 4 owns the literal key shape; this section owns what goes in it |
| Read | One Lua script per request fetches the 31 relevant minute keys and merges them server-side, returning a single payload. One round trip, no fan-out |
| Cost | A read is ~31 hash reads inside one script invocation, measured at under 3 ms at p99. It is independent of workspace size and independent of history depth |
| Update mechanism | HTTP polling every 10 seconds from the client. No WebSocket, no SSE, no long-polling in v1 — polling is trivially cacheable, survives every proxy, needs no connection state, and at a 10-second interval on a 30-minute window is entirely adequate. Real-time streaming transport is named as roadmap |
| Server-side cache | The merged payload is cached for 5 seconds per workspace, so ten dashboards open in one workspace cost one Redis read every 5 seconds |
| Failure | If Redis is unavailable, the widget renders "Live view unavailable" and the rest of the dashboard is entirely unaffected (17.12). It never falls back to querying the database |
| Not a system of record | Counters expire after 40 minutes and are never read for any historical range. Every number outside this widget comes from PostgreSQL (invariant I5) |
18.7.3 The freshness guarantee, stated explicitly #
Events appear in the live view within 60 seconds of happening, under normal operation. The delay comes from batching in the ingestion pipeline and is typically 2–5 seconds.
The widget does not merely assert this — it measures it. analytics_ingest_lag_seconds (17.13.1) is exposed to the dashboard, and:
| Measured lag | Widget behaviour |
|---|---|
| < 60 s | Shows "Live" with a subtle pulse indicator |
| 60 s – 5 min | Shows "Data delayed ~2 min" with the measured value, in an amber tone |
| > 5 min | Shows "Data delayed ~12 min — we're catching up" and a link to the status page |
| Redis unavailable | "Live view unavailable" |
The historical charts carry the same honesty: any view whose range includes the last 5 minutes shows a small "updated s ago" stamp. A user is never shown a stale number that looks fresh.
18.8 Filtering and Segmentation #
18.8.1 The filter catalogue #
| Filter | Operators | Values | Answerable from | Notes |
|---|---|---|---|---|
| Date range | between | Any range ≤ 400 days, clamped to plan reach | Both | Always present; not optional |
| Resource type | is, is one of | link, qr, page, block |
Rollup | — |
| Resource | is, is one of | Up to 25 resource ids | Rollup | Beyond 25, the filter is rejected with filter_too_many_values |
| Country | is, is one of, is not | ISO alpha-2 or ZZ |
Rollup (single), Raw (combined) | — |
| Region | is, is one of | ISO 3166-2 subdivision | Rollup (single), Raw (combined) | — |
| Device type | is, is one of | Closed vocabulary | Rollup (single), Raw (combined) | — |
| Operating system | is, is one of | Closed vocabulary | Rollup (single), Raw (combined) | — |
| Browser | is, is one of | Closed vocabulary | Rollup (single), Raw (combined) | — |
| Referrer host | is, is one of, contains | Host string | Rollup (is only), Raw (contains) |
contains always requires raw |
| Channel | is, is one of | Closed vocabulary | Rollup (single), Raw (combined) | — |
| UTM source / medium / campaign / term / content | is, is one of, is set, is not set | String, max 255 | Rollup (single), Raw (combined) | — |
| Variant | is, is one of | Variant id | Rollup (single), Raw (combined) | — |
| Language | is, is one of | Language tag | Rollup (single), Raw (combined) | — |
| Bots | include / exclude | Boolean | Rollup | Default exclude; a first-class toggle, not a filter chip |
| Measurement | is | tracked, estimated |
Raw | Bio page block clicks only |
| Fallback stage | is, is one of | Closed vocabulary | Rollup (single), Raw (combined) | QR only |
| Experiment | is | Experiment id | Rollup (single), Raw (combined) | — |
18.8.2 How filters combine #
| Rule | Behaviour |
|---|---|
| Across different fields | AND. Country = DE and Device = mobile means both |
| Multiple values of the same field | OR. Country is one of DE, AT, CH means any of them |
| Negation | Supported only as is not on Country, and only against a single value. It is implemented as "total minus that value" from rollups, which is exact |
| Nesting | Not supported. There are no parentheses, no OR across different fields, and no arbitrary boolean trees. A visual query builder is named as roadmap in Section 28 |
| Why the restriction | AND-of-ORs covers the questions users actually ask, maps cleanly onto both the rollup and raw query planners, and produces a filter bar a non-technical user can read at a glance. Arbitrary boolean nesting produces queries that cannot be routed to rollups at all and a UI most users cannot operate |
| Display | Applied filters render as removable chips above the content, always visible, never collapsed behind a menu. A "Clear all" control is always present when any filter is applied |
| Persistence | Encoded in the URL (18.1.5) |
18.8.3 Rollup versus raw answerability, and what the user sees #
The routing rule, restating 17.9.6 in the terms the interface uses:
Zero or one dimension filter → served from rollups, available for the full dashboard reach. Two or more dimension filters → served from raw, available only within raw retention.
Date range, resource, resource type and the bot toggle are not dimension filters for this purpose — they are part of the rollup key and cost nothing.
| Situation | Interface behaviour |
|---|---|
| Adding a second dimension filter while the range is inside raw reach | Applies immediately. A subtle "Detailed view" chip appears indicating the query is running against event-level data |
| Adding a second dimension filter while the range extends beyond raw reach | The filter is applied and the range is automatically clamped to raw reach, with a prominent, dismissible notice: "Combining two filters needs event-level data, which your plan keeps for 90 days. The range was shortened to 1 Jun – 19 Aug." The original range is offered as a one-click restore, which removes the second filter instead |
| Attempting a third or fourth dimension filter | Permitted, same rules. Beyond four dimension filters the interface warns that the result is likely to be too sparse to interpret, and below the low-data threshold of 18.13 the percentages suppress themselves anyway |
contains on referrer host |
Always requires raw; the same clamping applies |
| Bots included, together with any dimension filter | Also a two-dimension query (17.9.1), and routed as one: raw, clamped to raw reach. Beyond raw reach the bot toggle is disabled with "Including bots in a filtered view needs event-level data, kept for 90 days on your plan." Bots-included totals stay exact from rollups at full reach |
| Unique visitors under a single dimension filter | This is the sanctioned way to ask a per-dimension uniqueness question, and it is the only one. The filter scopes the resource-total unique count, which is served from raw with COUNT(DISTINCT (visitor_hash, event_date)) and therefore carries the raw-reach bound and the "Detailed view" chip. It is deliberately a different gesture from reading a breakdown row (18.4.1): a filter changes the whole view and announces what it costs, where a table column would look free and would quietly stop working |
| API | meta.source reports raw or rollup_*; meta.range_clamped and meta.notices carry the same information as the UI notice (17.10.5) |
18.8.4 Saved segments #
| Property | Value |
|---|---|
| What | A named, reusable filter set, workspace-scoped |
| Fields | name (≤ 60 chars, unique per workspace), description (≤ 200 chars), definition (the normalised filter JSON), created_by, created_at, updated_at |
| Limit | Governed by the saved_segments entitlement key. Section 22.1.3 owns the key registry and Section 22.1.2 owns its per-plan value; neither is restated here. Exceeding it returns 403 plan_limit_reached with one issue object: field: "saved_segments", issue: "limit_reached", kind: "count", plus limit, current and plan |
| Who can create/edit/delete | Editor and above. Viewers can apply any segment but cannot create one |
| Visibility | All members with dashboard access see all segments; a scoped member applying a segment gets it intersected with their grants |
| Applying | Sets the filter chips exactly as if typed, and encodes as ?segment={uuid} in the URL. Modifying the filters afterwards detaches from the segment and offers "Save as new" or "Update segment" |
| Reuse | A saved segment can be selected as the scope of a CSV export (18.10), as the scope of a scheduled report (18.10.5), and as the condition scope of a milestone alert (18.12) |
| Date range | A segment stores its filters but not its date range, because a segment saved with "last 28 days" would silently mean something different every week. The range is always chosen at the point of use |
| Deletion | Deleting a segment referenced by a scheduled report or an alert is blocked with 409 segment_in_use and details.used_by[] |
18.9 Comparison and Benchmarking #
18.9.1 Comparing resources #
| Aspect | Behaviour |
|---|---|
| Selection | 2–5 resources, any mix of links, QR codes and bio pages |
| Chart | One line per resource over the shared range, with the granularity rule of 18.3 |
| Normalised mode | "Index to 100 at range start" rescales every series so relative growth is comparable between a resource with 50 clicks and one with 50,000. Resources with fewer than 10 events in the first bucket are excluded from this mode with a stated reason, because indexing on a tiny base produces nonsense |
| Table | The full metric catalogue applicable to the selected types, one column per resource, with the best value in each row subtly marked — and never marked when the metric is a rate whose denominator is below 30. Unique-visitor rows are available here because each column is a whole resource, which is exactly the grain uniques are maintained at (17.8.3) |
| Mixed types | When types differ, only metrics defined for all selected types are shown; the rest are listed beneath as "Not comparable across these resource types", named explicitly rather than silently dropped |
| Breakdowns | A dimension selector renders a grouped bar chart of that dimension across all selected resources |
| Limit reason | Five is the ceiling because a six-line chart is unreadable at dashboard scale and the categorical palette (Section 24's contrast requirements) supports five reliably distinguishable series |
18.9.2 Comparing time periods #
| Aspect | Behaviour |
|---|---|
| Selection | One resource (or the whole workspace) and two ranges of equal length |
| Presets | Previous period, same period last month, same period last year |
| Chart | Both ranges overlaid on a shared positional axis (day 1 vs day 1), with the calendar dates in the tooltip |
| Table | Every metric side by side with absolute and percentage deltas, suppressed per the below-10 rule of 18.3.3 |
| Breakdowns | Every dimension breakdown gains a second value column and a delta column, sorted by absolute change so the biggest movers surface first |
| Unequal lengths | Rejected. The interface adjusts the second range to match the first and says so |
18.9.3 Comparing campaigns #
Campaigns are compared by selecting 2–5 utm_campaign values across the whole workspace, producing the same chart and table treatment as 18.9.1 with campaign-appropriate metrics: events, conversions, conversion rate, channel mix and top referrers. Unique visitors is not among them, because a campaign is a dimension value and uniques are maintained at the resource grain (17.8.3, 18.4.1); the comparison table states this once beneath its header rather than showing an empty row. Campaign comparison reads rollups and therefore covers full dashboard reach; adding a second dimension filter on top applies 18.8.3.
18.9.4 Benchmarking — decided: no cross-customer benchmarks #
LinkHub does not show a customer how their numbers compare to other customers'. Not by industry, not by plan, not by follower count, not anonymised, not aggregated.
| Reason | Detail |
|---|---|
| Small-n leakage | Any benchmark cohort narrow enough to be useful ("beauty creators with 10–50k followers in Germany") is narrow enough that a member of it can infer a competitor's numbers from movements in the aggregate. There is no cohort size threshold that makes this safe and also makes the benchmark meaningful |
| Cohort validity | LinkHub has no reliable industry, audience-size or geography classification for its customers. Any cohort would be built on self-declared or inferred attributes, producing a comparison that is confidently wrong |
| Privacy commitment | Section 23 commits that customer analytics data is processed on the customer's behalf and is not used to build aggregate data products. A benchmark is exactly such a product. Honouring the commitment matters more than the feature |
| Actionability | "Your CTR is below average" prompts no action, because the average includes people with entirely different goals, audiences and content |
What is offered instead — a self-benchmark, which is both safe and more useful:
| Element | Definition |
|---|---|
| Reference line | On any resource's primary-metric chart, an optional faint horizontal line showing the workspace's own trailing 90-day median for that metric across resources of the same type |
| Eligibility | Shown only when the workspace has at least 10 resources of that type, each with at least 100 events in the trailing 90 days. Below that the control is disabled with "Not enough of your own history to compare against yet" |
| Label | "Your typical <link/page/code>", never "average", never "benchmark", never "industry" |
| Table variant | The resource list can be sorted by "distance from your median", which is the actionable version of the same idea |
18.10 Export #
18.10.1 CSV format rules #
Applied identically to every export type. These are contract-level guarantees a customer's spreadsheet or script can depend on.
| Rule | Value |
|---|---|
| Standard | RFC 4180 |
| Encoding | UTF-8 with byte-order mark, because the dominant consumer is a spreadsheet application that misreads UTF-8 without one |
| Line ending | CRLF |
| Delimiter | Comma. Not configurable — a configurable delimiter doubles the test matrix and every consumer handles commas |
| Quoting | Fields containing a comma, a quote, CR or LF are quoted; embedded quotes are doubled. Other fields are unquoted |
| Header row | Always present, always the first row, always the exact column names in this section, always in the order given |
| Timestamps | ISO 8601 UTC with Z and second precision (2026-08-19T14:32:07Z) |
| Dates | YYYY-MM-DD |
| Local time columns | Where a local variant is provided, it is ISO 8601 with the numeric offset (2026-08-19T16:32:07+02:00) |
| Booleans | true / false, lower-case |
| Decimals | . as separator, rates to 4 decimal places as a proportion (0.2331), never as a pre-formatted percentage string |
| Integers | No thousands separators |
| Null | Empty string. Never null, never N/A, never - |
| Enumerations | The exact stored value in snake_case, not a display label, so a script can match on it |
| CSV injection defence | Any value whose first character is =, +, -, @, tab or CR is prefixed with a single apostrophe before quoting. This is a security requirement (Section 23), applies to every user-supplied string column — resource names, UTM values, referrer hosts, destination URLs — and is asserted by a test that round-trips a payload beginning with each dangerous character |
| Row order | Deterministic and stated per type below, so two exports of the same data are byte-identical |
| Trailing newline | Present |
| Compression | Files above 20 MB are gzipped and served as .csv.gz |
| Filename | linkhub_{workspace_slug}_{export_type}_{effective_start}_{effective_end}.csv[.gz] — dates as YYYYMMDD, so the file's own name records the effective (post-clamping) range |
18.10.2 Export types and their column contracts #
Column names are the stored column names of Section 6, unchanged. An export is a contract, and renaming a column on its way out of the product is how a customer's script and the product's own documentation come to disagree.
events_clicks — event-level click, scan and block-click data. Raw-backed. Row order: occurred_at ASC, id ASC.
id, occurred_at, occurred_at_local, event_date, event_type, resource_type,
resource_id, resource_name, host, slug, destination_url, fallback_stage,
experiment_id, variant_id, experiment_epoch, assignment_source, source_page_id,
source_page_variant_id, targeting_rule_id, excluded_by_rule_id, visitor_hash,
is_bot, bot_reason, is_prefetch, is_datacenter_asn, is_estimated, sample_rate,
country_code, region_code, geo_resolved, device_type, os_family, os_version_major,
browser_family, browser_version_major, user_agent_family, referrer_host, channel,
language, utm_source, utm_medium, utm_campaign, utm_term, utm_content, capture_sourceThree inclusions and three exclusions, each deliberate:
visitor_hashis included, because it is the only way a customer can compute their own uniqueness figures, and it is not personal data — it is a salted one-way digest whose salt was destroyed the same day. The export's accompanying metadata states this.excluded_by_rule_idis included, so a customer can reproduce the "excluded by targeting" figure of 16.12.2 from their own copy of the data rather than taking the dashboard's word for it.user_agent_familyis included, and it is the only device-string column that exists to include.- The raw user-agent string is not included, and cannot be: it is never stored (invariant I9). Where an export from another product would carry it, LinkHub carries the parsed columns and the family label. The export documentation says this rather than leaving the absence to be inferred.
ua_hashis excluded. It is an internal clustering key with a one-day meaning, and shipping it invites a downstream tool to treat it as a device identifier, which is precisely what it is designed not to be.stream_message_idandingest_batch_idare excluded. They describe how a row got here, not what happened, and exposing them would make an internal implementation detail part of a customer-facing contract.
events_page_views — event-level bio page views. Raw-backed. Row order: occurred_at ASC, id ASC.
id, occurred_at, occurred_at_local, event_date, bio_page_id, bio_page_name,
handle, host, page_version_id, experiment_id, variant_id, experiment_epoch,
assignment_source, visitor_hash, is_bot, bot_reason, is_prefetch, sample_rate,
country_code, region_code, geo_resolved, device_type, os_family, os_version_major,
browser_family, browser_version_major, user_agent_family, referrer_host, channel,
language, utm_source, utm_medium, utm_campaign, utm_term, utm_content,
render_ms, cache_hitsummary_daily — one row per resource per day. Rollup-backed. Row order: bucket_start ASC, resource_type ASC, resource_id ASC.
bucket_start, bucket_start_local, timezone, workspace_id, workspace_slug,
resource_type, resource_id, resource_name, resource_url, events, unique_visitors,
conversions, is_completeunique_visitors is populated on every row of this file, because every row is a whole resource — the grain uniques are maintained at (17.8.3).
summary_hourly — identical to summary_daily, at hourly buckets. Available for ranges within the plan's hourly rollup retention (17.9.7), which is not the same horizon as the daily rollup on Free; a request beyond it is clamped with a notice naming the effective range.
breakdown_<dimension> — one row per dimension value per day. <dimension> is one of the dimension_type values of 17.9.2. Rollup-backed. Row order: bucket_start ASC, resource_id ASC, events DESC, dimension_value ASC.
bucket_start, timezone, resource_type, resource_id, resource_name,
dimension_type, dimension_value, dimension_label, events, share_of_totaldimension_label is the human-readable rendering (country name, browser display name); dimension_value is the stored value, including the '*' sentinel on a total row. Both are present so the file is readable and machine-matchable. There is no unique_visitors column in this file at all — not an empty one — because uniques do not exist per dimension value (17.8.3), and an always-empty column in a CSV is an invitation to file a bug report about a product that is working correctly. A consumer who needs both joins this file to summary_daily on (bucket_start, resource_id), which is exactly the arithmetic the product itself does.
links_inventory — one row per short link. Row order: created_at ASC.
link_id, slug, host, full_url, destination_url, title, tags, created_at, created_by_email,
updated_at, archived_at, expires_at, scheduled_start_at, has_targeting_rules,
has_experiment, experiment_status, qr_code_id, total_clicks_in_range,
unique_clickers_in_range, last_click_at, statusbio_pages_inventory — one row per bio page. Row order: created_at ASC.
bio_page_id, handle, host, full_url, title, published, published_at, block_count,
created_at, created_by_email, updated_at, archived_at, has_experiment,
experiment_status, views_in_range, unique_visitors_in_range, block_clicks_in_range,
click_through_rate, last_view_at, statusqr_inventory — one row per QR code. Row order: created_at ASC.
qr_code_id, slug, host, full_url, destination_url, title, error_correction_level,
has_logo, foreground_color, background_color, contrast_ratio, created_at,
created_by_email, updated_at, paused_fallback_url, scans_in_range,
unique_scanners_in_range, last_scan_at, active_share, fallback_share, statusexperiment_results — one row per arm per epoch. Row order: experiment_id ASC, epoch ASC, variant_index ASC.
experiment_id, experiment_name, mechanism, resource_type, resource_id, resource_name,
status, force_promoted, epoch, epoch_started_at, epoch_ended_at, variant_id,
variant_index, variant_name, is_control, is_removed, allocation_bp, observed_share,
exposures, conversions, rate, rate_ci_low, rate_ci_high, lift_vs_control,
lift_ci_low, lift_ci_high, p_value, is_significant, guard_state, srm_state,
srm_p_value, promoted, promoted_atleads — lead capture submissions. The column contract is owned by Section 20; the export mechanics (async generation, signed link, rate limits, gating) are those of this section.
18.10.3 Synchronous versus asynchronous generation #
| Condition | Path |
|---|---|
| Estimated rows ≤ 50,000 and estimated generation ≤ 30 s | Synchronous. 200 OK, Content-Type: text/csv; charset=utf-8, Content-Disposition: attachment; filename="…", streamed from a server-side cursor so memory stays flat |
| Anything larger | Asynchronous. 202 Accepted with an export job resource |
Row estimation uses rollup events sums for rollup-backed types and a partition-pruned COUNT(*) estimate for raw-backed types; the estimate is deliberately conservative, so borderline requests go async rather than time out.
The asynchronous flow:
POST /v1/exports
{
"type": "events_clicks",
"range": { "start": "2026-05-01T00:00:00Z", "end": "2026-08-19T00:00:00Z" },
"resource_ids": ["0192f3a1-6c4e-7b3a-9d21-8f5c2e7b1a04"],
"filters": { "country_code": ["DE","AT"] },
"segment_id": null,
"include_bots": false,
"timezone": "Europe/Berlin",
"strict": false
}
202 Accepted
{
"data": {
"id": "0192f4b7-2d81-7c0e-b6a9-31f7c4d9e502",
"type": "events_clicks",
"status": "queued",
"requested_range": { "start": "2026-05-01T00:00:00Z", "end": "2026-08-19T00:00:00Z" },
"effective_range": { "start": "2026-05-21T00:00:00Z", "end": "2026-08-19T00:00:00Z" },
"range_clamped": true,
"estimated_rows": 2140000,
"created_at": "2026-08-19T14:32:07Z",
"expires_at": "2026-08-26T14:32:07Z",
"download_url": null
},
"meta": {
"notices": [
{ "code": "retention_boundary",
"message": "Event-level data is kept for 90 days on your plan. The range was adjusted.",
"plan": "pro", "retention_days": 90 }
]
}
}GET /v1/exports/{id}
200 OK
{
"data": {
"id": "0192f4b7-2d81-7c0e-b6a9-31f7c4d9e502",
"type": "events_clicks",
"status": "ready",
"row_count": 2138472,
"byte_size": 486203914,
"content_type": "application/gzip",
"filename": "linkhub_acme_events_clicks_20260521_20260819.csv.gz",
"download_url": "https://files.linkhub.app/exports/0192f4b7…?sig=…&exp=1755700327",
"download_url_expires_at": "2026-08-20T14:32:07Z",
"downloads_remaining": 20,
"created_at": "2026-08-19T14:32:07Z",
"completed_at": "2026-08-19T14:36:41Z",
"expires_at": "2026-08-26T14:32:07Z"
},
"meta": {}
}| Aspect | Decision |
|---|---|
| Job states | queued → running → ready | failed; ready → expired after the artefact retention elapses |
| Generation | A BullMQ worker streams from a server-side cursor to a temporary file, gzips above 20 MB, uploads to object storage, then marks ready. Memory is bounded regardless of row count |
| Progress | progress_pct on the job record, updated every 5,000 rows, surfaced as a progress bar |
| Signed link | Valid 24 hours, single object, capped at 20 successful fetches after which the signature is rejected. Regenerating a link is one click while the artefact still exists |
| Artefact retention | 7 days, then the object is deleted and the job moves to expired |
| Job record retention | 90 days, so the export history remains auditable after the file is gone |
| Maximum size | 2 GB compressed. A job that would exceed it fails with export_too_large and the message suggests narrowing the range, which the interface offers as a one-click split into monthly exports |
| Notification | In-app notification on completion; email if generation took over 2 minutes |
| Failure | status = "failed" with error_code and error_message; automatically retried once after 60 seconds for transient causes |
| Audit | Every export request writes data_export_requested to the audit log (Section 8) with type, range, filters and row count |
| Permissions | Viewer can export data for resources they can see. A scoped member's export is silently intersected with their grants, and the job record states the applied scope so the file's contents are explainable |
18.10.4 PDF summary reports #
| Aspect | Decision |
|---|---|
| Generation | Server-rendered HTML with a dedicated print stylesheet, converted by a headless browser in the worker. The same components as the dashboard, so there is one implementation of every chart |
| Page sizes | A4 and US Letter, selected per report; portrait only |
| Contents, in order | Cover (workspace name, brand logo, range, generated timestamp, applied filters); headline metrics; primary time series; top 5 resources; geography map and top-10 table; device, browser and channel mix; campaign table when UTM data exists; per-resource appendix table |
| Length cap | 30 pages. Content beyond it is truncated with an explicit "Truncated — export as CSV for the full data" line rather than silently dropped |
| Branding | Business: the workspace's logo and colours, no LinkHub footer. Pro: the workspace's logo with a discreet LinkHub footer line. Consistent with the branding entitlement of Section 22 |
| Accessibility | Tagged PDF with a document title, reading order matching visual order, and alternative text on every chart image, per Section 24 |
| Delivery | Always asynchronous, using the identical job model, signed link and retention as CSV |
| Determinism | Charts render from the same data payload with animation disabled and a fixed random seed for any jitter, so regenerating a report for the same range produces a visually identical document |
18.10.5 Scheduled recurring email reports #
| Field | Rules |
|---|---|
name |
≤ 60 chars, unique per workspace |
frequency |
weekly (Monday 08:00 workspace timezone) or monthly (1st of the month, 08:00 workspace timezone). No daily option — a daily report on this product's traffic volumes is noise that trains people to ignore email |
range |
Weekly → previous 7 complete days. Monthly → previous complete calendar month. Never includes an incomplete period |
scope |
Whole workspace, a saved segment, or up to 5 named resources |
content |
Always: headline metrics with period-over-period deltas, the primary time series, top 5 resources, top 5 countries, channel mix. Optionally: a PDF attachment, a CSV attachment, or both |
attachments |
Attached directly when under 5 MB combined; otherwise replaced by a signed download link valid 7 days |
recipients |
Workspace members, up to 10. External addresses are governed by the external_report_recipients entitlement key and each must confirm via a verification email before it receives anything. Section 22.1.3 owns the key registry and Section 22.1.2 its per-plan value |
| Per-recipient unsubscribe | Every email carries a one-click unsubscribe that removes only that recipient from that schedule, with no login required, honoured immediately |
| Schedule limit | Governed by the scheduled_report_schedules entitlement key. Exceeding it returns 403 plan_limit_reached with field: "scheduled_report_schedules", issue: "limit_reached", kind: "count", plus limit, current and plan. A plan with no schedules at all returns 403 plan_feature_unavailable instead, because that is a feature gate and not a cap |
| Empty periods | A period with zero events still sends, with the subject "No activity this week" and the same layout showing zeros — a silent gap would be indistinguishable from a broken schedule |
| Failure | A send failure retries 3 times over 6 hours; persistent failure disables the schedule, notifies workspace Admins in-app, and records the reason |
| Preview and test | "Send me a test now" generates the report against the most recent complete period and sends to the requesting member only |
| Permissions | Editor and above may create or edit a schedule; any member may unsubscribe themselves |
| Deletion | Deleting a resource or segment referenced by a schedule leaves the schedule intact and skips the missing scope, noting it in the email; deleting the last remaining scope disables the schedule with a notification |
18.10.6 Plan gating and rate limits #
Entitlement keys, not values. Section 22.1.3 owns the key registry and Section 22.1.2 owns every per-plan value; this section restates neither. The export and reporting surfaces depend on these keys:
| Entitlement key | What it gates | Gate type |
|---|---|---|
analytics_csv_export |
Whether CSV export exists for the workspace at all | Feature |
analytics_pdf_report |
Whether PDF summary reports exist, and whether they carry workspace branding | Feature |
scheduled_report_schedules |
How many recurring email report schedules may exist | Cap |
external_report_recipients |
How many verified non-member addresses a schedule may send to | Cap |
analytics_share_links |
Whether shared read-only links exist, and how many may be active (18.11) | Feature + cap |
experiment_results_export |
Whether the experiment_results export type is offered (16.13.1) |
Feature |
| Retention reach | Event-level and summary export reach follow the retention matrix of 17.10.1 rather than a separate key, so there is one place a reach can be wrong | — |
A feature gate returns 403 plan_feature_unavailable; a cap returns 403 plan_limit_reached with kind: "count"; a reach violation under strict=true returns 403 plan_limit_reached with kind: "period" (17.10.5).
Rate limits — these are throughput controls, not entitlements, and they are stated here because this section owns the surfaces they protect:
| Rate limit | Scope | Value | On exceed |
|---|---|---|---|
| Export jobs created | Per workspace | 10 per rolling hour | 429 rate_limited with Retry-After |
| Concurrent running export jobs | Per workspace | 3 | 202 with the job queued behind the others — queued, not rejected |
| Synchronous CSV requests | Per workspace | 30 per rolling hour | 429 rate_limited |
| PDF generation | Per workspace | 5 per rolling hour | 429 rate_limited |
| Signed download fetches | Per artefact | 20 | 403 download_link_exhausted |
| Test report sends | Per member | 5 per rolling hour | 429 rate_limited |
| API analytics reads | Per API key | Section 21.7 is authoritative for every public API rate limit; this row does not restate it | 429 rate_limited |
Export error codes, all registered in the generated registry of Section 30.2, all returning the canonical envelope with meta present and error.details present as an array:
| Code | HTTP | When |
|---|---|---|
not_found |
404 | Unknown export job, or one belonging to another workspace. The cross-workspace case is indistinguishable from a genuine miss, deliberately (Section 3.5) |
export_expired |
410 | The artefact has passed its 7-day retention |
export_type_invalid |
422 | Unknown type |
export_range_invalid |
422 | Start after end, or a span exceeding 400 days |
export_too_large |
422 | Estimated output above 2 GB compressed |
export_filter_invalid |
422 | A filter not in the catalogue, or an operator not supported for that field |
filter_too_many_values |
422 | More than 25 values on a multi-value filter |
download_link_exhausted |
403 | Fetch cap reached |
download_link_expired |
403 | Signature past its 24-hour validity |
plan_feature_unavailable |
403 | CSV export, PDF reports, scheduled reports or experiment-results export not included in the plan |
plan_limit_reached |
403 | A schedule or recipient cap reached (kind: "count"), or strict=true with an out-of-reach range (kind: "period") |
rate_limited |
429 | Any rate limit above |
18.11 Shared Read-Only Analytics Links #
For showing a client or a stakeholder the numbers without giving them an account.
18.11.1 The object #
A share link is a row in analytics_share_links. Section 6 defines the table, its columns, its types and its constraints, and this section does not restate them. What belongs here is what each part of that row means to the product:
| Aspect | Behaviour this section owns |
|---|---|
| Identity and ownership | Every share belongs to exactly one workspace and records the member who created it. A share is never workspace-less and never survives its workspace |
| Name | A human label, unique per workspace, shown only inside the dashboard's share list — never on the shared page itself, because a name like "Q3 numbers for the Acme pitch" is internal |
| Scope | Either the whole workspace or an explicit resource set. 18.11.3 governs what each means and how it is re-evaluated |
| Metric scope | summary or full. 18.11.3 defines what each exposes |
| Range mode | Either a fixed range pinned at creation, or a rolling window of a fixed number of days. There is no third mode and no viewer-adjustable range — 18.11.5 explains why |
| Token | Stored only as a hash, with a short clear-text prefix for identification. 18.11.2 owns the token rules |
| Password | Optional, hashed with the same algorithm and parameters as account passwords (Section 7). Never recoverable, only replaceable |
| Lifetime | A required expiry, plus an optional revocation timestamp. Both are checked on every request; neither is cached (18.11.4) |
| Usage | A view count and a last-viewed timestamp, maintained so a creator can see whether the link is being used. Individual views are deliberately not recorded — 18.11.4 says why |
The constraint that matters most is the one tying the range columns to the range mode: a fixed share must carry both endpoints and a rolling share must carry a day count. The interface cannot construct an invalid combination, and Section 6 makes sure nothing else can either.
18.11.2 Token #
| Property | Value |
|---|---|
| Generation | 256 bits from a cryptographically secure source, base64url-encoded to 43 characters |
| Storage | SHA-256 hash only. The token itself is displayed once, at creation, with a copy control and an explicit "this is the only time you will see it" warning |
| URL | https://app.linkhub.app/s/{token} |
| Lookup | The token is hashed and looked up by hash; a constant-time comparison guards against timing analysis |
| Prefix | The first 8 characters are stored in clear for identification in the share list, which is not enough to be guessable |
| Rotation | Not offered. Rotating a share token silently breaks the recipient's bookmark with no way to notify them. Revoke and create a new one instead |
18.11.3 Scope #
| Aspect | Behaviour |
|---|---|
workspace scope |
Every resource in the workspace, evaluated live at view time |
resource_set scope |
Exactly the listed resources, up to 50 |
| Creator ceiling | A share can never exceed the creator's own access. A scoped member (Section 3 per-resource grants) can only include resources they were granted. Enforced at creation |
| Re-evaluation at view time | Resources that have been deleted, archived beyond restore, or moved out of the workspace drop out silently. A share whose resources have all disappeared renders an empty state — "There is nothing to show here any more" — not an error, because a 404 to a client is confusing and reveals nothing useful |
| Plan changes | If the workspace downgrades below the analytics_share_links entitlement, existing shares stop resolving and render a branded "This report is no longer available" page. They are not deleted, and re-upgrading restores them |
metric_scope: summary |
Headline tiles, time series, and geography only |
metric_scope: full |
Adds every dimension breakdown, campaign performance and per-resource detail. Breakdowns on a shared view count events and carry the same standing line as everywhere else (18.4.1) |
| Limit | The number of simultaneously active (non-revoked, non-expired) shares is governed by the analytics_share_links entitlement key; Section 22.1.3 owns the registry and Section 22.1.2 the value. Exceeding it returns 403 plan_limit_reached with field: "analytics_share_links", issue: "limit_reached", kind: "count", plus limit, current and plan. A plan without the feature at all returns 403 plan_feature_unavailable |
18.11.4 Expiry, password and revocation #
| Aspect | Rule |
|---|---|
| Expiry | Required. Options: 7, 30 or 90 days, or a custom date. Maximum 365 days. Default 30. There is no "never expires" option — a permanent unauthenticated URL is a permanent liability |
| Expiry behaviour | After expires_at the URL returns 410 Gone with a branded page reading "This report has expired. Ask for a new link." No data, no metadata, no resource names |
| Renewal | The creator, or any Admin, can extend an unexpired share by up to 365 days from now. An expired share cannot be revived; a new one must be created |
| Password | Optional. Minimum 8 characters, hashed with Argon2id at the parameters used for account passwords (Section 7). Prompted on first view and remembered for the session in a strictly-necessary cookie scoped to the share path |
| Password attempts | 5 failures per token per 15 minutes, then the token is locked for 15 minutes and the workspace is notified. Prevents a leaked URL from being brute-forced into a leaked report |
| Revocation | Immediate. Sets revoked_at; the next request returns 410 Gone with the same branded page. No propagation delay — the token lookup reads the row every time and no share response is cached at a shared layer |
| Bulk revocation | "Revoke all shares" is available to Owner and Admin, with a typed confirmation |
| Audit | analytics_share_created, analytics_share_revoked and analytics_share_expiry_extended are written to the audit log. Individual views are not audited — an unbounded, unauthenticated event stream would swamp the log — but view_count and last_viewed_at are maintained and shown in the share list |
18.11.5 What is deliberately hidden on a shared view #
| Hidden | Reason |
|---|---|
| Full destination URLs | Only the destination host is shown. A destination URL frequently carries affiliate ids, campaign parameters or private paths the workspace does not intend to disclose to a stakeholder |
| Any event-level data | No recent-events table, no visitor_hash, no user_agent_family, no raw rows |
| Export controls | No CSV, no PDF, no API. The share is a view, not a data channel. A stakeholder who needs the file should be sent the file |
| Real-time view | It invites refresh-watching and adds load for no stakeholder value |
| Experiment configuration | Variant patches, destination lists and weights are internal. Experiment results are shown when metric_scope is full and the experiment belongs to a shared resource |
| Resources outside the scope | Including their existence — counts, totals and breakdowns are computed over the shared scope only, so nothing can be inferred about the rest of the workspace |
| Member names and emails | No created_by, no "last edited by", nowhere |
| Billing, plan and usage information | — |
| Audit log | — |
| Workspace settings, domains, integrations | — |
| Date range controls | The range is fixed at creation (fixed) or is a rolling window of a fixed length (rolling). The viewer cannot widen it, cannot shift it, and cannot probe outside it |
| Filter controls | Read-only view; the creator's saved filters, if any, are applied and displayed as static text |
| Any navigation to the authenticated application | The share page has no application chrome. The only outbound link is a "Powered by LinkHub" mark on Free-tier-equivalent branding rules |
Additional protections on the share surface:
| Protection | Detail |
|---|---|
| Indexing | X-Robots-Tag: noindex, nofollow, noarchive and an equivalent meta tag; never in a sitemap; the path is disallowed in the public robots file |
| Referrer | Referrer-Policy: no-referrer on the share page, so following any link from it cannot leak the token in a referrer header |
| Caching | Cache-Control: private, no-store so no shared cache retains a rendered report |
| Rate limit | 60 requests per token per minute, 600 per source per hour |
| Own analytics | The share page itself emits no visitor analytics events. Measuring the stakeholders who read the report is not a feature, and doing it silently would be worse |
18.12 Milestone Alerts #
18.12.1 Trigger catalogue #
| Trigger | Condition | Default | Cadence | Scope | Mutable |
|---|---|---|---|---|---|
clicks_milestone |
Cumulative lifetime events on a resource crosses a threshold | Thresholds 100, 500, 1k, 5k, 10k, 50k, 100k, 500k, 1M | On rollup update, evaluated at most once per minute per resource | Resource | Yes |
first_click |
The first ever event on a link | On | Real-time (once per resource, ever) | Resource | Yes |
first_scan |
The first ever scan of a QR code — the "your printed code is live" moment | On | Real-time (once per resource, ever) | Resource | Yes |
first_view |
The first ever view of a published bio page | On | Real-time (once per resource, ever) | Resource | Yes |
traffic_spike |
Events in the last complete hour exceed 5× the trailing 7-day median for that hour-of-week, and are at least 50 | On | Hourly | Resource + workspace | Yes |
traffic_drop |
Events on the last complete day are below 25% of the trailing 14-day median, and the resource averaged ≥ 100/day over that period | On | Daily, 09:00 workspace timezone | Resource | Yes |
experiment_ready |
An experiment's minimum-sample guard passes (Section 16.7) | On | Hourly | Experiment | Yes |
experiment_significant |
An experiment reaches significance and a winner can be declared | On | Hourly | Experiment | Yes |
experiment_srm |
Sample-ratio mismatch reaches critical (Section 16.11) |
On | Hourly | Experiment | Yes |
link_expiring |
A scheduled expiry (Section 15) is 72 hours away | On | Daily | Resource | Yes |
qr_fallback_serving |
Any scan in the last hour carried a fallback_stage other than active — that is, rung 2, 3 or 4 of the chain in Section 14 |
On | Hourly | Resource | No — printed material pointing at a fallback is an emergency |
destination_flagged |
A destination is flagged by the safety pipeline (Section 23) | On | Real-time | Resource | No |
domain_tls_expiring |
Certificate renewal thresholds of Section 13 | On | Daily | Domain | No |
retention_boundary |
Data will begin falling out of dashboard reach within 7 days, on a workspace with a retention shorter than 365 days | On | Weekly | Workspace | Yes |
export_ready |
An asynchronous export completes | On | Real-time | Job | Yes |
segment_threshold |
A saved segment's event count over a chosen window crosses a user-defined value. Event count, not unique count, because a segment is a filter set and uniques are not maintained per dimension (17.8.3) | Off | Hourly | Segment | Yes |
Thresholds are one-shot per threshold per resource: a link that passes 1,000 clicks fires once and never again for that threshold, even if the count later falls and rises. Custom thresholds may be added per resource, up to 5.
All alert evaluation reads rollups, never raw (17.9.6), and always excludes bots with no toggle (17.6.3).
18.12.2 Delivery channels #
| Channel | Detail |
|---|---|
| In-app notification centre | Always delivered, for every trigger, regardless of other channels. This is the system of record for what fired |
| Per-member preference, per trigger category (milestones, anomalies, experiments, operational). Digest option: immediate, hourly digest, or daily digest at 09:00 workspace timezone | |
| Slack | Via the workspace's Slack connection (Section 19). One channel per workspace, chosen at connection time. Message contains the trigger, the resource with a link, the number, and a compact 7-day sparkline rendered as text |
| Outbound webhook | Via the single workspace webhook URL (Section 19), signed with HMAC-SHA256 and subject to that section's retry policy. Payload carries the trigger, resource, threshold, value and timestamp |
| Not offered | SMS and mobile push. Named as roadmap; neither exists without a native app or a telephony dependency, both of which are out of scope |
18.12.3 Throttling #
Designed so a viral link produces one useful notification, not two hundred.
| Rule | Value |
|---|---|
| Per (workspace, trigger, resource) | At most 1 notification per 60 minutes |
| Per workspace, all triggers | At most 20 notifications per rolling hour. Beyond that, further alerts are collected and delivered as a single digest at the end of the hour: "14 more alerts — view all" |
| Milestone thresholds | One-shot per threshold per resource, permanently. A resource crossing three thresholds in one minute produces one notification naming the highest reached |
| Spike alerts | At most 1 per resource per 6 hours, because a genuine spike stays a spike for hours and re-notifying adds nothing |
| Drop alerts | At most 1 per resource per 24 hours |
| Bypass | destination_flagged, qr_fallback_serving and domain_tls_expiring bypass all throttling and all digests. These are correctness and safety events, not celebrations |
| Sampled resources | Milestone alerts are suppressed entirely while adaptive sampling is active on a resource (17.14.4), because firing "you hit 1,000,000 clicks" on an extrapolated estimate would be a false claim |
| Quiet hours | Optional per member: suppress email and Slack between chosen hours in the workspace timezone, queueing them to the next active hour. In-app notifications are never suppressed |
| Deduplication | Two evaluation runs producing the same (trigger, resource, threshold) tuple within the throttle window deliver once; the alert event row carries a unique constraint on that tuple plus the window bucket |
18.12.4 Configuration interface #
| Level | What can be set | Who |
|---|---|---|
| Workspace defaults | Which triggers are enabled, default thresholds, the Slack channel, the digest cadence | Admin or Owner |
| Per resource | Override enabled triggers, add up to 5 custom thresholds, mute entirely with an optional "until" date | Editor and above, for resources within their grants |
| Per member | Which channels they personally receive, per trigger category; digest preference; quiet hours | The member themselves |
| Test send | A "Send test" control per channel that delivers a sample of each enabled trigger, so a Slack connection or webhook can be verified without waiting for real traffic | Admin or Owner |
| History | An alert history view listing every fired alert with trigger, resource, value, timestamp and per-channel delivery status (delivered, failed, throttled, digested, suppressed_sampling), retained for 90 days |
Viewer and above |
Storage: alert_rules (configuration, one row per scope+trigger), alert_events (one row per firing, with the evaluated value and threshold), alert_deliveries (one row per channel attempt, with status, attempt count and error). Delivery failures retry with the same backoff schedule as outbound webhooks in Section 19, then mark failed with the reason visible in the history view.
18.13 Empty States, Low-Data States and Honesty Rules #
18.13.1 Empty states #
| Situation | What is shown |
|---|---|
| Resource has never had an event | An illustration, "This link hasn't been clicked yet", the resource's URL with a copy control, three concrete next actions (copy the link, generate a QR code, add it to a bio page), and "Clicks usually appear within a minute of happening." |
| Resource has events, but none in the selected range | "No activity between 1 Jul and 31 Jul. This link was last clicked on 12 Jun." plus a one-click "Jump to the last active period" that sets the range to the 28 days ending at the last event |
| A filter produced no rows | "No data matches these filters" with the applied filters listed explicitly as removable chips and a "Clear all filters" control. The range is never silently widened to find data |
| Workspace has no resources at all | The analytics area is replaced by the workspace onboarding: create a link, build a bio page, or generate a QR code |
| A breakdown has no data while the parent does | The tab still renders with "No data in this period" rather than disappearing, so a user does not conclude the breakdown does not exist |
| All shared resources have been deleted (share link) | "There is nothing to show here any more." No error, no resource names, no counts |
| Real-time window is quiet | "Quiet right now — nothing in the last 30 minutes" with the last event's timestamp |
18.13.2 Low-data rules #
These are hard rules applied by the rendering layer, not guidelines.
| Rule | Behaviour |
|---|---|
| Denominator = 0 | The rate renders —, never 0%. Zero percent asserts a measurement that was not made |
| Denominator < 10 | The percentage is suppressed entirely. Only the fraction is shown: "3 of 7" |
| Denominator 10–29 | The percentage is shown with the fraction adjacent, always: "27.3% (3 of 11)". Never the percentage alone |
| Denominator ≥ 30 | The percentage is shown; the fraction is available on hover and in the exported data |
| Period-over-period delta, base < 10 | Delta suppressed, rendered — with "Too few events in the previous period to compute a meaningful change" |
| Sorting by a rate | Rows whose denominator is below 30 sort to the bottom regardless of their rate, and are visually de-emphasised. Otherwise a 100% CTR from a single visitor tops every table |
| "Best" / "top" markers | Never applied to a rate whose denominator is below 30 |
| Sparklines with fewer than 3 points | Rendered as discrete dots, not as a line. Two points connected by a line implies a trend that two observations cannot support |
| Trend statements | Any "up", "down" or "stable" language requires at least 7 complete buckets and a base of at least 30 events; otherwise no trend language is emitted |
| Charts with a single data point | Rendered as a single labelled point on a full axis, never as a line reaching the origin |
18.13.3 Honesty rules about statistical confidence #
| Rule | Detail |
|---|---|
| No winner without the guard | The word "winner", "wins", "beats" or "better" appears in relation to an experiment only when Section 16.7's guard has passed. Force-promoted results are permanently labelled "promoted without sufficient data" (16.7.9) |
| No p-value without n | Every significance figure is displayed adjacent to its sample sizes |
| No lift without an interval | Every lift figure carries its 95% confidence interval |
| Banned phrasings | A CI string-lint test fails the build if user-facing copy contains "clear winner", "definitely", "proves", "guaranteed", "significant" (without an adjacent numeric), or a future-tense claim about business outcomes |
| Bot filter always visible | A persistent "Bots excluded" or "Bots included" chip on every analytics surface. A user is never left to wonder why LinkHub's count differs from another tool's |
| The reconciliation panel | Available from every resource's analytics header, showing for the selected range: total events captured, bot events by reason, prefetch events, estimated (beacon) events, sampled events with their rate, and the resulting displayed figure — with each step's arithmetic shown. This panel is the answer to "why doesn't this match my server logs?", and it exists because that question is asked of every analytics product and most cannot answer it |
| Sampling disclosure | Every number derived from sampled data carries a "sampled 1-in-N" badge, in the dashboard, in exports, and in reports (17.14.4) |
| Estimated-click disclosure | Every figure including beacon-captured block clicks is marked, with the tracked/estimated split available (18.5.2) |
| Retention boundary disclosure | Visible on the date picker and as a chart boundary line (17.10.5) |
| Uniqueness disclosure | "Unique visitors (daily)" labelling and the visitor-day tooltip on first appearance of any uniqueness metric, including the statement that a multi-day figure is an upper bound on distinct people (18.2.2) |
| Events-not-uniques disclosure | Every dimension breakdown labels its count column "Events" and carries the standing line of 18.4.1. No breakdown anywhere in the product presents a unique-visitor figure, so no breakdown can imply one |
| Timezone disclosure | The active timezone is shown next to every date range, and where a range extends past the plan's hourly-rollup reach the UTC fallback is captioned on the chart with the exact date it begins (18.3.2) |
| Incomplete bucket disclosure | Visual distinction plus "(in progress)" in the tooltip (18.3.4) |
| No invented precision | Rates are shown to one decimal place in the interface and four in exports. Counts are never interpolated, smoothed or extrapolated for display |
18.14 Dashboard Performance #
18.14.1 Query budget #
| Surface | p50 | p95 | p99 | Hard timeout |
|---|---|---|---|---|
| Headline tiles | 60 ms | 200 ms | 500 ms | 5 s |
| Time series | 80 ms | 250 ms | 700 ms | 5 s |
| Single dimension breakdown | 70 ms | 220 ms | 600 ms | 5 s |
| Raw-backed (two or more filters) | 200 ms | 900 ms | 2,500 ms | 5 s |
| Recent events list | 80 ms | 300 ms | 800 ms | 5 s |
| Real-time widget | 8 ms | 25 ms | 60 ms | 2 s |
| Whole-page server time (all modules) | 150 ms | 400 ms | 1,200 ms | — |
Enforcement: analytics_query_duration_ms{source,endpoint} (17.13.2) plus a k6 scenario in Section 26 that exercises a seeded 500-million-event workspace and fails the build on a p95 regression above 400 ms.
18.14.2 Degradation instead of failure #
Every dashboard query runs with SET LOCAL statement_timeout = '5s'. On timeout:
| Behaviour | Detail |
|---|---|
| Response | 200 OK with the modules that succeeded, plus meta.partial: true and meta.unavailable_modules: ["breakdown_referrer"] |
| Interface | The failed module renders an inline "This section took too long to load" with a retry control. Every other module renders normally |
| Why not 5xx | A slow referrer breakdown must not blank an entire dashboard. Partial success with an explicit statement of what is missing is strictly more useful than a failure page |
| Logging | Every timeout logs the endpoint, the normalised filter set, the source (raw or rollup_*) and the workspace's data volume, so slow shapes are identifiable rather than anecdotal |
18.14.3 Read routing and precomputation #
| Layer | Decision |
|---|---|
| Read replica | Every analytics read goes to a PostgreSQL read replica. Replica lag budget 5 s, monitored; above 30 s reads fail over to the primary with a reduced 2 s timeout. Recent data is unaffected by lag because the real-time widget reads Redis, not the database |
| Strong consistency | An internal-only flag routes to the primary, used exclusively by the export worker so a file never omits rows the user just generated events for |
| Precomputation, layer 1 | The rollups themselves (17.9). This is the main event: a 90-day time series reads roughly 90 rows, not 25 million |
| Precomputation, layer 2 | Workspace overview headline tiles cached in Redis, keyed by workspace, range, timezone and bot mode, with a 60 s TTL. The key names themselves live in the Redis key catalogue Section 4 owns; this section does not mint a rival naming scheme |
| Precomputation, layer 3 | Breakdown responses cached, keyed by workspace, resource scope, dimension, range, filter hash, timezone and bot mode, with a 120 s TTL |
| Current-bucket freshness | Cached responses always have the in-progress bucket merged in from the real-time counters at read time, so a 60-second cache never makes "today" look stale |
| Invalidation | TTL only. Analytics data is append-only and 60 seconds of staleness sits well inside the stated 60-second freshness guarantee (18.7.3). Write-through invalidation would mean touching cache keys on every ingest batch — thousands of invalidations per second to save at most 60 seconds of staleness on data nobody is watching that closely |
| Cache stampede protection | A short-lived per-key lock; the first requester computes while others receive the previous value with a meta.stale: true marker for up to 5 seconds |
| Client caching | TanStack Query with a 30-second stale time and background refetch, so navigating between tabs is instant |
18.14.4 Behaviour on a very large history #
Concrete target: a Business workspace with 24 months of data, 500 million raw events, 40 million rollup rows and 4,000 resources.
| Guardrail | Value | Reason |
|---|---|---|
| Default range | 28 days, always, on every screen | The most common question is about recent performance. Defaulting to "all time" would make the first page load the most expensive query in the product |
| "All time" range | Not offered | 18.3.1 |
| Maximum selectable span | 400 days | — |
| Granularity coercion | Ranges > 92 days are served at weekly granularity; > 400 days at monthly | Bounds the row count of any time series to roughly 400 points, which is also the readability limit of a chart at dashboard width |
| Breakdowns over long ranges | Ranges > 92 days read analytics_rollup_daily only, and the cross-tab (second dimension filter) control is disabled with the notice of 18.8.3 |
The constraint is raw retention, not hourly retention — the cross of two dimensions has never been stored in either rollup (17.9.6) |
| Raw query row cap | Every raw-backed query carries an internal LIMIT 250000. Exceeding it returns the capped result with meta.truncated: true and an interface notice: "This view is showing the first 250,000 matching events. Narrow the range or export the full data." |
A truncated answer that says so is better than a five-second timeout |
| Recent events list | Always cursor-paginated at 50 per page, always bounded to the last 7 days by default | — |
| Resource lists | Cursor-paginated at 25 (Section 21's canonical pagination), never a full list, never a total count above 1,000 — the interface shows "1,000+" | Counting 4,000 resources' events to produce a total is work nobody asked for |
| Export | Any export spanning more than 92 days is always asynchronous regardless of the estimated row count | — |
| Per-workspace query concurrency | 6 concurrent analytics queries. Beyond that, requests queue for up to 2 s, then return 429 rate_limited with Retry-After: 2 |
One user opening ten tabs must not degrade the workspace for their colleagues |
| Mandatory query shape | A repository-layer type makes it impossible to construct an analytics query without a workspace_id and a bounded occurred_at range. Both are constructor parameters with no defaults and no nullable variants, so every query prunes partitions and every query is workspace-scoped. This is how invariant I6 and partition pruning are enforced structurally rather than by review |
— |
| Index alignment | Every dashboard query shape maps to one of the four raw access paths described in 17.8.6 or to one of the two rollup access paths of 17.9.1 — all of them declared in Section 6. A CI test runs EXPLAIN on 30 representative query shapes against a seeded database and fails the build on any sequential scan of a raw partition |
— |
19. Integrations & Pixels #
Integrations connect a LinkHub workspace to third-party systems. Every integration in this section is optional, per-workspace, and independently failable: no integration failure may ever degrade the public delivery path (Section 11), the redirect path (Section 12), or QR resolution (Section 14).
The full launch scope is: GA4 (client tag + server-side Measurement Protocol), Meta Pixel, TikTok Pixel, one outbound webhook endpoint, a Zapier app, Slack milestone alerts, and the embed-provider allow-list. Email service providers (Mailchimp, ConvertKit) share the connection lifecycle defined in 19.1 but their configuration, field mapping and sync engine are owned by Section 20.5 and 20.6.
19.1 The integration model #
19.1.1 Scope and ownership #
| Rule | Decision |
|---|---|
| Ownership | An integration belongs to exactly one workspace. There is no user-level or account-level integration. |
| Cross-workspace reuse | Not permitted. An agency managing five client workspaces configures five separate GA4 connections. Credentials are never shared across workspaces even when the values are identical. |
| Per-resource grants | Per-resource grants (Section 3) do not apply to integrations. Integrations are workspace-wide configuration. |
| Multiplicity | One connection per provider per workspace, except the embed allow-list (not a connection) and the generic lead webhook (Section 20.5), which is separate from the outbound event webhook (19.7). |
| Environment separation | There is no sandbox/test integration mode. See Section 21.13 for the reasoning, which applies identically here. |
19.1.2 Permissions #
| Action | Owner | Admin | Editor | Viewer |
|---|---|---|---|---|
| View integration catalogue and status | Yes | Yes | Yes | Yes |
| View credential display fragment (last 4 / prefix) | Yes | Yes | No | No |
| Connect / reconfigure an integration | Yes | Yes | No | No |
| Reveal or rotate a webhook signing secret | Yes | Yes | No | No |
| Run a health check on demand | Yes | Yes | Yes | No |
| Disconnect an integration | Yes | Yes | No | No |
| Replay a webhook delivery | Yes | Yes | No | No |
Every connect, reconfigure, rotate and disconnect action writes an audit-log entry (Section 8) with the actor, provider, and the before/after values of non-secret fields. Secret values are never written to the audit log — only the fact that a secret changed.
19.1.3 The integration record #
The canonical DDL is in Section 6. The logical shape each integration row carries:
| Field | Type | Notes |
|---|---|---|
id |
uuid (UUIDv7) | Public identifier. |
workspace_id |
uuid | Tenancy key. Every query is filtered by it. |
provider |
enum | ga4, meta_pixel, tiktok_pixel, webhook, zapier, slack, mailchimp, convertkit, lead_webhook. |
status |
enum | connected, degraded, error, disconnected. |
config |
jsonb | Non-secret configuration. Readable by the API and the UI. Provider-specific shape defined below. |
credentials_ciphertext |
bytea | Envelope-encrypted secret material. Never leaves the server process in plaintext. |
credentials_key_id |
text | Identifier of the wrapping key used, for rotation. |
credentials_nonce |
bytea | 96-bit AES-GCM nonce, unique per write. |
credentials_fingerprint |
text | SHA-256 of the plaintext secret, first 8 hex chars. Used to detect "did the user actually paste a new value" without decrypting. |
display_fragment |
text | Safe-to-render hint, e.g. G-XXXX…7Q9 or ••••••••ab12. |
health_status |
enum | healthy, warning, failing, unknown. |
last_health_check_at |
timestamptz | Null until the first check. |
last_success_at |
timestamptz | Last confirmed successful use (send, sync, or check). |
last_error_code |
text | Integration error code from 19.12. Null when healthy. |
last_error_message |
text | Provider-supplied message, truncated to 500 characters, scrubbed of secret-looking substrings. |
consecutive_failures |
integer | Reset to 0 on any success. |
connected_by_user_id |
uuid | Member who most recently connected it. |
created_at, updated_at, deleted_at |
timestamptz | Soft delete follows the workspace-wide rule. |
19.1.4 Connection lifecycle #
connect (validated) failure threshold
─────────────────────────► connected ──────────────────► degraded
▲ │ │
success │ │ disconnect │ continued failure
│ ▼ ▼
degraded ◄────────────────── error
│ │
└──────────► disconnected ◄────┘
(terminal until reconnected)State definitions and transitions:
| State | Meaning | Entered when | Behaviour |
|---|---|---|---|
connected |
Credentials validated, provider reachable. | A successful validation probe at connect time, or any successful use after a failure. | Fully operational. |
degraded |
Working but unreliable. | consecutive_failures reaches 3, or the last health check returned warning. |
Still attempted. Warning badge in the UI. No email. |
error |
Not working. | consecutive_failures reaches 10, or a non-retryable authentication/authorisation failure (401/403 from the provider) occurs once. |
Delivery attempts are suspended for the provider. Email to Owner and Admins, at most once per 24 hours per integration. |
disconnected |
Explicitly removed by a user, or credentials crypto-shredded. | User action, or workspace deletion. | No delivery attempts. Configuration retained (without credentials) for 30 days so reconnection is one click. |
A single non-retryable authentication failure jumps straight to error because retrying a rejected credential is pure waste and delays the user's awareness.
19.1.5 Credential storage and encryption #
- Envelope encryption. Each workspace has a data encryption key (DEK). The DEK is generated on first integration write, encrypted ("wrapped") with a master key held in the managed secret store, and stored alongside the workspace record. Secret material is encrypted with
AES-256-GCMusing the DEK, a fresh 96-bit nonce per write, and the stringintegration:{workspace_id}:{provider}as additional authenticated data (AAD). Binding the AAD to workspace and provider makes a ciphertext copied between rows undecryptable. - No plaintext at rest, ever. Not in the database, not in logs, not in error messages, not in the audit log, not in support exports, not in the GDPR export bundle (secrets are the workspace's, not a data subject's).
- Decryption happens only in the worker or API process that is about to make the outbound call, in memory, and the plaintext is not retained beyond the call.
- Write-only fields. Secret fields are write-only across every surface. The dashboard renders
display_fragment; the public API never returns credential fields at all. Submitting the literal string••••••••(the masked placeholder) as a secret value is interpreted as "unchanged" and is not written. - Rotation. Master-key rotation re-wraps DEKs without re-encrypting ciphertexts. DEK rotation re-encrypts every integration row for the workspace in a single transaction, driven by the
credentials-rekeyjob. The runbook lives in Section 25. - Crypto-shredding. Disconnecting an integration overwrites
credentials_ciphertextwithNULLand clears the nonce and fingerprint in the same statement. Deleting a workspace destroys its DEK, which renders any residual ciphertext in backups unrecoverable. - Redaction rule. Any log line, error payload or delivery record passes through a redactor that replaces values matching the configured secret patterns (API keys, bearer tokens,
api_secret,access_token,client_secret, anything longer than 20 characters that matched a known credential fingerprint) with[redacted]. See Section 23 for the full policy.
19.1.6 Validation probe at connect time #
Connecting is not "save the form". Every provider defines a synchronous validation probe that must succeed before status becomes connected. The probe has a 10-second total budget. If it fails, nothing is persisted except a failed-attempt audit entry, and the UI shows the mapped error from 19.12.
| Provider | Probe |
|---|---|
| GA4 | Format-validate the measurement ID, then send one lh_connection_test event to the Measurement Protocol debug endpoint. Zero validationMessages in the response is required. |
| Meta Pixel | Format-validate the pixel ID. No server call is possible without a CAPI token, so the probe is format-only, and the UI states plainly that live verification happens on the first real page view (surfaced in the debug view within 60 seconds). |
| TikTok Pixel | Format-validate the pixel ID. Same first-page-view verification statement as Meta. |
| Outbound webhook | URL safety checks (19.7.9), then deliver a signed webhook.test event. A 2xx within 10 seconds is required. |
| Slack | OAuth exchange succeeds and a "LinkHub connected" message posts to the selected channel. |
| Zapier | Handled by Zapier's own connection test, which calls GET /v1/account (Section 21.8.14). |
| Mailchimp / ConvertKit | Section 20.5. |
19.1.7 Health checks #
A recurring job on the integration-health queue evaluates every non-disconnected integration.
| Provider | Cadence | Check |
|---|---|---|
| GA4 | Every 6 hours | Send one lh_health_check event to the debug endpoint; assert zero validation messages. This event is never sent to the production endpoint, so it cannot pollute the customer's reports. |
| Meta Pixel / TikTok Pixel | Every 24 hours | Passive: assert at least one client-side fire was recorded in the last 24 hours on a workspace with public traffic. If the workspace had traffic and the pixel never fired, set warning with pixel_never_fired. |
| Outbound webhook | Every 6 hours | Only when consecutive_failures > 0: re-deliver a webhook.test event. Healthy endpoints are not probed, to avoid pointless traffic. |
| Slack | Every 24 hours | auth.test; a token_revoked or account_inactive response moves the integration to error. |
| Zapier | Every 24 hours | Assert the bound API key is still active; a revoked key moves it to error. |
| Mailchimp / ConvertKit | Every 6 hours | Section 20.5. |
Health checks never mutate customer data at the provider. On-demand checks are available from the UI and are rate-limited to 1 per integration per 60 seconds.
19.1.8 The disconnect flow #
- User selects Disconnect on the integration detail panel.
- A confirmation dialog names the provider and states, explicitly, what stops: for GA4, "server-side forwarding and the client tag stop immediately; historical data already in GA4 is unaffected and LinkHub cannot delete it". For the webhook, "queued deliveries are cancelled; the delivery log is retained for 30 days".
- Confirm requires clicking a button labelled with the provider name. No typed confirmation — this is reversible in the sense that reconnecting is a one-minute task, and typed confirmations are reserved for destructive, irreversible actions.
- On confirm, in one transaction:
status = 'disconnected', credentials crypto-shredded, pending queue jobs for that integration removed by job-id prefix, audit entry written. - The public page render cache for the workspace is invalidated so the next render omits the pixel loader and the corresponding CSP origins (19.5.4).
- Non-secret configuration (measurement ID, pixel ID, webhook URL, Slack channel name) is retained for 30 days to make reconnection one click; after 30 days the retention worker clears
configtoo.
19.2 GA4 #
19.2.1 Configuration #
| Field | Type | Required | Validation | Secret |
|---|---|---|---|---|
measurement_id |
string | Yes | ^G-[A-Z0-9]{6,12}$ |
No |
api_secret |
string | Only when server_side_enabled |
20–64 chars, [A-Za-z0-9_-] |
Yes |
client_tag_enabled |
boolean | Yes | Default true |
No |
server_side_enabled |
boolean | Yes | Default true |
No |
debug_mode |
boolean | Yes | Default false. Auto-disables after 24 hours. |
No |
include_bot_traffic |
boolean | Yes | Default false. When false, events classified as bot (Section 17) are never forwarded. |
No |
custom_dimension_prefix |
string | No | Default lh_. ^[a-z][a-z0-9_]{0,7}$. Applied to every LinkHub-specific event parameter. |
No |
Both paths may be enabled together. When both are on, the same visitor action produces exactly one GA4 event: the client tag owns the events that only exist in the browser (bio page view, bio link click, lead capture), the server forwarder owns the events that have no browser (short-link click, QR scan). The two sets are disjoint by construction, so double counting is impossible. This split is stated in the UI next to the toggles.
19.2.2 Event map #
{p} denotes custom_dimension_prefix (default lh_).
| LinkHub event | Path | GA4 event name | Parameters |
|---|---|---|---|
| Bio page view | Client | page_view |
page_location, page_title, page_referrer, engagement_time_msec, {p}resource_type=bio_page, {p}page_id, {p}handle, {p}variant_id, {p}template |
| Bio link click | Client | select_content |
content_type=bio_link, content_id={block_id}, {p}resource_type=bio_link, {p}page_id, {p}block_id, {p}block_kind, {p}position, {p}link_url, {p}link_text, {p}variant_id |
| Embed facade activated | Client | select_content |
content_type=embed, content_id={block_id}, {p}provider, {p}page_id |
| Lead captured | Client | generate_lead |
{p}page_id, {p}block_id, {p}double_opt_in (true/false), currency and value omitted deliberately — LinkHub does not assign monetary value to a lead |
| Share action | Client | share |
method, content_type=bio_page, {p}page_id |
| Short-link click | Server | {p}link_click |
{p}resource_type=short_link, {p}link_id, {p}slug, {p}domain, {p}destination_host, {p}variant_id, {p}country, {p}region, {p}device_type, {p}os, {p}browser, {p}referrer_host, campaign_source, campaign_medium, campaign_name, campaign_term, campaign_content |
| QR scan | Server | {p}qr_scan |
{p}resource_type=qr_code, {p}qr_id, {p}slug, {p}destination_host, {p}country, {p}region, {p}device_type, {p}os, {p}browser |
| Experiment exposure | Server | {p}experiment_exposure |
{p}experiment_id, {p}variant_id, {p}resource_type, {p}resource_id |
Rules that apply to every forwarded event:
- Event names are ≤ 40 characters, start with a letter, and contain only
[A-Za-z0-9_]. Parameter names are ≤ 40 characters; parameter values are truncated to 100 characters. - At most 25 parameters per event. If the parameter set would exceed 25 (possible only when all five UTM values are present on a short-link click),
{p}referrer_hostis dropped first, then{p}region. The drop order is fixed so reports are consistent. user_idis never sent. LinkHub has no identifier for the visitor that is stable beyond the daily salt window, and sending a pseudo-identifier asuser_idwould misrepresent it as a durable user.non_personalized_ads: trueis always set.
19.2.3 Server-side Measurement Protocol forwarding #
The forwarder is a consumer on the ga4-forward queue, fed by the analytics ingest worker (Section 17) after the event has been persisted. Forwarding is strictly downstream of persistence: a GA4 outage can never lose a LinkHub analytics event.
POST https://www.google-analytics.com/mp/collect
?measurement_id=G-XXXXXXX&api_secret=<decrypted secret>
Content-Type: application/json{
"client_id": "8471932055.1755561600",
"timestamp_micros": "1755598323221000",
"non_personalized_ads": true,
"consent": {
"ad_user_data": "DENIED",
"ad_personalization": "DENIED",
"analytics_storage": "GRANTED"
},
"events": [
{
"name": "lh_link_click",
"params": {
"lh_resource_type": "short_link",
"lh_link_id": "0192f3c1-8a44-7b31-9d02-6f1c2b7a4e55",
"lh_slug": "spring-sale",
"lh_domain": "go.acme.com",
"lh_destination_host": "acme.com",
"lh_country": "DE",
"lh_region": "BE",
"lh_device_type": "mobile",
"lh_os": "android",
"lh_browser": "chrome",
"lh_referrer_host": "instagram.com",
"campaign_source": "instagram",
"campaign_medium": "social",
"campaign_name": "spring-sale",
"engagement_time_msec": "1"
}
}
]
}client_id derivation. GA4 requires a client_id shaped like <digits>.<digits>. LinkHub derives it deterministically and cookielessly:
numeric = first 10 decimal digits of BigInt(sha256(visitor_hash || measurement_id))
salt_epoch = unix seconds of 00:00 UTC on the day the daily salt was minted
client_id = `${numeric}.${salt_epoch}`Consequence, stated plainly in the UI and in this specification: because visitor_hash rotates with the daily salt, GA4 will treat the same human as a new client each UTC day. GA4 user counts sourced from server-side LinkHub events are therefore daily-unique, not durably unique. This is the deliberate cost of the cookie-free identity model in Section 23; LinkHub's own analytics (Section 18) are unaffected because they never claim cross-day uniqueness either.
Batching and delivery:
| Property | Value |
|---|---|
| Batch size | Up to 25 events per request (Measurement Protocol maximum). |
| Batch window | 2 seconds, or 25 events, whichever comes first. |
| Batching key | (workspace_id, measurement_id, client_id, consent state). Events with different client_id values are never batched together — the Measurement Protocol carries one client_id per request. |
| Timeout | Connect 2s, total 5s. |
| Success | HTTP 204 (the production endpoint returns 204 with an empty body and does not validate; this is why the debug endpoint exists). |
| Retry | 429 and 5xx: 3 retries at 5s, 30s, 5m with ±20% jitter. 4xx other than 429: no retry, increment consecutive_failures, record ga4_rejected. |
| Dead-letter | After the final retry the event is dropped, not dead-lettered. Rationale: GA4 rejects events older than 72 hours anyway, and a dead-letter queue of analytics duplicates is a liability, not an asset. A dropped-event counter is exported to metrics (Section 25) and shown in the debug view. |
| Ordering | Not guaranteed and not required. timestamp_micros carries the true event time, so out-of-order delivery still reports correctly. Events older than 72 hours are dropped before sending, with ga4_event_too_old. |
19.2.4 Consent gating on both paths #
| Path | Gate |
|---|---|
| Client tag | The gtag loader is not injected at all until consent resolution completes (19.6). Google Consent Mode v2 defaults are set to denied for analytics_storage, ad_storage, ad_user_data and ad_personalization before the tag loads, via an inline nonce'd snippet. On grant, gtag('consent','update',{analytics_storage:'granted'}) fires. ad_storage, ad_user_data and ad_personalization remain denied permanently — LinkHub does not offer an advertising integration for GA4. |
| Server forwarder | The consent snapshot is carried on the event record itself (19.6.5). If consent_required = true and consent_analytics = false, the event is dropped before the request is built. It is not queued, not retried, and not counted as a failure. If consent_required = false (visitor outside a gated region and the workspace has not forced global gating), the event is forwarded with analytics_storage: "GRANTED". |
| Unknown consent on the redirect host | Short-link and QR events are captured on a host that may not share a registrable domain with the page that carries the consent cookie, so consent is frequently unknowable there. Decision: when consent state is unknown and the resolved country is in the gated set, the event is not forwarded to GA4. First-party analytics still records it in full. This is fail-closed and costs some GA4 completeness in the EEA/UK/CH; that trade is intentional. |
19.2.5 The debugging view #
Enabling debug_mode on the GA4 integration does three things for 24 hours (after which it self-disables and the UI says so):
- Server-side forwarding is mirrored to
https://www.google-analytics.com/debug/mp/collect. The mirrored request is sent in addition to the production request, so debugging never suppresses real data. - The last 50 forwarded events per workspace are written to a Redis list
ga4:debug:{workspace_id}with a 24-hour TTL and a 50-entry cap. - The GA4 Debug panel in the integration detail view renders those entries.
Panel columns: local time, event name, resolved client_id, consent flags, HTTP status, round-trip latency in ms, and a validation badge. Expanding a row shows the exact JSON body sent (with api_secret stripped from the URL) and the full validationMessages array returned by the debug endpoint, each rendered with its fieldPath, description and validationCode.
The panel has three empty states: "Debug mode is off" with an enable button; "Debug mode is on — waiting for the first event" with a hint to open the public page; and "No events in the last 24 hours" with a link to the analytics dashboard to confirm traffic exists at all.
19.3 Meta Pixel #
19.3.1 Configuration #
| Field | Type | Required | Validation | Secret |
|---|---|---|---|---|
pixel_id |
string | Yes | ^[0-9]{15,16}$ |
No |
enabled |
boolean | Yes | Default true |
No |
track_link_clicks |
boolean | Yes | Default true |
No |
track_leads |
boolean | Yes | Default true |
No |
Meta Pixel is a client-only integration. There is no server component, therefore no secret, therefore no api_secret field.
19.3.2 Events fired #
| Trigger | Call | Payload |
|---|---|---|
| Bio page rendered and consent granted | fbq('track','PageView') |
none |
| Bio link click | fbq('trackCustom','LinkClick', {...}) |
{ block_id, block_kind, position, link_host, page_id, variant_id } |
| Embed facade activated | fbq('trackCustom','EmbedPlay', {...}) |
{ block_id, provider, page_id } |
| Lead capture succeeded | fbq('track','Lead') |
{ content_name: <block label>, page_id } |
LinkClick and EmbedPlay are custom events because no Meta standard event describes them accurately; using ViewContent for an outbound click would corrupt the customer's standard-event reporting. No advanced matching parameters (em, ph, fn, ln, external_id) are ever sent — LinkHub does not hash and transmit personal data to Meta. This is a hard rule, not a default.
The full pixel base snippet is injected by the loader in 19.5, never inline in the document head.
19.3.3 Consent gating #
Meta Pixel is in the marketing category. It is not injected, and fbq is not defined, until marketing consent is granted in a gated context. On withdrawal, the loader calls fbq('consent','revoke') and stops queuing events; the already-loaded script remains in the page but is inert.
fbq('consent','revoke') is also called before fbq('init', …) in the rare case where the pixel was loaded under a prior grant that is no longer valid at render time.
19.3.4 Conversions API — not implemented #
The Meta Conversions API (CAPI) is not implemented at launch and is not specified in this document. No CAPI access token field exists, no server-side Meta forwarding job exists, and no partially-built UI is shipped. It is named as roadmap in 19.13. Do not build a placeholder.
19.4 TikTok Pixel #
19.4.1 Configuration #
| Field | Type | Required | Validation | Secret |
|---|---|---|---|---|
pixel_id |
string | Yes | ^[A-Z0-9]{20}$ |
No |
enabled |
boolean | Yes | Default true |
No |
track_link_clicks |
boolean | Yes | Default true |
No |
track_leads |
boolean | Yes | Default true |
No |
Client-only, exactly as Meta. No secret.
19.4.2 Events fired #
| Trigger | Call | Payload |
|---|---|---|
| Bio page rendered, consent granted | ttq.page() |
none |
| Bio link click | ttq.track('ClickButton', {...}) |
{ content_id: <block_id>, content_type: 'product', content_name: <link label> } |
| Embed facade activated | ttq.track('ViewContent', {...}) |
{ content_id: <block_id>, content_type: 'product', content_name: <provider> } |
| Lead capture succeeded | ttq.track('SubmitForm', {...}) |
{ content_name: <block label> } |
ttq.identify() is never called. No email, phone or external ID is hashed and sent.
19.4.3 Consent gating #
Marketing category, identical mechanics to 19.3.3. On withdrawal the loader stops dispatching to ttq and clears its buffered queue.
19.4.4 Events API — not implemented #
The TikTok Events API (server-side) is not implemented at launch and is not specified. Named as roadmap in 19.13.
19.5 The pixel execution model on the public path #
The bio page budget in Section 11 is 0 bytes of blocking JavaScript. Third-party tags are the classic way that budget is destroyed. The following model is mandatory.
19.5.1 What ships in the initial HTML #
Exactly one inline <script type="module" nonce="…"> of ≤ 1.4 KB minified, the loader. Inline module scripts are deferred by specification, so this is not blocking JS and does not count against the blocking budget. It contains no provider code — only the consent read, the event queue, and the injection scheduler. No third-party origin appears in the initial HTML: no <script src>, no <link rel="preconnect">, no <img> fallback pixel.
The decision not to emit preconnect for pixel origins is deliberate: a preconnect costs DNS + TCP + TLS on the critical path for a resource that is intentionally loaded after the page is interactive. It would trade a real LCP regression for a saving on a non-critical request.
19.5.2 The loading sequence #
1. Document parsed. Loader module executes (deferred, non-blocking).
2. Loader reads the lh_consent cookie and the server-rendered
data-consent-required attribute on <html>.
3. If consent is required and not yet decided → loader returns immediately
and re-arms on the 'lh:consent' CustomEvent. Nothing is loaded.
4. If consent is satisfied → loader waits for BOTH:
a) the 'load' event, and
b) requestIdleCallback({ timeout: 1500 })
(fallback: setTimeout 1500ms where rIC is unavailable).
5. For each enabled provider whose category is granted, inject:
<script async crossorigin="anonymous"
referrerpolicy="strict-origin-when-cross-origin"
src="…"></script>
Injection is staggered by 120ms per provider so a three-pixel workspace
does not open three TLS connections in the same frame.
6. On each script's 'load', drain that provider's queued events.19.5.3 Failure, slowness and unavailability #
| Condition | Behaviour |
|---|---|
Provider script exceeds 3000 ms without firing load |
The loader marks the provider unavailable for this page view, discards its queue, and stops dispatching to it. No retry, no second injection, no user-visible message. |
Provider script fires error (blocked by an extension, DNS failure, 4xx) |
Same as timeout, immediately. This is the common case — a large share of visitors run content blockers, and it must be a non-event. |
Provider script loads but its global (gtag / fbq / ttq) is undefined |
Treated as unavailable. The loader never assumes a global exists. |
| Events fired before the provider is ready | Buffered per provider in an array capped at 50 events. Overflow drops the oldest. The buffer is discarded 30 seconds after page load if the provider never became ready. |
| A provider's script is slow but eventually loads | No effect on any Core Web Vital: injection happens after load and after idle, so it cannot affect FCP, LCP or CLS. It can theoretically affect INP if the provider runs long tasks; the stagger in step 5 and the post-idle scheduling keep this within budget. INP is monitored in synthetic CI (Section 26) with all three pixels enabled on a reference page. |
| Navigation occurs before the provider loads | The click already navigated via a plain <a href> (Section 11). Analytics is not lost, because LinkHub's own first-party click record is written server-side on the redirect, independently of any pixel. |
The rule that makes all of this safe: no user-visible behaviour on the public page ever depends on a third-party script having loaded. Links navigate, forms submit, embeds play, with or without pixels.
19.5.4 CSP implications #
Section 23.5 is the sole owner of the Content Security Policy: the baseline directive set, the header form, the nonce mechanism and the reporting endpoint are defined there and are never redefined here. This subsection contributes only the per-provider origin additions that Section 23.5's tables consume, and every origin listed below must also appear in the corresponding Section 23.5 directive table.
Pixel origins are not in the baseline policy. They are appended per render, computed from the set of enabled integrations for that workspace, and cached with the render.
| Provider | script-src additions |
img-src additions |
connect-src additions |
|---|---|---|---|
| GA4 | https://www.googletagmanager.com |
https://www.google-analytics.com https://*.google-analytics.com |
https://www.google-analytics.com https://*.google-analytics.com https://*.analytics.google.com |
| Meta Pixel | https://connect.facebook.net |
https://www.facebook.com |
https://www.facebook.com |
| TikTok Pixel | https://analytics.tiktok.com |
https://analytics.tiktok.com |
https://analytics.tiktok.com |
Rules:
- A workspace with no pixels emits a CSP with zero third-party script origins. The strict baseline is the default, not an aspiration.
'unsafe-inline'and'unsafe-eval'are never added, for any provider. Google Tag Manager container support would require'unsafe-eval'; GTM is therefore not supported and is not on the roadmap. This is stated in the integrations UI so users stop looking for it.strict-dynamicis used so the nonce'd loader may inject provider scripts without each origin needing to be script-src-listed for its own sub-resources. Origins are still listed explicitly for browsers that do not honourstrict-dynamic.- CSP violation reports are collected at a first-party endpoint with sampling (Section 25). A spike in violations mentioning a pixel origin is an alerting condition, because it usually means a provider changed its CDN hostname.
- The bot-challenge provider used by the escalation path in Section 20.3.5 also contributes origins. Those origins are declared in Section 23.5's
frame-srcandconnect-srctables, not here, because the challenge is not an integration a workspace connects.
19.6 Consent gating — the mechanics #
The privacy position, lawful bases, and the reasoning behind cookie-free first-party analytics live in Section 23. This subsection specifies only the mechanism.
19.6.1 Banner trigger conditions #
The banner is rendered if and only if both are true:
- The workspace has at least one third-party integration enabled that runs in the visitor's browser or forwards to a third party: GA4 (either path), Meta Pixel, or TikTok Pixel. Embeds do not trigger the banner on their own, because the facade-first model (Section 10) means no third-party request is made until the visitor clicks the facade — that click is itself the consent signal for that embed.
- The visitor is in a gated context:
consent_mode = 'geo'(default) and the resolved country is in the gated set; orconsent_mode = 'global'(workspace override) — always gated, every visitor, every country; orconsent_mode = 'geo'and the country could not be resolved. Unknown resolves to gated. Fail-closed.
Gated set (default): all EU/EEA member states, plus GB, CH, IS, LI, NO. The set is a server-side constant, versioned with the consent policy version, not a per-workspace list.
consent_mode = 'off' is not offered. A workspace cannot enable a marketing pixel and switch consent gating off. The toggle simply does not exist.
The banner is server-rendered inline with the page (≤ 3 KB of markup + critical CSS, inside the 14 KB critical CSS budget), uses position: fixed, and reserves no layout space, so CLS is 0. It is not injected by JavaScript, so it is visible with JS disabled; in that case the accept/reject controls are native form buttons posting to /c/consent and redirecting back with 303 See Other.
19.6.2 The category model #
| Category | Key | Toggleable | Default in a gated context | Covers |
|---|---|---|---|---|
| Necessary | n |
No (always 1) |
Granted | Session integrity, consent storage, abuse prevention, first-party cookieless analytics (legitimate interest — see Section 23). |
| Analytics | a |
Yes | Denied | GA4 client tag, GA4 server-side forwarding. |
| Marketing | m |
Yes | Denied | Meta Pixel, TikTok Pixel. |
Banner controls: Accept all, Reject all, Manage preferences. Accept all and Reject all are the same size, same prominence, same visual weight, and adjacent. No pre-ticked optional categories. No "reject" hidden behind a second screen. Accessibility requirements (focus management, role="dialog", aria-modal, escape behaviour, 24×24 target size) are in Section 24.
Dismissing the banner without choosing (Escape, or clicking outside) is treated as no decision, not as consent. The banner re-renders on the next page view. It is never treated as acceptance.
19.6.3 Storage #
Cookie lh_consent:
| Attribute | Value | Reason |
|---|---|---|
| Host | The public page host (custom domain or linkhub.app) |
First-party. |
Path |
/ |
|
Max-Age |
15552000 (180 days) | Re-prompt every 6 months. |
Secure |
Yes | |
HttpOnly |
No | The loader must read it client-side to decide what to inject. |
SameSite |
Lax |
Value: base64url of a compact JSON object.
{ "v": 1, "ts": 1755598323, "c": { "n": 1, "a": 1, "m": 0 }, "g": "DE", "p": 3, "s": "banner" }v schema version · ts decision time · c category grants · g resolved country at decision time · p consent policy version · s source (banner, preferences, nojs_form).
Validation on read: unknown v or malformed value → treat as no decision and delete the cookie. A stale p (policy version incremented because the workspace enabled a new pixel category) → treat as no decision for the newly-added category only; previously granted categories remain granted.
Server-side proof of consent. A consent_events row is appended on every decision: id, workspace_id, bio_page_id, visitor_hash, country, categories_granted, categories_denied, action (granted, denied, updated, withdrawn), source, policy_version, consent_text_version, user_agent_family, created_at. No raw IP, no full user agent. This is the record produced if the workspace is asked to demonstrate consent. Retention follows the workspace's analytics retention plan, with a floor of 12 months so proof outlives a 30-day retention plan.
19.6.4 Reaching the client tag #
The banner writes the cookie, then dispatches document.dispatchEvent(new CustomEvent('lh:consent', { detail: { n:1, a:1, m:0 } })). The loader (19.5) subscribes to that event on first execution. There is no polling and no page reload on grant — providers are injected in place.
19.6.5 Reaching the server-side forwarder #
Consent must travel with the event, not be looked up later (by the time the forwarder runs, the visitor is gone).
Every event written to the clicks:raw stream (Section 17) carries three additional fields:
| Field | Type | Meaning |
|---|---|---|
consent_required |
boolean | Whether the visitor was in a gated context at capture time. |
consent_analytics |
boolean | Analytics category granted. false when unknown. |
consent_marketing |
boolean | Marketing category granted. false when unknown. |
Population rules:
- Bio page render / on-page beacon: the server reads
lh_consentfrom the request and populates all three exactly. - Short link / QR redirect: if the redirect host shares a registrable domain with a page host that has a consent cookie, the cookie is read and used. Otherwise
consent_requiredis derived from the country and the workspace'sconsent_mode, and both grant flags arefalse.
The GA4 forwarder reads these fields off the event and never re-derives consent. Its rule is a single line: forward if consent_analytics === true || consent_required === false.
19.6.6 Behaviour on withdrawal #
A Cookie preferences link is rendered in the public page footer whenever the banner is applicable to that workspace (even after a decision). It reopens the preferences panel.
On withdrawal of a category:
| Step | Action |
|---|---|
| 1 | Cookie rewritten with the category set to 0, ts updated, s = "preferences". |
| 2 | consent_events row appended with action = "withdrawn". |
| 3 | lh:consent dispatched. Loader stops dispatching to every provider in the withdrawn category and clears their buffers. |
| 4 | Provider-level revocation is called where the provider supports it: GA4 → gtag('consent','update',{ analytics_storage:'denied' }); Meta → fbq('consent','revoke'); TikTok → the loader stops calling ttq (TikTok exposes no revoke API, so suppression is at the LinkHub layer). |
| 5 | No page reload. Reloading on withdrawal is user-hostile and unnecessary, since every provider is either revocable in place or fully suppressible at the dispatch layer. |
| 6 | Server-side: the next event carries consent_analytics: false, so GA4 forwarding stops on the next action. Events already forwarded are not retracted — LinkHub cannot delete data from a customer's GA4 property. This is stated verbatim in the preferences panel. Section 23 covers the data-subject rights path. |
Withdrawal never affects first-party cookieless analytics, which do not depend on consent. The preferences panel says so explicitly, including what is and is not collected.
19.7 Outbound webhooks #
19.7.1 The shape of the feature — stated plainly #
A workspace has exactly one outbound webhook URL. Every enabled event type for that workspace is delivered to that one URL.
There is no webhook subscription-management UI at launch. There is no endpoint list, no per-endpoint event selection, no multiple destinations, no per-endpoint secret rotation matrix. A customer who needs fan-out to several systems points the single URL at their own dispatcher, or uses Zapier (19.8). This is a deliberate scope decision, not an omission; a multi-endpoint subscription model is named as roadmap in 19.13.
What the workspace can configure is a small, fixed set of coarse toggles:
| Setting | Type | Default | Notes |
|---|---|---|---|
url |
string | — | https only, port 443, passes the checks in 19.7.9. |
secret |
string | generated | 32 random bytes, base64url. Viewable by Owner/Admin, rotatable. |
enabled |
boolean | true |
|
send_click_events |
boolean | false |
Gates link.clicked and qr.scanned, which are high-volume. Off by default so nobody accidentally points a firehose at a small server. |
send_lead_events |
boolean | true |
Gates lead.captured and lead.sync_failed. |
send_management_events |
boolean | true |
Gates every remaining event type. |
Transport is HTTPS only, with no exception anywhere in the product. A webhook target must be https on port 443. There is no plain-HTTP delivery path, no port-80 allowance, no "internal network" exemption, no environment flag that relaxes it, and no per-workspace opt-out. This is stated here because it is the rule the whole document follows: any other statement of webhook transport elsewhere is wrong and defers to this one. The enforcement points are the save-time and per-attempt checks in 19.7.9.
Plan gating: outbound webhooks require Pro or Business. Free workspaces see the panel with an upgrade prompt and cannot save a URL.
19.7.2 The delivery envelope #
Every delivery is a POST with a JSON body in this exact shape:
{
"id": "evt_01K3M9Q2W8F5TYRB4C7NXZJ0HD",
"type": "link.clicked",
"api_version": "v1",
"created_at": "2026-08-19T10:12:03.221Z",
"workspace_id": "0192f3c1-8a44-7b31-9d02-6f1c2b7a4e55",
"data": { }
}| Field | Type | Notes |
|---|---|---|
id |
string | evt_ + 26-char Crockford base32 of a UUIDv7. Stable across retries — use it for deduplication. |
type |
string | Dotted, resource.verb, from the catalogue in 19.7.3. |
api_version |
string | "v1". Follows the public API version (Section 21.1). Payload shapes follow the same compatibility promise. |
created_at |
string | RFC 3339, UTC, millisecond precision. Time the event occurred, not the time of this delivery attempt. |
workspace_id |
string | uuid. |
data |
object | Event-specific. Always an object, never an array or scalar. |
There is no livemode field, because there is no test mode (Section 21.13). The webhook.test event exists as an event type, which is unambiguous.
Headers on every delivery:
POST /hooks/linkhub HTTP/1.1
Host: hooks.acme.com
Content-Type: application/json; charset=utf-8
Content-Length: 1184
User-Agent: LinkHub-Webhooks/1.0
Accept: */*
X-LinkHub-Event-Id: evt_01K3M9Q2W8F5TYRB4C7NXZJ0HD
X-LinkHub-Event-Type: link.clicked
X-LinkHub-Delivery-Id: whd_01K3M9Q2WA1PGCE6VJ4T8N2QZR
X-LinkHub-Delivery-Attempt: 1
X-LinkHub-Workspace-Id: 0192f3c1-8a44-7b31-9d02-6f1c2b7a4e55
X-LinkHub-Signature: t=1755598323,v1=6b1f0c4e...9aX-LinkHub-Delivery-Id differs per attempt; X-LinkHub-Event-Id does not. Deduplicate on the event id.
19.7.3 Event catalogue #
This table is the canonical webhook event catalogue for the whole specification. It contains exactly 20 event types and it is exhaustive: no other section, appendix or generated registry may introduce an event type that is not listed here, and any registry elsewhere is regenerated from this table verbatim. In particular there is no page.viewed event — page views are a high-cardinality analytics stream, not a webhook, and are read through the analytics endpoints in Section 21.8.10 instead. Adding an event type is an additive change under the compatibility rules in Section 21.1.3 and requires this table to be edited first.
| Type | Gate | Volume | Fires when |
|---|---|---|---|
webhook.test |
always | manual | The user sends a test, or a health check runs. |
link.clicked |
send_click_events |
high | A short link resolves successfully. Emitted post-ingest, not on the redirect path. |
qr.scanned |
send_click_events |
high | A QR code resolves successfully. |
lead.captured |
send_lead_events |
medium | A lead is stored and (when double opt-in is on) confirmed. |
lead.sync_failed |
send_lead_events |
low | An ESP sync exhausts its retries (Section 20.6). |
lead.unsubscribed |
send_lead_events |
low | A lead unsubscribes (Section 20.8). |
link.created |
send_management_events |
low | |
link.updated |
send_management_events |
low | Any field change other than the destination. |
link.destination_changed |
send_management_events |
low | Separate from link.updated because it is an audited, security-relevant change. |
link.deleted |
send_management_events |
low | Soft delete. |
qr.created |
send_management_events |
low | |
qr.destination_changed |
send_management_events |
low | |
page.published |
send_management_events |
low | |
page.unpublished |
send_management_events |
low | |
experiment.significant |
send_management_events |
low | The minimum-sample guard passes (Section 16). |
experiment.promoted |
send_management_events |
low | Includes forced boolean. |
domain.verified |
send_management_events |
low | Domain reached active (Section 13). |
domain.failed |
send_management_events |
low | Domain entered dns_failed or tls_failed. |
usage.threshold_reached |
send_management_events |
low | An entitlement hits 80% or 100% (Section 22). |
export.ready |
send_management_events |
low | An async export completed (Section 18, Section 21.8.12). |
Click-volume protection: link.clicked + qr.scanned deliveries are capped at 10,000 per hour per workspace. Beyond the cap, further click events are dropped, not queued — a queue that grows faster than it drains is an outage in slow motion. A webhook.throttled banner appears in the delivery log with the dropped count for the hour, and the counter is exported to metrics. The cap resets on the hour boundary.
19.7.4 Example payloads #
link.clicked:
{
"id": "evt_01K3M9Q2W8F5TYRB4C7NXZJ0HD",
"type": "link.clicked",
"api_version": "v1",
"created_at": "2026-08-19T10:12:03.221Z",
"workspace_id": "0192f3c1-8a44-7b31-9d02-6f1c2b7a4e55",
"data": {
"link_id": "0192f3c1-9012-7c88-b4aa-2d55e9f1a733",
"slug": "spring-sale",
"domain": "go.acme.com",
"short_url": "https://go.acme.com/spring-sale",
"destination_url": "https://acme.com/spring?utm_source=instagram",
"destination_host": "acme.com",
"occurred_at": "2026-08-19T10:12:03.180Z",
"country": "DE",
"region": "BE",
"device_type": "mobile",
"os": "android",
"browser": "chrome",
"fallback_stage": "active",
"referrer_host": "instagram.com",
"is_bot": false,
"utm": {
"source": "instagram",
"medium": "social",
"campaign": "spring-sale",
"term": null,
"content": null
},
"variant_id": "0192f3c2-1111-7a01-9f3e-77c1b0d2e455",
"targeting_rule_id": null,
"visitor_hash": "b2xkc2FsdGVkX18AAAA"
}
}qr.scanned:
{
"id": "evt_01K3M9R4V0J2S8ZQ7YB1XKT5CN",
"type": "qr.scanned",
"api_version": "v1",
"created_at": "2026-08-19T10:14:41.902Z",
"workspace_id": "0192f3c1-8a44-7b31-9d02-6f1c2b7a4e55",
"data": {
"qr_code_id": "0192f3c1-a4d0-7e11-8b90-51fa3c2d9e07",
"slug": "pack-2026",
"scan_url": "https://go.acme.com/pack-2026",
"fallback_stage": "active",
"fallback_rung": 1,
"destination_url": "https://acme.com/spring-pack",
"destination_host": "acme.com",
"occurred_at": "2026-08-19T10:14:41.860Z",
"country": "FR",
"region": "IDF",
"device_type": "mobile",
"os": "ios",
"browser": "safari",
"is_bot": false,
"visitor_hash": "c3RhbXBlZHNhbHQAAAB"
}
}A QR scan URL is https://{host}/{slug} — bare, with no /q/ path segment, so that a printed symbol encodes as few characters as possible and QR slugs share one namespace with short-link slugs (Section 14).
fallback_stage is one of active, paused_fallback, workspace_unavailable, generic, and fallback_rung is the matching rung number 1–4. There are exactly four rungs and no fifth; the chain, its conditions and its status codes are owned by Section 14 and restated for billing in Section 22.6.2.
Dimension names on the wire (country, region, os, browser) are the API's presentation names. They map one-to-one onto the storage column names defined in Section 6 (country_code, region_code, os_family, browser_family); the wire names are stable under the compatibility promise in Section 21.1.5 independently of the column names.
lead.captured:
{
"id": "evt_01K3M9S6Z2H4D9WM3P8QLR0TVB",
"type": "lead.captured",
"api_version": "v1",
"created_at": "2026-08-19T10:20:11.004Z",
"workspace_id": "0192f3c1-8a44-7b31-9d02-6f1c2b7a4e55",
"data": {
"lead_id": "0192f3c2-77aa-7d20-9c11-8e4b0a6f2c31",
"email": "sam@example.com",
"name": "Sam Rivera",
"custom_fields": { "company": "Example Ltd" },
"status": "subscribed",
"double_opt_in": false,
"bio_page_id": "0192f3c1-3300-7b02-a1d4-9e77c5b81f66",
"bio_page_handle": "acme",
"block_id": "0192f3c1-3311-7f19-8ad2-40b9c1e77a02",
"consent": {
"given": true,
"text": "I agree to receive marketing emails from Acme Ltd.",
"given_at": "2026-08-19T10:20:10.771Z",
"method": "checkbox",
"policy_version": 3
},
"utm": { "source": "tiktok", "medium": "social", "campaign": "launch", "term": null, "content": null },
"country": "GB",
"region": "ENG",
"is_disposable_domain": false,
"created_at": "2026-08-19T10:20:10.900Z"
}
}link.destination_changed:
{
"id": "evt_01K3M9T80P6R2YFA5J1DKM7XQZ",
"type": "link.destination_changed",
"api_version": "v1",
"created_at": "2026-08-19T11:02:47.311Z",
"workspace_id": "0192f3c1-8a44-7b31-9d02-6f1c2b7a4e55",
"data": {
"link_id": "0192f3c1-9012-7c88-b4aa-2d55e9f1a733",
"slug": "spring-sale",
"domain": "go.acme.com",
"previous_destination_url": "https://acme.com/spring",
"destination_url": "https://acme.com/spring-2026",
"actor": { "type": "user", "id": "0192f3b0-1111-7000-8000-aaaabbbbcccc", "display_name": "Dana Ruiz" }
}
}qr.destination_changed mirrors this with qr_code_id and slug. page.published / page.unpublished:
{
"id": "evt_01K3M9V1C4A8N3TQ6E2WPZ5RJY",
"type": "page.published",
"api_version": "v1",
"created_at": "2026-08-19T11:10:02.140Z",
"workspace_id": "0192f3c1-8a44-7b31-9d02-6f1c2b7a4e55",
"data": {
"bio_page_id": "0192f3c1-3300-7b02-a1d4-9e77c5b81f66",
"handle": "acme",
"public_url": "https://acme.link/acme",
"block_count": 11,
"actor": { "type": "api_key", "id": "0192f3b0-2222-7000-8000-ddddeeeeffff", "name": "CI deploy key" }
}
}experiment.promoted:
{
"id": "evt_01K3M9W3E6B0Q5VS8G4YRX7ZKM",
"type": "experiment.promoted",
"api_version": "v1",
"created_at": "2026-08-19T11:30:00.512Z",
"workspace_id": "0192f3c1-8a44-7b31-9d02-6f1c2b7a4e55",
"data": {
"experiment_id": "0192f3c2-0a0a-7c10-9911-2b3c4d5e6f70",
"resource_type": "bio_page",
"resource_id": "0192f3c1-3300-7b02-a1d4-9e77c5b81f66",
"winning_variant_id": "0192f3c2-1111-7a01-9f3e-77c1b0d2e455",
"forced": false,
"confidence": 0.972,
"samples": { "control": 1841, "variant": 1799 },
"actor": { "type": "user", "id": "0192f3b0-1111-7000-8000-aaaabbbbcccc", "display_name": "Dana Ruiz" }
}
}domain.verified / domain.failed:
{
"id": "evt_01K3M9X5G8D2S7XU0J6ATZ9BNP",
"type": "domain.failed",
"api_version": "v1",
"created_at": "2026-08-19T11:44:19.077Z",
"workspace_id": "0192f3c1-8a44-7b31-9d02-6f1c2b7a4e55",
"data": {
"domain_id": "0192f3c1-c0de-7a44-8ee1-33aa55bb77cc",
"hostname": "go.acme.com",
"state": "dns_failed",
"failure_code": "domain_cname_mismatch",
"expected": { "type": "CNAME", "name": "go.acme.com", "value": "cname.linkhub.app" },
"observed": { "type": "CNAME", "name": "go.acme.com", "value": "acme.hosting-provider.net" },
"retry_available": true
}
}usage.threshold_reached:
{
"id": "evt_01K3M9Y7J0F4V9ZW2M8CQB1DRS",
"type": "usage.threshold_reached",
"api_version": "v1",
"created_at": "2026-08-19T12:00:00.001Z",
"workspace_id": "0192f3c1-8a44-7b31-9d02-6f1c2b7a4e55",
"data": {
"entitlement": "dynamic_qr_codes",
"plan": "pro",
"limit": 100,
"current": 100,
"threshold": 1.0,
"period_start": null,
"period_end": null
}
}export.ready:
{
"id": "evt_01K3M9Z9L2H6X1BY4P0EScD3TU",
"type": "export.ready",
"api_version": "v1",
"created_at": "2026-08-19T12:07:42.900Z",
"workspace_id": "0192f3c1-8a44-7b31-9d02-6f1c2b7a4e55",
"data": {
"export_id": "0192f3c2-9999-7ccc-8ddd-1a2b3c4d5e6f",
"kind": "analytics_csv",
"row_count": 148213,
"byte_size": 9127344,
"download_url": "https://exports.linkhub.app/d/0192f3c2-9999-7ccc-8ddd-1a2b3c4d5e6f?sig=…",
"expires_at": "2026-08-20T12:07:42.900Z"
}
}Actor identity never includes an email address. An actor object carries type, id, and either display_name (for a user) or name (for an API key). Member email addresses are not emitted on any machine-readable surface a credential can reach — not in a webhook payload and not in an API response (Section 21.2.4). They are visible only in the dashboard, to a session-authenticated member with a role that may see the member list.
webhook.test, link.created, link.updated, link.deleted, qr.created, lead.sync_failed, lead.unsubscribed and experiment.significant follow the same envelope; their data objects are the corresponding API resource representation (Section 21.8) plus, for the failure events, failure_code and failure_message. The full payload schema for every event type is part of the OpenAPI document (Section 21.10) under components/schemas/WebhookEvent*, so customers can generate types from it.
19.7.5 Signature scheme #
timestamp = current unix seconds at the moment of this delivery attempt
signed_payload = `${timestamp}.${raw_request_body}`
v1 = hex( HMAC_SHA256(secret, signed_payload) ) // lowercase
header = `X-LinkHub-Signature: t=${timestamp},v1=${v1}`- The HMAC is computed over the raw bytes of the body, before any framework parsing. Customers must verify against the raw body too; re-serialising parsed JSON will not match.
tis per-attempt, so a retry has a different signature. This is intentional: it keeps the replay window tight.- Tolerance window: 300 seconds. Reject a delivery whose
tdiffers from local time by more than 300 seconds in either direction. - Rotation: rotating the secret starts a 24-hour dual-signing window during which the header carries two values,
v1=<new>,v1=<old>(space-free, comma-separated, order not guaranteed). Verifiers must iterate everyv1and accept if any matches. After 24 hours the old secret is destroyed. The UI shows a countdown. - Comparison must be constant-time.
19.7.6 Verification pseudocode #
Language-agnostic algorithm:
1. Read the raw request body as bytes. Do not parse it yet.
2. Read the X-LinkHub-Signature header. If absent → 400, stop.
3. Split on ",". Collect t (exactly one) and every v1 value.
Malformed → 400, stop.
4. If |now - t| > 300 seconds → 400 (replay window), stop.
5. expected = hex(HMAC_SHA256(secret, `${t}.${raw_body}`))
6. If no v1 equals `expected` under constant-time comparison → 401, stop.
7. Parse the JSON body.
8. If you have already processed body.id → 200, stop (idempotent no-op).
9. Process. Persist body.id.
10. Return 2xx within 10 seconds. Do the slow work asynchronously.Reference implementation a customer would write (Node / TypeScript, framework-agnostic):
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
export function verifyLinkHubWebhook(
rawBody: Buffer,
signatureHeader: string | undefined,
secret: string,
nowSeconds = Math.floor(Date.now() / 1000),
): { ok: true } | { ok: false; reason: string } {
if (!signatureHeader) return { ok: false, reason: 'missing_signature' };
let timestamp: number | undefined;
const candidates: string[] = [];
for (const part of signatureHeader.split(',')) {
const [key, value] = part.trim().split('=');
if (key === 't') timestamp = Number(value);
else if (key === 'v1' && value) candidates.push(value);
}
if (!timestamp || Number.isNaN(timestamp)) return { ok: false, reason: 'malformed_signature' };
if (candidates.length === 0) return { ok: false, reason: 'malformed_signature' };
if (Math.abs(nowSeconds - timestamp) > TOLERANCE_SECONDS) {
return { ok: false, reason: 'timestamp_outside_tolerance' };
}
const expected = createHmac('sha256', secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest();
for (const candidate of candidates) {
let provided: Buffer;
try {
provided = Buffer.from(candidate, 'hex');
} catch {
continue;
}
if (provided.length === expected.length && timingSafeEqual(provided, expected)) {
return { ok: true };
}
}
return { ok: false, reason: 'signature_mismatch' };
}This snippet, with an accompanying Python and PHP version, is published in the developer documentation and is covered by a contract test that signs a fixture with the production signer and verifies it with the published sample (Section 26).
19.7.7 Delivery, retries and dead-lettering #
| Property | Value |
|---|---|
| Scheme and port | https on port 443 only. There is no plain-HTTP delivery path and no port-80 exception, in any environment, for any workspace (19.7.1). |
| Method | POST, HTTP/1.1 |
| Connect timeout | 3 seconds |
| Total timeout | 10 seconds |
| Redirects | Not followed. A 3xx is a failure with webhook_redirect_not_followed. Following redirects reopens SSRF. |
| Success criterion | Any 2xx. |
| Response body | Read and discarded, capped at 8 KB. The first 2 KB is stored in the delivery log for debugging. |
| TLS | Certificate validation mandatory. No custom CA bundles, no insecure option, no self-signed acceptance. |
| Concurrency | 4 in-flight deliveries per workspace, so a slow endpoint cannot starve other workspaces. |
| Ordering | Best-effort, not guaranteed. Consumers must use created_at to order and id to deduplicate. Stated explicitly in the docs. |
Retry ladder — 6 attempts total:
| Attempt | Delay after previous attempt |
|---|---|
| 1 | immediate |
| 2 | 10 seconds |
| 3 | 1 minute |
| 4 | 10 minutes |
| 5 | 1 hour |
| 6 | 6 hours |
Each delay carries ±20% jitter to avoid synchronised retry storms after a customer outage. Total span is roughly 7.2 hours.
Response-code policy:
| Response | Retried? | Notes |
|---|---|---|
2xx |
— | Success. |
408, 425, 429 |
Yes, full ladder | 429 honours Retry-After (seconds or HTTP-date) and uses it instead of the ladder delay, clamped to 6 hours. |
410 Gone |
No | Endpoint is declaring itself permanently dead. Webhook is immediately set to error and disabled; email to Owner and Admins. |
Other 4xx |
One retry only (attempt 2), then dead-letter | A 4xx normally means a misconfiguration on the receiving side; hammering it for 7 hours helps nobody. One retry covers the case of a deploy that was mid-flight. |
5xx |
Yes, full ladder | |
| Connection error, DNS failure, TLS failure, timeout | Yes, full ladder |
Dead-lettering: after the final failed attempt, a row is written to webhook_dead_letters holding the full event body, every attempt's status/latency/response snippet, and the terminal failure code. Retention: 30 days on Pro, 90 days on Business. Dead letters are replayable individually or in bulk (up to 100 at a time). Replaying re-signs with the current secret and a fresh timestamp, and creates a new delivery id while keeping the original event id.
Auto-suspension: 100 consecutive failed deliveries or 24 continuous hours with zero successful deliveries and at least one attempt sets the webhook to error and stops delivery attempts. New events are dead-lettered directly for 7 days, then dropped (with a counter). An email goes to Owner and Admins once per 24 hours while suspended. Re-enabling requires a successful webhook.test.
19.7.8 The delivery log UI #
Location: Integrations → Webhook → Deliveries.
List columns: timestamp (workspace timezone, with UTC on hover), event type, event id (click to copy), final HTTP status or failure code, attempts used, total latency, and a status pill (delivered, retrying, dead_lettered, dropped).
Filters: event type (multi-select), status, date range (last 24h / 7d / 30d / custom), event id exact match. Sort: newest first only.
Detail drawer for a delivery shows:
- The rendered JSON body with syntax highlighting and a copy button.
- Request headers exactly as sent, with the signature header shown in full (it is not a secret) and no credential headers present.
- A per-attempt table: attempt number, sent at, status code or error, latency ms, and the first 2 KB of the response body.
- Actions: Replay, Copy as curl (which reproduces the request with a placeholder signature and a note that the signature cannot be reproduced without the secret).
Empty states: "No deliveries yet" with a Send test event button; "No deliveries match these filters" with a clear-filters action; "Webhooks are not configured" with a link to configuration; "Webhooks require Pro" with an upgrade link on Free.
A persistent banner appears above the list when the webhook is degraded (≥3 consecutive failures) or error/suspended, naming the most recent failure code and linking to its remediation.
19.7.9 SSRF protections on the target URL #
Applied at save time and re-applied at every delivery attempt.
| Check | Rule |
|---|---|
| Scheme | https only. http is rejected with webhook_url_scheme_invalid. No exception for private networks or localhost. |
| Port | 443 only. Any explicit non-443 port is rejected with webhook_url_port_invalid. |
| Userinfo | A URL containing user:pass@ is rejected — credentials belong in headers, and userinfo is a classic parser-confusion vector. |
| Hostname form | Must be a DNS name. Literal IPv4/IPv6 addresses are rejected outright. |
| Reserved names | .local, .internal, .localhost, .home.arpa, single-label hostnames, and the public-suffix-only case are rejected. |
| DNS resolution | Resolve A and AAAA at save time and again immediately before each attempt. Every resolved address must be publicly routable. |
| Blocked ranges | 0.0.0.0/8, 10/8, 100.64/10, 127/8, 169.254/16 (including 169.254.169.254), 172.16/12, 192.0.0/24, 192.0.2/24, 192.168/16, 198.18/15, 198.51.100/24, 203.0.113/24, 224/4, 240/4, 255.255.255.255/32; IPv6 ::/128, ::1/128, ::ffff:0:0/96 (IPv4-mapped, checked against the v4 list), 64:ff9b::/96, 100::/64, 2001:db8::/32, fc00::/7, fe80::/10, ff00::/8. |
| DNS rebinding | The socket is connected to the exact IP that passed validation, not re-resolved by the HTTP client. Implemented with a custom lookup function pinned to the validated address. |
| Redirects | Not followed (19.7.7). |
| Request body echo | The response body is never surfaced to another tenant and is capped at 8 KB read, 2 KB stored. |
| Header injection | The URL is parsed and re-serialised; CR/LF and control characters cause rejection with webhook_url_invalid. |
The identical rule set applies to the generic lead webhook target (Section 20.5.3) and to any ESP callback URL. It is implemented once in packages/core as assertSafeOutboundUrl() and is covered by the 95%-coverage gate in Section 26 as part of the security-critical set.
19.8 Zapier #
19.8.1 Integration shape #
LinkHub publishes a Zapier app built on the Zapier Platform CLI, versioned in the monorepo under apps/api tooling and deployed independently of the product release. It is a thin client over the public REST API (Section 21) — the Zapier app contains no business logic that does not exist as a documented API endpoint. If Zapier needs it, the API has it.
19.8.2 Authentication #
- Auth type: API Key (Zapier's
customauth), not OAuth. LinkHub has no OAuth authorisation server at launch (Section 21.2), and adding one solely for Zapier is disproportionate. - The user pastes a LinkHub API key. Zapier stores it and sends
Authorization: Bearer <key>on every request. - Connection test:
GET /v1/account. A 200 confirms the key and returns the workspace name and plan. - Connection label:
{{workspace_name}} ({{plan}}), so a user managing five client workspaces can tell their connections apart. - Required scopes are documented in the app's connection screen. A key missing a scope produces a 403 with
insufficient_scope, which the app maps to a ZapierErrornaming the exact scope to add. - Revoking the key in LinkHub immediately breaks the connection; Zapier surfaces it as a connection error on the next poll or hook delivery.
19.8.3 Triggers #
| Trigger | Type | Mechanism |
|---|---|---|
| New Lead | REST hook | Subscribes to lead.captured. |
| Link Clicked | REST hook | Subscribes to link.clicked. Zapier's own task limits make this appropriate only for low-volume links; the app description says so. |
| QR Scanned | REST hook | Subscribes to qr.scanned. |
| New Short Link | REST hook + polling fallback | Hook on link.created; poll GET /v1/links?sort=-created_at&limit=25. |
| Page Published | REST hook | Subscribes to page.published. |
| Experiment Promoted | REST hook | Subscribes to experiment.promoted. |
Every trigger provides performList in addition to the hook, because Zapier requires a sample-data path for the Zap editor and uses it to fill the "test trigger" step. For the five resource-backed triggers performList is a real poll against the corresponding list endpoint. For New Lead it returns a static committed sample record rather than querying a collection, because leads are not exposed by the public API at all (21.8.13); that trigger is hook-only in operation, and its Zapier help text says so. Deduplication key is the resource id for polling triggers and the event id for hooks.
19.8.4 REST hooks versus the single-webhook rule #
The single-webhook rule in 19.7.1 governs the customer-configurable webhook. Zapier subscriptions are a separate, system-managed delivery channel stored in zapier_subscriptions and are:
- created and deleted only by requests authenticated with an API key carrying the
zapier:managescope, which is granted exclusively to keys created through the Zapier connection flow; - invisible in the integrations UI as "webhooks" — they appear as Zapier → 4 active Zaps;
- not exposed by any general-purpose subscription API, so they do not constitute the subscription-management product that is explicitly out of scope;
- capped at 25 active subscriptions per workspace.
Endpoints (documented in the app, not part of the general API surface reference in 21.8):
POST /v1/zapier/subscriptions { "event_type": "lead.captured", "target_url": "https://hooks.zapier.com/..." }
DELETE /v1/zapier/subscriptions/{id}performUnsubscribe must be idempotent: deleting an already-deleted subscription returns 204, not 404. Zapier retries unsubscribes, and a 404 would strand the Zap in an error state.
Subscription hygiene: a Zapier target URL that returns 410 Gone — which Zapier does when a Zap is turned off — causes immediate deletion of the subscription. This is the documented Zapier contract and prevents orphaned subscriptions accumulating.
19.8.5 Actions and searches #
| Action | API call |
|---|---|
| Create Short Link | POST /v1/links |
| Update Link Destination | PATCH /v1/links/{id} |
| Create Dynamic QR Code | POST /v1/qr-codes |
| Update QR Destination | PATCH /v1/qr-codes/{id} |
| Create Bio Page Link Block | POST /v1/bio-pages/{page_id}/blocks |
| Search | API call |
|---|---|
| Find Short Link | GET /v1/links?slug[eq]=… |
| Find QR Code | GET /v1/qr-codes?slug[eq]=… |
| Find Bio Page | GET /v1/bio-pages?handle[eq]=… |
There is no Find Lead search. A lead lookup would require a lead-read endpoint, and the API exposes none (21.8.13); a Zap that needs lead data receives it in the lead.captured hook payload, which is delivered to a destination the workspace Owner chose.
Every action sends an Idempotency-Key derived from the Zap run id (Section 21.6), so a Zapier auto-replay cannot create a duplicate link.
19.8.6 App-review considerations #
Zapier's public-listing review checks the following; each is a build requirement, not a nice-to-have.
| Requirement | How LinkHub satisfies it |
|---|---|
Deduplication id on every trigger |
Present and stable on all six triggers. New Lead deduplicates on the webhook event id. |
| Sample data for every trigger and action | Static, realistic samples committed with the app; validated in CI against the OpenAPI schemas so they cannot drift. |
outputFields definitions |
Declared for every trigger/action so downstream Zap steps show named fields, not raw JSON. |
| Meaningful error messages | HTTP errors are mapped from the canonical envelope: the app surfaces error.message and, when present, the first error.details[].field. |
ThrottledError on 429 |
429 maps to z.errors.ThrottledError(message, retryAfterSeconds) using Retry-After. |
RefreshAuthError / connection errors on 401 |
401 maps to a connection error prompting reconnection. |
| Halt on user error | 403 plan_limit_reached and 422 map to HaltedError, which stops that Zap run without erroring the whole Zap. |
| No PII in logs | The app strips email, name and custom_fields from z.console output. |
Working performUnsubscribe |
Idempotent, as above. |
| Live-user threshold for public listing | The app ships as private/invite at launch and is submitted for public listing once the platform threshold of active users with live Zaps is met. Until then the invite link is published in the integrations UI. This staging is planned, not a workaround. |
| Documented rate limits | The app's help text states the plan-based limits from Section 21.7 and recommends Business for high-volume Zaps. |
19.9 Slack #
19.9.1 Connection method #
Primary: Slack OAuth v2 ("Add to Slack"). Requested scope: incoming-webhook only. During install, Slack's own UI asks the user to choose a channel, and returns a channel-scoped webhook URL. LinkHub stores that URL as encrypted credential material plus the non-secret channel_name and team_name for display.
Requesting only incoming-webhook is a deliberate minimisation: chat:write would grant the ability to post anywhere in the workspace, which LinkHub does not need.
Fallback: paste an incoming webhook URL. Some Slack workspaces disallow third-party app installs. The manual path accepts a https://hooks.slack.com/services/... URL, validates it with a test message, and stores it identically. The UI presents OAuth first and the manual path under "Can't install apps? Use a webhook URL".
Changing the channel requires re-installing (Slack binds the webhook URL to a channel). The UI states this next to the channel name, with a Change channel button that restarts the OAuth flow.
19.9.2 Milestone alert catalogue #
| Alert | Trigger | Default |
|---|---|---|
| Link milestone | A short link crosses 100, 1 000, 10 000, 100 000 or 1 000 000 total clicks | On |
| QR milestone | A QR code crosses the same thresholds in total scans | On |
| Page milestone | A bio page crosses 1 000, 10 000, 100 000 or 1 000 000 total views | On |
| New lead | A lead is captured | Off (digest only when enabled — see throttling) |
| Experiment reached significance | The minimum-sample guard passes (Section 16) | On |
| Experiment promoted | Winner promoted, including force-promote (flagged) | On |
| Domain verified | Domain reached active |
On |
| Domain problem | dns_failed, tls_failed, or a renewal failure at the 14-day alert point |
On |
| Plan usage | An entitlement reaches 80% or 100% | On |
| Webhook suspended | The outbound webhook auto-suspends (19.7.7) | On |
| Export ready | An async export finished | Off |
| Weekly summary | Monday 09:00 workspace timezone: clicks, scans, views, leads, top link, week-over-week deltas | On |
Each alert type has an independent on/off toggle in the integration settings. There is no per-resource subscription — that is complexity without a matching need.
19.9.3 Message formatting #
Block Kit, with a text fallback that is always populated (Slack uses it for notifications and accessibility).
{
"text": "🎉 go.acme.com/spring-sale just passed 10,000 clicks",
"blocks": [
{
"type": "header",
"text": { "type": "plain_text", "text": "🎉 10,000 clicks", "emoji": true }
},
{
"type": "section",
"fields": [
{ "type": "mrkdwn", "text": "*Link*\n<https://go.acme.com/spring-sale|go.acme.com/spring-sale>" },
{ "type": "mrkdwn", "text": "*Total clicks*\n10,004" },
{ "type": "mrkdwn", "text": "*Last 7 days*\n3,182 (+41%)" },
{ "type": "mrkdwn", "text": "*Top country*\nDE (38%)" }
]
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": { "type": "plain_text", "text": "Open analytics", "emoji": false },
"url": "https://app.linkhub.app/w/acme/links/0192f3c1-9012-7c88-b4aa-2d55e9f1a733/analytics",
"style": "primary"
}
]
},
{
"type": "context",
"elements": [ { "type": "mrkdwn", "text": "LinkHub · Acme workspace · 19 Aug 2026, 12:04 CEST" } ]
}
]
}Formatting rules: no @channel or @here ever; numbers use the workspace locale; every message ends with a context line naming the workspace so a channel receiving alerts from several workspaces stays legible; every message carries exactly one primary action button linking into the dashboard; problem alerts use :warning: and a danger-styled button labelled with the remedial action ("Fix DNS records").
19.9.4 Throttling #
| Rule | Value |
|---|---|
| Milestone dedupe | Redis key slack:alert:{workspace_id}:{type}:{resource_id}:{threshold}, TTL 30 days. A given threshold for a given resource alerts exactly once, ever (within the TTL). Prevents a count oscillating across a boundary from spamming. |
| New-lead alerts | Never per-lead. Batched into a digest at most once per 15 minutes, listing up to 10 leads with a "+N more" line. Ties into the notification throttling in Section 20.9 — one throttle decision, two delivery channels. |
| Global cap | 20 messages per hour per workspace. On exceeding it, a single "N further alerts suppressed this hour" summary is posted at the top of the next hour, and the cap resets. |
| Problem alerts | Exempt from the global cap up to 5 per hour, because suppressing an outage alert is worse than being noisy. |
Slack 429 |
Honour Retry-After; up to 3 retries. |
Slack 4xx other than 429 |
No retry. invalid_auth, token_revoked, channel_not_found and account_inactive move the integration to error and notify by email. |
Slack 5xx |
3 retries at 5s, 30s, 5m. |
| Durability | Slack alerts are best-effort. After the final retry the message is dropped, not dead-lettered. Slack is a notification channel, not a system of record; the same facts are always available in the dashboard, the audit log, and the outbound webhook. |
19.10 Embed providers #
19.10.1 Consolidated allow-list #
Only these providers may be embedded. Any other URL pasted into an embed block is rejected with embed_provider_not_supported, and the editor offers to create a link block instead. Per-block behaviour — facade rendering, poster sourcing, aspect ratios, accessible names, the click-to-load interaction — is owned by Section 10.
| Provider | Accepted source hosts | Iframe origin (frame-src) |
Metadata source |
|---|---|---|---|
| YouTube | youtube.com, www.youtube.com, m.youtube.com, youtu.be, youtube-nocookie.com |
https://www.youtube-nocookie.com |
oEmbed https://www.youtube.com/oembed |
| Vimeo | vimeo.com, www.vimeo.com, player.vimeo.com |
https://player.vimeo.com |
oEmbed https://vimeo.com/api/oembed.json |
| Spotify | open.spotify.com, spotify.link |
https://open.spotify.com |
oEmbed https://open.spotify.com/oembed |
| Apple Music | music.apple.com, embed.music.apple.com |
https://embed.music.apple.com |
URL parsing (no public oEmbed); title from the page's Open Graph tags, fetched server-side |
| SoundCloud | soundcloud.com, on.soundcloud.com, w.soundcloud.com |
https://w.soundcloud.com |
oEmbed https://soundcloud.com/oembed |
instagram.com, www.instagram.com |
https://www.instagram.com |
URL parsing; poster is a workspace-supplied or generated placeholder because Instagram's oEmbed requires an app token LinkHub does not hold | |
| TikTok | tiktok.com, www.tiktok.com, vm.tiktok.com |
https://www.tiktok.com |
oEmbed https://www.tiktok.com/oembed |
| X | x.com, twitter.com, www.x.com, www.twitter.com |
https://platform.twitter.com |
URL parsing; static card rendered from the URL, no third-party request before activation |
YouTube uses the -nocookie origin unconditionally. There is no toggle: the privacy-enhanced origin is functionally equivalent for playback and is strictly better for the visitor.
19.10.2 CSP frame-src and companion directives #
The frame-src set is computed per render from the providers actually present on that page — a page with one YouTube embed does not allow-list Spotify.
Baseline (no embeds): frame-src 'none'.
Additions per provider present:
| Provider | frame-src |
img-src (facade poster) |
connect-src |
|---|---|---|---|
| YouTube | https://www.youtube-nocookie.com |
https://i.ytimg.com |
— |
| Vimeo | https://player.vimeo.com |
https://i.vimeocdn.com |
— |
| Spotify | https://open.spotify.com |
https://i.scdn.co |
— |
| Apple Music | https://embed.music.apple.com |
https://is1-ssl.mzstatic.com https://*.mzstatic.com |
— |
| SoundCloud | https://w.soundcloud.com |
https://i1.sndcdn.com |
— |
https://www.instagram.com |
https://*.cdninstagram.com |
— | |
| TikTok | https://www.tiktok.com |
https://*.tiktokcdn.com |
— |
| X | https://platform.twitter.com https://twitter.com https://x.com |
https://pbs.twimg.com |
— |
Poster images are proxied and re-hosted on LinkHub's own image CDN at block-save time wherever the provider's terms permit (YouTube, Vimeo, Spotify, SoundCloud, Apple Music). Where a poster is re-hosted, the provider's img-src entry is not emitted, which removes a third-party request from the page entirely. The table above lists the fallback set used when re-hosting fails.
Iframe attributes applied to every activated embed:
<iframe
src="…"
title="<accessible name from Section 10>"
loading="lazy"
referrerpolicy="strict-origin-when-cross-origin"
allow="autoplay; encrypted-media; picture-in-picture; clipboard-write"
allowfullscreen
sandbox="allow-scripts allow-same-origin allow-presentation allow-popups allow-popups-to-escape-sandbox"
></iframe>allow-same-origin is safe here because every embed origin is cross-origin to the page; the combination that defeats sandboxing (same-origin document plus allow-scripts) cannot occur. allow-forms, allow-top-navigation and allow-modals are deliberately withheld.
19.11 The integrations UI #
19.11.1 Catalogue #
Route: /w/{workspace_slug}/settings/integrations.
A card grid grouped into four sections, in this order: Analytics & Pixels (GA4, Meta Pixel, TikTok Pixel), Automation (Webhook, Zapier, Slack), Email (Mailchimp, ConvertKit, Lead webhook — configured under Section 20.5 but surfaced here), Embeds (a single informational card linking to the allow-list, not a connection).
Each card shows: provider logo, name, one-line description, a status pill, and a primary action (Connect / Manage). Cards for providers gated by plan show a lock icon and Upgrade to Pro instead of Connect, with the entitlement named.
19.11.2 Status, last sync, and error surfacing #
| Pill | Colour semantics | Sub-label |
|---|---|---|
| Connected | success | Last activity 4 minutes ago |
| Connected (idle) | neutral | No activity yet — shown when last_success_at is null but the integration is healthy |
| Degraded | warning | 3 recent failures — last: rate limited by provider |
| Error | danger | Authentication failed — reconnect required |
| Not connected | neutral | Not connected |
| Unavailable on your plan | neutral, locked | Available on Pro |
Colour is never the only signal: every pill carries text and an icon (Section 24).
The detail panel for a connected integration shows, in this order: status banner (only when not healthy), configuration fields (secrets masked, write-only), provider-specific panels (GA4 debug view, webhook delivery log, Slack channel and alert toggles, Zapier active-Zap count), a Run health check button, and a Disconnect action in a visually separated destructive area at the bottom.
Error surfacing follows one rule: an error is shown where the user is, not only where the integration is. An integration in error state produces (a) the card pill, (b) a banner in the detail panel, (c) a badge on the workspace settings nav item, and (d) for lead-sync and webhook failures, an inline indicator on the affected lead row or delivery row.
Every error message pairs the provider's own message with a LinkHub remediation sentence and, where one exists, a one-click fix. Example: "Mailchimp rejected the API key (401). The key may have been revoked in Mailchimp. Reconnect Mailchimp to issue a new one."
19.11.3 Reconnection #
Reconnecting is always a single primary action from wherever the error surfaces. Reconnection preserves all non-secret configuration (measurement ID, audience selection, tags, field mapping, channel), so the user re-authenticates and nothing else. On success, consecutive_failures resets, status returns to connected, and any suspended queue for that integration is resumed — dead-lettered items are not auto-replayed, because silently replaying hours-old events after a reconnect can surprise the user. A banner offers Replay N dead-lettered items explicitly.
19.12 Integration error codes and failure-handling policy #
19.12.1 Shared failure-handling policy #
Applies to every integration in this section and to the lead sync targets in Section 20.5.
| Class | Definition | Handling |
|---|---|---|
| Transient | Timeout, connection reset, DNS failure, 5xx, 429. |
Retry on the provider's ladder. Do not change status until the consecutive-failure thresholds in 19.1.4 are crossed. |
| Authentication | 401, or a provider-specific token-revoked signal. |
No retry. Status → error immediately. Email Owner + Admins. Require reconnection. |
| Authorisation | 403, missing permission or scope at the provider. |
No retry. Status → error. The message names the permission required at the provider. |
| Configuration | 404 on a selected resource (list, form, channel, property), or a validation error on a stored config value. |
No retry. Status → error. The UI highlights the specific field that is now invalid and offers re-selection. |
| Payload | 400/422 on a single item that other items would not hit. |
No retry for that item. The item is dead-lettered with the provider's message. The integration stays connected — one bad record is not an outage. |
| Policy | The provider refuses for compliance reasons (a suppressed contact, a compliance state). | No retry, ever. The affected record is marked with a terminal reason and excluded from future syncs until the user acts. |
| Throttle (LinkHub-side) | A LinkHub-imposed cap was hit (webhook click cap, Slack global cap). | Drop with a counter and a UI notice. Never queue unboundedly. |
Two invariants that override everything above:
- No integration failure ever blocks a user-facing operation. Publishing a page, saving a link, capturing a lead and resolving a redirect all complete regardless of integration state. Integration work happens after commit, on a queue.
- No integration failure ever loses LinkHub's own data. Forwarding is always downstream of persistence.
19.12.2 Error codes #
| Code | HTTP (when surfaced via the API) | Class | Meaning / remediation |
|---|---|---|---|
integration_not_found |
404 | — | No such integration for this workspace. |
integration_already_connected |
409 | — | One connection per provider per workspace. Disconnect first. |
integration_disabled |
409 | — | The integration is in error or disconnected. Reconnect. |
integration_credentials_invalid |
422 | Authentication | Provider rejected the credentials at connect time. |
integration_probe_failed |
422 | Transient/Config | The validation probe did not succeed within 10 seconds. |
integration_provider_unavailable |
502 | Transient | Provider returned 5xx or timed out. Retried automatically. |
integration_rate_limited |
429 | Transient | Provider throttled LinkHub. Retried with backoff. |
integration_permission_denied |
403 | Authorisation | The connected account lacks a permission at the provider. |
integration_config_invalid |
422 | Configuration | A stored configuration value no longer resolves at the provider. |
ga4_measurement_id_invalid |
422 | Configuration | Does not match G-XXXXXXX. |
ga4_api_secret_invalid |
422 | Authentication | Measurement Protocol rejected the secret. |
ga4_rejected |
— | Payload | Debug endpoint returned validation messages; see the debug view. |
ga4_event_too_old |
— | Payload | Event older than 72 hours; dropped before sending. |
pixel_id_invalid |
422 | Configuration | Meta or TikTok pixel ID failed format validation. |
pixel_never_fired |
— | Configuration | Traffic occurred but the pixel never fired — usually blocked, or the ID is wrong. Warning only. |
webhook_url_scheme_invalid |
422 | Configuration | Not https. |
webhook_url_port_invalid |
422 | Configuration | Port other than 443. |
webhook_url_private_address |
422 | Configuration | Resolves to a non-routable address. |
webhook_url_invalid |
422 | Configuration | Malformed, contains userinfo, contains control characters, or is a bare IP. |
webhook_test_failed |
422 | Transient | The activation test delivery did not return 2xx. |
webhook_redirect_not_followed |
— | Configuration | Endpoint returned a redirect; redirects are never followed. |
webhook_delivery_timeout |
— | Transient | Exceeded the 10-second budget. |
webhook_endpoint_gone |
— | Configuration | Endpoint returned 410; webhook disabled. |
webhook_suspended |
409 | — | Auto-suspended after sustained failure. Send a test to re-enable. |
webhook_throttled |
— | Throttle | Click-event cap reached for the hour; events dropped. |
webhook_signature_secret_rotating |
409 | — | A rotation window is already open; wait for it to close before rotating again. |
slack_channel_not_found |
422 | Configuration | The channel was archived or deleted. Re-install to pick another. |
slack_token_revoked |
401 | Authentication | The Slack app was uninstalled. Reconnect. |
zapier_subscription_limit_reached |
403 | — | 25 active subscriptions per workspace. |
embed_provider_not_supported |
422 | Configuration | URL host is not in the 19.10.1 allow-list. |
embed_url_invalid |
422 | Configuration | Host is allow-listed but the URL does not identify an embeddable resource. |
plan_feature_unavailable |
403 | — | The workspace's plan does not include this integration. Carries details.plan and details.required_plan. |
These codes are aggregated with every other code in the document-wide appendix in Section 30. The API-surface subset is in Section 21.12.
19.13 Roadmap — named and explicitly not specified #
The following are not built, not specified, and not partially stubbed at launch. No database columns, no feature flags, no disabled UI, no placeholder settings are shipped for them. They are recorded here so the executor does not infer them from adjacent functionality and so the customer knows they were considered and deferred.
| Roadmap item | What it would be | Why deferred |
|---|---|---|
| Full CRM integrations (HubSpot, Salesforce, Pipedrive, Attio) | Bidirectional contact and activity sync, object mapping, custom-property mapping, deduplication against existing CRM records, and OAuth per vendor. | Each CRM is a multi-week integration with its own object model and its own review process. The generic lead webhook (Section 20.5.3) plus Zapier covers the launch need at a fraction of the cost. |
| Ad-platform conversion APIs (Meta Conversions API, TikTok Events API, Google Ads Enhanced Conversions) | Server-side conversion forwarding with hashed-identifier matching, event deduplication against the browser pixel, and per-platform consent signalling. | These require transmitting hashed personal identifiers to advertising platforms, which changes LinkHub's processor posture materially (Section 23) and needs a DPIA update, contractual changes, and a consent model with an advertising category. Shipping it badly is worse than not shipping it. |
| Multi-endpoint webhook subscriptions | Several endpoints per workspace with per-endpoint event selection, secrets and delivery logs. | The single-endpoint model in 19.7 covers the common case; customers needing fan-out use their own dispatcher or Zapier. |
| Google Tag Manager container support | Loading a customer GTM container on public pages. | Requires 'unsafe-eval' in the CSP, which is incompatible with the security posture in Section 23. Not planned, not merely deferred. |
When any of these is built, it will be specified in full before implementation, including its consent implications and its error catalogue.
20. Email Capture & Lead Management #
LinkHub captures email leads from a bio page block, stores them under the workspace, and optionally syncs them to an email service provider. LinkHub is not an email marketing tool: it does not send campaigns, does not manage segments, and does not host an unsubscribe list on the customer's behalf beyond what is required to honour a withdrawal. The product's job is capture, custody and reliable hand-off.
20.1 The lead model and its lifecycle #
20.1.1 Entities #
Three tables carry the model. Canonical DDL is in Section 6; the logical shape follows.
leads — one row per unique email per workspace.
| Field | Type | Notes |
|---|---|---|
id |
uuid (UUIDv7) | Public identifier. |
workspace_id |
uuid | Tenancy key. |
email |
citext | Stored normalised (20.2.5). Displayed as submitted via email_display. |
email_display |
text | The exact string the visitor typed, preserved for support and for provider systems that are case-sensitive in display. |
email_domain |
text | Denormalised for filtering and disposable-domain checks. |
name |
text | Nullable. Max 120 chars. |
custom_fields |
jsonb | At most 5 keys, key ≤ 40 chars [a-z0-9_], value ≤ 500 chars. |
status |
enum | pending_confirmation, subscribed, unsubscribed, bounced, suppressed, pending_review, locked. |
first_bio_page_id |
uuid | Page of the first submission. |
last_bio_page_id |
uuid | Page of the most recent submission. |
submission_count |
integer | Incremented on each repeat submission. |
consent_given |
boolean | |
consent_text |
text | Verbatim label rendered at submission, ≤ 500 chars. |
consent_text_hash |
text | SHA-256 of consent_text, hex. Lets the UI group leads by the exact wording they agreed to. |
consent_given_at |
timestamptz | |
consent_method |
enum | checkbox, double_opt_in, implied_none (used only where the workspace disabled the consent checkbox). |
consent_policy_version |
integer | The workspace's privacy-policy version at capture time. |
consent_country |
char(2) | Country derived at capture. Not an IP. |
consent_user_agent_family |
text | e.g. chrome, safari. Not the full UA string. |
double_opt_in_sent_at |
timestamptz | Null when DOI is off. |
double_opt_in_confirmed_at |
timestamptz | |
is_disposable_domain |
boolean | Evaluated at capture, not re-evaluated later. |
visitor_hash |
text | The rotating salted hash from Section 17. Used for rate limiting and attribution, never for identity resolution. |
country, region |
text | Country and region only. |
unsubscribed_at |
timestamptz | |
unsubscribe_source |
enum | preference_page, provider_sync_back, admin, api. |
created_at, updated_at, deleted_at |
timestamptz | Soft delete, 30-day restore window. |
Uniqueness: UNIQUE (workspace_id, email) WHERE deleted_at IS NULL. A lead is a workspace-level record, not a per-page record.
lead_submissions — append-only, one row per accepted form submission.
| Field | Notes |
|---|---|
id, workspace_id, lead_id |
|
bio_page_id, block_id |
Where it happened. |
submitted_values |
jsonb of the fields as submitted (email, name, custom fields). |
consent_given, consent_text_hash |
Proof is per-submission as well as per-lead. |
utm_source, utm_medium, utm_campaign, utm_term, utm_content |
From the page's inbound URL. |
referrer_host, country, region, device_type |
|
visitor_hash, user_agent_family |
|
path |
js or nojs. |
created_at |
lead_sync_states — one row per (lead, sync target).
| Field | Notes |
|---|---|
id, workspace_id, lead_id, integration_id |
|
status |
queued, syncing, synced, failed, skipped, dead_lettered. |
external_id |
Provider-side identifier (Mailchimp subscriber hash, ConvertKit subscriber id). |
synced_revision |
Integer. Bumped on the lead whenever a syncable field changes. |
attempts, last_attempt_at, next_attempt_at |
|
last_error_code, last_error_message |
|
skip_reason |
disposable_domain, pending_confirmation, unsubscribed, plan_locked, pending_review. |
Unique on (lead_id, integration_id).
20.1.2 Status lifecycle #
submit (DOI off)
[new submission] ────────────────────────► subscribed
│ │ │
│ submit (DOI on) │ │ unsubscribe
▼ │ ▼
pending_confirmation ──confirm──────────────► │ unsubscribed
│ │
│ 7 days, no confirm │ provider hard-bounce sync-back
▼ ▼
pending_confirmation (expired flag) bounced
│
│ 30 days
▼
purged
[anti-abuse hold] ────► pending_review ──approve──► subscribed
│ 24h auto-release (no abuse signal)
└──────────────────► subscribed
[Free plan over cap] ─► locked ──plan upgrade──► subscribed
│ 30 days, still Free
└──────────────────► purged| Status | Visible in the leads list | Counted against plan cap | Eligible for ESP sync | Eligible for CSV export |
|---|---|---|---|---|
pending_confirmation |
Yes, badged | Yes | No (unless the target itself performs the opt-in — see 20.5) | Yes, with the status column |
subscribed |
Yes | Yes | Yes | Yes |
unsubscribed |
Yes, badged | Yes | No; an unsubscribe is pushed once | Yes |
bounced |
Yes, badged | Yes | No | Yes |
suppressed |
Yes, badged | Yes | No | Yes |
pending_review |
Yes, in a filtered "Held" view | Yes | No | No |
locked |
No — surfaced only as an aggregate count | No | No | No |
20.1.3 Repeat submissions #
A second submission of an existing email is not an error and not a duplicate row. It:
- appends a
lead_submissionsrow (so per-page attribution and UTM history is complete); - increments
submission_countand updateslast_bio_page_id; - merges non-empty
nameandcustom_fieldsvalues (new non-empty values overwrite; empty values never blank an existing value); - records the new consent proof if the consent text changed;
- re-subscribes the lead if it was
unsubscribedonly when the consent checkbox was ticked in this submission — an unsubscribed contact resubscribing must give consent again; - bumps
synced_revisionif any syncable field changed, which re-enqueues the sync; - returns the same success state to the visitor. The visitor is never told "you are already subscribed", which leaks list membership.
20.2 The capture block on the public page #
The block's editor configuration, placement and styling are Section 9 and Section 10. This subsection defines its public behaviour.
20.2.1 Fields #
| Field | Configurable | Default | Constraints |
|---|---|---|---|
| Always present, cannot be removed | — | type="email", required, autocomplete="email", inputmode="email", maxlength="254" |
|
| Name | On/off | Off | Single text input, autocomplete="name", maxlength="120", optional or required (workspace choice) |
| Custom field | Up to 1 | None | Label ≤ 40 chars, text input, maxlength="500", optional or required |
| Consent checkbox | On/off | On | type="checkbox", required when on. Label is workspace-authored, ≤ 500 chars, plain text with at most two inline links |
| Button label | Yes | Subscribe |
≤ 40 chars |
| Success message | Yes | Thanks — you're on the list. |
≤ 200 chars |
| Success redirect URL | Yes | None | Must pass the destination-safety checks in Section 23. When set, replaces the inline success state. |
| Placeholder text | Yes | you@example.com |
≤ 60 chars. Placeholders are never the only label — a visible <label> is always rendered (Section 24). |
Markup is a real <form> with a real <button type="submit">. Every input has a programmatically associated <label>. Errors are associated with aria-describedby and announced through a single aria-live="polite" region per form.
20.2.2 Consent checkbox and stored proof #
Turning the consent checkbox off is possible but the editor requires a typed confirmation and displays: "Without an explicit consent checkbox you may not have a lawful basis to email these contacts in the EEA/UK. LinkHub will record consent_method as 'implied_none'." The choice and the actor are written to the audit log.
When the checkbox is on, the following is captured atomically with the lead, in the same transaction:
| Stored | Value |
|---|---|
consent_given |
true (the form cannot submit otherwise) |
consent_text |
The exact rendered label string, verbatim, including any link text |
consent_text_hash |
sha256(consent_text) |
consent_given_at |
Server timestamp at commit |
consent_method |
checkbox, or double_opt_in once confirmed |
consent_policy_version |
The workspace privacy policy version served on that page render |
consent_country, consent_user_agent_family |
Derived at capture; never raw IP, never full UA |
The consent label is versioned: changing it in the editor creates a new consent_text_hash, and the leads UI can filter by hash so a customer can answer "which contacts agreed to this wording".
20.2.3 Double opt-in #
Workspace-level option, default off. Rationale for the default: DOI reduces list size materially, and many customers already run DOI at their ESP; forcing it twice produces two confirmation emails. Customers in the EEA are prompted in the settings UI to consider enabling it.
| Property | Value |
|---|---|
| Sender | no-reply@linkhub.app, display name {workspace display name} via LinkHub |
| Reply-To | The workspace's configured contact email, when set |
| Subject | Confirm your subscription to {workspace display name} |
| Body | Plain-text + HTML, single confirm button, the exact consent text the visitor agreed to, the page they subscribed from, and a "you can ignore this email" line |
| Token | HMAC-SHA256 over lead_id | workspace_id | issued_at, base64url, single use, 7-day expiry |
| Confirm URL | https://{public page host}/l/confirm?token=… — on the same host as the page, so it is unambiguously first-party |
| Confirmation page | Server-rendered, uses the workspace theme, states what was confirmed, links back to the bio page |
| Already confirmed | Same success page, idempotent, no error |
| Expired / invalid token | A page offering Resend confirmation rather than a dead end |
| Resend limits | 1 per 10 minutes, 3 total per lead |
| Expiry behaviour | After 7 days the lead remains pending_confirmation, is excluded from sync and from the default list view, and is purged 30 days after capture |
While a lead is pending_confirmation, no ESP sync runs by default. The exception is Mailchimp with status_if_new = "pending", where the workspace has chosen to let Mailchimp own the opt-in; in that configuration LinkHub's own DOI is force-disabled with an explanatory note, so a subscriber never receives two confirmation emails.
20.2.4 Validation #
Client-side validation is progressive enhancement only. Every rule below is enforced server-side, and the server is the only authority.
| Rule | Detail | Error code |
|---|---|---|
| Presence | Email required | email_required |
| Length | Total ≤ 254 chars; local part ≤ 64; domain ≤ 253 | email_too_long |
| Syntax | Single @; local part matches ^[A-Za-z0-9!#$%&'*+/=?^_\{|}~.-]+$` with no leading, trailing or consecutive dots; domain is a valid DNS name with at least one dot and a TLD of ≥ 2 alphabetic characters |
email_invalid |
| Unicode | Internationalised domains are accepted and stored as A-labels (punycode) after IDNA 2008 conversion. Unicode local parts (SMTPUTF8) are rejected — the ESPs LinkHub syncs to do not reliably support them | email_unicode_local_unsupported |
| Deliverability | The domain must have an MX record, or an A/AAAA record as fallback. Cached 24 hours per domain. Resolver timeout or failure → fail open (accept), because rejecting a real subscriber due to LinkHub's DNS trouble is the worse error | email_domain_undeliverable |
| Role addresses | postmaster@, abuse@, noreply@, no-reply@ are rejected; other role addresses (info@, sales@) are accepted, because they are legitimate B2B subscribers |
email_role_address |
| Name | ≤ 120 chars after trim; control characters stripped | name_invalid |
| Custom field | ≤ 500 chars after trim; required only when configured required | custom_field_required, custom_field_invalid |
| Consent | Must be true when the checkbox is configured |
consent_required |
| Form token | Present, signature valid, not expired | form_token_invalid, form_expired |
20.2.5 Normalisation #
1. Trim leading/trailing whitespace and Unicode whitespace.
2. Strip zero-width and bidi control characters.
3. Split on the LAST '@'.
4. Domain: lowercase, IDNA-2008 to A-label, strip a single trailing dot.
5. Local part: preserved EXACTLY as typed except for case, which is lowercased.
6. email = `${lowercased_local}@${normalised_domain}`
7. email_display = the original string after step 3's whitespace/control cleanup.Gmail-style dot-stripping and +tag removal are not applied. Rationale: these are provider-specific conventions, not standards; a.b@gmail.com and ab@gmail.com are distinct addresses at most providers; and silently rewriting an address the subscriber typed produces an ESP record that does not match what they expect to see. The trade — occasional duplicates that are actually the same human at Gmail — is accepted and documented in the leads UI help text.
Lowercasing the local part is a deliberate exception to strict RFC 5321 (which makes local parts case-sensitive), because no mainstream provider treats them as case-sensitive and case-preserving them causes duplicate subscribers.
20.2.6 Success and error states #
| State | Presentation |
|---|---|
| Idle | Form as configured. |
| Submitting (JS path) | Button label replaced with a spinner and the accessible label Subscribing…; button disabled and aria-busy="true"; inputs remain readable, not disabled (disabling inputs mid-submit loses screen-reader context). |
| Success (no redirect) | The form is replaced in place by the configured success message with a check icon, role="status". Focus moves to the message. The block does not collapse or change height by more than the message allows — the container reserves the taller of form/message to keep CLS at 0. |
| Success (redirect configured) | 303 See Other on the no-JS path; location.assign() on the JS path, after the analytics beacon has been queued. |
| Field error | Inline message below the offending input, aria-describedby wired, input gets aria-invalid="true", focus moves to the first invalid input. |
| Form-level error | Message above the submit button in the aria-live region. |
| Rate limited | Generic message: "Too many attempts. Please try again in a few minutes." Never reveals which limit was hit. |
| Held for review | The visitor sees the normal success message. The lead is stored as pending_review. Telling a visitor they are "under review" is both alarming and useful to an abuser. |
| Honeypot / timing trip | The visitor sees the normal success message. Nothing is stored. |
| Plan cap reached (Free) | The visitor sees the normal success message. The lead is stored as locked (20.10). |
Visitor-facing error copy:
| Code | Message shown |
|---|---|
email_required |
Enter your email address. |
email_invalid |
That doesn't look like a valid email address. |
email_too_long |
That email address is too long. |
email_unicode_local_unsupported |
That email address format isn't supported. Please use a standard address. |
email_domain_undeliverable |
We couldn't find a mail server for that domain. Check the spelling. |
email_role_address |
Please use a personal or team inbox rather than that address. |
email_domain_not_allowed |
That email provider isn't accepted here. |
consent_required |
Please tick the box to continue. |
name_invalid / custom_field_required |
Field-specific, mirroring the label. |
form_expired |
This form expired. Refresh the page and try again. |
rate_limited |
Too many attempts. Please try again in a few minutes. |
form_unavailable |
This form isn't accepting responses right now. |
20.2.7 The no-JavaScript native POST path #
The capture block must work with JavaScript disabled. Section 11 requires it; this is how.
Rendered markup:
<form method="post"
action="/l/acme/subscribe"
id="lh-form-0192f3c1-3311-7f19-8ad2-40b9c1e77a02"
class="lh-capture">
<input type="hidden" name="block_id" value="0192f3c1-3311-7f19-8ad2-40b9c1e77a02">
<input type="hidden" name="ft" value="<signed form token>">
<div class="lh-hp" aria-hidden="true">
<label for="lh-website">Website</label>
<input type="text" id="lh-website" name="website" tabindex="-1" autocomplete="off">
</div>
<label for="lh-email-…">Email</label>
<input type="email" id="lh-email-…" name="email" required
autocomplete="email" inputmode="email" maxlength="254"
placeholder="you@example.com">
<label class="lh-consent">
<input type="checkbox" name="consent" value="1" required>
<span>I agree to receive marketing emails from Acme Ltd.</span>
</label>
<button type="submit">Subscribe</button>
</form>Endpoint: POST /l/{page_handle}/subscribe on the public page host, Content-Type: application/x-www-form-urlencoded.
The same endpoint serves both paths and discriminates on the request's Accept header:
| Request | Response |
|---|---|
Accept: application/json (enhanced path uses fetch) |
200 with { "data": { "status": "subscribed" }, "meta": {} } or a 4xx with the canonical error envelope (Section 21.3). |
| Anything else (native form POST) | 303 See Other with a Location header. |
Redirect-back behaviour on the native path:
| Outcome | Location |
|---|---|
| Success, no redirect configured | https://{host}/{handle}?lh_sub=ok&lh_b={block_id}#lh-form-{block_id} |
| Success, redirect configured | The configured URL, unchanged, with no LinkHub parameters appended. |
| Field error | https://{host}/{handle}?lh_sub=err&lh_code={code}&lh_f={field}&lh_b={block_id}#lh-form-{block_id} |
| Rate limited / held / unavailable | ?lh_sub=err&lh_code=rate_limited&lh_b={block_id} (or the relevant code) |
On the next render the server reads lh_sub, lh_code, lh_f and lh_b, and renders the corresponding success or error state server-side inside that specific block, with the erroring field re-focused via autofocus. The fragment #lh-form-{block_id} scrolls the browser back to the form without JavaScript.
Submitted values are not echoed back through the query string — that would be a reflected-content risk and would leak an email address into browser history, referrers and server logs. On the native path the email field is re-rendered empty with a note above it; on the enhanced path the value is still in the DOM and is preserved.
When JavaScript is available, the loader calls history.replaceState() on load to strip lh_sub, lh_code, lh_f and lh_b from the URL after rendering the state, so a shared or bookmarked URL is clean.
lh_sub=ok never causes a lead to be written — it is purely a display flag. Re-loading the URL does not re-submit. The native POST is a real POST, so a browser refresh triggers the standard resubmission prompt only if the user refreshes the POST result, which the 303 prevents by design (POST/redirect/GET).
20.3 Anti-abuse #
Public forms attract bots. The controls below are layered so that no single bypass admits garbage, and none of them degrades the experience of a legitimate visitor using a screen reader or a keyboard.
20.3.1 Rate limiting #
Sliding-window counters in Redis under rl:lead:{scope}:{key}.
| Scope | Limit | Window | On exceed |
|---|---|---|---|
Per visitor_hash |
5 submissions | 10 minutes | 429, generic message |
Per visitor_hash |
20 submissions | 24 hours | 429, generic message |
| Per email (normalised) per workspace | 3 submissions | 1 hour | 429, generic message. Blocks confirmation-email flooding of a third party. |
| Per bio page | 60 submissions | 1 minute | 429; also arms the challenge trigger (20.3.5) |
| Per bio page | 1 000 submissions | 1 hour | 429; arms the challenge and notifies Owner/Admins once per 24h |
| Per workspace | 5 000 submissions | 1 hour | 429; notifies Owner/Admins; the workspace is flagged for review |
Rate-limit responses never disclose which scope was hit, never include a Retry-After that reveals the window, and are identical in wording for all scopes.
20.3.2 Honeypot #
A single text input named website, wrapped in a container with aria-hidden="true", positioned off-screen with CSS (position:absolute; left:-9999px; width:1px; height:1px; overflow:hidden) rather than display:none (some bots skip display:none fields), with tabindex="-1" and autocomplete="off".
The field is not hidden by an inline style — it is styled by the page's critical CSS, so a bot that only parses HTML cannot detect it by attribute.
If website is non-empty: accept and drop. The endpoint returns the normal success response and writes nothing. A counter increments on (bio_page_id, hour). Returning an error would teach the bot operator to fix their script; returning success wastes their time indefinitely.
Screen readers do not announce the field (aria-hidden plus off-screen), and keyboard users cannot reach it (tabindex="-1"). Password managers are instructed to ignore it via autocomplete="off". The label is present so that a user who somehow reaches it understands what happened.
20.3.3 Timing checks #
Every rendered form carries a signed form token:
payload = `${block_id}.${rendered_at_unix}.${page_render_nonce}`
ft = base64url(payload) + '.' + base64url(HMAC_SHA256(form_token_secret, payload))| Check | Rule | Outcome |
|---|---|---|
| Signature invalid or malformed | — | form_token_invalid, 400 |
now - rendered_at < 1500 ms |
Faster than a human can read a label and type an address | Accept and drop, exactly like the honeypot |
now - rendered_at > 6 hours |
Stale page left open, or a replayed token farm | form_expired, 422, with the visitor prompted to refresh |
| Token replayed more than 10 times | Same token, same signature, 10+ submissions | Accept and drop; the token is added to a Redis deny set for its remaining lifetime |
page_render_nonce ties the token to a specific render, which prevents a scraped token being reused across a bot farm indefinitely without also re-fetching pages.
The 1 500 ms floor is measured from render, not from first interaction, so a genuinely fast user with autofill is not caught: autofill still requires the page to have been fetched, parsed and painted, which itself consumes most of that budget on the reference device in Section 11.
20.3.4 Disposable-domain policy #
LinkHub maintains a disposable/temporary-mailbox domain list, refreshed weekly from a maintained public dataset plus a LinkHub-curated overlay, and cached in Redis under leads:disposable_domains with a 7-day TTL and a committed fallback snapshot so a fetch failure never changes behaviour.
Default policy: flag, not block.
| Policy | Behaviour |
|---|---|
flag (default) |
The lead is stored with is_disposable_domain = true. It is visible in the leads UI with a badge, filterable, included in CSV export, and excluded from ESP sync by default (skip_reason = disposable_domain). The workspace can flip a per-target switch to sync them anyway. |
block |
The submission is rejected with email_domain_not_allowed and the visitor-facing message in 20.2.6. |
Rationale for defaulting to flag: disposable-domain lists have real false-positive rates, and blocking silently destroys legitimate leads with no audit trail. Flagging keeps the data, surfaces the judgement, and lets the customer decide.
Explicit allow-list that overrides the disposable list, because these are legitimate privacy relays used by ordinary people: privaterelay.appleid.com, icloud.com, duck.com, simplelogin.com, anonaddy.me, mozmail.com, relay.firefox.com. These are never flagged and never blocked.
20.3.5 The bot challenge — one mechanism, decided #
There is exactly one bot-challenge mechanism in this product, and this subsection defines it. It has two states: a default and one escalation.
Default state — honeypot plus submission timing. Every public form ships the hidden honeypot field of 20.3.2 and the signed, time-bound form token of 20.3.3. Together these are the challenge. They require no JavaScript, add no third-party origin, need no CSP change, cost the visitor nothing, and present no puzzle of any kind. This is what every visitor experiences unless a named trigger below has fired on that page.
Two mechanisms are explicitly not used, anywhere in the product, and must not be reintroduced:
- No client-side proof-of-work. A first-party hashing challenge taxes the slowest devices hardest, is trivially outsourced by a real attacker, and requires JavaScript on a surface that must work without it.
- No cognitive puzzle of any kind — no arithmetic question, no "which of these is a cat", no word puzzle, no logic riddle, in no fallback and under no condition. WCAG 2.2 success criterion 3.3.8 (Accessible Authentication) forbids requiring a cognitive function test, and Section 24.1.2 places the public capture form in scope for WCAG 2.2 AA. An arithmetic fallback would be a conformance failure on every customer's page, which is not a trade the product is willing to make.
Escalation state — Cloudflare Turnstile in managed mode. Turnstile is armed for a page only when one of the named triggers below fires. In managed mode it is invisible for the overwhelming majority of visitors and never presents a cognitive test; where the vendor escalates to an interactive step, the vendor's own accessible alternative is used and no LinkHub-authored puzzle is substituted.
Because Turnstile loads third-party resources, its origins are declared in the Section 23.5 CSP tables — https://challenges.cloudflare.com in the frame-src table (23.5.4), the script-src table, and the connect-src table (23.5.5). They are emitted only on a render where the page is armed, so an unarmed page still ships a policy with zero challenge origins. Section 23.5 remains the sole owner of the policy; this subsection only names the origins it must contain.
Triggers that arm the escalation:
| Trigger | Condition |
|---|---|
| T1 — volume | A page exceeds 100 accepted submissions in a rolling hour. |
| T2 — bot ratio | More than 30% of submissions on a page in the last hour were dropped by honeypot or timing. |
| T3 — single-visitor abuse | More than 20 submissions from one visitor_hash on one page in 24 hours. |
| T4 — manual | An Owner or Admin toggles Always require a challenge on the block or the workspace. |
Arming behaviour:
- Armed state is stored at
leads:challenge:{bio_page_id}with a 24-hour TTL, refreshed by each new trigger. It disarms automatically 24 hours after the last trigger. T4 does not expire. - While armed, the JS path renders the Turnstile widget inside the form and requires a valid token server-side (
siteverify), with a 5-second verification timeout that fails open on the provider's unavailability — LinkHub does not let a third-party outage stop a customer's lead capture. - Turnstile's script is loaded under the same rules as any third-party tag (19.5): after
load, non-blocking. It is classified necessary for consent purposes because it exists solely for abuse prevention, so it is not consent-gated. - The widget is keyboard-operable and screen-reader labelled; where the managed challenge escalates to an interactive step, the accessible alternative provided by the vendor is used.
- The honeypot and timing checks stay active while armed. Escalation adds a layer; it never replaces the default.
The no-JavaScript path receives no challenge of any kind — ever, armed or not. Turnstile cannot run without JavaScript, and no substitute challenge is offered in its place: substituting a puzzle here is exactly the WCAG 2.2 SC 3.3.8 failure ruled out above, and blocking the path outright would break the no-JS guarantee in Section 11. Instead, while a page is armed, no-JS submissions are accepted and stored with status = 'pending_review'. They are:
- excluded from ESP sync and from CSV export until released;
- shown in a Held tab in the leads UI with an Approve / Discard action, individually and in bulk;
- auto-released to
subscribedafter 24 hours if no further abuse signal fired on that page in the meantime; - auto-discarded after 7 days if the page is still armed and no human acted.
The workspace is notified once per 24 hours when items are held.
20.4 Storage #
20.4.1 What is stored #
Everything in the field tables in 20.1.1, and nothing else. Restated for clarity:
| Stored | Why |
|---|---|
| Normalised email and the display form | The product's purpose. |
| Name and up to five custom field values | Only when the workspace configured those fields. |
| Consent proof: text, hash, timestamp, method, policy version, country, UA family | Demonstrable consent under GDPR Art. 7(1). |
| Country and region | Analytics and consent-region determination. |
| UTM parameters and referrer host | Attribution. |
visitor_hash |
Rate limiting and cross-surface attribution within the salt window. |
user_agent_family |
Support and abuse diagnostics. |
Submission path (js / nojs), timestamps |
Diagnostics. |
| Sync state per target, including provider-side ids and last error | Reconciliation. |
20.4.2 What is never stored #
| Never stored | Note |
|---|---|
| Raw IP address | Used in memory to derive visitor_hash and the country/region lookup, then discarded. Never written to any durable store, never logged. |
Full User-Agent string |
Only the family. |
| City, coordinates, postal code | Geo resolution is country + region only. |
| Any field the workspace did not configure | The form cannot submit fields that do not exist; unknown POST keys are discarded server-side, not stored in a catch-all column. |
| Passwords, payment data, government identifiers | Not collected, and the custom field is a plain text input with no such semantics. |
| Email content or message bodies | LinkHub does not send marketing email. |
| The consent cookie value | Consent for pixels is separate (19.6) and is not attached to the lead. |
| Provider API credentials on the lead row | Credentials live only on the integration record, encrypted (19.1.5). |
20.4.3 Tenancy rule #
Every lead read and write is scoped by workspace_id. The rule is enforced in three independent layers:
- Data access layer. All lead queries go through repository functions that take a
WorkspaceContextas their first argument and injectworkspace_id = $1into every statement. There is no genericfindById(id)for leads; the only lookup isfindById(ctx, id). - Composite keys and indexes. Primary lookups are on
(workspace_id, id)and(workspace_id, email). A cross-tenant id guess returns zero rows, not a row from another tenant. - Test gate. A dedicated tenancy test suite asserts that every lead endpoint returns
404— never403, which would confirm existence — when given a valid id belonging to a different workspace. This suite is part of the 95%-coverage security-critical set in Section 26.
Per-resource grants (Section 3) intersect with lead access on Business: a member scoped to specific bio pages sees only leads whose first_bio_page_id or last_bio_page_id is in their grant set, and CSV exports are filtered identically.
20.5 Sync targets #
A workspace may enable any combination of the three targets. Each is an integration record following the lifecycle in 19.1, and each has its own sync state per lead.
20.5.1 Mailchimp #
Connection. OAuth 2.0 is the primary method; the datacenter prefix (us14, us21, …) is read from the OAuth metadata endpoint and stored in config.datacenter. An API-key fallback accepts a key of the form <32 hex chars>-<datacenter>; the suffix after the final - is parsed as the datacenter and the key is stored encrypted. A key without a parseable datacenter suffix is rejected with integration_credentials_invalid.
Validation probe: GET /3.0/ping, then GET /3.0/lists?count=1. Both must succeed.
Configuration.
| Field | Type | Required | Notes |
|---|---|---|---|
list_id |
string | Yes | Chosen from a picker populated by GET /3.0/lists?count=100. Stored with list_name for display. |
tags |
string[] | No | Max 10, each ≤ 100 chars. Applied to every synced lead. |
status_if_new |
enum | Yes | subscribed (default) or pending. Choosing pending hands opt-in to Mailchimp and force-disables LinkHub DOI. |
merge_field_map |
object | Yes | Field mapping (below). Defaults pre-filled from the audience's merge fields. |
sync_disposable |
boolean | No | Default false. |
sync_on_unsubscribe |
boolean | No | Default true. Pushes unsubscribes to Mailchimp. |
Endpoint used. PUT /3.0/lists/{list_id}/members/{subscriber_hash} where subscriber_hash = md5(lowercase(email)). This is an idempotent upsert: calling it twice with the same body produces one member and one result. Tags are applied with POST /3.0/lists/{list_id}/members/{subscriber_hash}/tags in a second call, which is itself idempotent (status: "active" on an existing tag is a no-op).
Field mapping.
| LinkHub field | Mailchimp target | Transformation |
|---|---|---|
email |
email_address |
Normalised form. |
status |
status_if_new / status |
subscribed → configured value on create; unsubscribed → unsubscribed on update. pending_confirmation leads are not sent unless status_if_new = "pending". |
name |
merge_fields.FNAME, merge_fields.LNAME |
Split on the first space: everything before it is FNAME, the remainder is LNAME. A single-token name goes entirely to FNAME. If the audience has a single NAME merge field instead, the whole string maps there. |
custom_fields.{key} |
merge_fields.{TAG} |
Mapped explicitly by the user in the mapping UI. Unmapped custom fields are not sent. |
country |
location.country_code |
Only when the audience has location enabled. |
consent_given, consent_text, consent_given_at |
marketing_permissions when the audience has GDPR fields enabled; otherwise merge_fields.LHCONSENT if that merge tag exists |
Never invented — if there is nowhere to put it, it is not sent, and the mapping UI says so. |
utm_source |
merge_fields.{TAG} |
Optional, user-mapped. |
| — | tags |
From config.tags, plus the fixed tag linkhub and a per-page tag page:{handle} when the user enables page tagging. |
| — | ip_signup, ip_opt |
Never sent. LinkHub does not retain IPs. The mapping UI states that Mailchimp's IP-based consent evidence will be empty and that LinkHub's own consent record is the evidence of record. |
Error catalogue.
| Mailchimp response | LinkHub code | Class | Retry | User-visible message |
|---|---|---|---|---|
200/204 |
— | — | — | Synced. |
400 title: "Invalid Resource", detail mentions a fake or invalid address |
mailchimp_invalid_email |
Payload | No | "Mailchimp rejected this address as invalid." Lead marked rejected_by_provider on that target. |
400 title: "Invalid Resource", detail "…looks fake or invalid, please enter a real email address" |
mailchimp_email_looks_fake |
Payload | No | Same as above, with a note that Mailchimp's own heuristics rejected it. |
400 title: "Member In Compliance State" |
mailchimp_compliance_state |
Policy | Never | "This contact previously unsubscribed or reported spam in Mailchimp. They must re-subscribe through Mailchimp themselves — LinkHub cannot re-add them." Lead marked suppressed on that target. |
400 title: "Forgotten Email Not Subscribed" |
mailchimp_forgotten_email |
Policy | Never | "This contact was permanently deleted in Mailchimp under GDPR. They must re-subscribe directly." |
400 title: "Member Exists" (from POST paths) |
mailchimp_member_exists |
— | No | Not an error in practice: LinkHub uses PUT, so this indicates a mapping bug. Recorded and alerted internally. |
400 with errors[] on merge fields |
mailchimp_merge_field_invalid |
Configuration | No | "Mailchimp rejected a merge field. Check your field mapping." Names the offending field. Integration → degraded. |
401 |
mailchimp_unauthorized |
Authentication | No | "Mailchimp rejected the credentials. Reconnect Mailchimp." Integration → error. |
403 |
mailchimp_forbidden |
Authorisation | No | "The connected Mailchimp account can't access this audience." Integration → error. |
404 on the list |
mailchimp_list_not_found |
Configuration | No | "The selected audience no longer exists in Mailchimp. Choose another." Integration → error, list picker highlighted. |
429 |
mailchimp_rate_limited |
Transient | Yes | Silent; retried. Mailchimp's limit is 10 simultaneous connections, so LinkHub caps concurrency at 5 per workspace. |
5xx, timeout, connection error |
mailchimp_unavailable |
Transient | Yes | Silent until the ladder is exhausted. |
Already-subscribed is not an error. Because LinkHub uses the PUT upsert, re-syncing an existing member returns 200 and simply updates it. The status field is only set to subscribed on creation (status_if_new); on update LinkHub sends status only when the lead has unsubscribed in LinkHub and sync_on_unsubscribe is on. This prevents LinkHub from resurrecting a contact who unsubscribed inside Mailchimp.
20.5.2 ConvertKit #
Connection. API key + API secret, both stored encrypted. Validation probe: GET /v3/account.
Configuration.
| Field | Type | Required | Notes |
|---|---|---|---|
target_type |
enum | Yes | form or sequence. Exactly one. |
target_id |
integer | Yes | From GET /v3/forms or GET /v3/sequences. Stored with the display name. |
tags |
integer[] | No | Max 10 tag ids from GET /v3/tags. |
custom_field_map |
object | Yes | Maps LinkHub fields to ConvertKit custom field keys. |
sync_disposable |
boolean | No | Default false. |
sync_on_unsubscribe |
boolean | No | Default true. |
Endpoints used.
POST /v3/forms/{form_id}/subscribe { api_key, email, first_name, fields, tags }
POST /v3/sequences/{sequence_id}/subscribe { api_key, email, first_name, fields, tags }
PUT /v3/unsubscribe { api_secret, email }ConvertKit's subscribe endpoints are idempotent by email: subscribing an existing subscriber returns 200 and does not create a duplicate. LinkHub relies on that, and additionally guards with lead_sync_states (20.6.4).
Field mapping.
| LinkHub field | ConvertKit target | Transformation |
|---|---|---|
email |
email |
Normalised form. |
name |
first_name |
Everything before the first space; the full string when there is no space. ConvertKit has no last-name concept by default; if the account defines a last_name custom field, the remainder maps there when the user selects it. |
custom_fields.{key} |
fields.{convertkit_key} |
User-mapped. ConvertKit auto-creates custom fields on first use, so an unmapped-but-selected key is safe. |
consent_text, consent_given_at |
fields.consent_text, fields.consent_given_at |
Mapped by default when those custom fields exist or can be created. |
utm_source, utm_campaign |
fields.* |
Optional, user-mapped. |
| — | tags |
From config.tags. |
Error catalogue.
| ConvertKit response | LinkHub code | Class | Retry | User-visible message |
|---|---|---|---|---|
200 |
— | — | — | Synced. subscription.subscriber.id stored as external_id. |
401 {"error":"Authorization Failed"} |
convertkit_unauthorized |
Authentication | No | "ConvertKit rejected the API key. Reconnect ConvertKit." Integration → error. |
404 on the form/sequence |
convertkit_target_not_found |
Configuration | No | "The selected ConvertKit form or sequence no longer exists. Choose another." Integration → error. |
400/422 with an email validation message |
convertkit_invalid_email |
Payload | No | "ConvertKit rejected this address as invalid." |
422 on a custom field |
convertkit_field_invalid |
Configuration | No | "ConvertKit rejected a custom field. Check your field mapping." Integration → degraded. |
429 |
convertkit_rate_limited |
Transient | Yes | Silent. LinkHub caps at 60 requests/minute per workspace against ConvertKit's documented 120/minute, leaving headroom for the customer's own automations. |
5xx, timeout |
convertkit_unavailable |
Transient | Yes | Silent until the ladder is exhausted. |
ConvertKit's own opt-in setting. A ConvertKit form may itself be configured to require confirmation. When LinkHub detects form.settings.opt_in (or an equivalent confirmation setting) on the selected form, the integration UI displays a persistent notice: "This ConvertKit form sends its own confirmation email. Turn LinkHub double opt-in off to avoid sending two." LinkHub does not silently change either setting; it surfaces the conflict and lets the user decide.
20.5.3 Generic webhook target #
For anything else. Distinct from the workspace event webhook in 19.7: different URL, different secret, lead payloads only.
Configuration.
| Field | Type | Required | Notes |
|---|---|---|---|
url |
string | Yes | Subject to the identical SSRF rules in 19.7.9. |
secret |
string | generated | 32 random bytes, base64url. Rotatable with the same 24-hour dual-signing window. |
include_custom_fields |
boolean | No | Default true. |
sync_disposable |
boolean | No | Default false. |
sync_on_unsubscribe |
boolean | No | Default true. |
Payload.
{
"id": "lsync_01K3MA1B2C3D4E5F6G7H8J9K0L",
"type": "lead.synced",
"api_version": "v1",
"created_at": "2026-08-19T10:20:11.004Z",
"workspace_id": "0192f3c1-8a44-7b31-9d02-6f1c2b7a4e55",
"data": {
"lead_id": "0192f3c2-77aa-7d20-9c11-8e4b0a6f2c31",
"action": "subscribe",
"email": "sam@example.com",
"name": "Sam Rivera",
"custom_fields": { "company": "Example Ltd" },
"status": "subscribed",
"bio_page_id": "0192f3c1-3300-7b02-a1d4-9e77c5b81f66",
"bio_page_handle": "acme",
"block_id": "0192f3c1-3311-7f19-8ad2-40b9c1e77a02",
"consent": {
"given": true,
"text": "I agree to receive marketing emails from Acme Ltd.",
"given_at": "2026-08-19T10:20:10.771Z",
"method": "checkbox",
"policy_version": 3
},
"utm": { "source": "tiktok", "medium": "social", "campaign": "launch", "term": null, "content": null },
"country": "GB",
"region": "ENG",
"is_disposable_domain": false,
"submission_count": 1,
"created_at": "2026-08-19T10:20:10.900Z",
"updated_at": "2026-08-19T10:20:10.900Z"
}
}action is subscribe, update, or unsubscribe.
Signature. Identical scheme, header and tolerance to 19.7.5, using this target's own secret: X-LinkHub-Signature: t=<unix>,v1=<hex> over ${t}.${raw_body}, HMAC-SHA256, 300-second tolerance, constant-time comparison, dual-signing during rotation. The verification code in 19.7.6 works unchanged.
Additional headers: X-LinkHub-Event-Type: lead.synced, X-LinkHub-Delivery-Attempt, X-LinkHub-Workspace-Id.
Errors.
| Response | LinkHub code | Class | Retry |
|---|---|---|---|
2xx |
— | — | — |
410 |
lead_webhook_endpoint_gone |
Configuration | No — target disabled |
4xx other than 408/425/429 |
lead_webhook_rejected |
Payload | One retry, then dead-letter |
408, 425, 429, 5xx, timeout, connection error |
lead_webhook_unavailable |
Transient | Full ladder |
| Redirect | lead_webhook_redirect_not_followed |
Configuration | No |
20.5.4 Retry policy, shared across targets #
| Attempt | Delay |
|---|---|
| 1 | immediate |
| 2 | 10 seconds |
| 3 | 1 minute |
| 4 | 10 minutes |
| 5 | 1 hour |
| 6 | 6 hours |
| 7 | 24 hours |
Seven attempts, ±20% jitter, spanning roughly 31 hours. The extra 24-hour attempt (compared with the six-attempt event-webhook ladder) exists because a lead is a business record worth recovering after a full-day provider outage, whereas a stale click event is not. 429 responses honour Retry-After when present, clamped to 24 hours.
20.5.5 What the user sees when a sync fails #
| Surface | Presentation |
|---|---|
| Lead row in the list | A per-target sync chip: green Synced, amber Retrying, red Failed, grey Skipped. Hovering shows the reason. |
| Lead detail drawer | A Sync section listing every target with its status, external_id, attempts used, next attempt time, the last error code and the provider's message, and a Retry now button. |
| Integrations page | The target's status pill goes degraded at 3 consecutive failures and error at 10, per 19.1.4. |
| Leads page banner | Appears when ≥ 10 leads currently have a failed sync on any target: "12 leads failed to sync to Mailchimp. Most recent reason: Mailchimp rejected the credentials." with Retry all failed and Fix connection actions. |
To Owner and Admins, at most once per 24 hours per target, when the target enters error or when more than 25 leads are in a failed state. |
|
| Slack | Not sent for individual failures. The target entering error triggers a problem alert (19.9.2). |
| Outbound webhook | lead.sync_failed fires once per lead per target when its ladder is exhausted. |
Nothing about a sync failure is ever shown to the visitor. The visitor's submission succeeded; the downstream hand-off is the workspace's problem to see, not theirs.
20.6 The sync engine #
20.6.1 Queueing #
Queue name lead-sync, BullMQ, one job per (lead, target) pair.
jobId = `lead-sync:${lead_id}:${integration_id}:${synced_revision}`The job id embeds the revision, so BullMQ's native job-id deduplication prevents two jobs for the same lead-target-revision from ever coexisting. A rapid sequence of edits collapses to one job per revision.
Enqueue points: lead created (and confirmed, when DOI is on), a syncable field changed, lead unsubscribed, manual retry, bulk re-sync, reconciliation job.
Enqueue is after commit, from an outbox row written in the same transaction as the lead. A crash between commit and enqueue is recovered by the outbox drainer, so a lead can never be stored without eventually being enqueued.
20.6.2 Ordering #
Ordering matters for exactly one thing: an unsubscribe must not be overtaken by a stale subscribe.
- Jobs are grouped by
(workspace_id, integration_id)with concurrency 1 per group. Within a target, a workspace's leads sync strictly in enqueue order. - Global worker concurrency is 20 groups in flight, so one slow workspace cannot block others.
- Before executing, a job re-reads the lead and compares
synced_revisionon the state row to the lead's current revision. If the job's revision is older than the lead's current revision, the job completes as a no-op — a newer job is already queued and will carry the newer state. This makes stale work self-cancelling. - An unsubscribe always bumps the revision, so it can never be overtaken.
20.6.3 Rate limiting per provider #
Token buckets in Redis, per (workspace_id, integration_id).
| Target | Bucket |
|---|---|
| Mailchimp | 5 concurrent requests; 10 requests/second sustained |
| ConvertKit | 60 requests/minute |
| Generic webhook | 20 requests/second, 4 concurrent |
A job that cannot acquire a token reschedules itself with a 1–3 second jittered delay and does not count as an attempt.
20.6.4 Idempotency — no double subscribes #
Four independent mechanisms, any one of which is sufficient:
- Provider-native upsert. Mailchimp
PUTon the md5 subscriber hash is an upsert. ConvertKit subscribe is idempotent by email. Neither can create a duplicate. - Job-id deduplication. The revision-scoped job id prevents duplicate jobs for the same state.
- State-row short circuit. Before making any call, the worker loads
lead_sync_statesfor(lead_id, integration_id)FOR UPDATE. Ifstatus = 'synced'andsynced_revision >= job.revision, it returns immediately without calling the provider. - Row-level lock. The
FOR UPDATEon the state row serialises concurrent workers for the same pair, so a retry racing a first attempt blocks rather than duplicating.
For the generic webhook target, which has no provider-side idempotency, the payload carries a stable id derived as lsync_ + base32(UUIDv5 of lead_id | integration_id | revision | action), so a receiver can deduplicate deterministically even across LinkHub retries.
20.6.5 Backoff and dead-lettering #
Backoff follows 20.5.4. On exhausting the ladder:
lead_sync_states.status = 'dead_lettered', with the terminal code and message.- A row is written to
lead_sync_dead_lettersholding the full request payload (with credentials excluded), every attempt's response, and the terminal code. - Retention: 30 days on Pro, 90 days on Business.
- Replay: individually from the lead drawer, or in bulk from the reconciliation view (up to 500 at a time). Replay creates a fresh job at the lead's current revision, not the dead-lettered one, so a replay always sends current data.
- Policy-class failures (
mailchimp_compliance_state,mailchimp_forgotten_email) are not dead-lettered and are not replayable. They terminate withstatus = 'failed'and a terminal reason, because retrying is guaranteed to fail and would look like a bug.
20.6.6 The reconciliation view #
Location: Leads → Sync health.
Content:
- A date-range selector (default last 7 days) and a target selector.
- Counters: captured, synced, queued, retrying, failed, dead-lettered, skipped — with skipped broken down by
skip_reason. - A stacked bar chart by day showing the same buckets.
- A table of the most recent 100 problem rows: lead email, target, status, attempts, last error code, last attempt time, action buttons.
- Actions: Retry all failed (scoped to the current filters), Retry all dead-lettered, Re-sync date range (re-enqueues every
subscribedlead in the range at its current revision, with a typed confirmation because it can generate significant provider traffic), and Export sync report as CSV.
Nightly reconciliation job. At 03:00 UTC, for each workspace with at least one active target, the job finds leads where status = 'subscribed', deleted_at IS NULL, the lead is not skipped by policy, and either no lead_sync_states row exists for an active target or synced_revision < leads.revision, limited to leads created or updated in the last 30 days. It re-enqueues them at low priority, capped at 5 000 leads per workspace per night, and logs what it found. A non-zero count is a metric with an alert threshold (Section 25) because a persistently non-zero reconciliation count means the enqueue path is dropping work.
20.7 The leads UI #
20.7.1 List #
Route: /w/{workspace_slug}/leads.
| Column | Notes |
|---|---|
| Primary column. Truncated with a full value on hover and on the detail view. | |
| Name | Blank when not collected. |
| Status | Pill: Subscribed / Pending / Unsubscribed / Bounced / Suppressed / Held. |
| Source page | Bio page handle, linked. Shows the most recent page for a repeat subscriber, with a +N chip when there were several. |
| Captured | Relative time with an absolute value on hover, workspace timezone. |
| Sync | One chip per enabled target, colour + icon + text. |
| Flags | Badges for disposable domain, no-JS submission, DOI pending. |
Row density toggle (comfortable / compact). Bulk selection with a header checkbox, including "select all matching filters" for bulk actions beyond the current page.
Sort options: captured (newest/oldest, default newest), email A–Z/Z–A, status. Pagination is cursor-based per Section 21.4; the UI shows Load more rather than page numbers, because there is no total count on an unbounded collection.
20.7.2 Search and filters #
| Control | Behaviour |
|---|---|
| Search box | Matches email exactly, email prefix (sam@ → all sam@…), email domain (@example.com), and name substring (case-insensitive). Debounced 300 ms; minimum 2 characters. Backed by a trigram index on email and name. |
| Bio page | Multi-select of pages the current member can access. |
| Date captured | Presets (today, 7d, 30d, 90d, this month, last month) plus a custom range. Range is inclusive, in workspace timezone, converted to UTC for the query. |
| Status | Multi-select. |
| Sync status | Multi-select per target: synced, queued, retrying, failed, dead-lettered, skipped. |
| Consent wording | Select by consent_text_hash, rendered as the wording itself with a captured-count. |
| Flags | Disposable domain, held for review, DOI pending, no-JS submission. |
| Tabs | All, Held (pending review), Problems (any target failed or dead-lettered). |
Filter state is encoded in the URL query string so a view is shareable and bookmarkable. Filters compose with AND; values within one filter compose with OR.
Empty states: "No leads yet" with a link to add a capture block to a page; "No leads match these filters" with Clear filters; "Email capture isn't set up" when no page has a capture block, with a direct link to the editor; and for a scoped member with no granted pages, "You don't have access to any pages with lead capture."
20.7.3 Detail view #
A right-hand drawer, deep-linkable at /w/{slug}/leads/{lead_id}.
Sections, in order:
- Identity — email (copy button), name, status pill, captured timestamp, submission count.
- Custom fields — key/value list; empty state "No custom fields collected."
- Consent — given yes/no, the exact wording, timestamp, method, policy version, country, user-agent family, and for DOI the sent/confirmed timestamps. A Copy consent record button produces a JSON block suitable for pasting into a compliance response.
- Attribution — first and last page, block, UTM values, referrer host, country/region, submission path (
js/nojs). - Submissions — the
lead_submissionshistory, newest first, each expandable to show that submission's values and UTM. - Sync — per target: status,
external_id(with a deep link into Mailchimp/ConvertKit where the provider exposes one), attempts, next attempt, last error, and Retry now. - Actions — Re-sync all targets, Mark unsubscribed, Export this lead (JSON), Delete (destructive area, requires confirmation).
20.7.4 CSV export #
Lead export is a dashboard action only. There is no lead export endpoint on the public API, and no API key of any scope can produce a lead export (21.8.13). The reason is the step-up authentication requirement in Section 3.3.11: bulk export of personal data must be re-authenticated by a human at the moment of export, and an API key is a bearer credential that cannot be stepped up. The dashboard already enforces that step-up, so the dashboard is where the capability lives.
Concretely: requesting a lead export requires an active dashboard session, the Owner, Admin or Editor role, and a successful step-up challenge per Section 3.3.11 taken within the last 15 minutes. The export request, the actor, the filter set and the row count are written to the audit log (Section 8).
Plan gating: Pro and Business (CSV export is a Pro entitlement). Free sees the button with a lock and an upgrade prompt.
Behaviour: exports of ≤ 5 000 rows stream synchronously as a download. Larger exports are asynchronous — the request returns immediately, the job writes to object storage, and the user is notified in-app, by email, and by the export.ready webhook, with a signed download link valid 24 hours. Section 18 owns the shared export infrastructure.
Scope: exactly the rows matching the current filter set. The export header row is preceded by no preamble.
Exact columns, in this order:
lead_id,email,name,status,bio_page_handle,bio_page_id,block_id,submission_count,
source_path,utm_source,utm_medium,utm_campaign,utm_term,utm_content,referrer_host,
country,region,consent_given,consent_text,consent_given_at,consent_method,
consent_policy_version,double_opt_in_sent_at,double_opt_in_confirmed_at,
is_disposable_domain,custom_fields_json,sync_mailchimp_status,sync_mailchimp_external_id,
sync_convertkit_status,sync_convertkit_external_id,sync_webhook_status,
created_at,updated_at,unsubscribed_atFormat rules:
| Rule | Value |
|---|---|
| Encoding | UTF-8 with BOM, so Excel opens non-ASCII names correctly without an import wizard. |
| Line ending | CRLF, per RFC 4180. |
| Quoting | Every field quoted with "; embedded " doubled. |
| Timestamps | ISO 8601 with Z, e.g. 2026-08-19T10:20:10.900Z. Always UTC regardless of workspace timezone; the column names say nothing ambiguous. |
| Booleans | true / false, lowercase. |
| Empty values | Empty string, not NULL and not -. |
custom_fields_json |
Compact JSON object as a single quoted field. |
| Formula injection | Any field whose first character is =, +, -, @, tab or CR is prefixed with a single quote ' before quoting. This is mandatory — a lead can supply a name, and a spreadsheet formula in a name field is a real attack. |
| Locked leads | Never included. |
| Held leads | Included only when the Held tab filter is active, so a routine export is never polluted by unreviewed rows. |
20.7.5 Manual re-sync #
| Action | Scope | Confirmation |
|---|---|---|
| Retry now on a single target in the detail drawer | One lead, one target | None |
| Re-sync all targets in the detail drawer | One lead, all enabled targets | None |
| Retry failed on a bulk selection | Selected leads, targets currently failed | None |
| Retry all failed from the banner or reconciliation view | Every failed lead matching current filters | Confirmation naming the count |
| Re-sync date range | Every subscribed lead in the range | Typed confirmation naming the count and warning about provider rate limits |
All re-sync actions are rate-limited to one bulk operation per workspace per 60 seconds and are written to the audit log with the actor and the affected count.
20.8 Deletion, unsubscribe and the GDPR position #
20.8.1 Unsubscribe #
Three ways a lead becomes unsubscribed:
- The hosted preference page. Every LinkHub double-opt-in email, and every generic-webhook payload, carries
unsubscribe_urlof the formhttps://linkhub.app/u/{token}, where the token isbase64url(lead_id) + '.' + base64url(HMAC_SHA256(unsub_secret, lead_id)). The token does not expire — an unsubscribe link that expires is a compliance failure. It is single-purpose: it can only unsubscribe, never read or modify anything else. The page shows the workspace name, the email being unsubscribed (partially masked,sa••@example.com), and a single confirm button. Confirmation is required — one-click prefetchers and email-security scanners must not be able to unsubscribe someone by fetching a link.POSTon confirm;GETnever mutates. - Provider sync-back. Not implemented at launch as a poll. Instead, when a sync attempt returns a compliance/unsubscribed state from the provider, LinkHub records it on the lead (
suppressed) and stops syncing. Continuous bidirectional sync from the ESP is named as roadmap in 19.13's spirit and is not built. - Member action in the dashboard. From the lead drawer or in bulk. The public API does not expose leads at all — not read, not export, not mutation (Section 21.8.13) — so every unsubscribe of this kind carries an authenticated human actor in the audit trail.
On unsubscribe:
| Step | Action |
|---|---|
| 1 | status = 'unsubscribed', unsubscribed_at set, unsubscribe_source recorded, revision bumped. |
| 2 | For each target with sync_on_unsubscribe, enqueue an unsubscribe: Mailchimp PATCH …/members/{hash} with status: "unsubscribed"; ConvertKit PUT /v3/unsubscribe; generic webhook action: "unsubscribe". |
| 3 | lead.unsubscribed webhook event fires. |
| 4 | The lead remains in the list, badged, and remains in CSV exports with its status, because the customer needs the record to honour the suppression. |
| 5 | Audit log entry when the actor is a member; no entry when the actor is the data subject themselves, whose action is recorded on the lead row instead. |
Re-subscription requires a fresh consented submission (20.1.3).
20.8.2 Deletion #
| Trigger | Behaviour |
|---|---|
| Member deletes a lead | Soft delete: deleted_at set, row hidden everywhere, 30-day restore window from Leads → Deleted. Sync state is retained so a restore does not re-subscribe blindly. |
| 30 days elapse | The retention worker hard-deletes the lead row, its lead_submissions, and its lead_sync_states. |
| Data-subject erasure request | Immediate hard delete, bypassing the 30-day window, plus a tombstone in lead_erasures holding only workspace_id, email_hash (SHA-256 of the normalised address), and erased_at. The tombstone exists so a re-submission of the same address can be detected and, at the workspace's option, refused. It contains no personal data beyond an irreversible hash. |
| Bio page deleted | Leads are not deleted. They belong to the workspace, not the page. first_bio_page_id/last_bio_page_id are retained and the UI shows the page name with a "deleted" marker. |
| Workspace deleted | Leads follow the workspace deletion path in Section 23: 30-day grace, then irreversible purge. There is no QR-style carve-out for leads — nothing about a lead needs to outlive the workspace. |
| Retention policy | Optional per-workspace auto-purge of leads older than 12, 24 or 36 months. Default: off. Rationale: silently deleting a customer's contact list is never a safe default; the setting exists for customers with a data-minimisation policy. |
Deleting a lead in LinkHub does not delete it from Mailchimp or ConvertKit. The confirmation dialog says so in plain words and links to each provider's own deletion flow. LinkHub will not silently issue destructive calls against a customer's audience.
20.8.3 The GDPR position on leads #
Section 23 owns the framework — lawful bases, the DPIA, the sub-processor list, the DPA, and the data-subject request workflow. What is specific to leads:
| Question | Answer |
|---|---|
| Who is the controller for lead data? | The customer (the workspace). They decide to collect the addresses, they determine the purpose (marketing to their audience), and they author the consent wording. |
| Who is the processor? | LinkHub. It stores and transmits lead data on the customer's documented instructions, under the DPA in Section 23. |
| Who are the sub-processors for lead data? | The hosting provider, the managed database and cache providers, the object-storage provider used for exports, and the transactional email provider used for double-opt-in mail. Published and versioned per Section 23. Mailchimp and ConvertKit are not LinkHub sub-processors — the customer has a direct controller-to-processor relationship with them, and LinkHub is transmitting on the customer's instruction to a destination the customer chose. This distinction is stated in the DPA. |
| Where is the lawful basis evidenced? | On the lead row: consent_text, consent_text_hash, consent_given_at, consent_method, consent_policy_version, consent_country. This is the record LinkHub produces on request. |
| What if the customer disabled the consent checkbox? | consent_method = 'implied_none' is recorded, the audit log names who disabled it, and the customer bears the controller's responsibility for the basis. LinkHub warns at the point of the decision (20.2.2) and does not block it — the controller's lawful basis is the controller's call. |
| Data-subject access | A lead's full record is exported as JSON from the detail drawer, and is included in the workspace's GDPR export bundle. |
| Data-subject erasure | 20.8.2. Immediate, irreversible, tombstoned by hash only. |
| International transfers | Determined by the customer's chosen ESP and by LinkHub's hosting region, both documented in Section 23. |
| Retention | Indefinite while the workspace is active, unless the customer sets an auto-purge policy. |
20.9 Notifications on new leads #
| Channel | Default | Content |
|---|---|---|
| In-app | On | A badge on the Leads nav item with the unseen count; cleared on visiting the list. |
| Digest | Per-member preference: Every lead, Digest (default), or Off. | |
| Slack | Off | Enabled per 19.9.2; always a digest, never per-lead. |
| Outbound webhook | Per 19.7.1 | lead.captured, per lead, not throttled beyond the workspace's webhook caps. |
Throttling — one decision, applied to both email and Slack:
| Rule | Value |
|---|---|
| First lead of the day, per page | Sent immediately, even in Digest mode. The first lead on a newly published page is the signal the customer is waiting for. |
| Subsequent leads | Batched into a digest, emitted at most once every 15 minutes, so a maximum of 4 notification emails per hour per workspace. |
| Digest content | Count, the page(s), up to 10 leads (email, name, page, time), a +N more line, and a link to the filtered list. |
| Daily summary | 09:00 in the workspace timezone: yesterday's lead count, top page, week-over-week delta, and any sync problems. Sent only when there was at least one lead or at least one problem. |
| Burst suppression | If a page produces more than 200 leads in an hour, per-lead and 15-minute digests are suspended for that page for the rest of the hour and replaced by a single hourly summary, with a note that the volume triggered suppression. |
| Quiet hours | Per-member, off by default. When on, notifications between the configured hours are held and delivered in the first digest after the window ends. Problem notifications are exempt. |
| Recipients | Owner, Admin and Editor may subscribe. Viewer may subscribe to digests but not to per-lead emails, because a Viewer has no action to take on an individual lead. Scoped members receive notifications only for their granted pages. |
| Unsubscribe | Every notification email carries a link to the member's notification preferences. These are transactional-with-preference emails; the link goes to preferences, not to a global opt-out that would also suppress security mail. |
20.10 Plan gating and error codes #
20.10.1 Plan gating #
| Capability | Free | Pro | Business |
|---|---|---|---|
| Email capture block on a page | Yes | Yes | Yes |
| Stored leads visible in the UI | 100 | Unlimited | Unlimited |
| Leads captured beyond the cap | Stored as locked, counted, not viewable |
— | — |
| Double opt-in | Yes | Yes | Yes |
| Mailchimp sync | No | Yes | Yes |
| ConvertKit sync | No | Yes | Yes |
| Generic webhook target | No | Yes | Yes |
| CSV export | No | Yes | Yes |
| Held-for-review queue | Yes | Yes | Yes |
| Lead retention policy setting | No | Yes | Yes |
| Sync health / reconciliation view | No | Yes | Yes |
| Notifications (email digest) | Yes | Yes | Yes |
| Slack lead digest | No | Yes | Yes |
| Per-page lead scoping for members | No | No | Yes |
| Lead export (dashboard only, step-up authenticated) | No | Yes | Yes |
| Leads on the public API | Never, on any plan | Never | Never |
| Dead-letter retention | — | 30 days | 90 days |
The Free cap behaviour, stated precisely. A Free workspace stores 100 viewable leads. Submission 101 is still accepted, still stored, still consent-recorded, and is given status = 'locked'. Locked leads:
- are not shown in the list, not searchable, not exportable, and not synced;
- are surfaced only as an aggregate: "38 additional leads captured beyond your plan limit. Upgrade to Pro to view and export them.";
- are retained for 30 days, then hard-deleted with an email warning at 7 days remaining;
- are unlocked in bulk, oldest-first, the moment the workspace upgrades, and are then enqueued for sync.
The visitor never sees a difference. Refusing a real subscriber because the page owner is on a free plan damages the page owner's business to make a billing point, which is the wrong trade. Silently discarding them would be worse. Holding them, disclosing the count, and warning before deletion is honest and recoverable.
20.10.2 Error codes #
| Code | HTTP | Where | Meaning |
|---|---|---|---|
email_required |
400 | Capture endpoint | Email field empty. |
email_invalid |
400 | Capture endpoint | Syntax check failed. |
email_too_long |
400 | Capture endpoint | Exceeds 254 / 64 / 253 limits. |
email_unicode_local_unsupported |
400 | Capture endpoint | SMTPUTF8 local part. |
email_domain_undeliverable |
422 | Capture endpoint | No MX and no A/AAAA for the domain. |
email_domain_not_allowed |
422 | Capture endpoint | Disposable domain with policy block. |
email_role_address |
422 | Capture endpoint | Blocked role address. |
name_invalid |
400 | Capture endpoint | Length or character violation. |
custom_field_required |
400 | Capture endpoint | A required custom field was empty. |
custom_field_invalid |
400 | Capture endpoint | Length violation. |
consent_required |
400 | Capture endpoint | Consent checkbox configured and not ticked. |
form_token_invalid |
400 | Capture endpoint | Missing or badly signed form token. |
form_expired |
422 | Capture endpoint | Token older than 6 hours. |
capture_block_not_found |
404 | Capture endpoint | block_id does not belong to the page, or the block is not a capture block. |
page_not_published |
404 | Capture endpoint | The page is unpublished; the form is not accepting responses. |
form_unavailable |
409 | Capture endpoint | The workspace is suspended, or the block is disabled. |
rate_limited |
429 | Capture endpoint | Any of the 20.3.1 scopes. Carries Retry-After with a deliberately coarse value (60, 300 or 900). |
challenge_required |
403 | Capture endpoint | The page is armed and the JS path submitted without a challenge token. |
challenge_failed |
403 | Capture endpoint | Turnstile verification returned invalid. |
lead_not_found |
404 | Dashboard | Wrong workspace, deleted, or locked. |
lead_already_unsubscribed |
409 | Dashboard | Unsubscribe on an already-unsubscribed lead. Idempotent on the public preference page, which returns success. |
lead_export_too_large |
422 | Dashboard | More than 1 000 000 rows match; narrow the filters. |
lead_export_step_up_required |
401 | Dashboard | Export requested without a step-up challenge completed in the last 15 minutes. The code is totp_required where the member's second factor is TOTP (Section 3.3.11). |
lead_sync_target_not_configured |
409 | Dashboard | Retry requested for a target that is not connected. |
lead_sync_in_progress |
409 | Dashboard | A bulk re-sync is already running for this workspace. |
lead_bulk_rate_limited |
429 | Dashboard | More than one bulk operation per 60 seconds. |
double_opt_in_token_invalid |
400 | Confirm endpoint | Bad signature or malformed. |
double_opt_in_token_expired |
410 | Confirm endpoint | Older than 7 days. The page offers a resend. |
double_opt_in_resend_limited |
429 | Confirm endpoint | More than 1 per 10 minutes or more than 3 total. |
unsubscribe_token_invalid |
400 | Preference page | Bad signature. |
plan_limit_reached |
403 | Dashboard | Free lead cap. details[0] carries issue: "limit_reached", kind: "count", limit, current, plan — the one entitlement shape defined in Section 21.3.2. |
plan_feature_unavailable |
403 | Dashboard | Sync targets or CSV export on Free. details[0] carries issue: "feature_unavailable", plan and required_plan, and no limit/current/kind. |
Every code above uses the canonical envelope and the canonical status defined in Section 21.3. The two entitlement codes are the only two entitlement codes in the product; there is no feature_not_available and no per-resource cap code variant.
Sync-target error codes (mailchimp_*, convertkit_*, lead_webhook_*) are catalogued in 20.5 and are surfaced on the lead's sync state rather than as an HTTP response. All codes in this document are aggregated in Section 30.
21. Public REST API v1 #
This section owns the canonical API contract for the entire specification. The response envelope (21.3), the pagination model (21.4), the query grammar (21.5), the idempotency rules (21.6) and the rate-limit headers (21.7) defined here are referenced by every other section and are never redefined elsewhere. Where another section shows an API example, it conforms to this section.
21.1 Design principles, base URL, versioning and compatibility #
21.1.1 Design principles #
- Resource-oriented, predictable. Nouns in paths, verbs in HTTP methods. The only non-CRUD paths are explicit actions on a resource (
/publish,/promote,/verify,/reorder,/render), and each of those is aPOSTto a sub-path. - One envelope, always. Every response body is either
{ "data": …, "meta": … }or{ "error": … }. There is no third shape and no bare array at the top level. snake_caseeverywhere in JSON. Request fields, response fields, error codes, query parameters, enum values. It matches the database naming and it is the dominant convention for public APIs.camelCaseexists only inside TypeScript code, never on the wire.- UUIDs are the public identifiers. Every resource is addressed by its UUIDv7. Human-facing identifiers (
handle,slug) are attributes, not addresses, because they are mutable. - UTC, RFC 3339, everywhere. All timestamps are emitted as
2026-08-19T10:12:03.221Z, millisecond precision, alwaysZ. Date-only values areYYYY-MM-DD. The API never emits a local time and never accepts a timezone offset other thanZon input — clients convert. - Money in minor units. Integer
*_centsplus an ISO 4217currency. No floats for money, anywhere. - Strict input. Unknown request-body fields and unknown query parameters are rejected with
400, not ignored. Silently ignoring a misspelled field is the single most expensive class of integration bug. - Explicit nulls. A nullable field that has no value is emitted as
null, not omitted. A client can rely on the key existing. The only exception is sparse fieldsets (21.5.3), where the client asked for omission. - No surprises on write. Every mutating endpoint documents its side effects, including cache invalidation, audit entries and webhook events.
- Errors are actionable. Every error carries a stable machine code, a human message safe to log, a
detailsarray pinpointing offending fields, and arequest_idthat support can look up.
21.1.2 Base URL and transport #
| Property | Value |
|---|---|
| Base URL | https://api.linkhub.app/v1 |
| Protocol | HTTPS only. Plain HTTP is answered with 301 Moved Permanently to the identical HTTPS URL for GET/HEAD, and with 403 and code https_required for every other method — silently redirecting a POST would resend a credential over a cleartext hop. This is a transport upgrade to the identical URL, not a destination redirect; the never-301 rule in Section 12 governs destinations, whose targets are editable. The upgrade is permanent, cacheable, saves a round-trip on the highest-traffic surface, and pairs with the HSTS header below. |
| TLS | 1.2 minimum, 1.3 preferred. |
| HSTS | max-age=63072000; includeSubDomains; preload |
| HTTP versions | HTTP/2 and HTTP/1.1. |
Request Content-Type |
application/json for bodies. charset=utf-8 is assumed and any other charset is rejected with unsupported_media_type. |
Response Content-Type |
application/json; charset=utf-8, except binary render/export endpoints which state their own type. |
| Compression | gzip and br on responses when the client sends Accept-Encoding. Request bodies may be gzip-encoded with Content-Encoding: gzip, capped at 1 MB decompressed (2 MB for bulk endpoints); exceeding it returns 413 with request_too_large. |
| CORS | The API is not browser-callable with a key. Access-Control-Allow-Origin is never emitted, and preflight OPTIONS returns 405. An API key in browser JavaScript is a leaked key; the dashboard uses session auth against its own origin, not this API. |
| Maximum URL length | 8 192 bytes. Beyond it, 414 with uri_too_long. |
| Idle timeout | 30 seconds. |
| Maximum request duration | 30 seconds, except export-creation endpoints which return 202 immediately. |
21.1.3 Versioning policy #
The major version lives in the URL path: /v1. There is exactly one supported major version at launch, and there is no dated revision header. A single dimension of versioning is easier to reason about than two, and dated revisions only pay for themselves once there is a large installed base.
| Change class | Requires a new major version? |
|---|---|
| Adding an endpoint | No |
| Adding an optional request field | No |
| Adding a response field | No |
| Adding a value to a non-exhaustive enum | No |
| Adding a new error code | No |
| Relaxing a validation rule | No |
| Adding a new optional query parameter | No |
| Removing or renaming a response field | Yes |
| Changing a field's type or format | Yes |
| Removing an endpoint | Yes |
| Making an optional request field required | Yes |
| Removing an enum value | Yes |
| Changing the HTTP status for a documented case | Yes |
| Tightening validation such that a previously-accepted request fails | Yes |
| Changing the default value of an existing parameter | Yes |
Non-exhaustive enums — clients must tolerate unknown values in: block_kind, device_type, os, browser, referrer_host category, targeting_rule_type, qr_export_format, error.code. Every other enum in this API is exhaustive: plan, role, link_status, page_status, domain_state, experiment_status, fallback_stage, export_status. Exhaustive enums gain values only in a new major version. The OpenAPI document marks each enum with x-exhaustive: true|false so generated clients can be strict where it is safe.
21.1.4 Deprecation and sunset #
| Stage | Notice | Signals |
|---|---|---|
| Endpoint or field deprecated | 6 months before removal | Deprecation: @<unix seconds> header on every response from the affected endpoint; Link: <https://docs.linkhub.app/api/deprecations#…>; rel="deprecation"; the OpenAPI document marks it deprecated: true with x-sunset; a changelog entry; email to every workspace whose keys called it in the last 30 days. |
| Endpoint or field removed | at sunset | The field disappears in the next major version only. A deprecated endpoint that reaches sunset within /v1 returns 410 Gone with endpoint_sunset and a Link to the replacement — this happens only for endpoints deprecated before general availability. |
| Major version sunset | 12 months minimum | Sunset: <HTTP-date> header on every response from the old version for the entire notice period; monthly emails to affected key owners; a dashboard banner; documentation banners. After the sunset date the version returns 410 Gone with api_version_sunset. |
| Emergency change | as short as required | Reserved for a security defect where the compatible fix does not exist. Announced on the status page and by direct email, with the reasoning stated. This has never been used and is documented so that its use is visibly exceptional. |
Deprecation and Sunset are emitted together when both apply. Clients should log them; the quickstart in 21.11 shows how.
21.1.5 The compatibility promise #
Within /v1, LinkHub will not:
- remove or rename any documented response field;
- change the type, format or nullability of a documented response field;
- remove a documented endpoint without the notice period in 21.1.4;
- change a documented HTTP status code for a documented condition;
- remove a value from an exhaustive enum;
- change the meaning of an existing error code;
- reduce a documented rate limit without 30 days' notice (increases are immediate and require no notice);
- change the ordering guarantee of a list endpoint that documents one.
LinkHub may, at any time and without notice:
- add endpoints, response fields, optional request fields and optional query parameters;
- add error codes (clients must treat an unrecognised
error.codeas a generic failure of its HTTP status class); - add values to non-exhaustive enums;
- change the opaque contents and length of a pagination cursor (21.4.4);
- change the wording of
error.message— thecodeis the contract, the message is for humans; - change the ordering of arrays that are documented as unordered;
- change performance characteristics, internal request routing and infrastructure.
Clients must therefore: ignore unknown response fields rather than failing; branch on error.code and never on error.message; treat cursors as opaque strings; and not depend on the order of unordered arrays.
21.2 Authentication #
21.2.1 Mechanism #
API keys only. There is no OAuth authorisation server and no user-token flow at launch; a third-party authorisation model is roadmap and is not specified here. Session cookies from the dashboard are not accepted by this API — the dashboard has its own origin and its own session-authenticated endpoints, and accepting a cookie here would make the API CSRF-reachable.
The exact request header:
Authorization: Bearer lh_sk_a3F9KxQ2rV8pLmZ7dTn4WcB6yE1sHu0JRules:
Beareris case-insensitive per RFC 7235; the key is case-sensitive.- No other header carries the key.
X-Api-Keyand friends are not accepted. - A key supplied in a query parameter (
?api_key=,?access_token=) is rejected with400and codeapi_key_in_query, even if it is valid, and the key is flagged for rotation in the dashboard. Query strings leak into logs, referrers and browser history. - A missing or malformed
Authorizationheader returns401withWWW-Authenticate: Bearer realm="linkhub", error="invalid_request".
21.2.2 Key format and storage #
| Property | Value |
|---|---|
| Format | lh_sk_ + 32 characters from [A-Za-z0-9] (≈190 bits of entropy from a CSPRNG). |
| Total length | 38 characters. |
| Storage | SHA-256 of the full key string, hex, in api_keys.key_hash. No plaintext, no reversible encryption. No salt or KDF: the key is high-entropy random, so a slow hash buys nothing against a brute force that is already infeasible, and a fast hash keeps authentication inside the latency budget. |
| Display prefix | The first 6 characters after the lh_sk_ prefix, stored in key_prefix. The UI and API render the key as lh_sk_a3F9Kx••••••••••••••••••••••••••. |
| Lookup | Indexed on (workspace_id, key_prefix), then a constant-time comparison of the SHA-256 digest. The prefix narrows to at most a handful of rows; the comparison is what authenticates. |
| Cache | Successful authentications cache key_id → {workspace_id, scopes, plan, status} in Redis under apikey:{sha256_prefix} for 60 seconds, so a hot key does not hit Postgres on every request. Revocation deletes the cache entry synchronously, so revocation is effective immediately, not in 60 seconds. |
21.2.3 The show-once rule #
The full key is returned exactly once, in the response body of the creation request, and is never retrievable again by any means — not by the API, not by the dashboard, not by support, not from a database backup. The creation UI presents it with a copy button and a checkbox the user must tick ("I've stored this key securely") before the dialog can be dismissed; navigating away without ticking shows a confirmation warning.
There is no "reveal key" action anywhere in the product. Losing a key means rotating it.
21.2.4 The scope catalogue #
This table is the single scope catalogue for the entire specification. No other section defines, extends or counts the scopes; every other section references this subsection by number. There are fifteen scopes, and that count is stated here and nowhere else, so that adding or removing one cannot leave a stale number elsewhere in the document.
Scopes are resource:action. A key carries an explicit list; there is no implicit inheritance and no wildcard.
| Scope | Grants |
|---|---|
account:read |
Read the workspace record, plan, entitlements and usage, and the member roster without email addresses (21.8.1). |
pages:read |
Read bio pages and their blocks. |
pages:write |
Create, update, delete, reorder, publish and unpublish bio pages and blocks. |
links:read |
Read short links, their destinations and their targeting rules. |
links:write |
Create, update, delete and bulk-modify short links, destinations and targeting rules. |
qr:read |
Read QR codes and their render metadata. |
qr:write |
Create, update and delete QR codes; request renders and exports. |
domains:read |
Read custom domains and their verification state. |
domains:write |
Add domains, trigger verification, remove domains. |
experiments:read |
Read experiments and their results. |
experiments:write |
Create, update, start, stop and promote experiments. |
analytics:read |
Read time series, breakdowns and top-N; request and download analytics exports. |
webhooks:read |
Read the outbound webhook configuration and delivery log. |
webhooks:write |
Configure the outbound webhook and replay deliveries. |
zapier:manage |
Create and delete Zapier REST-hook subscriptions. Granted only to keys created through the Zapier connection flow; not selectable in the dashboard. |
Scopes that deliberately do not exist. These are named so that nobody adds them by inference from an adjacent capability:
| Absent scope | Why |
|---|---|
leads:read |
Leads are personal data whose bulk export must be step-up authenticated (Section 3.3.11). A bearer API key cannot be stepped up, so lead read and lead export live in the dashboard only (21.8.13, Section 20.7.4). |
audit:read |
The audit log records who did what, including security-relevant changes, and is the record used to investigate a compromised credential. A credential that can read it is a credential that can read its own cover story. The audit log is dashboard-only (21.8.13). |
billing:* |
No credential this product issues can reach billing. Billing is dashboard-session-only by design, so that a leaked API key can never reach payment state (Section 22). |
keys:write |
A key that can mint keys is a privilege-escalation primitive. Key lifecycle is dashboard-only (21.2.6). |
Rules:
*:writedoes not imply*:read. A write-only key exists and is useful for one-way automation. Endpoints that return the created resource require only the write scope.- A request missing a required scope returns
403withinsufficient_scope,details.required_scope, and aWWW-Authenticate: Bearer error="insufficient_scope", scope="links:write"header. - Scopes are fixed at creation. A key's scopes cannot be edited — create a new key. Editable scopes make a key's historical authority unauditable.
21.2.5 Key authority and its ceiling #
A key is workspace-scoped: it can act on exactly one workspace, chosen at creation. Authorization evaluates the workspace binding first: if the key's bound workspace is not the workspace addressed by the request, the request returns 404 not_found before any capability, scope or role is examined. A 403 at that point would confirm the target workspace exists.
A key's effective authority is the intersection of its scopes and the role of the member who created it, evaluated per request against the current role:
| Creator's current role | Maximum effective authority |
|---|---|
| Owner | All scopes on the key. |
| Admin | All scopes on the key except none — Admin covers every API-exposed capability (billing is not exposed by this API). |
| Editor | Read scopes, plus pages:write, links:write, qr:write, experiments:write. domains:write and webhooks:write are denied with insufficient_role. |
| Viewer | Read scopes only. Any write scope on the key is denied with insufficient_role. |
| Removed from the workspace, or membership soft-deleted | The key is automatically revoked within 60 seconds (immediately on the next cache miss, and eagerly by the membership-change handler). |
On Business, if the creating member has per-resource grants, the key inherits them: the key can only see and act on the granted bio pages, links and QR codes, and analytics queries are filtered to those resources. This is evaluated per request, so revoking a grant immediately narrows the key.
This design means a key can never be used to escalate beyond its creator, and a departing employee's automation dies with their membership rather than outliving it silently.
21.2.6 Rotation, revocation and last-used tracking #
| Operation | Behaviour |
|---|---|
| Create | Dashboard only (see below). Name (1–64 chars, required), scopes (≥1, required), optional expires_at (max 365 days out). Returns the plaintext once. Audit entry api_key.created with the name, scopes and prefix. |
| Rotate | Modelled as create-new + revoke-old, guided by a UI flow: the new key is issued, both keys are active, the user updates their systems, then revokes the old one. The flow offers a scheduled revocation at 24 hours, 7 days or 30 days, with a reminder email at 24 hours before. There is no in-place "rotate" that changes the secret behind one key id, because that makes the audit log ambiguous about which secret performed which action. |
| Revoke | Immediate. status = 'revoked', revoked_at set, revoked_by recorded, Redis cache entry deleted synchronously. In-flight requests already authenticated complete; the next request returns 401 with api_key_revoked. Irreversible. Audit entry api_key.revoked. |
| Expire | A key with expires_at in the past returns 401 with api_key_expired. A daily job marks them expired for display. Emails at 14 days and 1 day before expiry to the creating member and the workspace Owner. |
| Last-used tracking | last_used_at, last_used_ip_country (country only, never the IP) and last_used_user_agent (first 120 chars) are updated at most once per 60 seconds per key, via a Redis-buffered write flushed by the worker. Precise per-request timestamps would triple the write volume of the API for no operational benefit. The dashboard shows "Last used 4 minutes ago" and, when never used, "Never used" with the creation date. |
| Idle key warning | A key unused for 90 days produces a dashboard notice and one email suggesting revocation. It is not auto-revoked — silently breaking a quarterly job is worse than the risk. |
Key management is dashboard-only. There is no POST /v1/api-keys and no DELETE /v1/api-keys/{id} in this API. A key that can mint keys is a privilege-escalation primitive: any leaked key would become permanent and unbounded. Key lifecycle therefore requires an authenticated human session with the appropriate role. This is a deliberate reduction in API surface, stated so nobody adds it later by accident.
Read-only visibility is available: GET /v1/account (21.8.14) reports the calling key's own id, name, scopes and last_used_at, which is what an integration actually needs.
Per-plan key limits: Free 0 (no API access at all), Pro 5 active keys, Business 25 active keys. Exceeding the limit at creation returns 403 plan_limit_reached with details[0].kind = "count", limit, current and plan, in the shape defined in 21.3.2.
21.2.7 Authentication failure semantics #
| Condition | Status | Code |
|---|---|---|
No Authorization header |
401 | unauthenticated |
Malformed header (not Bearer <token>) |
401 | unauthenticated |
Key format does not match lh_sk_… |
401 | api_key_invalid |
| Key not found | 401 | api_key_invalid |
| Key revoked | 401 | api_key_revoked |
| Key expired | 401 | api_key_expired |
| Key belongs to a workspace that is suspended or deleted | 403 | workspace_suspended |
| Key valid, scope missing | 403 | insufficient_scope |
| Key valid, creator's role too low | 403 | insufficient_role |
| Key valid, plan does not include the API | 403 | plan_feature_unavailable |
| Key supplied in a query parameter | 400 | api_key_in_query |
api_key_invalid deliberately does not distinguish "no such key" from "wrong key", and the response time is equalised, so the endpoint cannot be used to enumerate valid prefixes. Unauthenticated requests are additionally rate-limited per source IP (21.7.5).
21.3 The canonical envelope #
Every other section of this specification refers here for the response shape, the error shape and the status codes.
21.3.1 Success #
{
"data": { },
"meta": { }
}| Key | Rule |
|---|---|
data |
The resource (object) for single-resource responses, or an array of resources for collections. Never a scalar, never null on a 2xx — an endpoint with nothing to return uses 204. |
meta |
Always present, even when empty ({}). Carries pagination fields on collections, and endpoint-specific metadata elsewhere. A client can safely read body.meta without a guard. |
There is no top-level success: true flag. The HTTP status carries that information, and a redundant flag invites clients to check the wrong thing.
Collection example:
{
"data": [
{ "id": "0192f3c1-9012-7c88-b4aa-2d55e9f1a733", "slug": "spring-sale" },
{ "id": "0192f3c1-9013-7a02-8e77-b1cc3f60d922", "slug": "summer-sale" }
],
"meta": {
"next_cursor": "eyJ2IjoxLCJrIjpbIjIwMjYtMDgtMTlUMTA6MTI6MDMuMjIxWiJdLCJpZCI6IjAxOTJmM2MxLTkwMTMtN2EwMi04ZTc3LWIxY2MzZjYwZDkyMiIsImQiOiJkZXNjIiwiZiI6ImE0YzFmOTNlIn0",
"has_more": true
}
}Bounded-collection example (a set whose size is inherently small):
{
"data": [ { "id": "…", "role": "owner" }, { "id": "…", "role": "editor" } ],
"meta": { "next_cursor": null, "has_more": false, "total": 2 }
}Accepted-work example (202):
{
"data": {
"id": "0192f3c2-9999-7ccc-8ddd-1a2b3c4d5e6f",
"status": "queued",
"kind": "analytics_csv"
},
"meta": { "poll_url": "/v1/analytics/exports/0192f3c2-9999-7ccc-8ddd-1a2b3c4d5e6f", "estimated_seconds": 20 }
}21.3.2 Error #
{
"error": {
"code": "link_slug_taken",
"message": "That slug is already in use on this domain.",
"details": [ { "field": "slug", "issue": "duplicate" } ],
"request_id": "req_01K3M9Q2W8F5TYRB4C7NXZJ0HD"
}
}| Field | Type | Required | Rule |
|---|---|---|---|
code |
string | Yes | snake_case, stable, documented. The contract. Never localised, never reworded. Clients branch on this. |
message |
string | Yes | One sentence, English, safe to log and safe to show to a developer. Not safe to show to an end user verbatim in every case; it may name internal concepts. May be reworded at any time. Never contains a secret, a raw IP, an email address belonging to another tenant, or a stack trace. |
details |
array | Yes — always present, [] when there is nothing to add |
Zero or more objects pinpointing the cause. Order is stable: fields in the order they appear in the request schema. |
request_id |
string | Yes | req_ + 26-character Crockford base32 encoding of a UUIDv7. Also emitted as the X-Request-Id response header on every response, success or failure. Quote it in support requests. |
details[] item shape:
| Field | Type | Required | Meaning |
|---|---|---|---|
field |
string | No | JSON path into the request body or query, dot/bracket notation: slug, blocks[2].url, filter.created_at. Absent for errors not tied to a field. |
issue |
string | Yes | snake_case machine token: required, invalid_format, too_long, too_short, out_of_range, duplicate, not_found, unsupported_value, mutually_exclusive, unknown_field, immutable, conflict, limit_reached, feature_unavailable, rate_limited. |
message |
string | No | Field-specific human hint, e.g. "must be 1–64 characters of a–z, 0–9 and hyphen". |
kind / limit / current / plan / required_plan / required_scope / retry_after_seconds |
varies | No | Present on the specific codes that document them. |
The entitlement failure shape — one shape, two codes, canonical for the whole document.
There are exactly two entitlement error codes in this product and both are 403:
| Code | Used for | details[0].issue |
details[0].kind |
limit / current |
|---|---|---|---|---|
plan_limit_reached |
A numeric or period cap is exhausted | limit_reached |
count or period |
Both present |
plan_feature_unavailable |
A binary feature gate — the plan does not include the capability at any quantity | feature_unavailable |
absent | absent |
plan_limit_reached, numeric cap:
{
"error": {
"code": "plan_limit_reached",
"message": "Your plan allows 100 dynamic QR codes. You have 100.",
"details": [
{ "field": "qr_codes", "issue": "limit_reached",
"limit": 100, "current": 100, "plan": "pro", "kind": "count" }
],
"request_id": "req_01K3M9R4V0J2S8ZQ7YB1XKT5CN"
}
}plan_limit_reached, period (fair-use) cap — identical shape with kind: "period" and a period object:
{
"error": {
"code": "plan_limit_reached",
"message": "You've created 10,000 short links this billing period, the fair-use ceiling for Pro.",
"details": [
{ "field": "short_links_created_per_period", "issue": "limit_reached",
"limit": 10000, "current": 10000, "plan": "pro", "kind": "period",
"period": { "start": "2026-08-01T00:00:00Z", "end": "2026-09-01T00:00:00Z" } }
],
"request_id": "req_01K3M9RB3T5W1Y7E9R2U4I6OPL"
}
}plan_feature_unavailable, binary gate — same envelope, no limit, no current, no kind:
{
"error": {
"code": "plan_feature_unavailable",
"message": "A/B testing is available on Pro and Business.",
"details": [
{ "field": "experiments_enabled", "issue": "feature_unavailable",
"plan": "free", "required_plan": "pro" }
],
"request_id": "req_01K3M9RQ0F2H8M4A6S9D2G1KX"
}
}Rules that make this one shape rather than several:
fieldis always the entitlement key from the catalogue in Section 22.1.3. There is no separateentitlementmember; carrying the same value under two names is how the three rival shapes arose in the first place.kindexists only onplan_limit_reachedand is exactlycountorperiod. No other value is defined.- There is no
feature_not_availablecode, noplan_feature_not_availablecode, and no per-resource cap code (qr_limit_reached,link_limit_reached,seat_limit_reachedand the like). Every entitlement refusal in this product, from every surface — dashboard, public API, background job — is one of the two codes above. - Optional presentation members
upgrade_toandupgrade_limitmay accompany either code. They are additive hints for the upgrade modal and clients must tolerate their absence. - Both codes are terminal: a client must not retry, because retrying cannot succeed.
Guarantees:
- Errors are always JSON. No HTML error page is ever returned by this API, including for
500,502,503and504. A terminal middleware guarantees the envelope even when the handler crashed, and the edge proxy is configured with a static JSON error document for the cases where the application never received the request. request_idis always present, including on responses generated by the proxy.- Multiple validation failures in one request produce one error response with all failures in
details. The API does not fail fast on the first bad field.
21.3.3 HTTP status code table #
| Status | Name | Used when | Body | Retryable |
|---|---|---|---|---|
200 |
OK | A successful read, a successful update, or a successful action that returns a representation. | data + meta |
— |
201 |
Created | A resource was created. Includes a Location header with the canonical URL of the new resource. |
The created resource | — |
202 |
Accepted | Work was queued and will complete asynchronously: export creation, QR export bundles, bulk operations over 100 items, domain verification triggers. meta.poll_url gives the status endpoint. |
The job resource | — |
204 |
No Content | A successful delete, or a successful action with nothing to return (e.g. Zapier unsubscribe). | Empty. No envelope, zero bytes. | — |
301 |
Moved Permanently | Only the HTTP→HTTPS transport upgrade in 21.1.2, to the identical URL. Never used to move a resource. | — | — |
302/307/308 |
— | Never emitted by this API. Resource moves are not modelled as redirects. | — | — |
304 |
Not Modified | A conditional GET with If-None-Match matched the current ETag. |
Empty | — |
400 |
Bad Request | Malformed JSON, unknown request field, unknown query parameter, wrong primitive type, a value failing a syntactic rule, a key in the query string, an invalid cursor. The request is wrong in form. | Error | No — fix the request |
401 |
Unauthorized | No credential, or a credential that is malformed, unknown, revoked or expired. | Error + WWW-Authenticate |
No — re-authenticate |
403 |
Forbidden | The credential is valid but not permitted: missing scope, insufficient role, per-resource grant excludes the target, plan entitlement exhausted, plan lacks the feature, workspace suspended. | Error | No |
404 |
Not Found | The resource does not exist, is soft-deleted, or belongs to another workspace. Cross-tenant access always returns 404, never 403 — a 403 would confirm the resource exists. Also returned for an unknown path. The generic code is not_found; typed variants (link_not_found, qr_code_not_found, …) are used where the resource type is unambiguous. There is no resource_not_found. |
Error | No |
405 |
Method Not Allowed | The path exists, the method does not. Includes an Allow header. |
Error | No |
409 |
Conflict | The request is valid but conflicts with current state: a duplicate slug or handle, an idempotency key reused with a different body, an operation already in progress, an experiment already promoted, a domain already claimed. | Error | Sometimes — after resolving the conflict |
410 |
Gone | The resource is permanently gone and will not return: a sunset API version, a sunset endpoint, an expired one-time token. Distinct from 404 in that the client should stop trying. |
Error | No |
412 |
Precondition Failed | If-Match was supplied and did not match the current ETag (optimistic concurrency on update). |
Error | Yes — re-read and retry |
413 |
Content Too Large | Request body over 1 MB (2 MB for bulk endpoints), or a decompressed gzip body over the cap. Code request_too_large. A body-size refusal is never a 400. |
Error | No |
414 |
URI Too Long | Over 8 192 bytes. | Error | No |
415 |
Unsupported Media Type | A body was sent with a Content-Type other than application/json, or with a charset other than utf-8. Code unsupported_media_type. A media-type refusal is never a 400. |
Error | No |
422 |
Unprocessable Content | The request is syntactically valid but semantically impossible: a destination URL that resolves to a private address, a schedule whose end precedes its start, a variant weight set that does not sum to 100, a domain whose DNS does not verify, a QR style that fails scannability validation. The distinction from 400 is that the shape was right and the meaning was wrong. |
Error | No — change the values |
429 |
Too Many Requests | Any rate limit, concurrency limit or burst limit. | Error + Retry-After + RateLimit-* |
Yes — after Retry-After |
500 |
Internal Server Error | An unhandled server fault. Always logged with the request_id. |
Error, code: "internal_error" |
Yes — with backoff |
502 |
Bad Gateway | An upstream dependency (a provider, the render service) returned an unusable response. | Error, code: "upstream_error" |
Yes |
503 |
Service Unavailable | Planned maintenance or a shed load condition. Includes Retry-After. |
Error, code: "service_unavailable" |
Yes |
504 |
Gateway Timeout | An upstream dependency exceeded its budget. | Error, code: "upstream_timeout" |
Yes |
400 versus 422, stated once so it is applied consistently: 400 means the request was not understood; 422 means it was understood and refused. A slug containing # is 400 (invalid_format). A well-formed slug that is on the reserved-word blocklist is 422 (slug_reserved). A well-formed short-link slug already in use is 409 (link_slug_taken). A well-formed QR slug that is permanently reserved is 409 (qr_slug_reserved).
Four status choices that are frequently got wrong elsewhere and are fixed here for the whole document: a body over the cap is 413 request_too_large, never 400; a bad Content-Type is 415 unsupported_media_type, never 400; a refused plan change is 409 plan_change_not_permitted, never 403; and a missing step-up factor is 401 totp_required, never 403 — a 403 says "you may not", whereas the correct meaning is "authenticate again".
21.3.4 Standard response headers #
| Header | On | Value |
|---|---|---|
X-Request-Id |
Every response | Matches error.request_id. Accepts a client-supplied X-Request-Id matching ^[A-Za-z0-9._-]{8,64}$ and echoes it; otherwise generates one. |
RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, RateLimit-Policy |
Every response | 21.7.3 |
Retry-After |
429, 503 |
Seconds. |
Location |
201 |
Canonical URL of the created resource. |
ETag |
Single-resource GET |
Strong validator: " + first 16 hex of sha256(canonical JSON of data) + ". |
Cache-Control |
Every response | private, no-store on everything. This API returns tenant data; nothing is cacheable by an intermediary. |
Idempotent-Replay |
Replayed idempotent responses | true (21.6). |
Deprecation, Sunset, Link |
Deprecated endpoints | 21.1.4 |
Allow |
405 |
Permitted methods for the path. |
WWW-Authenticate |
401, insufficient_scope 403 |
21.2 |
21.4 The canonical pagination model #
Cursor-based only. This is the pagination model for every collection in this specification.
21.4.1 Parameters #
| Parameter | Type | Default | Min | Max | Notes |
|---|---|---|---|---|---|
limit |
integer | 25 |
1 |
100 |
A value above 100 is an error, not a silent clamp: silently returning 100 when the client asked for 1 000 causes clients to believe they have everything. 400 with limit_out_of_range. |
cursor |
string | absent | — | 512 bytes | Opaque. Absent means "first page". |
21.4.2 Response metadata #
"meta": {
"next_cursor": "eyJ2IjoxLCJrIjpb…",
"has_more": true
}| Field | Type | Meaning |
|---|---|---|
next_cursor |
string | null | Pass as cursor to fetch the next page. null when the collection is exhausted. |
has_more |
boolean | true when next_cursor is non-null. Redundant by design: it makes while (meta.has_more) loops read naturally and prevents the common bug of testing next_cursor truthiness against an empty string. |
total |
integer | Only on bounded collections (21.4.6). Absent — not null — elsewhere. |
Determining has_more costs nothing: the query fetches limit + 1 rows, returns limit, and sets has_more from whether the extra row existed.
The final page returns "next_cursor": null, "has_more": false together with its data. A client never needs an extra empty request to discover the end. An empty collection returns "data": [] with has_more: false — never a 404.
21.4.3 The cursor #
Structure before encoding:
{
"v": 1,
"k": ["2026-08-19T10:12:03.221Z"],
"id": "0192f3c1-9013-7a02-8e77-b1cc3f60d922",
"d": "desc",
"f": "a4c1f93e"
}| Key | Meaning |
|---|---|
v |
Cursor schema version. Bumped if the internal shape changes; an old v is rejected with invalid_cursor rather than misinterpreted. |
k |
The sort key values of the last row on the page, in sort order. One entry per active sort key. |
id |
The UUIDv7 of the last row — the implicit final tie-breaker, guaranteeing a total order even when the sort key has duplicates. |
d |
Direction, asc or desc. |
f |
First 8 hex characters of sha256 over the canonicalised filter + sort + limit-independent query state. |
Encoding: base64url without padding, of the compact JSON. Typical length 90–160 characters. No signature and no encryption: the cursor contains no secret (the values are drawn from rows the caller may already read) and signing would add latency and a key-rotation problem for no security gain. It is validated structurally and by fingerprint.
Keyset predicate (descending by created_at, tie-broken by id):
SELECT * FROM links
WHERE workspace_id = $1
AND deleted_at IS NULL
AND (created_at, id) < ($2::timestamptz, $3::uuid)
ORDER BY created_at DESC, id DESC
LIMIT $4 + 1;Every paginated table carries a composite index matching its default sort, e.g. (workspace_id, created_at DESC, id DESC) WHERE deleted_at IS NULL, so page 1 and page 10 000 cost the same.
21.4.4 Stability guarantees #
| Guarantee | Statement |
|---|---|
| Opacity | A cursor is an opaque string. Clients must not parse, construct, modify, truncate or compare cursors. Its internal structure, encoding and length may change without a version bump. |
| No skipping | A row that existed at the start of iteration and is not modified will be returned exactly once across the full traversal. Keyset pagination cannot skip rows when earlier rows are deleted, which is the defect offset pagination has. |
| No duplication | A row will not be returned twice, unless its sort key is mutated mid-traversal such that it moves from a not-yet-visited position to an already-visited one. This is inherent to any sort-key pagination and is documented, not hidden. Default sorts use immutable keys (created_at, id) precisely to make this case rare. |
| Late arrivals | Rows created after iteration begins may or may not appear, depending on where they sort. With the default -created_at, newly created rows sort before the first page and therefore will not appear in an in-progress traversal. Start over to see them. |
| Lifetime | A cursor is valid for 24 hours from issue. Older cursors return 400 cursor_expired. This bounds how stale a traversal can be and lets index changes take effect. |
| Filter binding | A cursor is bound to the exact filter and sort of the request that produced it, through the f fingerprint. Reusing it with a different filter, sort or direction returns 400 cursor_filter_mismatch. limit may change between pages without invalidating the cursor. |
| Scope binding | A cursor is bound to a workspace. Presenting a cursor from a different workspace returns 400 invalid_cursor; it is never treated as a cross-tenant read. |
| Cross-endpoint | Cursors are not portable between endpoints. The fingerprint includes the endpoint identity. |
21.4.5 Why offset pagination is not offered #
Stated once, because customers ask:
- Correctness. With
?offset=100, deleting a row on page 1 shifts every later row up by one, and one row is silently never returned. Inserting shifts down and one row is returned twice. On a collection that is actively being written — every collection here — offset pagination is lossy. - Cost.
OFFSET nrequires the database to produce and discardnrows. Againstclick_events, which is range-partitioned by day and can hold hundreds of millions of rows, deep offsets are pathological. Keyset access is a single index seek regardless of depth. - Total counts. Offset UIs imply a total, and
COUNT(*)on a partitioned event table is a full scan. LinkHub does not emit a total it cannot compute cheaply, so it does not offer the pagination style that demands one.
offset, page and per_page parameters are rejected with 400 and unknown_parameter, with a details.message pointing to cursor and limit. Silently ignoring them would produce a client that appears to work and returns page 1 forever.
21.4.6 Bounded collections and meta.total #
meta.total is emitted only where the set is inherently small and the count is a cheap indexed aggregate. These, and no others:
| Collection | Practical bound |
|---|---|
| Workspace members | 25 (Business seat cap) |
| Pending invitations | 100 |
| API keys | 25 |
| Custom domains | 5 |
| Integrations | one per provider |
| Blocks within a bio page | 200 |
| Destinations within a link | 20 |
| Targeting rules within a link | 50 |
| Variants within an experiment | 8 (short link); 4 (bio page) — per experiment_arms_max_* in Section 22.1.3 |
| Bio pages | 100 (Business cap) |
Even for these, limit/cursor still work identically — total is additional information, not a different pagination model. Links, QR codes, webhook deliveries and analytics rows never carry a total.
21.5 Filtering, sorting and sparse fieldsets #
One grammar, applied identically across every collection endpoint. Each endpoint documents which fields are filterable, sortable and expandable; the syntax never varies.
21.5.1 Filtering #
GET /v1/links?status=active&created_at[gte]=2026-08-01&tags[in]=launch,promo&slug[starts_with]=spring| Form | Meaning |
|---|---|
field=value |
Shorthand for field[eq]=value. |
field[op]=value |
Explicit operator. |
| Operator | Applies to | Notes |
|---|---|---|
eq |
all | Default. |
ne |
all | |
gt, gte, lt, lte |
number, timestamp, date | |
in |
all | Comma-separated, max 50 values. A literal comma inside a value must be percent-encoded as %2C. |
nin |
all | Same limits. |
contains |
string | Case-insensitive substring. Only on fields documented as searchable, because it cannot use a plain B-tree index; those fields carry a trigram index. |
starts_with |
string | Case-insensitive prefix. Index-friendly. |
is_null |
nullable fields | Value true or false. |
Value formats:
| Type | Format |
|---|---|
| Boolean | true / false. 1/0/yes/no are rejected. |
| Timestamp | RFC 3339 with Z, or YYYY-MM-DD (interpreted as 00:00:00Z for gte/gt and 23:59:59.999Z for lte/lt, so a single-day range works as expected). |
| UUID | Canonical hyphenated form. |
| Enum | Exact snake_case value. |
| Null | The literal token null with eq/ne; or use is_null. |
Composition: different fields combine with AND. Multiple values within in combine with OR. Two operators on the same field combine with AND (created_at[gte]=…&created_at[lt]=… is a half-open range). There is no OR across different fields and no nested boolean grammar — that complexity belongs in a query language, not a query string, and every use case here is satisfied by AND + in.
Errors: unknown field → 400 invalid_filter with details.field. Unsupported operator for that field → 400 invalid_filter_operator. Unparseable value → 400 invalid_filter_value. More than 50 in values → 400 filter_too_many_values. More than 10 distinct filter fields in one request → 400 too_many_filters.
21.5.2 Sorting #
GET /v1/links?sort=-created_at
GET /v1/links?sort=-click_count,slug- Comma-separated list, leading
-for descending, no prefix for ascending. - Maximum 2 keys. Beyond that,
400too_many_sort_keys. - Only allow-listed fields per endpoint; others return
400invalid_sort_field. idis always appended as the final implicit tie-breaker (matching the primary direction), guaranteeing a total order and therefore stable pagination.- Default sort for every collection is
-created_atunless the endpoint documents otherwise. NULLordering isNULLS LASTfor ascending andNULLS FIRSTfor descending, matching PostgreSQL's default for the respective directions, so it is index-compatible.
21.5.3 Sparse fieldsets and expansion #
GET /v1/links?fields=id,slug,destination_url,click_count
GET /v1/bio-pages/{id}?expand=blocks| Parameter | Rule |
|---|---|
fields |
Comma-separated allow-listed top-level field names. id is always included whether requested or not. Unknown field → 400 invalid_field_selection. Max 40 names. Omitted fields are absent from the object, not null — this is the one documented exception to the explicit-nulls rule in 21.1.1, and it is what the client asked for. |
expand |
Comma-separated allow-listed relation names. Max 2 per request. No nesting (expand=blocks.link is rejected with invalid_expansion) — nested expansion is how an API acquires unbounded query cost. An unexpanded relation appears as an id field (bio_page_id) or is absent; it is never a partial object. |
Expansion allow-list:
| Endpoint | Expandable |
|---|---|
GET /v1/bio-pages, GET /v1/bio-pages/{id} |
blocks, active_experiment |
GET /v1/links, GET /v1/links/{id} |
destinations, targeting_rules, domain |
GET /v1/qr-codes, GET /v1/qr-codes/{id} |
link, latest_render |
GET /v1/experiments, GET /v1/experiments/{id} |
variants, results |
On collection endpoints, expansion is capped: expanding a relation on a page of more than 25 items returns 400 expansion_limit_exceeded, because the resulting query fan-out is unbounded. Fetch with limit=25 or fetch the relation separately.
21.5.4 Search #
Endpoints that support free-text search accept q. Its semantics are documented per endpoint (they differ: link search matches slug and destination; bio-page search matches handle and title). q requires at least 2 characters and is capped at 100; it combines with filters using AND.
21.5.5 Strictness #
Any query parameter not documented for the endpoint — including a misspelling of a valid one, and including offset/page — returns 400 with unknown_parameter and details.field naming it. There is no lenient mode. A typo that silently returns unfiltered data is a data-leak-shaped bug in a client, and the API refuses to enable it.
21.6 Idempotency #
21.6.1 The header #
Idempotency-Key: 6a1f4b2c-9d38-4e11-b0a7-2c5f8e0d7a13| Rule | Value |
|---|---|
| Format | 8–255 characters of [A-Za-z0-9_-]. A UUIDv4 is recommended. Outside that → 400 idempotency_key_invalid. |
| Honoured on | POST and PATCH. |
| Ignored on | GET, HEAD, DELETE, PUT. These are already idempotent by their HTTP semantics. The header is silently ignored — not an error — so a client library may attach it universally. |
| Required on | Every bulk endpoint (POST /v1/links/bulk, PATCH /v1/links/bulk). Missing → 400 idempotency_key_required. A partially-applied bulk operation retried without a key is the worst failure mode this API has, so the guard is mandatory there. |
| Recommended on | Every other POST. Not required, because requiring it would break the simplest possible curl example and the cost of a duplicate link is low. |
| Scope | The key is scoped to (api_key_id, method, path). The same key value used by a different API key, or on a different endpoint, is a different record and does not collide. |
21.6.2 Storage #
| Property | Value |
|---|---|
| Table | idempotency_records, hard-deleted (no soft delete). |
| Retained fields | key, api_key_id, workspace_id, method, path, request_body_sha256, state (in_flight | completed), response_status, response_body (jsonb, capped at 256 KB), locked_at, created_at. |
| Window | 24 hours from first receipt. After that the record is purged by the retention worker and the same key may be reused, which will execute a fresh operation. |
| Body cap | A response larger than 256 KB is not stored; the record is marked completed with response_status only, and a replay returns 409 idempotency_response_too_large telling the client to verify state by reading the resource. In practice only bulk responses approach this. |
21.6.3 Replay semantics #
Request arrives with Idempotency-Key K
├─ No record for (api_key, method, path, K)
│ → INSERT (state=in_flight, body_hash) with a unique constraint
│ → execute handler
│ → UPDATE (state=completed, status, body) [same transaction as the write where possible]
│ → return the response
│
├─ Record exists, state=completed, body_hash matches
│ → return the stored status and body verbatim
│ → add header Idempotent-Replay: true
│
├─ Record exists, state=completed, body_hash DIFFERS
│ → 409 idempotency_key_reused
│
└─ Record exists, state=in_flight
→ 409 idempotency_key_in_flight with Retry-After: 1| Rule | Detail |
|---|---|
| Body hash | sha256 over the exact raw request bytes. Two logically-equivalent JSON documents with different key order hash differently and therefore conflict. This is intentional and strict: a client that reuses a key must resend byte-identical bytes, which is what every retry actually does. |
| Which responses are stored | 2xx responses, and 4xx responses that are deterministic (400, 403, 404, 409, 422). Replaying a deterministic failure returns the same failure, which is correct — the request will not succeed on retry either. |
| Which responses are not stored | 429 and every 5xx. The in_flight record is deleted so the client may retry the same key and actually re-execute. Caching a transient failure under an idempotency key would make it permanent for 24 hours. |
Stuck in_flight |
A record in_flight for more than 60 seconds is considered abandoned (the process died mid-request) and is reclaimed by the next request with that key, which re-executes. |
| Concurrency | The unique constraint on (api_key_id, method, path, key) is the lock. Two simultaneous requests race on the insert; the loser gets idempotency_key_in_flight. |
| Atomicity | For single-resource creates, the idempotency record is updated in the same database transaction as the resource write, so a crash cannot leave a resource created without its idempotency record. For bulk and async endpoints, the record is written after the job is enqueued and before the response is returned, and the job itself is separately idempotent by job id. |
Replay of 202 |
Returns the same job id, so a retried export request does not queue a second export. |
21.7 Rate limiting #
This subsection is authoritative for every public-API rate limit in the specification. The consolidated limits table in Section 23.9 reproduces these numbers exactly or references this subsection; where the two ever disagree, this subsection wins and Section 23.9 is the defect. The limits here govern API-key and unauthenticated-API traffic only — the per-IP redirect limit, the per-workspace redirect limit and the public-form limits are separate budgets owned by Sections 23.9 and 20.3.1 and are never merged with these.
21.7.1 Limits by plan #
| Plan | API access | Requests/minute per key | Mutating requests/minute per key | Analytics queries/minute per key | Concurrent requests per key |
|---|---|---|---|---|---|
| Free | None | — | — | — | — |
| Pro | Read + limited write | 120 | 30 | 30 | 10 |
| Business | Full | 600 | 120 | 60 | 20 |
Additional ceilings:
| Ceiling | Value | Purpose |
|---|---|---|
| Per workspace, all keys combined | 2× the per-key limit | One workspace with 25 keys cannot consume 25× the intended share. |
| Per source IP, unauthenticated | 60 requests/minute | Slows key guessing and unauthenticated probing. Counted before authentication. |
| Export creation | 10 per hour per workspace | Analytics exports only — leads are not exportable through this API (21.8.13). Exports are expensive; this is a separate bucket so a burst of exports does not consume the general budget. |
Capability evaluation (POST /capabilities) |
Counted as a read against the general per-key limit | The endpoint is cheap by construction (21.8.15) and is not given its own budget. |
| Bulk endpoints | 10 per hour per workspace | Same reasoning. |
QR render (GET …/render) |
60 per minute per key | Rendering is CPU-bound. Cached renders do not count. |
"Limited write" for Pro means precisely: pages:write, links:write, qr:write and experiments:write are permitted; domains:write and webhooks:write are Business-only and return 403 plan_feature_unavailable on Pro; bulk endpoints accept at most 50 items per request on Pro versus 500 on Business.
21.7.2 Algorithm #
Sliding-window counter, evaluated in Redis with an atomic Lua script.
- The 60-second window is divided into six 10-second buckets keyed
rl:api:{key_id}:{bucket_epoch}, each with a 70-second TTL. - The current usage is the sum of the five completed buckets plus the current bucket weighted by elapsed fraction. This approximates a true sliding window within a few percent while costing one round trip and bounded memory — a full log of request timestamps would be exact and would cost far more memory at 600 rpm × many keys.
- Burst allowance: a token bucket of capacity 20% of the minute limit (Pro 24, Business 120) refilling continuously at
limit/60per second sits in front of the window counter. A client may spend the whole burst instantly, which makes a batch of parallel requests at the start of a job succeed, and then settles to the sustained rate. Both the burst bucket and the window counter must admit the request. - The three sub-limits (mutating, analytics, concurrency) are independent counters evaluated after the general limit. The response reports whichever is nearest exhaustion.
- Concurrency is tracked with an incrementing counter per key, decremented in a
finally, with a 60-second safety expiry so a crashed process cannot leak slots permanently. - Rate-limit state is best-effort. If Redis is unavailable, the limiter fails open and requests are admitted, with an alert (Section 25). Refusing all API traffic because the rate limiter is down converts a degradation into an outage.
21.7.3 Response headers #
Emitted on every response, not only on 429, so clients can pace themselves before hitting the wall.
RateLimit-Limit: 600
RateLimit-Remaining: 417
RateLimit-Reset: 23
RateLimit-Policy: 600;w=60, 120;w=60;scope="write", 120;w=60;scope="burst"| Header | Meaning |
|---|---|
RateLimit-Limit |
Requests permitted in the current window for the binding limit. |
RateLimit-Remaining |
Requests remaining in the current window. Never negative; floors at 0. |
RateLimit-Reset |
Delta-seconds until the window resets. Not a timestamp — a delta needs no clock synchronisation. |
RateLimit-Policy |
The full policy set, comma-separated, each as limit;w=seconds with an optional scope. |
Retry-After |
On 429 only. Delta-seconds. Always ≥ 1. |
Legacy mirrors X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset are emitted with identical values, because a large amount of existing client code reads them. X-RateLimit-Reset mirrors the delta, not an epoch, and this is documented explicitly to prevent the common misreading.
21.7.4 The 429 body #
{
"error": {
"code": "rate_limited",
"message": "Rate limit exceeded for this API key. Retry in 23 seconds.",
"details": [
{
"field": null,
"issue": "rate_limited",
"scope": "api_key",
"limit": 600,
"window_seconds": 60,
"retry_after_seconds": 23
}
],
"request_id": "req_01K3M9T80P6R2YFA5J1DKM7XQZ"
}
}details[0].scope is one of api_key, workspace, write, analytics, export, bulk, qr_render, concurrency, ip. The corresponding code is rate_limited for all of them except concurrency, which uses concurrency_limit so a client can distinguish "slow down" from "run fewer at once" — the remedies differ.
21.7.5 Retry guidance #
Published verbatim in the documentation and implemented in the quickstart:
- Retry only on
429,408,500,502,503,504, and on network/timeout errors. Never retry any other4xx. - On
429or503, wait exactlyRetry-Afterseconds. Do not apply your own backoff on top of it. - Otherwise use exponential backoff with full jitter:
delay = random(0, min(60, 1 * 2^attempt))seconds. - Maximum 5 attempts, then surface the error with its
request_id. - Attach the same
Idempotency-Keyto every attempt of aPOSTorPATCH, so a retry after a timeout cannot duplicate work. - Keep concurrency at or below the documented per-key limit. Parallelism above the limit produces
429s that are strictly slower than the serialised alternative. - Read
RateLimit-Remainingand slow down proactively when it drops below 10% ofRateLimit-Limit.
21.8 The endpoint reference #
Conventions for this subsection: every path is relative to https://api.linkhub.app/v1. Every endpoint requires authentication (21.2) and is subject to rate limiting (21.7) and the strict query grammar (21.5). Every endpoint can return 401, 403, 429, 500 and 503; those are omitted from the per-endpoint error lists and are documented once in 21.3.3. Every list endpoint accepts limit and cursor (21.4).
{ws} in examples is 0192f3c1-8a44-7b31-9d02-6f1c2b7a4e55.
There are no billing endpoints on this API. No /v1/billing/* path exists, billing:* is not an issuable scope (21.2.4), and no credential this product issues can reach subscription, invoice, payment-method or refund state. Billing is dashboard-session-only by design, so that a leaked API key can never reach payment state; the dashboard's billing routes are specified in Section 22. The read-only entitlement and usage view an integration actually needs is GET /account (21.8.14).
21.8.1 Workspaces (read) #
| Method | Path | Scope | Notes |
|---|---|---|---|
GET |
/workspaces |
account:read |
Returns the single workspace bound to the key. Bounded collection, meta.total present. |
GET |
/workspaces/{id} |
account:read |
404 not_found if {id} is not the key's workspace. The workspace check runs before any capability evaluation. |
GET |
/workspaces/{id}/members |
account:read |
Bounded, meta.total. |
Workspace representation:
| Field | Type | Notes |
|---|---|---|
id |
uuid | |
name |
string | 1–80 chars. |
slug |
string | Dashboard URL slug. |
plan |
enum | free, pro, business. Exhaustive. |
timezone |
string | IANA name, e.g. Europe/Berlin. Used for report bucketing and digests. |
default_short_domain |
string | e.g. go.acme.com or lnkhb.co. |
branding_removed |
boolean | Derived from the plan. |
created_at, updated_at |
timestamp |
Member representation: id, user_id, display_name, role (owner|admin|editor|viewer), has_resource_grants (boolean), status (active|pending), created_at.
account:read never returns a member email address. The member roster this endpoint exposes carries the membership id, the display name and the role, and nothing that identifies the human by contact detail — no email, no phone number, no external identity-provider subject. The reason is that account:read is the one scope almost every integration asks for (the Zapier connection test needs it), so it is the scope most likely to be present on a leaked key; a roster of names and roles is an org chart, whereas a roster of email addresses is a phishing target list with the roles attached. Member email addresses are visible only in the dashboard, to a session-authenticated member whose role may see the member list (Section 8). A pending invitation likewise exposes only its id, its intended role and its status — never the invited address.
curl -s https://api.linkhub.app/v1/workspaces \
-H "Authorization: Bearer $LINKHUB_API_KEY"{
"data": [
{
"id": "0192f3c1-8a44-7b31-9d02-6f1c2b7a4e55",
"name": "Acme",
"slug": "acme",
"plan": "business",
"timezone": "Europe/Berlin",
"default_short_domain": "go.acme.com",
"branding_removed": true,
"created_at": "2026-02-11T08:31:02.114Z",
"updated_at": "2026-08-02T15:44:19.900Z"
}
],
"meta": { "next_cursor": null, "has_more": false, "total": 1 }
}Errors: 404 not_found (also the response for a workspace the key is not bound to — see 21.2.5). Side effects: none.
21.8.2 Bio pages (CRUD) #
| Method | Path | Scope | Success |
|---|---|---|---|
GET |
/bio-pages |
pages:read |
200 |
POST |
/bio-pages |
pages:write |
201 |
GET |
/bio-pages/{id} |
pages:read |
200 |
PATCH |
/bio-pages/{id} |
pages:write |
200 |
DELETE |
/bio-pages/{id} |
pages:write |
204 |
POST |
/bio-pages/{id}/publish |
pages:write |
200 |
POST |
/bio-pages/{id}/unpublish |
pages:write |
200 |
List query: filter on status (draft|published), handle (eq, starts_with), created_at, updated_at, domain_id. Sort on created_at, updated_at, handle. q matches handle and title. Expand blocks, active_experiment.
Create/update body:
| Field | Type | Required (create) | Constraints | Mutable |
|---|---|---|---|---|
handle |
string | Yes | 1–64 chars [a-z0-9-], unique per host, not on the reserved/profanity/homoglyph blocklist (Section 23) |
Yes — changing it breaks existing links; the response warns via meta.warnings |
title |
string | Yes | 1–120 chars | Yes |
description |
string | null | No | ≤ 300 chars | Yes |
domain_id |
uuid | null | No | Must be an active custom domain in the workspace; null uses the LinkHub host |
Yes |
theme |
object | No | Theme token object (Section 9). Rejected with 422 theme_contrast_failed when button/text contrast is below 4.5:1 |
Yes |
avatar_image_id |
uuid | null | No | Must be an uploaded asset in the workspace | Yes |
seo |
object | No | { og_title, og_description, og_image_id, noindex } |
Yes |
is_published |
boolean | — | Not settable here. Use /publish and /unpublish so publication is a single audited action |
— |
Representation adds: id, status, public_url, block_count, published_at, created_at, updated_at.
curl -s -X POST https://api.linkhub.app/v1/bio-pages \
-H "Authorization: Bearer $LINKHUB_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6a1f4b2c-9d38-4e11-b0a7-2c5f8e0d7a13" \
-d '{
"handle": "acme-summer",
"title": "Acme — Summer 2026",
"description": "Everything we launched this summer.",
"domain_id": "0192f3c1-c0de-7a44-8ee1-33aa55bb77cc"
}'{
"data": {
"id": "0192f3c1-3300-7b02-a1d4-9e77c5b81f66",
"handle": "acme-summer",
"title": "Acme — Summer 2026",
"description": "Everything we launched this summer.",
"domain_id": "0192f3c1-c0de-7a44-8ee1-33aa55bb77cc",
"status": "draft",
"public_url": "https://acme.link/acme-summer",
"block_count": 0,
"theme": { "preset": "default" },
"avatar_image_id": null,
"seo": { "og_title": null, "og_description": null, "og_image_id": null, "noindex": false },
"published_at": null,
"created_at": "2026-08-19T10:02:11.441Z",
"updated_at": "2026-08-19T10:02:11.441Z"
},
"meta": {}
}Errors: 400 validation_failed · 409 page_handle_taken · 422 handle_reserved · 422 theme_contrast_failed · 422 domain_not_active · 403 plan_limit_reached (bio page cap) · 403 email_verification_required (publishing requires a verified account — Section 7) · 404 bio_page_not_found.
Publish side effects: sets status = published and published_at; primes the render cache; writes audit entry bio_page.published; emits the page.published webhook; requires at least one block (422 page_has_no_blocks) and a verified account.
Delete side effects: soft delete with a 30-day restore window; the public URL begins returning 404; blocks are soft-deleted with the page; leads captured by the page are not deleted (Section 20.8.2); audit entry.
21.8.3 Blocks (CRUD, reorder) #
| Method | Path | Scope | Success |
|---|---|---|---|
GET |
/bio-pages/{page_id}/blocks |
pages:read |
200 (bounded, meta.total) |
POST |
/bio-pages/{page_id}/blocks |
pages:write |
201 |
GET |
/blocks/{id} |
pages:read |
200 |
PATCH |
/blocks/{id} |
pages:write |
200 |
DELETE |
/blocks/{id} |
pages:write |
204 |
POST |
/bio-pages/{page_id}/blocks/reorder |
pages:write |
200 |
Block body:
| Field | Type | Required | Constraints |
|---|---|---|---|
kind |
enum | Yes (create) | The block catalogue in Section 10. Non-exhaustive enum. Immutable after creation — change of kind is delete + create. |
position |
integer | No | 0-based. Omitted appends to the end. Setting it on create inserts and shifts. |
is_visible |
boolean | No | Default true. |
config |
object | Yes | Kind-specific. Validated against that kind's schema from Section 10; a mismatch is 422 block_config_invalid with details naming the offending path. |
schedule |
object | null | No | { starts_at, ends_at }, Pro+. 422 schedule_invalid if ends_at <= starts_at. |
Reorder body — the full ordered id list, not a delta:
{ "block_ids": ["0192f3c1-3311-…", "0192f3c1-3312-…", "0192f3c1-3313-…"] }It must contain every non-deleted block id of the page exactly once. A missing or extra id is 422 reorder_set_mismatch with the offending ids in details. Sending the full set makes reordering idempotent and immune to lost updates, which a delta is not.
{
"data": [
{ "id": "0192f3c1-3311-7f19-8ad2-40b9c1e77a02", "position": 0, "kind": "link" },
{ "id": "0192f3c1-3312-7c04-9911-b2e70a5d61f3", "position": 1, "kind": "email_capture" },
{ "id": "0192f3c1-3313-7a88-8fd0-6c19e4b2a730", "position": 2, "kind": "embed" }
],
"meta": { "total": 3 }
}Errors: 404 bio_page_not_found · 404 block_not_found · 422 block_config_invalid · 422 reorder_set_mismatch · 422 block_kind_immutable · 403 plan_limit_reached (200 blocks per page) · 403 plan_feature_unavailable (scheduling on Free).
Side effects: any block mutation invalidates the page render cache and bumps the page's updated_at; reorder writes one audit entry, not one per block.
21.8.4 Links (CRUD, bulk) #
| Method | Path | Scope | Success |
|---|---|---|---|
GET |
/links |
links:read |
200 |
POST |
/links |
links:write |
201 |
GET |
/links/{id} |
links:read |
200 |
PATCH |
/links/{id} |
links:write |
200 |
DELETE |
/links/{id} |
links:write |
204 |
POST |
/links/bulk |
links:write |
200 or 202 |
PATCH |
/links/bulk |
links:write |
200 or 202 |
List query: filter on status (draft|active|paused|scheduled|expired|archived; exhaustive, and the same six values the schema's CHECK constraint permits — Section 6), domain_id, slug (eq, starts_with), created_at, tags[in], has_experiment. Sort on created_at, updated_at, click_count, slug. q matches slug and destination URL. Expand destinations, targeting_rules, domain.
Body:
| Field | Type | Required | Constraints | Mutable |
|---|---|---|---|---|
destination_url |
string | Yes (unless destinations is supplied) |
≤ 2 048 chars; scheme in http, https, mailto, tel, sms; passes SSRF/private-range and Safe Browsing checks (Section 23) |
Yes |
slug |
string | No | 1–64 [a-z0-9-]; auto-generated 7-char Crockford base32 when omitted; unique per domain; blocklist-checked |
Yes — changing it does not preserve the old slug, which stops resolving |
domain_id |
uuid | null | No | Must be active; null uses the workspace default short domain |
Yes |
title |
string | null | No | ≤ 120 chars, internal label only | Yes |
tags |
string[] | No | ≤ 10, each ≤ 40 chars [a-z0-9-] |
Yes |
utm |
object | null | No | { source, medium, campaign, term, content }, each ≤ 100 chars. Appended to the destination at resolve time. Pro+ |
Yes |
schedule |
object | null | No | { starts_at, ends_at }. Pro+ |
Yes |
expiry_url |
string | null | No | Where an expired link sends visitors. Default is a branded landing page, never a 404 |
Yes |
password |
string | null | No | 8–128 chars. Write-only; never returned. null clears it |
Yes |
is_paused |
boolean | No | Default false |
Yes |
publish |
boolean | No | Default true. false creates the link in draft — reserved, resolvable only by its owner in the dashboard preview, not yet serving the public |
Yes, via /publish |
Representation adds id, short_url, status, click_count, unique_click_count, last_clicked_at, has_targeting_rules, destination_count, created_at, updated_at. password is returned as has_password: true|false.
curl -s -X POST https://api.linkhub.app/v1/links \
-H "Authorization: Bearer $LINKHUB_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 9f2c7a1e-4b60-4d92-8f11-77c0ab3e5d24" \
-d '{
"destination_url": "https://acme.com/spring",
"slug": "spring-sale",
"domain_id": "0192f3c1-c0de-7a44-8ee1-33aa55bb77cc",
"title": "Spring campaign — Instagram",
"tags": ["spring", "social"],
"utm": { "source": "instagram", "medium": "social", "campaign": "spring-sale" }
}'{
"data": {
"id": "0192f3c1-9012-7c88-b4aa-2d55e9f1a733",
"slug": "spring-sale",
"short_url": "https://go.acme.com/spring-sale",
"destination_url": "https://acme.com/spring",
"domain_id": "0192f3c1-c0de-7a44-8ee1-33aa55bb77cc",
"title": "Spring campaign — Instagram",
"tags": ["spring", "social"],
"utm": { "source": "instagram", "medium": "social", "campaign": "spring-sale", "term": null, "content": null },
"schedule": null,
"expiry_url": null,
"has_password": false,
"status": "active",
"click_count": 0,
"unique_click_count": 0,
"last_clicked_at": null,
"has_targeting_rules": false,
"destination_count": 1,
"created_at": "2026-08-19T10:05:42.008Z",
"updated_at": "2026-08-19T10:05:42.008Z"
},
"meta": {}
}Errors: 400 validation_failed · 409 link_slug_taken · 409 qr_slug_reserved (the requested slug is permanently reserved by a QR code on that host; QR and short-link slugs share one namespace — Section 14) · 422 slug_reserved · 422 slug_confusable (homoglyph check) · 422 destination_url_invalid · 422 destination_url_private_address · 422 destination_url_blocked (Safe Browsing) · 422 domain_not_active · 403 plan_limit_reached (Free 25-link cap, kind: "count"; Pro/Business fair-use creation cap, kind: "period") · 403 plan_feature_unavailable (UTM, scheduling) · 404 link_not_found.
Side effects on create/update: write-through of the Redis resolution cache rd:{host}:{slug} and deletion of the negative-cache key rd:miss:{host}:{slug} (the canonical key set is Section 4's); Safe Browsing lookup queued; audit entry (link.created, link.updated, and additionally link.destination_changed when the destination changed); webhook events per 19.7.3. Delete: soft delete, resolution cache purged, the slug is released for reuse after the 30-day window (unlike QR slugs, which are never recycled — Section 14). A link that is pinned to a QR code is the exception: it is never archived, never released and never counts toward the link cap (Section 22.2.5).
Bulk create — POST /links/bulk, Idempotency-Key required:
{
"links": [
{ "destination_url": "https://acme.com/a", "slug": "a-1", "tags": ["batch"] },
{ "destination_url": "https://acme.com/b" }
],
"on_error": "continue"
}| Field | Type | Notes |
|---|---|---|
links |
array | 1–50 items on Pro, 1–500 on Business. Each item is a link create body. Over the cap → 422 bulk_too_many_items. |
on_error |
enum | continue (default) or abort. abort wraps the whole batch in one transaction — either all succeed or none do. |
Response, 200 for ≤ 100 items (synchronous):
{
"data": {
"created": 1,
"failed": 1,
"results": [
{ "index": 0, "status": "created", "id": "0192f3c1-9014-7b71-a0c2-3d99e1f0b7aa", "slug": "a-1" },
{ "index": 1, "status": "failed", "error": { "code": "link_slug_taken", "message": "That slug is already in use on this domain.", "details": [ { "field": "slug", "issue": "duplicate" } ] } }
]
},
"meta": {}
}The top-level status is 200, not a 4xx, when on_error: "continue" and at least one item was processed — the batch request succeeded even though items failed. With on_error: "abort" a single failure returns 422 bulk_aborted with the failing index in details and nothing created. Over 100 items the response is 202 with a job id and meta.poll_url pointing at /links/bulk/{job_id}, whose GET returns the same result shape once status is completed.
Bulk update — PATCH /links/bulk:
{
"updates": [
{ "id": "0192f3c1-9012-…", "destination_url": "https://acme.com/spring-2026" },
{ "id": "0192f3c1-9013-…", "tags": ["archive"] }
],
"on_error": "continue"
}Same limits, same response shape (updated/failed/results). An id belonging to another workspace fails that item with link_not_found; it never reveals existence.
21.8.5 Link destinations (splits) #
Multiple weighted destinations on one link. The split mechanics, sticky assignment and winner metrics are Section 16.
| Method | Path | Scope | Success |
|---|---|---|---|
GET |
/links/{link_id}/destinations |
links:read |
200 (bounded, meta.total) |
POST |
/links/{link_id}/destinations |
links:write |
201 |
PATCH |
/links/{link_id}/destinations/{id} |
links:write |
200 |
DELETE |
/links/{link_id}/destinations/{id} |
links:write |
204 |
| Field | Type | Required | Constraints |
|---|---|---|---|
url |
string | Yes | Same destination rules as a link. |
weight |
integer | Yes | 1–100. The weights of all active destinations on a link must sum to exactly 100 after the operation, or the request is 422 destination_weights_invalid with the computed sum in details. |
label |
string | null | No | ≤ 60 chars, for reporting. |
is_active |
boolean | No | Default true. Inactive destinations are excluded from the sum. |
Limits: 2–20 destinations per link. Removing a destination when only two remain converts the link back to single-destination and requires the survivor's weight to be set to 100 in the same request (PATCH the survivor first, or use on_error: abort semantics by ordering the calls) — the API returns 422 destination_weights_invalid otherwise rather than silently normalising, because silent normalisation of traffic splits is how experiments get invalidated.
Errors: 404 link_not_found · 404 destination_not_found · 422 destination_weights_invalid · 422 destination_url_invalid · 403 plan_feature_unavailable (splits are Pro+) · 409 destination_locked_by_experiment (a running experiment owns the split; stop or promote it first).
Side effects: resolution cache write-through; audit entry link.destination_changed.
21.8.6 Targeting rules #
Rule semantics, evaluation order and the matching model are Section 15.
| Method | Path | Scope | Success |
|---|---|---|---|
GET |
/links/{link_id}/targeting-rules |
links:read |
200 (bounded, meta.total) |
POST |
/links/{link_id}/targeting-rules |
links:write |
201 |
GET |
/links/{link_id}/targeting-rules/{id} |
links:read |
200 |
PATCH |
/links/{link_id}/targeting-rules/{id} |
links:write |
200 |
DELETE |
/links/{link_id}/targeting-rules/{id} |
links:write |
204 |
| Field | Type | Required | Constraints |
|---|---|---|---|
type |
enum | Yes | country, region, device_type, os, language, referrer_host, time_window. Non-exhaustive. |
operator |
enum | Yes | in, not_in. |
values |
string[] | Yes | 1–50 values, validated per type: ISO 3166-1 alpha-2 for country, ISO 3166-2 subdivision codes for region, mobile|tablet|desktop for device_type, BCP 47 primary tags for language. |
destination_url |
string | Yes | Same destination rules. |
priority |
integer | No | 0-based; lower evaluates first. Omitted appends. Duplicated priorities are resolved by created_at and the response returns the normalised priorities. |
is_active |
boolean | No | Default true. |
Max 50 rules per link. Errors: 404 link_not_found · 404 targeting_rule_not_found · 422 targeting_values_invalid (with the offending value in details) · 422 destination_url_invalid · 403 plan_feature_unavailable (Pro+) · 403 plan_limit_reached (50 rules).
Side effects: resolution cache write-through — the cached payload carries the compiled rule set, so rule changes take effect on the next resolve without a database read.
21.8.7 QR codes #
Generation, styling constraints, scannability validation and the immortality rule are Section 14. This is the API surface only.
| Method | Path | Scope | Success |
|---|---|---|---|
GET |
/qr-codes |
qr:read |
200 |
POST |
/qr-codes |
qr:write |
201 |
GET |
/qr-codes/{id} |
qr:read |
200 |
PATCH |
/qr-codes/{id} |
qr:write |
200 |
DELETE |
/qr-codes/{id} |
qr:write |
204 |
GET |
/qr-codes/{id}/render |
qr:read |
200 (binary) |
POST |
/qr-codes/{id}/exports |
qr:write |
202 |
GET |
/qr-codes/{id}/exports/{export_id} |
qr:read |
200 |
Body:
| Field | Type | Required | Constraints |
|---|---|---|---|
destination_url |
string | Yes | Same destination rules as a link. |
slug |
string | No | 1–64 [a-z0-9-], auto-generated when omitted. Immutable after creation — a printed code cannot have its address changed. 422 qr_slug_immutable on any attempt. |
title |
string | null | No | ≤ 120 chars. |
domain_id |
uuid | null | No | Must be active. |
style |
object | No | { foreground_color, background_color, module_shape, eye_shape, gradient, logo_image_id, error_correction }. Validated per Section 14; contrast below 4.5:1 is 422 qr_contrast_insufficient; a logo over 22% of symbol width is 422 qr_logo_too_large. |
paused_fallback_url |
string | null | No | Used when the code is paused. |
is_paused |
boolean | No | Default false. A paused code still resolves — to the fallback chain, never to a 404. |
Representation adds id, scan_url, scan_count, unique_scan_count, last_scanned_at, error_correction_effective (after auto-upgrade), scannability ({ validated_at, passed, conditions: [...] }), fallback_stage (the rung the code currently resolves through — active, paused_fallback, workspace_unavailable or generic), created_at, updated_at.
scan_url is https://{host}/{slug} — bare, with no /q/ segment. QR slugs and short-link slugs share one namespace per host, which is what lets one permanent reservation table protect both (Section 14).
GET /qr-codes/{id}/render query:
| Parameter | Values | Default |
|---|---|---|
format |
svg, png |
svg |
size |
128–4096 px (PNG only) | 1024 |
dpi |
300, 600 (PNG only, sets metadata) | 300 |
Returns the binary with Content-Type: image/svg+xml or image/png, Content-Disposition: inline; filename="qr-{slug}.{ext}", and ETag derived from the style revision. A 304 is returned on If-None-Match. Cached renders do not count against the render rate limit.
POST /qr-codes/{id}/exports requests the print bundle (SVG + PNG at 300 and 600 DPI + PDF + EPS as a zip). Returns 202 with a job; GET the export id for status (queued|processing|completed|failed), and on completion download_url (signed, 24-hour expiry) and byte_size.
Errors: 404 qr_code_not_found · 409 qr_slug_reserved (checked against the permanent reservation table, so a slug used by a long-deleted code — or by a short link on the same host — is still reserved) · 422 qr_slug_immutable · 422 qr_contrast_insufficient · 422 qr_logo_too_large · 422 qr_unscannable (validation failed after two error-correction escalations; details names the styling choice responsible) · 422 destination_url_invalid · 403 plan_limit_reached.
Side effects: create reserves the slug permanently in qr_slug_reservations; destination changes write-through the resolution cache and write audit entry qr.destination_changed; style changes write qr.styling_changed and trigger re-validation and a new version record. Delete is a soft delete of the QR record only — the slug reservation is never removed and the code continues to resolve through the fallback chain forever. The DELETE response body is empty but the dashboard flow states this explicitly before confirming.
21.8.8 Custom domains #
Lifecycle, DNS records and TLS are Section 13.
| Method | Path | Scope | Success |
|---|---|---|---|
GET |
/domains |
domains:read |
200 (bounded, meta.total) |
POST |
/domains |
domains:write |
201 |
GET |
/domains/{id} |
domains:read |
200 |
GET |
/domains/{id}/verification |
domains:read |
200 |
POST |
/domains/{id}/verify |
domains:write |
202 |
DELETE |
/domains/{id} |
domains:write |
204 |
Create body: { "hostname": "go.acme.com" } — 4–253 chars, valid DNS name, public suffix rejected, punycode accepted and normalised to A-labels, must not already be claimed by another workspace.
Representation: id, hostname, state (pending_dns|verifying|provisioning_tls|active|dns_failed|tls_failed|suspended; exhaustive), is_apex, certificate_expires_at, last_checked_at, failure_code, created_at, updated_at.
GET /domains/{id}/verification returns the copyable records and the live diagnostic:
{
"data": {
"domain_id": "0192f3c1-c0de-7a44-8ee1-33aa55bb77cc",
"hostname": "go.acme.com",
"state": "pending_dns",
"required_records": [
{ "type": "TXT", "name": "_linkhub-challenge.go.acme.com", "value": "lh-verify-9f2c7a1e4b60", "status": "missing" },
{ "type": "CNAME", "name": "go.acme.com", "value": "cname.linkhub.app", "status": "mismatch" }
],
"observed": [
{ "type": "TXT", "name": "_linkhub-challenge.go.acme.com", "values": [] },
{ "type": "CNAME", "name": "go.acme.com", "values": ["acme.hosting-provider.net"] }
],
"next_check_at": "2026-08-19T10:06:12.000Z",
"attempts": 4,
"give_up_at": "2026-08-22T09:51:04.000Z"
},
"meta": {}
}POST /domains/{id}/verify re-queues a check immediately, returns 202, and is rate-limited to 1 per domain per 30 seconds (429 rate_limited, scope api_key).
Errors: 409 domain_already_claimed · 422 domain_invalid · 422 domain_is_public_suffix · 403 plan_limit_reached (0 on Free, 1 on Pro, 5 on Business) · 403 plan_feature_unavailable (domains:write is Business-only via the API; Pro users add domains in the dashboard) · 409 domain_in_use on delete when links, pages or QR codes still reference it, with the counts in details.
21.8.9 Experiments #
Statistics, sticky assignment, the minimum-sample guard and winner metrics are Section 16.
| Method | Path | Scope | Success |
|---|---|---|---|
GET |
/experiments |
experiments:read |
200 |
POST |
/experiments |
experiments:write |
201 |
GET |
/experiments/{id} |
experiments:read |
200 |
PATCH |
/experiments/{id} |
experiments:write |
200 |
DELETE |
/experiments/{id} |
experiments:write |
204 |
POST |
/experiments/{id}/start |
experiments:write |
200 |
POST |
/experiments/{id}/stop |
experiments:write |
200 |
POST |
/experiments/{id}/promote |
experiments:write |
200 |
Body:
| Field | Type | Required | Constraints |
|---|---|---|---|
name |
string | Yes | 1–80 chars. |
resource_type |
enum | Yes | bio_page or short_link. Exhaustive. Immutable. |
resource_id |
uuid | Yes | Must exist in the workspace. Immutable. |
variants |
array | Yes | 2 to the plan's experiment_arms_max for that resource type — 4 for bio_page, 8 for short_link (Section 22.1.3). Each { label, weight, payload }. Weights sum to exactly 100 (422 variant_weights_invalid). Over the arm cap is 403 plan_limit_reached with kind: "count". payload is a page-variant reference or a destination URL depending on resource_type. |
primary_metric |
enum | No | click_through_rate (default for bio_page), unique_clicks (default for short_link), conversion (requires a configured conversion goal and the experiment_conversion_goals entitlement — Business only, Section 22.1.3; otherwise 403 plan_feature_unavailable). |
minimum_samples_per_variant |
integer | No | Default 100, min 100. Values below 100 are rejected — the guard is a floor, not a suggestion. |
minimum_days |
integer | No | Default 7, min 7. |
Representation adds id, status (draft|running|paused|concluded|promoted|archived; exhaustive, and the same six values the schema's CHECK constraint permits — Section 6, with the lifecycle owned by Section 16.9), started_at, paused_at, concluded_at, promoted_at, promoted_variant_id, is_significant, confidence, samples (per variant), and can_promote (boolean — the guard's verdict, so a client does not reimplement the statistics).
POST /{id}/stop moves a running experiment to concluded. There is no completed, active or retired status; those names appear nowhere in this API.
POST /{id}/promote body:
{ "variant_id": "0192f3c2-1111-7a01-9f3e-77c1b0d2e455", "force": false, "force_confirmation": null }- With
force: falseandcan_promote: false, the request is refused with422 experiment_guard_not_metcarryingdetails.samples,details.required_samples,details.days_elapsed,details.required_days,details.confidence. - With
force: true,force_confirmationmust equal the experiment's exactname, otherwise422 force_confirmation_required. This mirrors the dashboard's typed confirmation. - Promotion writes audit entry
experiment.promotedincludingforced, emits theexperiment.promotedwebhook, applies the winning variant to the underlying resource, setsstatus = promoted, and stops assignment.
Errors: 404 experiment_not_found · 404 not_found (the resource_id names a bio page or link that does not exist in this workspace) · 409 experiment_already_running (one running experiment per resource) · 409 experiment_already_promoted · 422 variant_weights_invalid · 422 experiment_guard_not_met · 422 force_confirmation_required · 403 plan_feature_unavailable (experiments_enabled, Pro+; and experiment_conversion_goals, Business-only, when primary_metric = conversion) · 403 plan_limit_reached (experiments_concurrent — 3 on Pro, 25 on Business — or experiment_arms_max_*).
21.8.10 Analytics — time series #
GET /analytics/timeseries · scope analytics:read
| Parameter | Type | Required | Notes |
|---|---|---|---|
resource_type |
enum | No | workspace (default), bio_page, short_link, qr_code. |
resource_id |
uuid | Conditional | Required unless resource_type=workspace. |
metric |
enum | Yes | clicks, unique_clicks, scans, unique_scans, views, unique_views, leads. |
from, to |
date or timestamp | Yes | Inclusive from, exclusive to. Max span 366 days. to - from must exceed the interval. |
interval |
enum | No | hour (default when span ≤ 7 days), day (default otherwise), week, month. hour is rejected for spans over 31 days with 422 interval_too_fine. |
timezone |
string | No | IANA name; defaults to the workspace timezone. Bucket boundaries are computed in this zone, so "day" means the customer's day. |
include_bots |
boolean | No | Default false. |
compare_to |
enum | No | previous_period or previous_year. Adds a comparison array of the same length. |
Reach is bounded by plan retention (30 days Free, 365 days Pro, unlimited Business). A from earlier than the retention window is clamped, and meta.clamped_from reports the actual start — clamping silently would misrepresent a zero as "no traffic".
curl -s -G https://api.linkhub.app/v1/analytics/timeseries \
-H "Authorization: Bearer $LINKHUB_API_KEY" \
--data-urlencode "resource_type=short_link" \
--data-urlencode "resource_id=0192f3c1-9012-7c88-b4aa-2d55e9f1a733" \
--data-urlencode "metric=clicks" \
--data-urlencode "from=2026-08-01" \
--data-urlencode "to=2026-08-19" \
--data-urlencode "interval=day"{
"data": {
"metric": "clicks",
"interval": "day",
"timezone": "Europe/Berlin",
"points": [
{ "bucket_start": "2026-08-01T00:00:00Z", "value": 412 },
{ "bucket_start": "2026-08-02T00:00:00Z", "value": 388 },
{ "bucket_start": "2026-08-03T00:00:00Z", "value": 0 }
],
"total": 800
},
"meta": {
"from": "2026-08-01T00:00:00Z",
"to": "2026-08-19T00:00:00Z",
"clamped_from": null,
"source": "rollup",
"include_bots": false
}
}Buckets with no data are returned as 0, never omitted — a client should not have to reconstruct a dense series. meta.source is rollup or raw, so a caller knows whether they are reading pre-aggregated or drill-down data (Section 17).
Errors: 400 validation_failed · 422 range_too_large · 422 interval_too_fine · 422 timezone_invalid · 404 not_found (the named resource does not exist in this workspace) · 403 plan_feature_unavailable (the API is Pro+).
21.8.11 Analytics — breakdowns and top-N #
GET /analytics/breakdown · scope analytics:read
Adds to the time-series parameters:
| Parameter | Type | Required | Notes |
|---|---|---|---|
dimension |
enum | Yes | country, region, device_type, os, browser, referrer_host, utm_source, utm_medium, utm_campaign, variant_id, fallback_stage, bio_page, short_link, qr_code. |
limit |
integer | No | 1–100, default 25. Paginated per 21.4 with a cursor over (value DESC, dimension_value ASC). |
include_other |
boolean | No | Default true. Appends a synthetic {"dimension_value": "__other__"} row carrying the sum of everything beyond the returned page, so percentages add to 100. |
{
"data": {
"dimension": "country",
"metric": "clicks",
"rows": [
{ "dimension_value": "DE", "label": "Germany", "value": 3182, "share": 0.412 },
{ "dimension_value": "GB", "label": "United Kingdom", "value": 1901, "share": 0.246 },
{ "dimension_value": "__other__", "label": "Other", "value": 2637, "share": 0.342 }
],
"total": 7720
},
"meta": { "next_cursor": null, "has_more": false, "from": "2026-08-01T00:00:00Z", "to": "2026-08-19T00:00:00Z", "source": "rollup" }
}share is rounded to 3 decimals and computed against total including __other__.
Unique metrics are not available on a breakdown. Unique-visitor deduplication is stored at resource grain, not per dimension (Section 17), so metric=unique_clicks, unique_scans or unique_views on /analytics/breakdown returns 422 unique_metric_not_available_by_dimension, whose message names the event metric to use instead. Uniques are available on /analytics/timeseries and /analytics/top, both of which are resource-grain. This is a real limit of the data, not a plan gate, and it is identical on every plan.
GET /analytics/top · scope analytics:read — a convenience shape for leaderboards.
| Parameter | Notes |
|---|---|
entity |
links, qr_codes, bio_pages, blocks. |
metric |
clicks, unique_clicks, scans, views, leads. |
from, to, timezone, include_bots |
As above. |
limit |
1–50, default 10. Not cursor-paginated — a top-N is by definition bounded, and meta.total is absent because the underlying set is not. |
Rows carry the entity's id, its display identifier (slug, handle, or block label), value, and share.
21.8.12 Analytics — export #
| Method | Path | Scope | Success |
|---|---|---|---|
POST |
/analytics/exports |
analytics:read |
202 |
GET |
/analytics/exports/{id} |
analytics:read |
200 |
GET |
/analytics/exports |
analytics:read |
200 |
Create body:
| Field | Type | Required | Notes |
|---|---|---|---|
kind |
enum | Yes | raw_events (drill-down rows) or aggregated (rollup rows). |
resource_type, resource_id |
No | As in 21.8.10. | |
from, to |
Yes | Max span 366 days; raw_events is additionally bounded by the plan's raw retention. |
|
dimensions |
string[] | No | For aggregated, the grouping. Max 3. |
format |
enum | No | csv (default) or ndjson. |
include_bots |
boolean | No | Default false. |
timezone |
string | No | Workspace default. |
{
"data": {
"id": "0192f3c2-9999-7ccc-8ddd-1a2b3c4d5e6f",
"kind": "aggregated",
"status": "queued",
"format": "csv",
"row_count": null,
"byte_size": null,
"download_url": null,
"expires_at": null,
"error_code": null,
"created_at": "2026-08-19T12:00:11.004Z"
},
"meta": { "poll_url": "/v1/analytics/exports/0192f3c2-9999-7ccc-8ddd-1a2b3c4d5e6f", "estimated_seconds": 25 }
}status transitions queued → processing → completed | failed. On completed, download_url is a signed URL valid 24 hours, row_count and byte_size are populated, and the export.ready webhook fires. On failed, error_code is populated (export_too_large, export_source_unavailable, export_timeout) and the job may be re-created.
Poll guidance: 2 seconds for the first 30 seconds, then 10 seconds, up to 15 minutes. Polling faster consumes the general rate limit for no benefit; the webhook exists precisely so polling is optional.
Errors: 422 export_too_large (over 1 000 000 rows; details.estimated_rows given) · 422 range_too_large · 422 export_retention_exceeded (raw events older than the plan's raw window) · 403 plan_feature_unavailable (export is Pro+) · 429 rate_limited scope export · 404 export_not_found · 410 export_expired (the download link and artefact have been purged after 24 hours).
21.8.13 Deliberately absent surfaces #
Four capabilities that a reader will look for are not on this API. Each is absent by decision, not by omission, and each is recorded here so that nobody restores it by inferring it from an adjacent endpoint.
| Absent surface | Where it lives instead | Why it is not here |
|---|---|---|
| Leads — read, list, export, mutate | Dashboard only: the leads list, detail drawer and CSV export in Section 20.7 | Bulk export of personal data must be step-up authenticated at the moment of export (Section 3.3.11). An API key is a bearer credential with no second factor and no human present, so it can never satisfy that requirement. There is therefore no leads:read scope (21.2.4), no GET /leads, no GET /leads/{id}, no POST /leads/exports and no lead mutation endpoint. A workspace that needs leads in another system receives them event-by-event through the lead.captured webhook (19.7.3) or through a configured sync target (Section 20.5), both of which deliver to a destination an Owner or Admin chose and neither of which lets a credential enumerate the list. |
| Audit log | Dashboard only, under workspace settings (Section 8) | The audit log is the record used to reconstruct what a compromised credential did. A credential that can read it can read its own cover story, and can also read every other actor's activity — which is the most sensitive read in the product and the one least likely to be noticed. There is no audit:read scope. Audit history is available to a session-authenticated Owner or Admin, and is exported through the workspace data export in Section 23. |
| Billing — subscriptions, invoices, payment methods, refunds, plan changes | Dashboard only, on the dashboard origin, session-authenticated (Section 22) | Billing is dashboard-session-only by design, so that a leaked API key can never reach payment state. No /v1/billing/* path exists and billing:* is not an issuable scope. The entitlement and usage figures an integration legitimately needs to decide whether it may proceed are on GET /account (21.8.14), which exposes limits and counts and nothing financial — no card, no invoice, no amount, no tax identifier. |
| API key lifecycle — create, rotate, delete | Dashboard only (21.2.6) | A key that can mint keys turns any single leak into permanent, unbounded access. |
Requests to any of these paths return 404 endpoint_not_found, which is the same response as any other unknown path — the API does not advertise the existence of routes it does not serve.
21.8.14 Account and usage #
GET /account · scope account:read
Reports the calling key's identity and the workspace's current entitlement consumption. This is the endpoint an integration calls to decide whether it may proceed before attempting a write, and the endpoint Zapier uses as its connection test.
{
"data": {
"workspace": {
"id": "0192f3c1-8a44-7b31-9d02-6f1c2b7a4e55",
"name": "Acme",
"slug": "acme",
"plan": "pro",
"timezone": "Europe/Berlin"
},
"api_key": {
"id": "0192f3b0-2222-7000-8000-ddddeeeeffff",
"name": "CI deploy key",
"prefix": "a3F9Kx",
"scopes": ["links:read", "links:write", "qr:read", "account:read"],
"created_at": "2026-05-02T09:14:00.000Z",
"last_used_at": "2026-08-19T12:04:31.000Z",
"expires_at": null
},
"entitlements": {
"bio_pages": { "limit": 10, "current": 4, "remaining": 6 },
"short_links": { "limit": null, "current": 812, "remaining": null, "fair_use_created_per_period": 10000, "created_this_period": 143 },
"qr_codes": { "limit": 100, "current": 100, "remaining": 0 },
"custom_domains": { "limit": 1, "current": 1, "remaining": 0 },
"seats_per_workspace": { "limit": 1, "current": 1, "remaining": 0 },
"api_keys": { "limit": 5, "current": 2, "remaining": 3 },
"experiments_concurrent": { "limit": 3, "current": 1, "remaining": 2 },
"analytics_share_links": { "limit": 25, "current": 4, "remaining": 21 },
"saved_segments": { "limit": 25, "current": 6, "remaining": 19 },
"scheduled_report_schedules": { "limit": 1, "current": 1, "remaining": 0 }
},
"features": {
"utm_builder": true,
"scheduling_and_expiry": true,
"experiments_enabled": true,
"experiment_conversion_goals": false,
"experiment_results_export": true,
"csv_export": true,
"team_roles_and_grants": false,
"outbound_webhooks": true,
"lead_sync": true,
"api_access": "read_limited_write",
"domains_write_api": false,
"branding_removed": true
},
"retention": {
"raw_events_days": 90,
"rollup_days": 365,
"dashboard_reach_days": 365,
"audit_log_days": 365
},
"rate_limits": {
"requests_per_minute": 120,
"writes_per_minute": 30,
"analytics_per_minute": 30,
"concurrent_requests": 10
}
},
"meta": {}
}Every key under entitlements and features is a catalogue key from Section 22.1.3, spelled identically, so a client can map a plan_limit_reached or plan_feature_unavailable refusal straight back onto the value it read here. limit: null means unlimited; remaining: null accompanies it. Clients must handle null rather than assuming a number.
This endpoint reports no financial data of any kind — no subscription status, no invoice, no amount, no payment method, no tax identifier, no renewal date. Those live on the dashboard's billing routes (Section 22) and are unreachable by any API key.
Errors: none beyond the universal set. Side effects: none. This endpoint is deliberately cheap and is excluded from the analytics sub-limit.
21.8.15 Capabilities — batch authorization #
POST /capabilities
The dashboard renders every control by asking whether the current actor may perform an action, rather than by re-implementing the permission matrix in the client. That question needs one round trip for a whole screen, not one per control, and it needs the same answer the eventual write would give — otherwise the UI and the server disagree and a user is shown a button that fails. This endpoint is that question, and it is the only sanctioned way to obtain a render-time authorization decision.
Authentication. Either an authenticated dashboard session on the dashboard origin, or an API key. A key must carry the read scope of every resource type named in the request; a request naming a resource type whose read scope the key lacks returns 403 insufficient_scope with details[0].required_scope. Evaluating "may I?" is itself a read of the authorization state, so it is gated like one.
Rate limiting. Counted as a read against the general per-key limit in 21.7.1. It has no separate budget: the endpoint resolves entirely from the cached capability set (cap:{user}:{ws}) and the cached entitlement snapshot, so it is cheap by construction.
Request body:
| Field | Type | Required | Constraints |
|---|---|---|---|
checks |
array | Yes | 1–100 items. Over 100 → 422 too_many_checks with details[0].limit: 100. |
checks[].action |
string | Yes | A resource.verb action token, e.g. link.update, bio_page.publish, qr_code.create, member.invite, experiment.promote, workspace.create. Non-exhaustive enum. An unknown token is not an error — it evaluates to allowed: false with deny_code: "unknown_action", so a newer dashboard against an older server degrades to hiding a control rather than crashing. |
checks[].resource_type |
string | Yes | The resource type the action applies to. workspace is permitted and, together with resource_id: null, expresses an account-scoped question such as "may I create a workspace?" — which has no workspace context to evaluate against and is answered from the billing account's plan cap. |
checks[].resource_id |
uuid | null | Yes — may be null |
null asks the type-level question ("may I create a link at all?"). A uuid asks the instance-level question ("may I edit this link?"), which additionally evaluates per-resource grants. |
checks[].key |
string | No | 1–64 chars, echoed back verbatim on the matching result so a client can correlate without relying on array order. Must be unique within the request; a duplicate is 400 validation_failed. |
Response, 200. The results array is in request order, one entry per check, always the same length as checks:
| Field | Type | Meaning |
|---|---|---|
key |
string | null | Echoed from the request. |
action, resource_type, resource_id |
Echoed, so a result is self-describing in a log. | |
allowed |
boolean | The decision. |
deny_code |
string | null | null when allowed is true. Otherwise the exact error code the write would have returned. |
deny_details |
object | null | For an entitlement denial, the same details[0] object the write would have returned (21.3.2), so the UI can render the upgrade modal without a second call. |
curl -s -X POST https://api.linkhub.app/v1/capabilities \
-H "Authorization: Bearer $LINKHUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"checks": [
{ "key": "new-link", "action": "link.create", "resource_type": "link", "resource_id": null },
{ "key": "edit-link", "action": "link.update", "resource_type": "link", "resource_id": "0192f3c1-9012-7c88-b4aa-2d55e9f1a733" },
{ "key": "new-exp", "action": "experiment.create", "resource_type": "experiment","resource_id": null },
{ "key": "invite", "action": "member.invite", "resource_type": "workspace", "resource_id": "0192f3c1-8a44-7b31-9d02-6f1c2b7a4e55" }
]
}'{
"data": {
"results": [
{ "key": "new-link", "action": "link.create", "resource_type": "link", "resource_id": null,
"allowed": true, "deny_code": null, "deny_details": null },
{ "key": "edit-link", "action": "link.update", "resource_type": "link",
"resource_id": "0192f3c1-9012-7c88-b4aa-2d55e9f1a733",
"allowed": true, "deny_code": null, "deny_details": null },
{ "key": "new-exp", "action": "experiment.create", "resource_type": "experiment", "resource_id": null,
"allowed": false, "deny_code": "plan_limit_reached",
"deny_details": { "field": "experiments_concurrent", "issue": "limit_reached",
"limit": 3, "current": 3, "plan": "pro", "kind": "count" } },
{ "key": "invite", "action": "member.invite", "resource_type": "workspace",
"resource_id": "0192f3c1-8a44-7b31-9d02-6f1c2b7a4e55",
"allowed": false, "deny_code": "plan_feature_unavailable",
"deny_details": { "field": "team_roles_and_grants", "issue": "feature_unavailable",
"plan": "pro", "required_plan": "business" } }
]
},
"meta": { "evaluated_at": "2026-08-19T12:09:44.201Z", "entitlement_version": 41 }
}Rules that make this endpoint safe to depend on:
- One evaluator. A capability check runs the identical authorization function the write path runs, with the same inputs, in the same order: workspace binding first (a resource in another workspace is never disclosed — see 4 below), then role, then per-resource grants, then billing enforcement mode, then the entitlement reservation simulated without writing. There is no second, "UI-only" permission table. A divergence between this endpoint and a write is a bug in one shared function, not a drift between two implementations.
- Advisory, never authoritative.
allowed: trueis a prediction, not a grant. Every write re-evaluates from scratch inside its own transaction, and may still refuse — a counter can fill between the check and the write. A client must handle a403on a write it was told would succeed. This endpoint exists to avoid showing dead controls, not to move the enforcement boundary into the browser. - No
404for an individual check. The response is200even when every check is denied. Only a malformed request, a missing scope, or an unauthenticated caller produces a non-200. - Cross-workspace ids are indistinguishable from missing ones. A
resource_idbelonging to another workspace returnsallowed: falsewithdeny_code: "not_found"— exactly what a nonexistent id returns. The endpoint cannot be used as an existence oracle, which would otherwise make it the softest cross-tenant probe in the product. - Entitlement denials carry their payload. Because
deny_detailsis the same object the write would emit, the dashboard's upgrade modal is fed from the check and never needs to provoke a real refusal to learn what to say. - Freshness.
meta.entitlement_versionis the monotonic entitlement version the decision used (Section 22.2.6). A client that holds a rendered screen across an entitlement change can compare versions and re-check rather than guess. Decisions are computed against caches with a worst-case staleness of 10 seconds, which is stated so no caller treats them as transactionally current. - Never cached by an intermediary.
Cache-Control: private, no-store, like every other response from this API (21.3.4).
Errors: 400 validation_failed (malformed check, duplicate key) · 422 too_many_checks · 403 insufficient_scope · 401 unauthenticated.
Side effects: none. This endpoint never writes, never reserves a counter, never emits an audit entry and never fires a webhook.
21.9 Webhooks as an API concern #
The event catalogue, payload shapes, signature scheme, retry ladder, dead-lettering and SSRF rules are Section 19.7. They are not restated here. This subsection covers only the API-side configuration surface.
| Method | Path | Scope | Success |
|---|---|---|---|
GET |
/webhook-endpoint |
webhooks:read |
200 |
PUT |
/webhook-endpoint |
webhooks:write |
200 |
DELETE |
/webhook-endpoint |
webhooks:write |
204 |
POST |
/webhook-endpoint/test |
webhooks:write |
202 |
GET |
/webhook-deliveries |
webhooks:read |
200 |
GET |
/webhook-deliveries/{id} |
webhooks:read |
200 |
POST |
/webhook-deliveries/{id}/replay |
webhooks:write |
202 |
The path is singular (/webhook-endpoint) and the method is PUT, because there is exactly one endpoint per workspace (19.7.1). Modelling a singleton as a collection would imply a subscription API that does not exist.
PUT body:
| Field | Type | Required | Notes |
|---|---|---|---|
url |
string | Yes | Validated against 19.7.9 synchronously. |
enabled |
boolean | No | Default true. |
send_click_events |
boolean | No | Default false. |
send_lead_events |
boolean | No | Default true. |
send_management_events |
boolean | No | Default true. |
rotate_secret |
boolean | No | Default false. When true, opens the 24-hour dual-signing window and returns the new secret once in the response. |
The signing secret is returned only on creation and on explicit rotation. GET returns secret_prefix (first 6 characters) and secret_rotating_until, never the secret.
{
"data": {
"url": "https://hooks.acme.com/linkhub",
"enabled": true,
"status": "connected",
"secret_prefix": "whsec_",
"secret_rotating_until": null,
"send_click_events": false,
"send_lead_events": true,
"send_management_events": true,
"consecutive_failures": 0,
"last_success_at": "2026-08-19T12:03:58.220Z",
"last_error_code": null,
"created_at": "2026-06-02T08:00:00.000Z",
"updated_at": "2026-08-19T12:03:58.220Z"
},
"meta": {}
}Errors: 422 webhook_url_scheme_invalid · 422 webhook_url_port_invalid · 422 webhook_url_private_address · 422 webhook_url_invalid · 422 webhook_test_failed (the activation probe did not return 2xx) · 409 webhook_signature_secret_rotating · 409 webhook_suspended (send a successful test before re-enabling) · 403 plan_feature_unavailable (Pro+; webhooks:write via the API is Business-only per 21.7.1).
GET /webhook-deliveries filters on event_type, status (delivered|retrying|dead_lettered|dropped), event_id, created_at. Sort -created_at only. Each delivery carries id, event_id, event_type, status, attempts, last_status_code, last_latency_ms, next_attempt_at, created_at. Bodies are available on the single-delivery GET.
POST /webhook-deliveries/{id}/replay returns 202 and creates a new delivery with a fresh delivery id and signature, retaining the original event id. Rate-limited to 100 replays per hour per workspace.
21.10 The OpenAPI document #
| Property | Value |
|---|---|
| Specification version | OpenAPI 3.1 (JSON Schema 2020-12 dialect, so the Zod-derived schemas map without lossy translation). |
| Document version | Semantic, e.g. 1.4.0, incremented on every merged change to the API surface. Independent of the /v1 path version — the path version is the compatibility boundary, the document version is a changelog handle. |
| Served at | https://api.linkhub.app/v1/openapi.json and https://api.linkhub.app/v1/openapi.yaml, unauthenticated, cacheable for 5 minutes, with an ETag. |
| Rendered docs | https://api.linkhub.app/docs, generated from the same document at build time. |
| Committed copy | apps/api/openapi.json in the repository, so the document is reviewable in a pull-request diff. |
21.10.1 Generation — how drift is made impossible #
There is exactly one source of truth for every request and response shape: the Zod schemas in packages/core. Those schemas are used by:
- the API request validators,
- the API response serialisers,
- the TypeScript types shared with the dashboard,
- the OpenAPI generator.
The generator walks the route table, reads each route's declared request/response schemas, and emits the document. Because the validator and the document are produced from the same object, a handler cannot accept a field the document does not describe, and cannot return a field the document does not declare.
The build fails if the emitted document differs from the committed one:
// package.json scripts in apps/api
{
"openapi:generate": "tsx scripts/generate-openapi.ts > openapi.json",
"openapi:check": "tsx scripts/generate-openapi.ts | diff -u openapi.json - "
}openapi:check runs in CI on every pull request. A route change without a regenerated document is a red build, not a documentation debt.
21.10.2 Contract tests #
Four gates, all in CI (Section 26 owns the wider testing strategy):
| Gate | What it does | Failure mode it prevents |
|---|---|---|
| Schema lint | Spectral with the OpenAPI ruleset plus LinkHub rules: every operation has an operationId, a summary, at least one 4xx response, a tagged security requirement, and every schema property has a description. |
An endpoint shipped with no documented error cases. |
| Response validation | In test and preview environments, a middleware validates every outgoing response body against the document's schema for that operation and status, and fails the request loudly on mismatch. Every integration test therefore doubles as a contract test. |
A handler quietly returning an extra or missing field. |
| Example validation | Every example and every examples entry in the document — including the ones reproduced in this specification and in the developer docs — is validated against its own schema. |
A copy-pasteable example that does not actually work. |
| Breaking-change diff | An OpenAPI diff between the pull request's document and the one on the default branch. Any change classified as breaking under 21.1.3 fails the build unless the pull request carries an explicit api-breaking-change label and a linked deprecation entry. |
An accidental compatibility break. |
Additionally, the webhook payload schemas (19.7) live in the same document under components/schemas and are covered by the same gates, and the signature-verification sample in 19.7.6 is executed against a signature produced by the production signer in a test.
21.11 SDK and client guidance #
21.11.1 The decision on SDKs #
LinkHub does not publish a hand-written SDK at launch. Instead it publishes @linkhub/api-types — TypeScript types and Zod schemas generated from the OpenAPI document and released on every document version. Rationale: a hand-written SDK is a second surface that drifts from the API and needs its own release process, its own tests and its own deprecation policy; generated types give TypeScript users full safety with zero drift risk, and every other language has a mature OpenAPI generator.
Recommended clients:
| Language | Recommendation |
|---|---|
| TypeScript / JavaScript | openapi-fetch with @linkhub/api-types, or orval for a TanStack Query layer. |
| Python | openapi-python-client against the served document. |
| Go | oapi-codegen. |
| Ruby, PHP, Java, C# | OpenAPI Generator. |
| Anything | Plain HTTP. The API is deliberately simple enough that curl plus a JSON parser is a legitimate integration. |
A hand-written SDK for TypeScript and Python is on the roadmap and will be published only if it can be generated-plus-hand-polished in a way that keeps the drift guarantee.
21.11.2 Quickstart — curl #
export LINKHUB_API_KEY="lh_sk_a3F9KxQ2rV8pLmZ7dTn4WcB6yE1sHu0J"
export LINKHUB_API="https://api.linkhub.app/v1"
# 1. Confirm the key works and see what it may do.
curl -s "$LINKHUB_API/account" \
-H "Authorization: Bearer $LINKHUB_API_KEY" | jq '.data.workspace, .data.api_key.scopes'
# 2. Create a short link, safe to retry.
curl -s -X POST "$LINKHUB_API/links" \
-H "Authorization: Bearer $LINKHUB_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"destination_url": "https://acme.com/spring",
"slug": "spring-sale",
"utm": { "source": "instagram", "medium": "social", "campaign": "spring-sale" }
}' | jq '.data.short_url'
# 3. Page through every link, 100 at a time.
cursor=""
while :; do
resp=$(curl -s -G "$LINKHUB_API/links" \
-H "Authorization: Bearer $LINKHUB_API_KEY" \
--data-urlencode "limit=100" \
${cursor:+--data-urlencode "cursor=$cursor"})
echo "$resp" | jq -r '.data[].short_url'
[ "$(echo "$resp" | jq -r '.meta.has_more')" = "true" ] || break
cursor=$(echo "$resp" | jq -r '.meta.next_cursor')
done
# 4. Read yesterday's clicks.
curl -s -G "$LINKHUB_API/analytics/timeseries" \
-H "Authorization: Bearer $LINKHUB_API_KEY" \
--data-urlencode "metric=clicks" \
--data-urlencode "from=2026-08-18" \
--data-urlencode "to=2026-08-19" \
--data-urlencode "interval=day" | jq '.data.total'21.11.3 Quickstart — TypeScript #
A dependency-free client showing authentication, the envelope, error handling, retry with Retry-After, idempotency and cursor pagination. Paste it and it runs.
const BASE_URL = 'https://api.linkhub.app/v1';
const API_KEY = process.env.LINKHUB_API_KEY!;
type Envelope<T> = { data: T; meta: Record<string, unknown> };
type ApiErrorBody = {
error: {
code: string;
message: string;
details: Array<{ field?: string; issue: string; [k: string]: unknown }>;
request_id: string;
};
};
export class LinkHubError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
readonly details: ApiErrorBody['error']['details'],
readonly requestId: string,
) {
super(`${code}: ${message} (request_id=${requestId})`);
this.name = 'LinkHubError';
}
}
const RETRYABLE = new Set([408, 429, 500, 502, 503, 504]);
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function request<T>(
path: string,
init: RequestInit & { idempotencyKey?: string } = {},
): Promise<Envelope<T>> {
const headers = new Headers(init.headers);
headers.set('Authorization', `Bearer ${API_KEY}`);
if (init.body) headers.set('Content-Type', 'application/json');
if (init.idempotencyKey) headers.set('Idempotency-Key', init.idempotencyKey);
for (let attempt = 0; attempt < 5; attempt++) {
const res = await fetch(`${BASE_URL}${path}`, { ...init, headers });
// Deprecation signals are worth surfacing, not swallowing.
const sunset = res.headers.get('Sunset');
if (sunset) console.warn(`[linkhub] endpoint ${path} sunsets on ${sunset}`);
if (res.status === 204) return { data: undefined as T, meta: {} };
if (res.ok) return (await res.json()) as Envelope<T>;
if (RETRYABLE.has(res.status) && attempt < 4) {
const retryAfter = Number(res.headers.get('Retry-After'));
const delayMs = Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter * 1000
: Math.random() * Math.min(60_000, 1000 * 2 ** attempt); // full jitter
await sleep(delayMs);
continue;
}
const body = (await res.json().catch(() => null)) as ApiErrorBody | null;
throw new LinkHubError(
res.status,
body?.error.code ?? 'unknown_error',
body?.error.message ?? res.statusText,
body?.error.details ?? [],
body?.error.request_id ?? res.headers.get('X-Request-Id') ?? 'unknown',
);
}
throw new LinkHubError(0, 'retries_exhausted', 'Retries exhausted', [], 'unknown');
}
/** Create a short link. Safe to retry: the idempotency key prevents duplicates. */
export async function createLink(input: {
destination_url: string;
slug?: string;
tags?: string[];
}) {
const { data } = await request<{ id: string; slug: string; short_url: string }>('/links', {
method: 'POST',
body: JSON.stringify(input),
idempotencyKey: crypto.randomUUID(),
});
return data;
}
/** Iterate an entire collection. Cursors are opaque — never build one yourself. */
export async function* paginate<T>(
path: string,
params: Record<string, string> = {},
): AsyncGenerator<T> {
let cursor: string | null = null;
do {
const query = new URLSearchParams({ ...params, limit: '100' });
if (cursor) query.set('cursor', cursor);
const { data, meta } = await request<T[]>(`${path}?${query}`);
for (const item of data) yield item;
cursor = (meta.has_more ? (meta.next_cursor as string) : null);
} while (cursor);
}
// Usage
const link = await createLink({ destination_url: 'https://acme.com/spring', slug: 'spring-sale' });
console.log(link.short_url);
for await (const l of paginate<{ short_url: string }>('/links', { status: 'active' })) {
console.log(l.short_url);
}21.11.4 Client requirements #
Any client, generated or hand-written, must:
- Send
Authorization: Bearerand never place the key in a URL. - Ignore unknown response fields rather than failing deserialisation.
- Branch on
error.code, never onerror.message. - Treat cursors as opaque and never construct one.
- Retry only the statuses in 21.7.5, honouring
Retry-After, with full jitter. - Send an
Idempotency-Keyon everyPOSTandPATCHthat it may retry. - Log
request_idon every failure. - Never call this API from a browser with a key.
21.12 The error-code reference for the API surface #
Every code this API can return. code is the contract; message may be reworded at any time. Section 30 aggregates these with the codes owned by other sections; where a code appears in both, this table is authoritative for the API surface.
Authentication and authorisation
| Code | Status | Retryable | Meaning |
|---|---|---|---|
unauthenticated |
401 | No | No credential, or a malformed Authorization header. |
api_key_invalid |
401 | No | Unknown key, or a key whose format is wrong. |
api_key_revoked |
401 | No | The key was revoked. |
api_key_expired |
401 | No | The key passed expires_at. |
api_key_in_query |
400 | No | A credential was supplied in the query string. |
insufficient_scope |
403 | No | The key lacks the scope named in details.required_scope. |
insufficient_role |
403 | No | The key's creating member's role is too low for this operation. |
workspace_suspended |
403 | No | The workspace is suspended or being deleted. |
https_required |
403 | No | A non-GET request arrived over plain HTTP. |
Request shape
| Code | Status | Retryable | Meaning |
|---|---|---|---|
validation_failed |
400 | No | One or more body fields failed validation. details lists all of them. |
malformed_json |
400 | No | The body is not valid JSON. |
unknown_field |
400 | No | An undocumented body field was sent. |
unknown_parameter |
400 | No | An undocumented query parameter was sent, including offset/page. |
invalid_filter |
400 | No | Unknown filter field. |
invalid_filter_operator |
400 | No | Operator not supported for that field. |
invalid_filter_value |
400 | No | Value could not be parsed for that field's type. |
filter_too_many_values |
400 | No | More than 50 values in in/nin. |
too_many_filters |
400 | No | More than 10 distinct filter fields. |
invalid_sort_field |
400 | No | Field not sortable on this endpoint. |
too_many_sort_keys |
400 | No | More than 2 sort keys. |
invalid_field_selection |
400 | No | Unknown name in fields. |
invalid_expansion |
400 | No | Unknown or nested expand. |
expansion_limit_exceeded |
400 | No | Expansion requested with limit above 25. |
limit_out_of_range |
400 | No | limit outside 1–100. |
invalid_cursor |
400 | No | Cursor is malformed, from another workspace, or of an unsupported version. |
cursor_expired |
400 | No | Cursor older than 24 hours. |
cursor_filter_mismatch |
400 | No | Cursor reused with a different filter or sort. |
unsupported_media_type |
415 | No | Content-Type was not application/json, or the charset was not utf-8. Never a 400. |
request_too_large |
413 | No | Over 1 MB (2 MB bulk), including after gzip decompression. Never a 400. |
uri_too_long |
414 | No | Over 8 192 bytes. |
method_not_allowed |
405 | No | Path exists, method does not. |
endpoint_not_found |
404 | No | No such path, including the deliberately absent surfaces in 21.8.13. |
too_many_checks |
422 | No | More than 100 entries in a POST /capabilities body. |
Idempotency, concurrency and rate limiting
| Code | Status | Retryable | Meaning |
|---|---|---|---|
idempotency_key_invalid |
400 | No | Format violation. |
idempotency_key_required |
400 | No | Missing on a bulk endpoint. |
idempotency_key_reused |
409 | No | Same key, different body. |
idempotency_key_in_flight |
409 | Yes, after 1s | A request with this key is still executing. |
idempotency_response_too_large |
409 | No | The stored response exceeded 256 KB; verify state by reading the resource. |
precondition_failed |
412 | Yes | If-Match did not match the current ETag. |
rate_limited |
429 | Yes | Any rate-limit scope. details.scope names it. |
concurrency_limit |
429 | Yes | Too many simultaneous requests for this key. |
Entitlements
These two codes are the only entitlement codes in the product. feature_not_available, plan_feature_not_available and every per-resource cap variant are not codes; they do not exist and must not be emitted, accepted or documented anywhere.
| Code | Status | Retryable | Meaning |
|---|---|---|---|
plan_limit_reached |
403 | No | A numeric or period cap is exhausted. details[0] carries field (the catalogue key), issue: "limit_reached", kind (count or period), limit, current, plan, and for kind: "period" a period object. Shape defined in 21.3.2. |
plan_feature_unavailable |
403 | No | The plan does not include the capability at any quantity. details[0] carries field, issue: "feature_unavailable", plan, required_plan, and no limit, current or kind. Shape defined in 21.3.2. |
email_verification_required |
403 | No | Publishing a public surface requires a verified account. |
Resources
| Code | Status | Retryable | Meaning |
|---|---|---|---|
not_found |
404 | No | The generic form. Used where the resource type is not implied by the path, and as the cross-workspace response: a request naming a workspace the key is not bound to returns this before any capability evaluation (21.2.5). There is no resource_not_found. |
bio_page_not_found · block_not_found · link_not_found · destination_not_found · targeting_rule_not_found · qr_code_not_found · domain_not_found · experiment_not_found · export_not_found · webhook_delivery_not_found |
404 | No | Typed forms, used where the path makes the type unambiguous. Missing, soft-deleted, or in another workspace. Never 403. |
page_handle_taken |
409 | No | Handle in use on that host. |
link_slug_taken |
409 | No | Short-link slug in use on that domain. |
qr_slug_reserved |
409 | No | Slug is permanently reserved — possibly by a deleted QR code, possibly by a short link on the same host, since the two share one namespace. There is no qr_slug_unavailable, qr_slug_taken or link_slug_permanently_reserved. |
domain_already_claimed |
409 | No | Claimed by another workspace. |
domain_in_use |
409 | No | Still referenced by links, pages or QR codes. Counts in details. |
experiment_already_running |
409 | No | One running experiment per resource. |
experiment_already_promoted |
409 | No | Terminal state. |
destination_locked_by_experiment |
409 | No | A running experiment owns the split. |
handle_reserved · slug_reserved |
422 | No | On the reserved-word or profanity blocklist. |
slug_confusable |
422 | No | Failed the homoglyph/confusable check. |
destination_url_invalid |
422 | No | Scheme not allowed, or unparseable. |
destination_url_private_address |
422 | No | Resolves to a private, loopback or link-local address. |
destination_url_blocked |
422 | No | Flagged by the safe-browsing lookup. |
domain_not_active |
422 | No | Referenced domain is not in active. |
domain_invalid · domain_is_public_suffix |
422 | No | Not a usable hostname. |
theme_contrast_failed |
422 | No | Theme fails the 4.5:1 contrast requirement. |
block_config_invalid |
422 | No | Config does not match the block kind's schema. |
block_kind_immutable |
422 | No | kind cannot be changed after creation. |
reorder_set_mismatch |
422 | No | The id list is not exactly the page's block set. |
page_has_no_blocks |
422 | No | Cannot publish an empty page. |
schedule_invalid |
422 | No | ends_at is not after starts_at. |
destination_weights_invalid |
422 | No | Active destination weights do not sum to 100. |
targeting_values_invalid |
422 | No | A value is not valid for the rule type. |
variant_weights_invalid |
422 | No | Variant weights do not sum to 100. |
experiment_guard_not_met |
422 | No | Minimum sample or duration guard not satisfied. |
force_confirmation_required |
422 | No | Force-promote without the exact typed confirmation. |
qr_slug_immutable |
422 | No | A QR slug can never change. |
qr_contrast_insufficient |
422 | No | Below 4.5:1. |
qr_logo_too_large |
422 | No | Logo exceeds 22% of symbol width. |
qr_unscannable |
422 | No | Failed decode validation after escalation. |
bulk_too_many_items |
422 | No | Over the plan's per-request item cap. |
bulk_aborted |
422 | No | on_error: "abort" and one item failed; nothing was applied. |
range_too_large |
422 | No | Analytics or audit range over 366 days. |
interval_too_fine |
422 | No | hour interval over a span longer than 31 days. |
timezone_invalid |
422 | No | Not a recognised IANA zone. |
export_too_large |
422 | No | Over 1 000 000 estimated rows. |
export_retention_exceeded |
422 | No | Raw events requested beyond the plan's raw retention. |
unique_metric_not_available_by_dimension |
422 | No | A unique metric was requested on /analytics/breakdown; uniques are resource-grain only (21.8.11). |
export_expired |
410 | No | The artefact and its link were purged after 24 hours. |
endpoint_sunset · api_version_sunset |
410 | No | Past the announced sunset date. |
Integration configuration (full catalogue in Section 19.12): webhook_url_scheme_invalid, webhook_url_port_invalid, webhook_url_private_address, webhook_url_invalid, webhook_test_failed, webhook_suspended, webhook_signature_secret_rotating.
Codes this API deliberately never emits. Named so that a reader does not add them by analogy:
| Code | Status | Where it belongs |
|---|---|---|
totp_required |
401 | The dashboard session surface only (Section 3.3.11). Step-up authentication requires a human and a second factor; an API key has neither, so this API never asks for one and never emits this code. It is a 401, not a 403, because the correct remedy is to authenticate again. |
billing_write_blocked, plan_change_not_permitted, card_declined, payment_requires_action, and every other billing code |
— | The dashboard's billing routes (Section 22.14.1). No credential this API issues can reach billing state. |
feature_not_available, plan_feature_not_available, resource_not_found, qr_slug_taken, qr_slug_unavailable, link_slug_permanently_reserved, request_body_too_large |
— | Nowhere. These are retired synonyms of codes above and are not valid anywhere in the product. |
Server
| Code | Status | Retryable | Meaning |
|---|---|---|---|
internal_error |
500 | Yes | Unhandled fault. Always logged with request_id. |
upstream_error |
502 | Yes | A dependency returned an unusable response. |
service_unavailable |
503 | Yes | Maintenance or load shedding. Retry-After present. |
upstream_timeout |
504 | Yes | A dependency exceeded its budget. |
21.13 Sandbox and test mode #
Decision: LinkHub ships no sandbox environment, no test-mode keys, and no livemode flag.
The reasoning, stated so it is not revisited by accident:
- A sandbox is a second production system with its own data, its own drift, its own incidents and its own support burden. Every schema change must land in two places, and a sandbox that lags production is worse than none, because it teaches clients a contract that is not real.
- The dominant reason to want a test mode elsewhere is money — nobody wants to charge a real card while developing. This API moves no money: billing is not exposed here at all (Section 22 handles it in the dashboard with Stripe's own test mode).
- The remaining risk — creating junk data while developing — is cheap to contain with the alternatives below, all of which use the real system and therefore cannot drift from it.
What is provided instead:
| Facility | Detail |
|---|---|
validate_only |
Every create and update endpoint accepts ?validate_only=true. The request runs the full pipeline — authentication, scope check, role check, schema validation, semantic validation, entitlement check, uniqueness check, destination-safety check — and then rolls back without writing anything. It returns 200 with the resource as it would have been created and meta.validate_only: true. No audit entry, no webhook, no cache write, no id reservation (id is returned as null). This gives clients a real dry run against real rules. It is not honoured on delete or on action endpoints, where it would be meaningless. |
| A disposable workspace | Business includes 10 workspaces. Teams are advised, in the documentation, to keep one named Development for integration work. It is fully isolated by the tenancy rules in 21.2.5, and deleting it removes everything except QR slug reservations (Section 14). |
| Bulk cleanup | PATCH /links/bulk and the dashboard's bulk archive make removing development data a single operation. |
webhook.test |
Webhook receivers can be exercised without generating real events (19.7). |
| A local stack | The repository ships a docker-compose.yml and a seed command that bring up the full API, database, cache and worker locally with realistic fixtures. This is the actual code, so it cannot drift, and it costs nothing. |
If a sandbox is ever built, it will be built as a full mirror deployed from the same commit as production, or not at all.
21.14 Plan gating for the API #
| Capability | Free | Pro | Business |
|---|---|---|---|
| API access at all | No | Yes | Yes |
| API keys | 0 | 5 | 25 |
| Requests/minute per key | — | 120 | 600 |
| Mutating requests/minute per key | — | 30 | 120 |
| Analytics queries/minute per key | — | 30 | 60 |
| Concurrent requests per key | — | 10 | 20 |
| Read scopes | — | All | All |
pages:write, links:write, qr:write, experiments:write |
— | Yes | Yes |
domains:write |
— | No | Yes |
webhooks:write |
— | No | Yes |
| Bulk endpoints | — | ≤ 50 items/request | ≤ 500 items/request |
| Analytics export via API | — | Yes | Yes |
| Leads via API | Never | Never | Never |
| Audit log via API | Never | Never | Never |
| Billing via API | Never | Never | Never |
| Analytics reach via API | — | 365 days | Unlimited |
| Export creations per hour | — | 10 | 10 |
POST /capabilities |
— | Yes | Yes |
Error behaviour by case:
| Situation | Response |
|---|---|
| A Free workspace attempts any API call | 403 plan_feature_unavailable, details: [{ "field": "api_access", "issue": "feature_unavailable", "plan": "free", "required_plan": "pro" }]. In practice a Free workspace cannot create a key at all, so this is reached only when a workspace downgrades while keys exist. |
| A workspace downgrades to Free with active keys | Keys are not deleted. They are marked plan_suspended and every request returns the 403 above. Upgrading restores them immediately with the same secrets — destroying a customer's keys on a downgrade would make an upgrade path require re-integration. |
A Pro key calls a Business-only endpoint (domains:write, webhooks:write) |
403 plan_feature_unavailable with required_plan: "business" and feature naming the capability. |
| A Pro key sends a bulk request of 200 items | 422 bulk_too_many_items with details.limit: 50, details.received: 200, details.plan: "pro". |
| A numeric entitlement is exhausted (links, QR codes, pages, domains, keys, concurrent experiments) | 403 plan_limit_reached with field, issue: "limit_reached", kind, limit, current, plan. |
| A read is requested beyond the plan's retention window | Clamped, with meta.clamped_from set. Never an error — returning an error for a range that partially overlaps the window would make a naive 90-day query fail for a Free workspace instead of returning the 30 days it is entitled to. |
Clients should call GET /account (21.8.14) on start-up and branch on features and entitlements rather than discovering limits through 403s, and should use POST /capabilities (21.8.15) to decide what to render. Both endpoints exist for exactly this purpose; /account is excluded from the analytics sub-limit so calling it is effectively free.
22. Billing, Plans & Entitlements #
Billing is implemented against Stripe (Checkout, Billing, Customer Portal, Tax). Stripe is the source of truth for subscription state; LinkHub holds a mirrored projection used for fast entitlement checks. Any disagreement is resolved in Stripe's favour by the reconciliation job in Section 22.10.4.
Three concerns are kept strictly separate throughout this section:
| Concern | Owner | Meaning |
|---|---|---|
| Subscription state | Stripe | Is there a paid subscription, in what status, for which price? |
| Plan | LinkHub projection | free | pro | business, derived from subscription state |
| Entitlement | packages/core/entitlements |
The effective numeric/boolean allowance a workspace has right now, after plan, overrides and enforcement mode are applied |
Billing is attached to a workspace, not to a user. A user who owns three workspaces has three subscriptions. Only the Owner role may view or change billing (Section 3); Admin explicitly cannot.
Billing is dashboard-session-only, by design, so that a leaked API key can never reach payment state. Every route in this section is served by the dashboard application on the dashboard origin, is authenticated by a session cookie, is CSRF-protected, and requires the Owner role. There is no /v1/billing/* path on the public REST API, billing:* is not an issuable API-key scope, and Section 21.8.13 records the absence so it cannot be reintroduced by inference. The consequence is worth stating plainly: an attacker holding a stolen API key with every scope the product issues can read links and analytics, but cannot see an invoice, cannot see a card, cannot change a plan, cannot request a refund and cannot trigger a charge. Dashboard billing routes are written in this section as POST /app/billing/… to make the origin and the authentication model unambiguous; they are not part of the versioned public API and carry no compatibility promise.
The read-only entitlement and usage view an integration legitimately needs — limits, counts, feature flags, nothing financial — is GET /v1/account (Section 21.8.14).
22.1 Plan Catalogue (Canonical Entitlement Table) #
This table is the canonical entitlement definition for the entire product. Every other section referencing a limit references this table by number rather than restating a value.
22.1.1 Pricing #
| Plan | Plan key | Monthly | Monthly price_cents |
Annual | Annual price_cents |
Annual saving | Currency |
|---|---|---|---|---|---|---|---|
| Free | free |
$0 | 0 |
$0 | 0 |
— | USD |
| Pro | pro |
$12 / mo | 1200 |
$108 / yr | 10800 |
$36 (25%) | USD |
| Business | business |
$39 / mo | 3900 |
$348 / yr | 34800 |
$120 (25.6%) | USD |
Rules:
- Prices are stored and transmitted as integer minor units with an ISO 4217 currency code (Section 5). No floating point ever touches a monetary value.
- USD is the only launch currency. Multi-currency pricing is a roadmap item and is not specified here.
- Prices displayed in the product are rendered from the catalogue record, never hard-coded in a component.
- Annual is billed as a single up-front charge for twelve months, not as a discounted monthly.
22.1.2 Entitlements #
| Entitlement | Free | Pro | Business |
|---|---|---|---|
| Price (USD) | $0 | $12/mo or $108/yr | $39/mo or $348/yr |
| Workspaces | 1 | 1 | 10 |
| Seats per workspace | 1 | 1 | 25 |
| Bio pages | 1 | 10 | 100 |
| Short links | 25 | Unlimited (fair use 10,000/mo created) | Unlimited (fair use 50,000/mo created) |
| Dynamic QR codes | 3 | 100 | Unlimited (fair use 5,000) |
| Custom domains | 0 | 1 | 5 |
| LinkHub branding on public surfaces | Shown | Removed | Removed |
| Analytics retention | 30 days | 365 days | Unlimited |
| UTM builder | – | Yes | Yes |
| Scheduling & expiry | – | Yes | Yes |
| A/B testing | – | Yes | Yes |
| Concurrent running experiments | 0 | 3 | 25 |
| Experiment arms (bio page / short link) | – | 4 / 8 | 4 / 8 |
| Experiment conversion goals | – | – | Yes |
| Experiment results export | – | Yes | Yes |
| Saved analytics segments | – | 25 per workspace | 25 per workspace |
| Analytics share links (active) | – | 25 | 25 |
| Scheduled report schedules | – | 1 | 10 |
| External report recipients (per schedule) | – | – | 5 |
| Team roles & per-resource grants | – | – | Yes |
| Audit log retention | 30 days | 365 days | Unlimited |
| CSV export | – | Yes | Yes |
| Public API | – | Read + limited write, 120 req/min | Full, 600 req/min |
| Support | Community | Email 48h | Priority 8h |
Notes that are part of the table, not commentary:
- "Unlimited" means no hard cap on the stored count. It does not mean no fair-use ceiling on the creation rate. The fair-use figures are per billing period, not per calendar month, and are enforced as entitlements in their own right (Section 22.2.4).
- Seats per workspace = 1 on Free and Pro. Pro is a single-operator plan. Invitations are therefore refused on Free and Pro, because a pending invitation consumes a seat (Section 22.2.5). Teams require Business.
- Workspaces = 1 on Free and Pro means one workspace owned by that billing account. Being a member of somebody else's Business workspace does not consume the member's own workspace allowance.
- Analytics retention governs raw event and rollup retention separately, per the retention table in Section 17.
- Public API "Read + limited write" on Pro is defined precisely in Section 21.7.1, which is authoritative for every API limit: the read scopes plus
pages:write,links:write,qr:writeandexperiments:write, withdomains:writeandwebhooks:writeBusiness-only, and bulk requests capped at 50 items on Pro against 500 on Business. The scope catalogue itself is Section 21.2.4. Neither is restated here. - Custom domains = 0 on Free. Free workspaces use the default redirect host and the default bio-page host only.
- Nothing in this table is reachable by an API key. The table describes what a workspace is entitled to; changing what it is entitled to is a billing action, and billing is dashboard-session-only.
22.1.3 Machine-Readable Catalogue #
The catalogue lives in packages/core/src/entitlements/catalogue.ts as a frozen constant. It is the only place any limit literal appears in the codebase.
ENTITLEMENT_KEYS is generated, not hand-written. A build step parses the entitlement table in 22.1.2 — which is itself a machine-readable fixture committed alongside the catalogue — and emits both the key union and the per-plan resolved values. pnpm entitlements:generate writes the file; pnpm entitlements:check regenerates it and fails if the result differs from the committed copy, and that check runs on every pull request. The consequence is the property this catalogue previously lacked: a key can never be present in the table and missing from the constant, and a value can never disagree between the two. Adding an entitlement means editing 22.1.2's fixture and regenerating; there is no second place to remember.
The shape:
export type EntitlementValue = number | boolean | string;
export interface PlanDefinition {
key: 'free' | 'pro' | 'business';
display_name: string;
sort_order: number;
prices: {
monthly: { price_cents: number; currency: 'USD'; price_env: string };
annual: { price_cents: number; currency: 'USD'; price_env: string };
};
entitlements: Record<EntitlementKey, EntitlementValue>;
}// GENERATED FROM THE ENTITLEMENT TABLE IN 22.1.2 — DO NOT EDIT BY HAND.
export const ENTITLEMENT_KEYS = [
// Stock and creation caps
'workspaces', // number, per billing account
'seats_per_workspace', // number
'bio_pages', // number
'short_links', // number, -1 = uncapped
'short_links_created_per_period', // number, fair use
'qr_codes', // number, -1 = uncapped
'qr_codes_created_per_period', // number, fair use
'custom_domains', // number
// Experiments — relied on by Section 16
'experiments_enabled', // boolean
'experiments_concurrent', // number, running at once
'experiment_arms_max_bio_page', // number, variants per bio-page experiment
'experiment_arms_max_short_link', // number, variants per short-link experiment
'experiment_conversion_goals', // boolean, unlocks the `conversion` primary metric
'experiment_results_export', // boolean
// Analytics and reporting — relied on by Section 18
'analytics_retention_days', // number, -1 = unlimited
'saved_segments', // number, per workspace
'analytics_share_links', // number, active at once
'scheduled_report_schedules', // number
'external_report_recipients', // number, per schedule
'csv_export', // boolean
// Product features
'branding_removed', // boolean
'utm_builder', // boolean
'scheduling_and_expiry', // boolean
'team_roles_and_grants', // boolean
'audit_log_retention_days', // number, -1 = unlimited
// API — relied on by Section 21
'api_access', // 'none' | 'read_limited_write' | 'full'
'api_rate_limit_rpm', // number
'api_keys', // number, active keys
// Support
'support_tier', // 'community' | 'email_48h' | 'priority_8h'
] as const;Resolved values:
| Key | Type | free |
pro |
business |
|---|---|---|---|---|
workspaces |
number | 1 |
1 |
10 |
seats_per_workspace |
number | 1 |
1 |
25 |
bio_pages |
number | 1 |
10 |
100 |
short_links |
number | 25 |
-1 |
-1 |
short_links_created_per_period |
number | 25 |
10000 |
50000 |
qr_codes |
number | 3 |
100 |
-1 |
qr_codes_created_per_period |
number | 3 |
100 |
5000 |
custom_domains |
number | 0 |
1 |
5 |
experiments_enabled |
boolean | false |
true |
true |
experiments_concurrent |
number | 0 |
3 |
25 |
experiment_arms_max_bio_page |
number | 0 |
4 |
4 |
experiment_arms_max_short_link |
number | 0 |
8 |
8 |
experiment_conversion_goals |
boolean | false |
false |
true |
experiment_results_export |
boolean | false |
true |
true |
analytics_retention_days |
number | 30 |
365 |
-1 |
saved_segments |
number | 0 |
25 |
25 |
analytics_share_links |
number | 0 |
25 |
25 |
scheduled_report_schedules |
number | 0 |
1 |
10 |
external_report_recipients |
number | 0 |
0 |
5 |
csv_export |
boolean | false |
true |
true |
branding_removed |
boolean | false |
true |
true |
utm_builder |
boolean | false |
true |
true |
scheduling_and_expiry |
boolean | false |
true |
true |
team_roles_and_grants |
boolean | false |
false |
true |
audit_log_retention_days |
number | 30 |
365 |
-1 |
api_access |
string | none |
read_limited_write |
full |
api_rate_limit_rpm |
number | 0 |
120 |
600 |
api_keys |
number | 0 |
5 |
25 |
support_tier |
string | community |
email_48h |
priority_8h |
Notes on the keys other sections depend on, so their meaning is fixed here rather than inferred:
experiments_enabledis the key. The plan-comparison row labelled "A/B testing" resolves to it; there is no separateab_testingkey, and that name must not appear as an entitlement key anywhere.experiments_concurrentcounts experiments inrunningstatus. Concluded, promoted and archived experiments do not occupy a slot. Exceeding it isplan_limit_reachedwithkind: "count".experiment_arms_max_*are two keys because the caps differ by resource type — 4 variants on a bio-page experiment, 8 on a short-link experiment. Both are constant across the plans that have experiments at all; they are entitlements rather than constants so that a support override can widen one for a specific customer.experiment_conversion_goalsgates theconversionprimary metric (Section 21.8.9). Business only, because a conversion goal requires a configured pixel and the analysis that makes it meaningful is the Business reporting surface.external_report_recipientsis per schedule and counts recipients who are not members of the workspace. Members are always allowed and never counted; sending a report outside the workspace is what Business is paying for.analytics_share_linkscounts links in theactivestate. Revoked and expired share links do not count.api_keysappears here as well as in Section 21.2.6's prose because the reservation engine needs a catalogue key to refuse against; 21.2.6's numbers and this row are generated from the same fixture.
Sentinel conventions, applied consistently:
-1on a numeric entitlement means uncapped. Comparison code must special-case-1before any</>=comparison; a helperisUncapped(value)exists and a lint rule forbids raw comparison against an entitlement value.0on a numeric entitlement means the feature is available in the product but this plan gets none of it (e.g.custom_domainson Free).falseon a boolean entitlement means refused, surfaced as the same 403 refusal as a numeric cap (Section 22.2.7).- On Free,
short_links_created_per_periodequalsshort_links(25). The stored cap binds first, so the fair-use counter never becomes the refusing rule on Free; it exists so the two enforcement paths share one code path.
Price identifiers are never literals in the codebase. Each plan/period pair reads its price id from an environment variable — PAYMENTS_PRICE_PRO_MONTHLY, PAYMENTS_PRICE_PRO_YEARLY, PAYMENTS_PRICE_BUSINESS_MONTHLY, PAYMENTS_PRICE_BUSINESS_YEARLY, exactly as declared in the configuration reference in Section 27.4 — so test-mode and live-mode deployments share one build. Those four names are the only names; no other spelling of them exists anywhere in the codebase or in this document. A boot-time assertion resolves every configured price against the payment processor's API and fails startup if any price is missing, inactive, or has a unit_amount that disagrees with price_cents in the catalogue. That assertion is the guard against the catalogue and the payment processor silently drifting apart.
22.1.4 Plan Comparison Surfaces #
The same catalogue renders three surfaces, all generated, never hand-maintained:
| Surface | Route | Content |
|---|---|---|
| Marketing pricing page | /pricing |
All three plans, monthly/annual toggle, full entitlement matrix behind a "compare all features" disclosure |
| In-app plan picker | /w/{workspace_slug}/settings/billing/plans |
Current plan marked, per-plan CTA, current usage shown against each plan's caps so an upgrade or downgrade consequence is visible before clicking |
| Upgrade prompt (contextual) | Modal, triggered by a 403 refusal | The single entitlement that refused, its current value and cap, and the cheapest plan that satisfies it |
22.2 The Entitlement Engine #
22.2.1 Design Requirement #
An entitlement check must be impossible to omit. Placing checks in route handlers guarantees that some future route forgets one. The engine therefore satisfies three properties:
- Single chokepoint. Every mutation that can consume a limited resource passes through a domain service in
packages/core, and those services are the only code permitted to call the persistence layer for those tables. - Transactional reservation. The check and the write happen in one database transaction against a locked counter row, so two concurrent requests cannot both observe headroom for the last slot.
- Uniform refusal. Every refusal produces the identical error envelope regardless of which surface triggered it — dashboard, public API, or a background job.
22.2.2 Where the Check Lives #
apps/web (dashboard) ─┐
apps/api (REST v1) ─┼──► packages/core/src/services/* ──► packages/core/src/entitlements/reserve.ts
apps/worker (jobs) ─┘ (domain services) │
▼
packages/db (Drizzle, transactional)Enforcement of the chokepoint:
Lint rule.
packages/configships an ESLintno-restricted-importsrule forbidding any import of@linkhub/dbfromapps/web,apps/api, orapps/workeroutside each app'ssrc/composition-root.ts. A route handler physically cannot reach the tables.Type-level requirement. Every creating service function takes a
WorkspaceContextas its first parameter:interface WorkspaceContext { workspace_id: string; // UUIDv7 actor: { type: 'user' | 'api_key'; id: string; role: Role }; tx: Transaction; // an open transaction, never optional }There is no overload that omits it. A service cannot be called without a workspace scope, which is also what makes tenancy isolation structural rather than remembered (Section 23.3).
Runtime assertion.
reserve()throwsEntitlementEngineMisuseif called outside a transaction, ifworkspace_idis absent, or if the entitlement key is not inENTITLEMENT_KEYS. This is a 500-class programmer error, never surfaced as a 403.Test gate. A test enumerates every exported service function whose name matches
/^create|^restore|^duplicate|^import/and asserts each one callsreserve()with a key. Adding a new creating service without a reservation fails CI. Entitlement code carries a 95% line-coverage gate (Section 26).
22.2.3 The Counter Table and the Reservation Protocol #
Counting by SELECT count(*) on every create is both slow and racy. The engine maintains an authoritative counter row per (workspace, resource type) in the workspace_resource_counters table. Its columns, types, constraints and indexes are defined in Section 6, which is the sole schema authority for this specification; no DDL appears here. What matters for this subsection is the behaviour of four of its fields: active_count (stock, never negative), period_created_count (flow within the current window, never negative), and the period_start/period_end pair that bounds that window.
Reservation protocol, executed inside the caller's transaction:
BEGIN; -- opened by the calling service
SELECT * FROM workspace_resource_counters
WHERE workspace_id = $1 AND resource_type = $2
FOR UPDATE; -- row lock, serialises concurrent creates
-- lazily insert the row on first use, then re-select FOR UPDATE
IF period_end <= now() THEN
period_created_count := 0;
period_start, period_end := next billing window;
END IF;
effective := resolveEntitlement(workspace_id, key);
IF NOT isUncapped(effective.stored_cap)
AND active_count + 1 > effective.stored_cap THEN RAISE plan_limit_reached; END IF;
IF NOT isUncapped(effective.period_cap)
AND period_created_count + 1 > effective.period_cap THEN RAISE plan_limit_reached; END IF;
UPDATE workspace_resource_counters
SET active_count = active_count + 1,
period_created_count = period_created_count + 1,
updated_at = now()
WHERE workspace_id = $1 AND resource_type = $2;
INSERT INTO bio_pages (...); -- the actual create
COMMIT;Because the counter row is locked before the limit comparison and released only at commit, two simultaneous requests for the last remaining slot serialise: one succeeds, one receives plan_limit_reached. A rolled-back transaction rolls back the increment automatically — there is no compensating action to forget.
Decrements happen in the same transaction as the state change that frees the slot: archive, soft delete, hard purge, and seat removal all call release(). Restores call reserve() again and can therefore be refused.
Drift correction. A nightly job (counter-recount, 03:20 UTC) recomputes active_count from the source tables for every workspace, writes the corrected value, and emits a billing.counter_drift metric with the delta. Any non-zero delta raises a warning alert (Section 25); a delta greater than 5 on any workspace raises a page, because it means a code path is mutating rows outside the services.
22.2.4 Counting Rules per Resource — Decided #
Which rows the counters count, per resource type:
| Resource | Counts toward active_count |
Does not count | Counts toward period_created_count |
|---|---|---|---|
| Bio page | status IN ('draft','published','unpublished') AND archived_at IS NULL AND deleted_at IS NULL |
archived, soft-deleted, purged | Every insert, including duplicates and imports |
| Short link | archived_at IS NULL AND deleted_at IS NULL AND pinned_qr_code_id IS NULL, regardless of draft/active/paused/scheduled/expired |
archived, soft-deleted, purged, and any link pinned to a QR code | Every insert, including bulk creates and API creates |
| Dynamic QR code | archived_at IS NULL AND deleted_at IS NULL AND over_cap = false |
archived, soft-deleted, over_cap = true (see Section 22.6) |
Every insert |
| Custom domain | status IN ('pending_dns','verifying','provisioning_tls','active') AND qr_bound = false |
suspended, dns_failed older than 7 days, removed, and any domain that has ever served a QR code (Section 22.5.10) |
Not metered |
| Seat | membership status = 'active' plus invitations status = 'pending' |
suspended, revoked, expired invitations |
Not metered |
| Workspace | workspaces owned by the billing account with deleted_at IS NULL AND archived_at IS NULL |
archived, soft-deleted | Not metered |
| Experiment | status = 'running' (against experiments_concurrent) |
draft, paused, concluded, promoted, archived |
Not metered |
| Analytics share link | status = 'active' |
revoked, expired | Not metered |
| Saved segment | deleted_at IS NULL |
deleted | Not metered |
| Scheduled report | status = 'active' |
paused, deleted | Not metered |
Fair-use period windows are billing-period aligned, not calendar-month aligned: period_start/period_end are copied from the subscription's current period. On Free (no subscription) the window is a rolling calendar month starting at the workspace's created_at day-of-month, at 00:00 UTC, with month-end clamping (a workspace created on the 31st rolls on the 28th/29th/30th in shorter months).
22.2.5 What Counts Toward a Cap #
This subsection is the single authority on the question. Every other section that needs the answer — including the authorization rules in Section 3.4 — references this number rather than restating a rule.
The rule, in one sentence: a cap counts what a workspace can currently act on, and nothing else.
Three consequences follow, and each is a decision rather than an implementation detail:
Archived resources do not count, and archiving is always permitted regardless of plan. If archived resources counted, a downgraded workspace would be permanently over its cap and unable to create anything ever again, which converts a downgrade into a dead account and makes the archive — the very mechanism offered as the humane alternative to deletion — useless. Archiving is therefore the escape hatch from any cap and must never itself be gated, throttled, or refused for billing reasons.
A link pinned to a QR code never counts toward the link cap, and is never archived. A dynamic QR code resolves through a backing short link. That link is marked
pinned_qr_code_idand is thereby exempt from the ordinary link lifecycle: the downgrade flow skips it, the archive selection UI does not offer it,release()is never called on it, and no automated path can set itsarchived_at. Both halves of that sentence are necessary. Archiving it would break a printed code, which Section 22.6 forbids absolutely. Counting it would let a printed code consume the workspace's ability to create ordinary links — so a customer who printed 100 QR codes on Pro and then moved to Free would find their 25-link allowance already spent by objects they cannot archive and cannot delete. Exempting it on both axes is the only combination that is internally consistent. The same logic makes aqr_boundcustom domain not count (Section 22.5.10).Soft-deleted resources do not count during the 30-day restore window. Deleting frees the slot immediately. Restoring calls
reserve()again and is refused withplan_limit_reachedif there is no headroom — the restore dialog states this before the user confirms, and offers to archive something else first.
The remaining per-resource decisions, each of which follows from the same rule:
- Draft bio pages count. A draft occupies a page slot; otherwise the Free cap of 1 is trivially bypassed by keeping ten drafts.
- Draft, paused, scheduled and expired short links count. They are objects the workspace owns and can publish or un-pause at any moment.
- Pending invitations consume a seat. Otherwise a 1-seat workspace could hold 20 outstanding invitations and burst over the cap the moment they are accepted. An expired or revoked invitation releases the seat.
- The Owner consumes a seat.
seats_per_workspace = 1therefore means "the Owner, alone". - Per-resource grants (Business) do not consume anything. Grants are an authorization concept, not a metered resource.
- Experiment variants do not consume link, page or QR allowances. A variant is part of its parent resource; variants are bounded separately by
experiment_arms_max_*. - QR codes in the
over_capstate do not count — see Section 22.6. They continue to resolve forever; they simply do not occupy a slot, because a permanently-resolving object cannot also be a permanent tax on the workspace's ability to create.
Enforcement: the counter-recount job in 22.2.3 recomputes active_count using exactly the predicates in the 22.2.4 table, so a divergence between this rule and the counters is detected nightly rather than discovered by a customer.
22.2.6 Resolving the Effective Entitlement, Caching and Invalidation #
resolveEntitlement(workspace_id, key):
1. plan := projection.plan_key for this workspace
2. base := CATALOGUE[plan].entitlements[key]
3. override := active row in entitlement_overrides (Section 22.12), else none
4. enforcement := enforcementMode(subscription_status) -- Section 22.7.5
5. effective := combine(base, override, enforcement)combine rules, applied in this order:
- Numeric:
effective = override ?? base, except that an override is only applied if it is more generous (override > base, with-1treated as greater than any finite value). Support can raise a limit, never lower one. - Boolean:
effective = base || override. - Enum (
api_access,support_tier): override replaces base outright; these are ordered enums and the override tooling validates that the override is not lower than base. - Enforcement mode may downgrade
effectiveto a write-blocking state, but never changes the stored values; it is a separate flag on the resolved snapshot (writes_blocked: boolean,reason: string).
The resolved snapshot:
{
"workspace_id": "0192f3c1-8a4f-7b21-9e77-5c2b1a0d3e44",
"plan": "pro",
"billing_period": "annual",
"subscription_status": "active",
"writes_blocked": false,
"resolved_at": "2026-08-19T09:14:02Z",
"version": 41,
"entitlements": {
"bio_pages": { "limit": 10, "current": 4, "source": "plan" },
"short_links": { "limit": -1, "current": 812, "source": "plan" },
"short_links_created_per_period": { "limit": 10000, "current": 233, "source": "plan" },
"qr_codes": { "limit": 100, "current": 17, "source": "plan" },
"custom_domains": { "limit": 3, "current": 1, "source": "override",
"override_expires_at": "2026-12-31T23:59:59Z" },
"seats_per_workspace": { "limit": 1, "current": 1, "source": "plan" },
"experiments_enabled": { "limit": true, "current": null, "source": "plan" },
"experiments_concurrent": { "limit": 3, "current": 1, "source": "plan" },
"csv_export": { "limit": true, "current": null, "source": "plan" },
"api_access": { "limit": "read_limited_write", "current": null, "source": "plan" }
}
}Caching and invalidation of that snapshot:
| Layer | Key | TTL | Contents |
|---|---|---|---|
| Redis | ent:workspace:{id} |
300 s | The resolved snapshot JSON above. This is the canonical key name from Section 4's key set; no other spelling is used. |
| Redis | ent:workspace:{id}:version |
none (persistent) | Monotonic integer, incremented on every entitlement-affecting change |
In-process LRU (apps/edge, apps/api, apps/web server) |
same workspace id | 10 s, max 10,000 entries | Same snapshot, guarded by the version integer |
Read path: the in-process cache is consulted first; if its entry is older than 10 s, the process reads ent:workspace:{id}:version (one round trip, sub-millisecond) and reuses the cached snapshot if the version matches. This keeps the steady-state cost of an entitlement check at one small Redis GET, well inside the redirect budget in Section 11.
The counters are never read from cache. active_count and period_created_count are read FOR UPDATE from PostgreSQL inside the write transaction. The cached snapshot's current values are display-only and are explicitly documented as such; a stale current can make the usage meter briefly wrong, but it can never let a create past the cap.
Invalidation is a bump-and-publish, never a delete-and-recompute:
invalidateEntitlements(workspace_id, reason):
INCR ent:workspace:{workspace_id}:version
DEL ent:workspace:{workspace_id}
PUBLISH ent:invalidate {"workspace_id": "...", "version": n, "reason": "..."}Every process subscribes to ent:invalidate and drops the matching in-process entry immediately, so propagation is typically under 50 ms and bounded at 10 s by the local TTL even if the pub/sub message is lost.
Triggers that must call invalidateEntitlements:
| Trigger | Reason string |
|---|---|
| Any consumed payment-processor subscription webhook | payment_webhook:<event_type> |
| Successful checkout completion | checkout_completed |
| Plan change applied (up or down) | plan_changed |
| Entering or leaving dunning states | enforcement_changed |
| Entitlement override created, edited, expired, revoked | override_changed |
| Downgrade selection applied | downgrade_applied |
| Nightly reconciliation correcting a projection | reconciliation |
| Workspace restored from soft delete | workspace_restored |
A failure to invalidate is a correctness bug with a 10 s worst-case blast radius. It is not a security boundary: authorization is enforced separately (Section 23.3) and never reads the entitlement cache.
22.2.7 The Refusal — Exact Payload #
There are exactly two entitlement refusal codes in this product, both 403, and both use the single details shape defined in Section 21.3.2. They are not restated in a rival form here; this subsection shows how the engine populates that shape.
| Situation | Code | details[0].issue |
details[0].kind |
|---|---|---|---|
| A numeric stock cap is exhausted | plan_limit_reached |
limit_reached |
count |
| A fair-use period cap is exhausted | plan_limit_reached |
limit_reached |
period |
| A boolean feature is not on the plan at all | plan_feature_unavailable |
feature_unavailable |
absent |
Stock cap:
{
"error": {
"code": "plan_limit_reached",
"message": "Your plan includes 10 bio pages. Archive a page or upgrade to add another.",
"details": [
{ "field": "bio_pages", "issue": "limit_reached", "kind": "count",
"limit": 10, "current": 10, "plan": "pro",
"upgrade_to": "business", "upgrade_limit": 100 }
],
"request_id": "req_01JQ8ZK4M2X9R7V0C3T5B1N6WD"
}
}Fair-use period cap — same code, kind: "period", plus the window so the message can name a reset date:
{
"error": {
"code": "plan_limit_reached",
"message": "You've created 10,000 short links this billing period, the fair-use ceiling for Pro. The counter resets on 1 September.",
"details": [
{ "field": "short_links_created_per_period", "issue": "limit_reached", "kind": "period",
"limit": 10000, "current": 10000, "plan": "pro",
"period": { "start": "2026-08-01T00:00:00Z", "end": "2026-09-01T00:00:00Z" },
"upgrade_to": "business", "upgrade_limit": 50000 }
],
"request_id": "req_01JQ8ZKB3T5W1Y7E9R2U4I6OPL"
}
}Boolean feature gate — a different code, no kind, no limit, no current:
{
"error": {
"code": "plan_feature_unavailable",
"message": "A/B testing is available on Pro and Business.",
"details": [
{ "field": "experiments_enabled", "issue": "feature_unavailable",
"plan": "free", "required_plan": "pro" }
],
"request_id": "req_01JQ8ZK7Q0F2H8M4A6S9D2G1KX"
}
}Field semantics inside details[0]:
| Field | Type | Present on | Meaning |
|---|---|---|---|
field |
string | both | The catalogue key from 22.1.3 that refused. There is no separate entitlement member — carrying the same value under two names is what produced three rival shapes in the first place. |
issue |
string | both | limit_reached or feature_unavailable. There is no plan_limit and no period_limit_reached. |
kind |
count | period |
plan_limit_reached only |
Stock cap or fair-use window cap. No third value is defined. |
limit |
number | plan_limit_reached only |
The effective allowance. |
current |
number | plan_limit_reached only |
Usage at the moment of refusal, before the attempted operation. |
plan |
string | both | The workspace's current plan key. |
required_plan |
string | plan_feature_unavailable only |
Cheapest plan that includes the feature. |
period |
object | kind: "period" only |
{ "start": "…", "end": "…" } |
upgrade_to / upgrade_limit |
string / number | boolean | optional on both | Presentation hints for the upgrade modal. Clients must tolerate their absence. |
Codes that are not used, anywhere, on any surface: feature_not_available, plan_feature_not_available, and every per-resource cap variant (bio_page_limit_reached, qr_limit_reached, seat_limit_reached, link_limit_reached and the like). One consumer handler serves every entitlement refusal in the product, and it branches on the two codes above.
Response headers accompanying either code:
HTTP/1.1 403 Forbidden
Content-Type: application/json; charset=utf-8
X-LinkHub-Entitlement: bio_pages
X-LinkHub-Plan: pro
Cache-Control: private, no-storeDashboard behaviour on receipt: the mutation is not retried, the optimistic update is rolled back, and the contextual upgrade modal from Section 22.1.4 opens pre-filled from details[0]. Public API behaviour: documented in Section 21.12 as a terminal error — clients must not retry, because retrying cannot succeed. The render-time equivalent, which lets the dashboard avoid provoking a refusal at all, is POST /v1/capabilities (Section 21.8.15); it returns this exact details[0] object as its deny_details.
Refusals are never silent and never partial. A bulk create that would exceed a cap fails atomically: either all rows are written or none are, and the error reports how many of the submitted items would have fit (details[0].current reflects pre-attempt usage). Partial success on bulk operations is explicitly rejected as a design; it produces unrecoverable client state.
22.2.8 Operations Gated by Boolean Entitlements #
Every refusal in this table is plan_feature_unavailable (403), never plan_limit_reached — these are binary gates, not counts.
| Entitlement | Gated operations | Ungated even when false |
|---|---|---|
utm_builder |
Saving UTM presets, appending UTM parameters via the builder | Manually typing a URL that already contains UTM parameters |
scheduling_and_expiry |
Setting scheduled_at, expires_at, or targeting rules on a link/QR |
Existing schedules created on a higher plan continue to execute (Section 22.5.9) |
experiments_enabled |
Creating an experiment, adding a variant, starting an experiment, promoting a winner | Viewing results of an existing experiment; existing experiments are concluded at the current leader on downgrade |
experiment_conversion_goals |
Selecting conversion as an experiment's primary metric |
Every other primary metric |
experiment_results_export |
Exporting experiment results as CSV | Viewing results on screen |
team_roles_and_grants |
Sending invitations, changing a member's role beyond Owner, creating per-resource grants | Viewing the member list; the Owner's own access |
csv_export |
Any CSV/XLSX export of analytics or leads | The GDPR data export in Section 23.14, which is a legal right and is available on every plan including Free |
branding_removed |
Hiding the LinkHub badge on public surfaces | Nothing — the badge is rendered server-side and is not removable by CSS on Free (Section 22.13.6) |
api_access |
Creating API keys, authenticating to /v1 |
Nothing |
Numeric caps among the newer keys — experiments_concurrent, saved_segments, analytics_share_links, scheduled_report_schedules, external_report_recipients, experiment_arms_max_* — refuse with plan_limit_reached and kind: "count", because they are quantities rather than gates. Where a plan's value for one of them is 0, the refusal is still plan_limit_reached with limit: 0: the feature exists in the product and this plan gets none of it, which is a quantity of zero and not a missing capability. plan_feature_unavailable is reserved for keys whose type is boolean.
The GDPR export exception is deliberate and must not be "simplified" later: a statutory right cannot be a paid feature.
22.2.9 Entitlement Checks in the Redirect Path #
The redirect resolver in apps/edge performs no entitlement lookups on the hot path. Entitlement state is baked into the cached redirect payload at write time:
{
"target": "https://example.com/landing",
"status": 302,
"resource_type": "qr_code",
"resource_id": "0192f3c4-1b7e-7c02-8d41-9aa2f6e0cc19",
"workspace_id": "0192f3c1-8a4f-7b21-9e77-5c2b1a0d3e44",
"fallback_stage": "active",
"fallback_rung": 1,
"over_cap": false,
"branding": false,
"cached_at": "2026-08-19T09:14:02Z"
}There is one state machine here, not three. fallback_stage is the only serving-state field: exactly four values — active, paused_fallback, workspace_unavailable, generic — matching rungs 1 to 4 of the chain in 22.6.2, with fallback_rung carrying the same information as a number for logging and analytics. There is no serving_mode field and no serving_mode vocabulary anywhere in this product; the older names over_cap_grace, over_cap_landing, workspace_branded_unavailable, generic_landing and memorial are retired and must not appear in code, cache payloads, metrics or support tooling.
Billing state that used to be encoded as a distinct serving mode is now carried as data alongside the stage. over_cap: true on a short link means the link is above its plan's cap: within its 90-day window it still serves fallback_stage: "active" and redirects normally, and after the window it serves the branded link-inactive page, which is fallback_stage: "workspace_unavailable" at rung 3 (22.5.5). The memorial page is not a separate mode either — it is rung 3 before erasure and rung 4 after, per 22.6.2.
The stage is computed by the entitlement engine when the resource or the subscription changes and written through to rd:{host}:{slug} (Section 4 owns the Redis key set). The redirect path reads one Redis key and acts on fallback_stage; it never resolves a plan. This is what keeps the p95 inside the 50 ms budget while still honouring billing state.
When a subscription event changes the stage for a workspace, a job (redirect-cache-repaint) enqueues a re-write of every affected rd: key in batches of 500. For a workspace with 50,000 links this completes in under 60 s; during the repaint, stale keys serve the previous stage, which is always the more permissive of the two by construction — enforcement is allowed to lag, never to over-enforce.
22.3 Checkout #
22.3.1 Preconditions #
| Precondition | Enforcement | Failure |
|---|---|---|
| Request arrives on an authenticated dashboard session | Session middleware. An API key is not a credential here and never will be | 401 unauthenticated |
| Actor is the workspace Owner | Role check (Section 3) | 403 billing_forbidden |
| Actor's email is verified | Section 7 | 403 email_not_verified |
| Workspace is not soft-deleted | Service guard | 404 not_found |
No subscription already active/trialing for this workspace |
Projection check | 409 subscription_already_active — the client is redirected to the plan-change flow instead |
Selected plan_key + billing_period resolve to a configured price |
Catalogue lookup | 400 validation_failed with details[0].field = "plan_key" |
22.3.2 Flow #
1. Owner selects plan + period in the in-app plan picker.
2. POST /app/billing/checkout-sessions { plan_key, billing_period, success_path, cancel_path }
(dashboard origin, session cookie + CSRF token; no /v1 equivalent exists)
3. Server ensures a Stripe Customer exists for the workspace:
- lookup billing_accounts by workspace_id
- if absent, create with metadata { workspace_id, owner_user_id, workspace_slug }
- store stripe_customer_id
4. Server creates a Stripe Checkout Session (mode=subscription) with:
- line_items: [{ price: <resolved price id>, quantity: 1 }]
- customer: <stripe_customer_id>
- client_reference_id: <workspace_id>
- subscription_data.metadata: { workspace_id, plan_key, billing_period }
- subscription_data.trial_period_days: 14 (only if eligible — Section 22.3.3)
- payment_method_collection: 'always'
- billing_address_collection: 'required'
- tax_id_collection: { enabled: true }
- automatic_tax: { enabled: true }
- customer_update: { address: 'auto', name: 'auto' }
- allow_promotion_codes: true
- idempotency key: sha256(workspace_id | plan_key | billing_period | minute_bucket)
5. Response 201 { data: { checkout_url, expires_at } }; the browser navigates to the hosted page.
6. Stripe collects payment details, address, tax id, and performs SCA/3DS where required.
7. On success Stripe redirects to success_path with ?session_id=...; the dashboard shows a
"finalizing your plan" state and polls GET /app/billing/subscription every 1s for up to 30s.
8. Entitlements change on webhook, not on redirect — Section 22.3.6.The hosted Checkout page is used rather than an embedded card form for one substantive reason: card data never touches LinkHub infrastructure, which removes the entire PCI-DSS SAQ-D surface and reduces the compliance obligation to SAQ-A. This is restated in Section 23.16.
Every route named in this section — /app/billing/checkout-sessions, /app/billing/portal-sessions, /app/billing/subscription and its actions, /app/billing/downgrade-selection, /app/billing/invoices, /app/billing/usage, /app/billing/refund-requests — is a dashboard route on the dashboard origin, authenticated by session cookie, CSRF-protected, and Owner-only. None of them has a public-API equivalent, and no API key can call any of them; the absence is recorded in Section 21.8.13 so it cannot be reintroduced.
success_path and cancel_path are validated against an allow-list of in-app paths (^/w/[a-z0-9-]{1,64}/settings/billing(/[a-z0-9-]*)?$). Arbitrary URLs are rejected with 400; this is an open-redirect guard and is tested (Section 23.6).
22.3.3 Trial Policy — Decided #
A 14-day free trial is offered on any paid plan, once per billing account, and requires a payment method up front.
| Rule | Value |
|---|---|
| Trial length | 14 days |
| Eligible plans | Pro and Business |
| Payment method required | Yes, collected at checkout, not charged during the trial |
| Eligibility | Once per billing account, ever. Tracked by billing_accounts.trial_used_at |
| Anti-abuse | Additionally keyed on the Stripe payment-method fingerprint and the account's verified email; a second workspace under a new email but the same card fingerprint is ineligible (Section 22.13) |
| Entitlements during trial | Full entitlements of the selected plan from the moment the subscription reaches trialing |
| Reminder | Email and in-app banner at day 11 (3 days before end), driven by the customer.subscription.trial_will_end webhook |
| End of trial | Stripe attempts the first invoice automatically. Success → active. Failure → dunning (Section 22.7), starting from the first retry, not from cancellation |
| Cancel during trial | Entitlements continue until the trial end date, then the workspace moves to Free and the downgrade flow runs (Section 22.5) |
| Upgrade during trial | Switching Pro→Business mid-trial keeps the original trial end date and changes the price that will be charged then; no proration occurs because nothing has been charged |
Rationale, stated once: requiring a card is the single most effective filter against automated trial farming, and 14 days is long enough to print a QR code, place it, and see scan data — the product's actual proof point. A card-free trial is not offered; the Free plan is the card-free path.
22.3.4 Promotion Codes #
Promotion codes are created in Stripe and enabled on Checkout. LinkHub does not implement its own coupon engine. Constraints: a promotion code may reduce price but may never alter entitlements — entitlements always come from the catalogue for the purchased plan. A code that Stripe reports as applied is recorded on the local subscription projection (promotion_code, discount_amount_cents, discount_ends_at) for display on the billing page.
22.3.5 Proration on Upgrade #
| Change | Proration behaviour | Charge timing | Effective |
|---|---|---|---|
| Pro → Business, same period | proration_behavior: 'always_invoice' |
Immediate invoice for the prorated difference | On invoice.paid |
| Monthly → Annual, same plan | always_invoice |
Immediate invoice for the annual price less unused monthly credit | On invoice.paid |
| Pro monthly → Business annual | always_invoice |
Immediate invoice, credit applied | On invoice.paid |
| Free → any paid | Not a proration; a new subscription via Checkout | At checkout | On checkout.session.completed + invoice.paid |
| Any downgrade | proration_behavior: 'none', scheduled at period end |
No charge, no credit | At period end (Section 22.5) |
Downgrades produce no refund and no credit. The customer keeps the higher plan for the period they already paid for. This is stated on the confirmation screen before the downgrade is submitted, in exactly these words: "You'll keep {current_plan} until {period_end}. We don't issue credits for the unused time."
The prorated invoice may require SCA. If Stripe reports requires_action, the API returns 402-equivalent semantics as 409 payment_requires_action with details[0].confirmation_url, and the dashboard opens the hosted confirmation page. Entitlements do not change until the invoice is paid.
22.3.6 The Exact Moment Entitlements Change #
This is the single most misimplemented part of billing, so it is specified as a state rule rather than a narrative.
| Event | Condition | Entitlement effect | Latency |
|---|---|---|---|
checkout.session.completed |
mode = subscription, session payment_status ∈ {paid, no_payment_required} |
Plan projection set; not yet granted | — |
customer.subscription.created |
status = trialing |
Granted immediately. Trial entitlements are full entitlements | ≤ 2 s from checkout |
customer.subscription.created |
status = active |
Granted immediately | ≤ 2 s |
invoice.paid |
subscription's first invoice | Granted (idempotent if already granted by the created event) |
≤ 5 s |
customer.subscription.updated |
plan price changed upward, status active, latest_invoice.status = paid or amount_due = 0 |
Raised immediately | ≤ 2 s |
customer.subscription.updated |
plan price changed upward, latest invoice unpaid | Held in pending_upgrade; entitlements unchanged until invoice.paid |
until payment |
customer.subscription.updated |
cancel_at_period_end = true |
No change. Entitlements persist to period end | — |
customer.subscription.updated |
status → past_due |
No numeric change; enforcement mode changes (Section 22.7.5) | ≤ 2 s |
customer.subscription.deleted |
— | Plan → free, downgrade flow armed (Section 22.5) |
≤ 2 s |
| Scheduled downgrade reaching its date | subscription schedule phase change, surfaced as customer.subscription.updated |
Lowered at that instant | ≤ 2 s |
Rules that make this safe:
- Entitlements are granted by webhook, never by the browser redirect. The success page is cosmetic. A user who closes the tab still gets their plan.
- A grant is idempotent. Receiving
subscription.createdandinvoice.paidfor the same subscription grants once. - Every grant re-fetches the subscription from Stripe before writing the projection, so the projection reflects the current object even if the webhook payload is stale (Section 22.10.3).
- Every grant calls
invalidateEntitlementsand enqueuesredirect-cache-repaint. - A grant never lowers. Lowering is exclusively the job of the downgrade path, which has its own guarded flow.
- If no webhook has arrived 30 s after a checkout redirect, the polling UI stops and shows: "Payment received. Your plan is being activated — this can take a minute. Refresh, or contact support if it hasn't changed in 10 minutes." A background job (
billing-checkout-reconcile) fetches the session directly from Stripe at T+60 s and completes the grant, so the fallback is automatic rather than dependent on the user contacting support.
22.3.7 Checkout Abandonment #
If the user cancels on the hosted page, they return to cancel_path. No subscription exists, nothing is recorded beyond an analytics event (billing.checkout_abandoned, workspace-scoped, no personal data). Checkout sessions expire after 24 hours; expired sessions are not reused — a new session is created on the next attempt.
22.4 Subscription Management #
22.4.1 The Billing Page #
Route: /w/{workspace_slug}/settings/billing. Owner only; Admin, Editor and Viewer receive 403 billing_forbidden and the navigation entry is not rendered for them.
Contents:
| Block | Data | Empty/edge state |
|---|---|---|
| Current plan | Plan name, price, period, next renewal date, or "Free — no subscription" | Free: shows upgrade CTA only |
| Status banner | Only when status ≠ active: trialing, past_due, canceling, canceled, paused |
Hidden when healthy |
| Usage | The meters from Section 22.11 | Zero-usage state shows "Nothing used yet" per meter |
| Payment method | Brand, last four, expiry month/year, "Update" → portal | Free: hidden |
| Billing details | Company name, billing address, tax ID | Missing address on a paid plan: warning + "Complete billing details" |
| Invoices | Last 12, with amount, date, status, PDF link, "See all" → portal | No invoices: "Your first invoice will appear here after your trial ends" |
| Plan actions | Change plan, switch period, cancel / reactivate | — |
22.4.2 The Customer Portal #
Payment-method updates, invoice history beyond 12 entries, tax-ID edits and billing-address edits are delegated to the Stripe Customer Portal. LinkHub does not build these screens; it creates a portal session and redirects.
POST /app/billing/portal-sessions
{ "return_path": "/w/acme/settings/billing" }
→ 201
{ "data": { "portal_url": "https://billing.stripe.com/p/session/…",
"expires_at": "2026-08-19T10:14:02Z" }, "meta": {} }Portal configuration (created once per environment by a setup script, and asserted at boot):
| Feature | Enabled | Notes |
|---|---|---|
| Update payment method | Yes | — |
| Update billing address | Yes | Required for tax |
| Update tax ID | Yes | — |
| View / download invoices | Yes | — |
| Cancel subscription | No | Cancellation runs through LinkHub so the downgrade preview and QR notice are shown first (Section 22.4.6) |
| Switch plans | No | Plan changes run through LinkHub so entitlement consequences are shown first |
| Update quantity | No | Seats are plan-fixed, not quantity-based |
return_path is validated against the same in-app path allow-list as success_path. Portal sessions expire in 60 minutes and are single-use in practice; a new one is created on each click.
22.4.3 Plan Change — Upgrade #
POST /app/billing/subscription/plan
{ "plan_key": "business", "billing_period": "annual" }Server behaviour:
- Verify Owner role, verify a subscription exists (else
409 subscription_not_found, client is sent to Checkout). - Compute direction by comparing catalogue
sort_order.business>pro>free. - Upgrade path:
stripe.subscriptions.update(id, { items: [{ id, price }], proration_behavior: 'always_invoice', payment_behavior: 'error_if_incomplete' })with an idempotency key ofsha256(subscription_id|price_id|"upgrade"). - On success, respond
200with the updated projection andmeta.effective_at = "immediate". - On
requires_action, respond409 payment_requires_actionwithdetails[0].confirmation_url. - On decline, respond
409 card_declinedwithdetails[0].decline_codemapped through Section 22.14.1.
22.4.4 Plan Change — Downgrade #
Downgrades never take effect immediately. The request returns a preview that the client must confirm:
POST /app/billing/subscription/plan/preview
{ "plan_key": "pro", "billing_period": "monthly" }
→ 200
{
"data": {
"direction": "downgrade",
"effective_at": "2026-09-14T00:00:00Z",
"from": { "plan": "business", "billing_period": "annual" },
"to": { "plan": "pro", "billing_period": "monthly", "price_cents": 1200 },
"credit_issued": false,
"impacts": [
{ "entitlement": "bio_pages", "current": 23, "new_limit": 10, "over_by": 13 },
{ "entitlement": "qr_codes", "current": 140, "new_limit": 100, "over_by": 40,
"note": "qr_exempt" },
{ "entitlement": "seats_per_workspace","current": 6, "new_limit": 1, "over_by": 5 },
{ "entitlement": "custom_domains", "current": 3, "new_limit": 1, "over_by": 2 },
{ "entitlement": "workspaces", "current": 4, "new_limit": 1, "over_by": 3 },
{ "entitlement": "team_roles_and_grants", "current": null, "new_limit": false,
"kind": "feature" }
],
"selection_required": true,
"selection_deadline": "2026-09-14T00:00:00Z"
},
"meta": {}
}Confirmation applies a Stripe subscription schedule: the current phase ends at current_period_end, the next phase starts on the new price. proration_behavior: 'none'. The projection records pending_plan_key, pending_billing_period and pending_effective_at, all of which are shown on the billing page with a "Keep my current plan" undo that deletes the schedule.
22.4.5 Annual ⇄ Monthly Switching #
| Switch | Direction | Timing | Proration |
|---|---|---|---|
| Monthly → Annual, same plan | Treated as an upgrade | Immediate | always_invoice; unused monthly time credited against the annual charge |
| Annual → Monthly, same plan | Treated as a downgrade | At the end of the annual period | None; no refund of the unused annual term |
| Monthly → Annual with a plan increase | Upgrade | Immediate | always_invoice |
| Annual → Monthly with a plan decrease | Downgrade | At annual period end | None |
| Annual → Monthly with a plan increase | Upgrade (plan increase dominates) | Immediate | always_invoice; annual remainder credited |
The rule is: any change that increases the effective entitlement level is immediate; any change that decreases it waits for period end. Where a change does both (higher plan, shorter period), the entitlement increase dominates and the change is immediate.
22.4.6 Cancellation #
POST /app/billing/subscription/cancel
{ "reason_code": "too_expensive", "reason_text": "…", "confirm": true }reason_code∈too_expensive,missing_feature,switching_tool,no_longer_needed,technical_problems,temporary_pause,other. Optional free text, max 1,000 chars, stored for product analysis, never shown publicly.- Sets
cancel_at_period_end = true. The subscription is not deleted immediately. - Effective date =
current_period_end. The response and the confirmation screen both state it explicitly: "Business stays active until 14 September 2026. After that your workspace moves to Free." - Before
confirm: trueis accepted, the client must have fetched the downgrade preview (22.4.4) for the Free plan; the server rejects a cancel that arrives without a matchingpreview_token(opaque, 30-minute TTL) with409 downgrade_selection_required. - The cancellation screen displays the QR notice verbatim: "Your QR codes will keep working. They resolve forever, on every plan, including after cancellation."
- An audit entry is written (
plan changed, per Section 8). - Emails: immediate confirmation with the effective date; a reminder 3 days before the effective date describing exactly what will change, generated from the same
impactsarray as the preview.
Immediate cancellation (no waiting for period end) is available only through support and is executed as a cancel-now plus a manual prorated refund where the refund policy in Section 22.9 allows it.
22.4.7 Reactivation #
| Situation | Action | Result |
|---|---|---|
cancel_at_period_end = true, before period end |
POST /app/billing/subscription/reactivate |
Sets cancel_at_period_end = false. No charge, no proration, no interruption. Pending downgrade schedules are also deleted |
Subscription already canceled, workspace on Free, within 30 days |
New Checkout session for the same plan | New subscription. Archived resources are restored automatically up to the new caps, oldest-first, and the user is shown exactly what was restored (Section 22.5.8) |
Subscription canceled more than 30 days ago |
New Checkout session | New subscription. Archived resources are not auto-restored; the user restores them manually from the archive, which is retained indefinitely |
| Canceled after a chargeback | Blocked | 409 billing_blocked; requires support review (Section 22.9.4) |
22.5 The Downgrade Flow #
A downgrade is any transition to a plan with a lower cap on at least one entitlement, including cancellation to Free and including involuntary transition after dunning. All of them run this identical flow. The governing principle: nothing is ever deleted by a billing event.
22.5.1 Timeline #
T-0 Downgrade requested (or dunning cancellation confirmed)
→ preview generated, selection UI presented, reminder email sent
T-3d Reminder email #2 with the impact list, if selection is still incomplete
T-0h Effective date reached
→ selection applied if made, deterministic default applied if not
→ entitlements lowered, invalidation fired, redirect cache repainted
T+90d Short links above cap stop redirecting and begin serving the branded landing page22.5.2 The Guided Selection Experience #
Route: /w/{workspace_slug}/settings/billing/downgrade. Presented immediately on requesting a downgrade and reachable from the status banner until the effective date.
For each over-cap entitlement the user gets a dedicated step:
| Step | Shown | Interaction |
|---|---|---|
| Bio pages | All pages, sorted newest-first, with 30-day view counts, published status, and which are linked from a QR code | Checkbox "keep" on exactly new_limit items; a running counter "10 of 10 selected" |
| Short links | All links, sorted newest-first, with 30-day click counts and creation date; a "sort by clicks" toggle. Links pinned to a QR code are not listed at all, because they are never archived and never count (Section 22.2.5) | Checkbox "keep" on exactly new_limit items — not shown when new_limit is uncapped, which is the case for every plan except Free |
| QR codes | Read-only informational panel: "All 140 QR codes keep working. Nothing to choose." | No selection; QR codes are exempt (Section 22.6) |
| Seats | Member list with role, join date, last active | Checkbox "keep" on new_limit - 1 members (the Owner always keeps a seat and cannot be deselected) |
| Custom domains | Domain list with status, attached resources, and 30-day traffic; domains that have ever served a QR code are marked "always active" and cannot be selected against | Checkbox "keep" on new_limit items |
| Workspaces | Workspace list with resource counts and last activity | Radio "keep active" on new_limit items |
| Feature loss | Read-only summary: A/B tests that will stop, schedules that will freeze, exports that will be unavailable | Acknowledgement checkbox |
UI rules:
- Selection is saved incrementally (
PATCH /app/billing/downgrade-selection), so a user can leave and return. - Over-selection is blocked client-side and rejected server-side with
400 validation_failed(details[0].issue = "too_many_selected"). - Under-selection is permitted; unselected slots are filled by the deterministic default at apply time, and the UI says so: "You've chosen 6 of 10. We'll keep your 4 oldest remaining pages."
- The screen states the outcome in plain terms at the top: "Nothing is deleted. Anything above your new plan's limits is archived and stays in your archive until you restore it."
- A "Compare: what if I keep Business?" link returns to the plan picker.
22.5.3 The Deterministic Default #
If the effective date arrives with an incomplete selection, the engine fills the gap with a rule that is fully deterministic and identical in every environment:
Keep the oldest by
created_at; archive the newest above the cap first. Ties break onidascending (UUIDv7 is time-ordered, so this is a stable, monotonic tiebreak).
Applied per resource type:
| Resource | Default keep set | Default archive set |
|---|---|---|
| Bio pages | User selections first; then oldest-created until the cap is filled | Everything else, newest-created archived first |
| Short links | Links pinned to a QR code are kept unconditionally and do not consume the cap; then user selections; then oldest-created until the cap is filled | Everything else, newest-created archived first. A pinned link is never in this set |
| QR codes | All of them. Exempt | None |
| Seats | Owner, then earliest-joined members until the cap is filled | Remaining memberships → suspended |
| Custom domains | Any domain that has ever served a QR code (unconditionally, does not consume the cap); then user selections; then oldest-verified | Remaining domains → suspended |
| Workspaces | The workspace the Owner most recently opened, then oldest-created | Remaining workspaces → archived |
Rationale for oldest-first retention: the oldest resource is the most likely to be printed, embedded, shared, or indexed. Newest-created is the most likely to be experimental. Choosing "keep newest" would systematically break the links that matter most.
The default is computed at apply time, not at request time, so resources created or deleted during the notice window are handled correctly.
22.5.4 The Archived State — Definition #
Archiving is a state change, not a deletion. archived_at is set; archived_reason is set to downgrade, manual, or dunning. The columns themselves are defined in Section 6.
One class of resource is never archived, by any path: a short link pinned to a QR code. The downgrade flow skips it, the selection UI omits it, the deterministic default cannot reach it, dunning cannot touch it, and no manual action in the dashboard offers it — archiving it would break a printed symbol, which Section 22.6 forbids without exception. It correspondingly never counts toward the link cap (Section 22.2.5). The same exemption covers a qr_bound custom domain (22.5.10).
| Property | Archived resource |
|---|---|
| Row in database | Retained indefinitely. No purge timer. deleted_at remains NULL |
| Counts toward cap | No |
| Visible in dashboard | Yes, in a dedicated "Archive" tab per resource type, with a filter and a restore action |
| Editable | No. Every mutation returns 409 resource_archived with details[0].restore_path |
| Analytics | Historical data remains queryable, subject to the plan's retention window |
| Included in exports | Yes, flagged archived: true |
| Public behaviour | Per resource type, Sections 22.5.5–22.5.7 |
| Restorable | Yes, at any time, provided there is headroom; restore calls reserve() and can be refused with plan_limit_reached |
| Restored state | Exactly the pre-archive state, including slug, destination, styling, schedule and experiment attachment |
The public API exposes archived resources in list endpoints only when ?include_archived=true is passed, and always with "archived": true in the object body.
22.5.5 Short Links Above the Cap #
Only Free has a finite short-link cap, so this path is reached by downgrades to Free (voluntary or via dunning).
| Window | Behaviour | HTTP | Headers |
|---|---|---|---|
| Day 0 → Day 90 | Continues to redirect normally to its destination. over_cap = true, fallback_stage = active |
302 Found |
Cache-Control: private, no-store |
| Day 90 onwards | Serves the branded link-inactive landing page at the short URL itself. over_cap = true, fallback_stage = workspace_unavailable |
200 OK |
Cache-Control: public, max-age=60, X-Robots-Tag: noindex |
| Any time after restore | Redirects normally. over_cap = false, fallback_stage = active |
302 Found |
Cache-Control: private, no-store |
Explicitly: the URL never returns 404 and never returns 410. A 404 would tell every downstream referrer, crawler and messaging preview that the link is dead, which is unrecoverable once caches and link-checkers have seen it. A 200 landing page keeps the URL alive and reversible.
The landing page content:
- Workspace display name and logo where available (this is not gated on
branding_removed; a workspace that paid at some point keeps its identity on this page). - Heading: "This link isn't active right now."
- Body: "The owner of this link is on a plan that doesn't include it. If you're the owner, sign in to reactivate it."
- A "Sign in to LinkHub" action.
- No destination URL is disclosed — the destination may be private and the visitor is not authenticated.
- LinkHub badge, always.
- The page is fully static, server-rendered, meets the bio-page performance budgets in Section 11, and meets WCAG 2.2 AA per Section 24.
Analytics: requests during the grace window are recorded as normal clicks. Requests to the landing page are recorded with outcome = 'over_cap_landing' so the owner can see, on restore, how much traffic they lost. Both are visible in the dashboard even on Free, within the 30-day retention window.
The 90-day window is per-link, measured from the moment that link was archived, not from the downgrade date — so links archived later in a staged downgrade get their own full window.
22.5.6 Bio Pages Above the Cap #
| Aspect | Behaviour |
|---|---|
| Handle | Reserved to the workspace permanently. It is never released to another workspace, even after archiving. This prevents handle-squatting on a lapsed creator's identity |
| Public response | Branded "This page isn't available right now" page with HTTP 404 and X-Robots-Tag: noindex, nofollow |
| Why 404 and not the QR treatment | A bio page URL is shared digitally and can be corrected by its owner; search engines should de-index it rather than keep a placeholder ranked. A printed QR code cannot be corrected, which is why it gets the opposite treatment (Section 22.6) |
| Content | Workspace display name, the message, a sign-in action, LinkHub badge. No block content, no links, no embeds, no email-capture form |
| Custom-domain hosted pages | Same behaviour, on the custom domain, provided the domain is still active |
| Editor | Read-only. The editor opens, renders the page, and disables every control with an explanatory banner and a "Restore this page" action |
| Analytics | Historical view/click data preserved and viewable within retention. 404 responses recorded with outcome = 'archived_page' |
| Attached email-capture leads | Fully preserved and exportable via the GDPR export on every plan; CSV export remains gated by csv_export |
| Restore | Immediate; the page returns to its previous published/unpublished state, and the handle resolves again within the redirect-cache repaint window |
22.5.7 Team Members Above the Seat Cap #
| Aspect | Behaviour |
|---|---|
| State | workspace_members.status = 'suspended', suspended_reason = 'seat_cap', suspended_at set. The row is not deleted and deleted_at is not set |
| Access | The member can sign in, sees the workspace in a "no longer accessible" state with the reason, and can access nothing inside it. Every workspace-scoped request returns 403 seat_suspended with details[0].workspace_id |
| Owner | Never suspendable. If the Owner is somehow the only member, nothing changes |
| Pending invitations | All pending invitations to the workspace are revoked at apply time, with an email to each invitee explaining the workspace no longer has seats. Revoked invitations release their seat |
| Per-resource grants | Preserved on the suspended row. Restoring the member restores their grants exactly |
| Content authored by suspended members | Untouched. Authorship attribution persists |
| API keys created by a suspended member | Disabled while the member is suspended (api_keys.disabled_reason = 'owner_seat_suspended'), re-enabled on restore. This is a security requirement, not a billing one |
| Retention | Suspended memberships persist indefinitely. They cost nothing and their restoration is the entire point |
| Restore | Automatic on upgrade if seats allow, oldest-joined first, up to the new cap; the Owner is notified of exactly who was restored and who was not |
| Audit | Every suspension and restoration writes an audit entry (Section 8) |
22.5.8 Workspaces Above the Cap #
Reached when a billing account holding multiple Business workspaces downgrades to Pro or Free.
- The kept workspace stays fully active.
- Other workspaces enter
archivedstate: read-only dashboard, no publishing, no mutations, all public surfaces following the per-resource rules above (bio pages 404-branded, short links 90-day grace then landing page, QR codes fully exempt and resolving normally). - Archived workspaces are listed in the workspace switcher under "Archived", with a restore action.
- Archived workspaces are never purged by billing. The only purge path is explicit user-initiated workspace deletion, which follows Section 23.14 (and still preserves QR slug reservations).
- Members of an archived workspace are notified once, by email, naming the Owner.
22.5.9 Feature Downgrade Effects #
Boolean entitlements lost on downgrade do not delete configuration; they freeze it.
| Lost entitlement | Effect on existing configuration |
|---|---|
experiments_enabled |
Running experiments move to concluded at apply time. The current leader by the Section 16 winner metric becomes the sole served variant — never a coin flip, never a revert to the original. Experiment records and results are retained and viewable read-only. Restarting requires re-upgrading |
scheduling_and_expiry |
Existing scheduled_at / expires_at values are retained and continue to execute. A link scheduled to expire next Tuesday still expires next Tuesday. Only creating or editing schedules is blocked. Silently ignoring an expiry the owner deliberately set would be worse than enforcing it |
utm_builder |
Saved presets retained and viewable; new presets blocked. Existing links keep their UTM parameters |
team_roles_and_grants |
Per-resource grants retained on suspended/active rows; new grants blocked. Any member above seat cap is suspended per 22.5.7 |
csv_export |
Export buttons disabled with an upgrade prompt. Previously generated export files remain downloadable until their signed URL expires. GDPR export remains available |
branding_removed → false |
The LinkHub badge reappears on all public surfaces at apply time, including custom-domain-hosted pages |
api_access → none |
API keys are disabled, not deleted. Requests return 403 plan_feature_unavailable with details[0].field = "api_access" — a feature gate, not a count. Keys are re-enabled automatically on upgrade, with their original scopes and prefix |
analytics_retention_days lowered |
No retroactive deletion at apply time. Data outside the new window becomes invisible in the dashboard immediately, and is purged by the retention worker on its normal nightly schedule per Section 17. A 7-day "your history beyond 30 days will be removed on {date}" warning is shown, with an export offer |
22.5.10 Custom Domains Above the Cap #
| Aspect | Behaviour |
|---|---|
| State | custom_domains.status = 'suspended', suspended_reason = 'plan_cap' |
| DNS records | Left in place; the user is told they may remove them, and told not to if any QR code uses the domain |
| TLS certificate | Renewal continues for 90 days while short links on the domain are still redirecting. Allowing the certificate to lapse would produce a browser interstitial, which is worse than any billing enforcement. After 90 days, renewal stops unless the QR exception below applies |
| Short-link traffic on the domain | Follows Section 22.5.5 exactly: redirects for 90 days, then the branded landing page at 200. The landing page is served over valid TLS |
| Bio-page traffic on the domain | Follows Section 22.5.6: branded 404 |
| QR traffic on the domain | Unaffected, permanently. See below |
| Restore | On upgrade, suspended domains are reactivated oldest-verified-first up to the new cap. If DNS is still correct, reactivation is immediate; otherwise the domain re-enters pending_dns and the Section 13 verification flow runs |
The QR domain exception. A custom domain that has ever served a dynamic QR code is flagged qr_bound = true and is never suspended for billing reasons. It remains active, its TLS certificate is renewed indefinitely, and it does not consume the workspace's custom_domains allowance while the workspace is over cap. Non-QR paths on that domain still follow the suspension rules above — the domain stays up, but an archived short link on it still reaches the landing page after 90 days.
The reason is unavoidable: a QR code printed with go.acme.com/menu encodes that hostname into ink. If LinkHub stops answering on that hostname, the code is dead and no fallback chain can save it, because the request never reaches LinkHub. Keeping the domain live is the only way to honour the permanence rule in Section 22.6.
The one failure mode LinkHub cannot control is the customer deleting their own DNS record. This is surfaced as a permanent, non-dismissible warning on the domain settings page and in the downgrade flow: "This domain is used by {n} QR codes. If you remove its DNS records, those printed codes will stop working and we cannot fix it." An hourly health check on qr_bound domains (Section 13) alerts the Owner by email within one hour of the record disappearing, and continues to alert weekly.
22.5.11 Apply-Time Algorithm #
downgrade-apply runs as a single job per workspace, idempotent, at the effective date.
1. Acquire advisory lock on workspace_id.
2. Re-resolve target plan from Stripe (source of truth).
3. Recompute over-cap sets from live counts.
4. Load saved selection; validate it against live state (dropping items deleted since selection).
5. Fill unselected slots with the deterministic default (oldest-first keep).
6. In one transaction:
- set archived_at / status on every affected resource
- release() counters
- write pending_plan → plan on the projection
- write audit entries (one per resource type, with counts and the full id list in the payload)
7. invalidateEntitlements(workspace_id, 'downgrade_applied')
8. Enqueue redirect-cache-repaint for every affected link, page and domain.
9. Enqueue the "downgrade applied" email with the exact list of what was archived and how to restore it.
10. Release lock.Failure handling: the job is retried with exponential backoff up to 5 attempts. Because step 6 is a single transaction, a partial apply is impossible. If all attempts fail, the workspace stays on the higher entitlements — enforcement fails open, never closed — a billing.downgrade_apply_failed alert pages the on-call engineer (Section 25), and the projection records apply_failed_at so the reason is visible on the internal support view.
22.5.12 Downgrade Case Matrix #
Every case has a stated outcome. No case is undefined.
| Case | Outcome |
|---|---|
| Business → Pro, 23 bio pages | 10 kept (selection, else oldest), 13 archived, handles reserved |
| Business → Pro, 6 members | Owner + 0 others keep seats (Pro = 1 seat); 5 suspended; pending invitations revoked |
| Business → Free, 812 short links | 25 kept, 787 archived; archived redirect for 90 days, then branded landing page at 200 |
| Any → any, 140 QR codes | All 140 continue resolving. Nothing archived, nothing selected, nothing degraded |
| Business → Pro, 3 custom domains, 1 QR-bound | QR-bound domain stays active permanently and does not consume the cap; 1 more kept; 1 suspended |
| Business → Free, 4 workspaces | 1 kept active, 3 archived; each archived workspace's resources follow the per-type rules |
| Downgrade while an experiment is running | Experiment moved to concluded, leader promoted, results retained read-only |
Downgrade while a custom domain is provisioning_tls and over cap |
Provisioning completes (abandoning mid-ACME leaves a broken state), then the domain is immediately suspended |
| Downgrade while a QR render job is queued | Job completes normally. QR operations are never blocked by billing |
| Downgrade with an unpaid final invoice | Downgrade applies on schedule; the unpaid invoice continues through dunning independently |
| Downgrade requested, then upgrade requested before the effective date | The subscription schedule is deleted; no downgrade occurs; selection state is discarded |
| Downgrade applies while the user is editing an affected page | The next save returns 409 resource_archived with details[0].restore_path; unsaved editor state is preserved locally and re-applied on restore |
| Two downgrades stacked (Business → Pro → Free) before the first applies | Only the final target survives; the schedule is replaced and a fresh preview is required |
| Workspace already below every new cap | No selection required; selection_required: false; the downgrade applies silently with a confirmation email |
Downgrade to a plan lacking api_access with active API keys |
Keys disabled, not deleted; in-flight requests complete; subsequent requests get 403 plan_feature_unavailable |
| Downgrade of a workspace whose Owner account is being deleted | Account deletion takes precedence and runs Section 23.14; QR slugs still survive |
22.6 The QR Code Exemption #
22.6.1 The Rule #
Dynamic QR codes are entirely exempt from downgrade enforcement and from non-payment enforcement. They always resolve. Always. On every plan, in every subscription state, including after cancellation, including after the workspace is deleted, including after the account is deleted.
There is no hedge, no grace window, and no configuration that turns this off — not a plan setting, not an admin toggle, not an environment variable.
Specifically, and without exception:
- A QR slug reservation is permanent. It is written to
qr_slug_reservations, a table that has nodeleted_atcolumn and no delete path in the application. Nothing in the codebase issuesDELETE FROM qr_slug_reservations, and a migration test asserts the table has noON DELETE CASCADEinbound reference that could remove a row. - A QR slug is never recycled and never reassigned. Once used, it belongs to that reservation forever. A new QR code requesting that slug receives
409 qr_slug_reservedeven if the original workspace no longer exists — and so does a new short link requesting it, because QR and short-link slugs share one namespace per host (Section 14). - QR resolution never returns 404 and never returns 410. No rung of the fallback chain is an error status.
- Billing state may add branding and may disable editing. It may never stop resolution. A past-due workspace cannot edit a QR destination; the QR still resolves to that destination.
- QR codes above a plan cap are not archived. They are flagged
over_cap = true, which removes them from the counter (so the workspace is not permanently frozen) while leaving resolution, analytics ingestion, and the fallback chain fully intact. - The downgrade selection UI never asks the user to choose which QR codes to keep, because there is no scenario in which one is dropped. The QR step is informational only.
- QR rendering and download remain available on every plan and in every billing state, because a customer who needs to reprint a damaged sign must be able to.
- The short link that backs a QR code is pinned. It is never archived by any billing path and never counts toward the link cap (Section 22.2.5). Sections 12 and 22.5 archive ordinary links above a cap; a pinned link is excluded from both the selection UI and the deterministic default, because archiving it would break the printed symbol, which rule 1 forbids.
What billing state does affect:
| Capability | Free / over cap | Past due | Canceled | Workspace deleted |
|---|---|---|---|---|
| Resolves | Yes | Yes | Yes | Yes |
| Destination editable | Yes, within cap; over-cap codes are read-only | No | No | No |
| Styling editable | Same as destination | No | No | No |
| New QR creation | Subject to cap | Blocked after grace | Subject to Free cap | N/A |
| Analytics recorded | Yes | Yes | Yes | Yes, retained per the last active plan's window |
| Analytics viewable | Within plan retention | Yes | Yes, read-only | No |
| Download / reprint | Yes | Yes | Yes | No |
| LinkHub branding on fallback pages | Yes | Yes | Yes | Yes |
22.6.2 The Fallback Chain, In Order, With Status Codes #
The chain has exactly four rungs. There is no rung 5. Rungs are numbered 1 to 4, each has one name and one fallback_stage enum value, and those names and values are the only vocabulary used for this concept anywhere in the product — there is no parallel serving_mode, no resolved_via, and no separate memorial state.
Resolution of GET https://{host}/{slug} — bare, with no /q/ segment (Section 14) — evaluates these rungs in order and serves the first that applies:
| Rung | Name | fallback_stage |
Condition | HTTP status | Body / Location | Cache-Control |
|---|---|---|---|---|---|---|
| 1 | Active destination | active |
QR is active, has a destination, and passes scheduling/targeting rules (Section 15) | 302 Found | Location: <destination> |
private, no-store |
| 2 | Paused / expiry fallback URL | paused_fallback |
QR is paused, expired, or blocked, and the workspace has configured a fallback URL on the code | 302 Found | Location: <fallback_url> |
private, no-store |
| 3 | Workspace branded unavailable page | workspace_unavailable |
No usable destination or fallback; the workspace still exists, or the workspace has been deleted but not yet erased — the pre-erasure memorial case | 200 OK | Workspace-branded page. Pre-erasure it may carry the workspace display name; that is what makes it a memorial rather than an anonymous notice | public, max-age=60 |
| 4 | Neutral platform landing page | generic |
The workspace is erased, has no retained branding, or cannot be determined at all — or any dependency failure prevents evaluating rungs 1 to 3. This is the post-erasure memorial case | 200 OK | Neutral platform text only | public, max-age=60 |
Rung 3 and rung 4 are the memorial page, before and after erasure. They are not a fifth state. Before erasure, rung 3 may display the workspace display name, which is what makes the page recognisable to somebody scanning a printed code. After erasure, rung 4 must not display the workspace display name, or any other retained personal or identifying data — it serves neutral platform text only. That boundary is load-bearing for the legal argument in Section 23.15.3: the "no personal data is retained" limb of that argument is only true if the post-erasure page contains none, and the legitimate-interest limb that justifies keeping the routing record at all does not extend to keeping a name once erasure has been requested. Both limbs are retained; neither substitutes for the other.
No rung is 404. No rung is 410. No rung is 5xx. If the database is unreachable, the resolver serves the last cached payload; if there is no cached payload, it serves rung 4 with 200 OK and increments qr.fallback.hard_failure. A monitoring alert fires, but the scanner gets a page rather than an error. The mechanism, the cache layout and the render pipeline are specified in Section 14; the privacy carve-out that lets a routing record survive an erasure request is specified in Section 23.15.
Cache-Control is fixed by rung and has one value each: rungs 1 and 2 are private, no-store because they carry a destination that may be personalised by targeting; rungs 3 and 4 are public, max-age=60 because they are static pages whose content changes only when the workspace acts, and a 60-second window bounds how long a repaired code keeps serving the unavailable page. No other Cache-Control value is used on any rung.
Every rung emits an analytics event carrying fallback_stage and the rung number, so a workspace can see that scans are still arriving even while the code is in a fallback state — which is precisely the signal that tells them to fix it. The same two fields appear on the qr.scanned webhook payload (19.7.4) and in the API representation of a QR code (21.8.7).
22.6.3 Why #
Printed material cannot be recalled. A QR code on a restaurant menu, a product carton, a conference banner, a vehicle wrap, or a museum placard is a physical object in the world, and it will be scanned for years by people who have no relationship with LinkHub, no knowledge of the customer's billing status, and no way to get to the destination another way. If a lapsed credit card could turn that code into a 404, the product would be converting a customer's billing problem into a stranger's dead end, and it would be doing so at the exact moment the customer is least able to notice. Every competitor that breaks codes on downgrade has decided that the leverage is worth it. LinkHub decides the opposite: the fallback chain always terminates in something a human can read, resolution costs effectively nothing to keep running, and the trust earned by a code that still works three years later is worth more than the small number of upgrades that breaking it would force. This is a permanent product commitment, not a launch-period concession, and any future proposal to gate QR resolution on payment should be treated as a change to the product's core promise rather than a pricing experiment.
22.7 Dunning #
22.7.1 Retry Schedule #
Stripe's automatic collection is configured with an explicit retry schedule rather than smart retries, so support can answer "when will it try again?" with a date instead of a guess.
| Attempt | When | Trigger | On failure |
|---|---|---|---|
| 1 | Day 0 (invoice due date) | Automatic | Enter past_due, start dunning |
| 2 | Day 3, 09:00 UTC | Automatic retry | Continue |
| 3 | Day 5, 09:00 UTC | Automatic retry | Continue |
| 4 | Day 7, 09:00 UTC | Automatic retry | Continue; enforcement tightens (22.7.5) |
| 5 | Day 11, 09:00 UTC | Automatic retry | Continue |
| Final | Day 14, 09:00 UTC | Automatic retry | Invoice marked uncollectible; subscription canceled |
A manual "Retry payment now" button on the billing page calls stripe.invoices.pay(invoice_id) and is rate-limited to 3 attempts per hour per workspace. A successful retry at any point exits dunning immediately, restores full enforcement, clears all banners, and sends a confirmation email.
Updating the payment method during dunning triggers an immediate retry automatically; the user does not have to press anything else.
22.7.2 Email Sequence #
All emails are sent to the workspace Owner only. Admins do not receive billing email, because they do not have billing access (Section 3). Each email states the amount, the currency, the invoice number, the exact next retry date, and the exact date the subscription will cancel.
| Day | Subject | Content summary |
|---|---|---|
| 0 | "We couldn't process your payment for {workspace}" | Amount, decline reason in plain language (from the mapping in Section 22.14.1), "Update payment method" button, next retry date, cancellation date. States that links and QR codes are unaffected today |
| 3 | "Payment retry failed — {workspace}" | Same, updated dates, plus a note that the workspace becomes read-only on day 8 |
| 7 | "Your workspace becomes read-only tomorrow" | Explicit list of what stops (creating and editing) and what continues (all redirects, all QR resolution, all analytics collection, all exports, sign-in) |
| 11 | "3 days until {workspace} moves to Free" | The downgrade impact list generated from the same preview as Section 22.4.4, so the user sees exactly what will be archived |
| 14 | "Your subscription has been canceled" | What was archived (or what will be archived after the 7-day selection window), how to reactivate, and the explicit QR statement |
| 14 + 7 | "Your downgrade has been applied" | Final list of archived resources, restore instructions |
Every one of these emails contains the sentence, verbatim: "Your QR codes keep working. They resolve on every plan and after cancellation."
Email delivery uses the transactional provider configured in Section 27. Failures are retried; a hard bounce on the Owner's address raises a support task, because a silently undelivered dunning sequence produces an involuntary churn the customer never saw coming.
22.7.3 In-App Messaging #
| Enforcement stage | Banner | Placement | Dismissible |
|---|---|---|---|
| Day 0–7 | Amber: "Payment failed. We'll retry on {date}. Update your payment method to avoid interruption." | Top of every dashboard route, workspace-scoped | No |
| Day 8–14 | Red: "This workspace is read-only until payment succeeds. Your links and QR codes are still working." | Same, plus an inline notice on every disabled control | No |
| Canceled, selection window open | Red: "Your subscription was canceled. Choose what to keep by {date} or we'll keep your oldest items automatically." | Same, with a direct link to the selection flow | No |
| Canceled, applied | Neutral: "You're on Free. {n} items are in your archive." | Billing page and archive tabs | Yes |
Banners are rendered server-side so they appear even with JavaScript disabled, and they are announced to assistive technology as role="status" on load (Section 24.7). Only the Owner sees the amount and the payment-method action; other roles see a reduced message ("This workspace is read-only. Contact the workspace owner.") with no financial detail.
22.7.4 Grace Period #
This subsection defines the past-due write-blocking schedule for the whole product. The authorization function's billing branch implements exactly this schedule and nothing stricter.
- Grace period: 14 days from the first failed charge, matching the retry schedule.
- Days 0 through 7: full write access. Every mutation a healthy paid workspace can perform is still permitted. Writes are not blocked on day 0, on day 1, or at any point before day 8, and
billing_write_blockedis never emitted during this window. A genuine card expiry is usually fixed within a week, and taking write access away the moment a renewal bounces punishes the overwhelmingly common case — the customer whose bank reissued a card — in order to apply leverage that the dunning emails are already applying. - Days 8 through 14: writes blocked, reads unaffected. From the start of day 8 (168 hours after the first failed charge, evaluated in UTC against the timestamp of that first failure) every mutating request returns
403 billing_write_blocked. This is the only window in which that code is emitted for apast_duesubscription. - Cancellation occurs at the end of day 14.
- After cancellation, a 7-day downgrade selection window opens during which the workspace retains its previous entitlements in read-only form so the Owner can choose what to keep and export anything they need. The deterministic default applies at the end of it.
- Total elapsed time from first failure to enforced Free entitlements: 21 days.
- Reactivating at any point during the 21 days by paying the outstanding invoice restores everything with no archiving and no data loss.
22.7.5 Enforcement Modes #
Enforcement mode is a function of subscription status and dunning day. It never alters the numeric entitlement values; it sets writes_blocked on the resolved snapshot. The table below is the complete specification of that function — there is no additional, stricter rule applied elsewhere, and in particular no code path blocks writes from day 0 of past_due.
| Subscription status | Dunning day | Mode | Writes | Reads | Public surfaces |
|---|---|---|---|---|---|
trialing |
— | normal |
Allowed | Allowed | Normal |
active |
— | normal |
Allowed | Allowed | Normal |
past_due |
0–7 | warned |
Allowed — in full | Allowed | Normal |
past_due |
8–14 | read_only |
Blocked | Allowed | Normal |
unpaid |
— | read_only |
Blocked | Allowed | Normal |
canceled, selection window |
0–7 after cancel | read_only |
Blocked | Allowed | Normal |
canceled, applied |
— | normal at Free entitlements |
Allowed within Free caps | Allowed | Per Sections 22.5.5–22.5.7 |
paused (support-initiated) |
— | read_only |
Blocked | Allowed | Normal |
incomplete (checkout not finished) |
— | normal at Free entitlements |
Free caps | Allowed | Free |
read_only refusal payload:
{
"error": {
"code": "billing_write_blocked",
"message": "This workspace is read-only because a payment is outstanding.",
"details": [
{ "field": "workspace", "issue": "past_due", "grace_ends_at": "2026-09-02T09:00:00Z",
"invoice_url": "https://invoice.stripe.com/i/acct_…/…" }
],
"request_id": "req_01JQ8ZM0V4C7N2K9J5H3F8D6QR"
}
}HTTP status: 403. Distinct from plan_limit_reached because the remedy is entirely different — paying an invoice, not upgrading a plan — and clients render a different affordance.
billing_write_blocked is emitted only in read_only mode. For a past_due subscription that means only from the start of day 8. A mutation refused with this code on day 0, 1 or 7 is a defect, and the enforcement test suite asserts a successful write on each of days 0 through 7 and a refusal on each of days 8 through 14 against a fixture that advances the clock.
22.7.6 What Remains Accessible Throughout #
Unconditionally available in every dunning stage and after cancellation:
| Capability | Available | Notes |
|---|---|---|
| QR code resolution | Always | Every rung of the chain, every status 302 or 200, never an error. Restated deliberately: dunning does not touch QR resolution, and cancellation does not touch QR resolution |
| Short-link redirects | Always during the 21 days; then per Section 22.5.5 | Free-cap links keep redirecting; over-cap links get the 90-day window |
| Bio page rendering | Always during the 21 days; then per Section 22.5.6 | — |
| Analytics collection | Always | Data continues to accumulate so nothing is lost while the customer sorts out payment |
| Analytics viewing | Always | Read access is never blocked |
| Data export (GDPR bundle) | Always, every plan | A statutory right |
| CSV export | Subject to csv_export for the plan in force |
During dunning the paid plan is still in force, so CSV export works |
| Sign-in and account management | Always | — |
| Billing page and Customer Portal | Always | The user must always be able to pay |
| QR download / reprint | Always | — |
| Support contact | Always | — |
Blocked in read_only: creating or editing bio pages, blocks, links, QR codes, domains, experiments, integrations, API keys, members and invitations; publishing; and every POST/PATCH/PUT/DELETE endpoint on /v1. The dashboard's billing routes are the deliberate exception and remain fully writable in every enforcement mode — the customer must always be able to pay, and blocking the payment path to enforce non-payment would be self-defeating. Archiving also remains permitted, because archiving is the escape hatch from a cap and is never gated (22.2.5).
22.7.7 Involuntary Churn Reduction #
- Card expiry pre-warning. A daily job flags subscriptions whose stored payment method expires within 30 days and emails the Owner once at 30 days and once at 7 days.
- Network updater. Stripe's automatic card-network updates are enabled, so reissued cards keep working without customer action.
- Failed-payment reason routing.
insufficient_fundsretries on the standard schedule;expired_cardandincorrect_cvcadditionally surface a targeted in-app prompt because they cannot succeed without user action;do_not_honorandgeneric_declineinclude "contact your bank" wording. - 3DS re-authentication.
requires_actioninvoices produce an email with the hosted confirmation link and an in-app "Confirm your payment" button; they do not consume a retry attempt.
22.8 Invoices, Receipts and Tax #
22.8.1 Invoice Records #
Every finalized Stripe invoice is mirrored locally into the invoices table so the billing page renders without a synchronous API call. The table's columns, types, constraints and indexes are defined in Section 6, the sole schema authority; no DDL appears here, and the table is named invoices — not billing_invoices — everywhere in the codebase and in this document.
What matters for this subsection is the mirroring behaviour rather than the column list:
| Behaviour | Rule |
|---|---|
| What is mirrored | Every invoice that reaches finalized. Drafts are mirrored on invoice.created so a pending charge is visible, and updated in place on finalization |
| Money | Subtotal, tax, discount, total, amount paid and amount due, all as integer minor units with an ISO 4217 currency, never floats (Section 5) |
| Identity | The processor's invoice id is stored and is unique, so a replayed webhook updates rather than duplicates |
| Hosted URLs | Stored but never cached at the CDN, never emitted to a machine credential, and rendered only to an authenticated Owner. Re-fetched from the processor if older than 30 days, because the processor rotates them |
| Status | Mirrors the processor's own lifecycle: draft, open, paid, void, uncollectible. The processor is the source of truth and the reconciliation job in 22.10.4 repairs any divergence |
22.8.2 Reading invoices #
GET /app/billing/invoices?limit=25&cursor=…
→ 200
{
"data": [
{
"id": "0192f4a0-77b1-7c33-8e10-1f2d3c4b5a60",
"number": "C1A2B3C4-0007",
"status": "paid",
"currency": "USD",
"subtotal_cents": 10800,
"tax_cents": 2268,
"discount_cents": 0,
"total_cents": 13068,
"period_start": "2026-08-14T00:00:00Z",
"period_end": "2027-08-14T00:00:00Z",
"paid_at": "2026-08-14T12:03:41Z",
"invoice_pdf_url": "https://…",
"hosted_invoice_url": "https://…"
}
],
"meta": { "next_cursor": "eyJpZCI6IjAxOTJm…", "has_more": true }
}This is a dashboard route on the dashboard origin: an authenticated session, the Owner role, and a CSRF token. No API key can reach it, because billing:* is not an issuable scope and no /v1/billing/* path exists (Section 21.8.13). An invoice is the most financially revealing object the product holds — amounts, tax jurisdiction, company name, billing address, a hosted URL that renders the whole document — and a bearer token that could fetch it would turn a leaked key into a disclosure incident. Requiring a session means a human authenticated recently, on the dashboard origin, with CSRF protection, and the access is attributable in the audit log.
The response uses the canonical envelope and cursor pagination from Section 21.3 and 21.4 even though it is not part of the public API, so that one client-side helper handles every list in the product.
22.8.3 Tax Handling #
- Stripe Tax is enabled for automatic calculation on every subscription and invoice. LinkHub does not implement tax logic.
- The product's tax code is registered as a SaaS/electronically-supplied-service code, applied at the catalogue level.
- A billing address is required.
billing_address_collection: 'required'at Checkout, and the Customer Portal permits editing it. A paid subscription without a complete address is a tax-calculation failure; the billing page shows a blocking warning and the next invoice cannot finalize until it is supplied. Requests to change plan while the address is missing return409 billing_address_required. - Address fields collected: line 1, line 2, city, state/province, postal code, country (ISO 3166-1 alpha-2). Country is mandatory; state is mandatory for US, CA, IN, AU and any other jurisdiction Stripe Tax requires it for.
- Prices are exclusive of tax. The plan picker displays "$12/mo" with the note "plus applicable tax"; Checkout shows the tax line and the true total before payment.
22.8.4 VAT / GST Identifiers #
tax_id_collection: { enabled: true }at Checkout, and tax IDs are editable in the Customer Portal.- Supported identifier types are whatever Stripe validates for the customer's country — EU VAT, UK VAT, GST/HST (CA), ABN (AU), GST (IN, NZ, SG), and others.
- Format validation is performed by Stripe at entry; an invalid identifier is rejected inline with
tax_id_invalid. Where a real-time registry check is available (EU VIES, UK), Stripe's verification status is mirrored on the local record (unverified,pending,verified,unavailable) and displayed on the billing page. - Reverse charge: for EU B2B customers with a verified VAT ID outside LinkHub's country of establishment, Stripe Tax applies the reverse charge and the invoice carries the required "VAT reverse charge — Article 196, Directive 2006/112/EC" notation. LinkHub does not decide this; it is a consequence of enabling Stripe Tax with a valid ID.
- A tax ID added after an invoice was issued does not retroactively alter that invoice. The billing page states this. Customers requesting a retroactive correction are handled by support under the runbook in Section 22.14.2.
- The tax ID and its verification state are recorded on the billing account and appear on every subsequent invoice PDF.
22.8.5 Receipts #
- Stripe sends an automatic email receipt on every successful charge, to the billing email on the customer record.
- The billing email defaults to the Owner's account email and is editable in the Customer Portal, so accounting departments can receive invoices without holding a LinkHub login.
- Receipts and invoices are the same artefact for subscriptions: the invoice PDF is the receipt once
status = paid. LinkHub does not generate a separate document. - Invoice PDFs include the customer's company name, billing address, tax ID, the LinkHub legal entity, its tax registration numbers, the line items, the tax breakdown by jurisdiction and rate, and the amount paid.
22.8.6 Retention #
Invoice records are retained for 7 years from issue, independent of workspace deletion and independent of any erasure request. This is an explicit legal-obligation carve-out under GDPR Article 17(3)(b), documented in the data inventory in Section 23.12 and in the erasure workflow in Section 23.14. Erasure removes the workspace's operational data; it does not remove statutory financial records, and the deletion confirmation says so plainly.
22.9 Refunds #
22.9.1 Policy #
| Situation | Outcome | Window |
|---|---|---|
| First paid invoice on a new subscription, monthly | Full refund on request, no reason required | 14 days from charge |
| First paid invoice on a new subscription, annual | Full refund on request, no reason required | 30 days from charge |
| Annual subscription, after 30 days | No refund; the subscription may be canceled and runs to period end | — |
| Monthly subscription, after 14 days | No refund for the current or any prior month | — |
| Renewal charge the customer says they did not intend | Full refund of the renewal if requested within 14 days and there has been no usage since the renewal (no resource created, no destination edited, no export run) | 14 days |
| Duplicate or double charge | Always refunded in full, immediately, no questions | Any time |
| Charge after a confirmed cancellation (LinkHub error) | Always refunded in full, immediately | Any time |
| Service unavailability breaching the availability target in Section 25 | Service credit applied to the next invoice, calculated per that section | Per incident |
| Proration charge on an upgrade the customer immediately reverses | Refunded on request | 7 days |
The policy is published on the pricing page and linked from the billing page. It is stated in the product in exactly these terms, and support does not have discretion to be stricter than it — only more generous, with the audit requirement in Section 22.12.
22.9.2 Requesting a Refund #
POST /app/billing/refund-requests
{ "invoice_id": "0192f4a0-…", "reason_code": "not_as_expected", "reason_text": "…" }
→ 202
{ "data": { "request_id": "0192f4b1-…", "status": "pending", "eligible": true,
"expected_decision_by": "2026-08-24T09:00:00Z" }, "meta": {} }- Owner only.
- The server computes eligibility from the table above and returns
eligible: true|falseimmediately, withdetailsexplaining why when false. An ineligible request is still recorded and still reviewed by a human — the automatic answer is never the final answer. - Eligible requests within the automatic windows are processed automatically within 1 business day; ineligible requests are routed to support with a 5-business-day decision SLA.
- Refunds are issued to the original payment method via Stripe. Cash, credit-to-balance and alternative methods are not offered.
- Ineligible request response:
{
"error": {
"code": "refund_window_expired",
"message": "This invoice is outside the refund window. We've still sent your request to support.",
"details": [ { "field": "invoice_id", "issue": "outside_window",
"charged_at": "2026-03-14T12:03:41Z", "window_days": 30 } ],
"request_id": "req_01JQ8ZP7A1B2C3D4E5F6G7H8IJ"
}
}22.9.3 Effect of a Refund on Entitlements #
- A full refund with cancellation cancels the subscription immediately and runs the downgrade flow with the standard 7-day selection window. Entitlements do not drop the instant the refund is issued.
- A full refund without cancellation (goodwill, service-credit style) leaves entitlements untouched.
- A partial refund never changes entitlements.
- In every case: QR codes continue to resolve. A refund does not touch the fallback chain.
22.9.4 Chargebacks #
| Stage | Action |
|---|---|
charge.dispute.created |
Subscription canceled immediately (cancel_now). Workspace enters read_only. Billing account flagged dispute_open. New checkout blocked with 409 billing_blocked. A support task is opened automatically |
| Evidence | Assembled by support from the invoice, the usage record, the signup and acceptance timestamps, and the delivery evidence (resources created, redirects served). Submitted within Stripe's deadline |
charge.dispute.closed, won |
Flag cleared, workspace restored to its prior state, apology email sent |
charge.dispute.closed, lost |
Flag becomes dispute_lost, permanent. The billing account may not start a new subscription self-serve; support may clear it after payment of the outstanding amount |
| Throughout | QR codes continue to resolve, at every rung, without branding penalty beyond the standard Free-plan badge. A payment dispute is a commercial matter between LinkHub and the customer; it is not a reason to break a stranger's scan |
Two chargebacks on the same billing account permanently disable self-serve subscription creation for that account.
22.10 Payment Processor Webhooks #
22.10.1 Endpoint #
POST https://api.linkhub.app/webhooks/stripe- Served by
apps/apion a route excluded from API-key authentication and from the standard rate limiter, with its own limiter (Section 23.9). - Reads the raw request body before any JSON parsing. A body-parsing middleware that mutates the payload breaks signature verification; the route is registered ahead of the global parser and a test asserts this.
- Responds 200 within 200 ms in all cases where the signature is valid, having done nothing but persist the event. All processing is asynchronous. Stripe's timeout is not something to gamble on.
- Returns 400 with no body on signature failure. Never returns 500 for a business-logic failure — that would cause Stripe to retry an event that will never succeed. Business failures are recorded and alerted.
22.10.2 Signature Verification and Idempotency #
Verification:
- Read the
Stripe-Signatureheader, parsetandv1. - Compute
HMAC-SHA256(secret, "{t}.{raw_body}")and compare in constant time against everyv1value present. - Reject if
|now - t| > 300seconds (replay window). - Reject if the signing secret does not match the configured environment secret. Separate secrets per environment; secrets are stored per Section 23.7.
Idempotency is provided by the payment_events table. Its columns, types, constraints and indexes are defined in Section 6, the sole schema authority; no DDL appears here, and the table is named payment_events — not billing_webhook_events — everywhere in the codebase and in this document. The fields this subsection relies on are the processor's event id (the primary key), the event type, the processor-side creation timestamp, a status of received, processed, skipped or failed, an attempt counter, the raw payload, the affected object id used for ordering checks, and the workspace id.
INSERT … ON CONFLICT (processor_event_id) DO NOTHING. If zero rows are inserted, the event is a duplicate: respond 200 immediately and do nothing else.- After a successful insert, enqueue
billing-webhook-processwith the event id. Handlers are additionally idempotent in their own right, so re-processing is safe.
22.10.3 Events Consumed and Their Effects #
| Event | Handler effect |
|---|---|
checkout.session.completed |
Link the Stripe customer and subscription to client_reference_id (the workspace id); record trial_used_at if a trial was granted; write the projection; do not grant entitlements here (the subscription event does it) |
checkout.session.expired |
Record abandonment metric. No state change |
customer.subscription.created |
Fetch the subscription from Stripe; write the projection (plan, period, status, current_period_end, price ids); grant entitlements if status ∈ {trialing,active}; invalidate; repaint redirect cache; audit plan changed |
customer.subscription.updated |
Re-fetch; diff the projection; apply the rules in Section 22.3.6 for raise/hold/lower; handle cancel_at_period_end transitions; handle schedule phase changes; handle status transitions into and out of past_due/unpaid/paused; invalidate |
customer.subscription.deleted |
Set plan to free with pending_downgrade_apply_at = now() + 7 days; open the selection window; send the cancellation email; invalidate; do not archive anything yet |
customer.subscription.trial_will_end |
Send the day-11 trial reminder; show the in-app banner |
customer.subscription.paused / resumed |
Set/clear the paused enforcement mode |
invoice.created |
Mirror the draft invoice record |
invoice.finalized |
Update the mirrored record with number, totals, tax, hosted URLs |
invoice.paid |
Mark paid; if this completes a held pending_upgrade, apply the upgrade and invalidate; if the workspace was in dunning, exit dunning, clear banners, restore enforcement, send the recovery email |
invoice.payment_failed |
Enter or advance dunning; compute the dunning day from the first failure; schedule the correct email; set enforcement mode; invalidate |
invoice.payment_action_required |
Store the confirmation URL; send the SCA email; show the in-app confirm action |
invoice.marked_uncollectible |
Terminal dunning state; proceed to cancellation |
invoice.upcoming |
Send the renewal-notice email 7 days before an annual renewal (not sent for monthly, which would be noise) |
customer.updated |
Mirror billing email, name, address, tax IDs, and the default payment method summary (brand, last four, expiry only — never a PAN, never a token that could be replayed) |
payment_method.attached / payment_method.detached |
Refresh the stored payment-method summary; if attached during dunning, trigger an immediate retry |
charge.dispute.created |
Execute the chargeback path in Section 22.9.4 |
charge.dispute.closed |
Clear or harden the flag per outcome |
charge.refunded |
Mirror the refund on the invoice record; apply the entitlement rules in Section 22.9.3 |
Any event type not in this table is stored with status = 'skipped' and ignored. Unknown types are not an error; Stripe adds events over time and an unknown-event 500 would create noise and retries.
22.10.4 Out-of-Order Delivery and Reconciliation #
Webhook delivery is not ordered. Three mechanisms make ordering irrelevant:
- Re-fetch, don't trust. Every subscription and invoice handler fetches the current object from the Stripe API by id before writing the projection. The webhook payload is used only to identify what changed, never as the value of the change. A stale event therefore writes current truth.
- Monotonic guard.
subscriptions.last_event_created_at(Section 6) stores the processor-side creation timestamp of the most recent event applied for that object. An event older than the stored value is recorded asskippedwith reasonstale_eventand does not write. This prevents an oldpast_duefrom clobbering a freshactivein the narrow window where a re-fetch and a write race. - Advisory lock per object. Handlers take
pg_advisory_xact_lock(hashtext(object_id)), so two events for the same subscription serialise rather than interleave.
Reconciliation job (billing-reconcile, hourly at :17, and a full sweep nightly at 02:40 UTC):
For every billing account with a Stripe customer:
1. Fetch the customer's subscriptions from Stripe (paginated).
2. Compare against the local projection: status, price ids, period bounds,
cancel_at_period_end, trial_end, pending schedule.
3. On any difference, Stripe wins. Rewrite the projection, invalidate entitlements,
enqueue redirect-cache-repaint, and emit billing.reconcile_drift with the field names.
4. Detect orphans in both directions:
- local subscription with no Stripe counterpart → mark canceled, run the downgrade path
- Stripe subscription whose metadata.workspace_id has no local match → alert; never
auto-create a workspace from a webhook
5. Compare invoice status for the last 90 days and repair mirrored records.Any drift raises a warning alert. Sustained drift (the same workspace drifting on three consecutive runs) pages, because it indicates a handler that is writing the wrong thing rather than a transient race.
Replay tooling. An operator command re-enqueues any stored event by id, and a second command re-fetches and re-projects a single subscription without needing an event at all. Both are documented in the Section 25 runbooks and both write an audit entry.
Dead letters. An event whose handler fails 5 times moves to status = 'failed' and appears on an internal dashboard with its error. Failed billing events page the on-call engineer; unlike most queues, silently dropping one has financial consequences.
22.11 Usage Metering and Display #
22.11.1 What Is Metered #
| Meter | Source | Reset |
|---|---|---|
| Bio pages | workspace_resource_counters.active_count |
Never (stock, not flow) |
| Short links | active_count |
Never |
| Short links created | period_created_count |
Billing period |
| Dynamic QR codes | active_count (excludes over_cap = true) |
Never |
| QR codes created | period_created_count |
Billing period |
| Custom domains | active_count |
Never |
| Seats | active_count |
Never |
| Workspaces | Owned, non-archived count on the billing account | Never |
| API requests | Rate-limit counters (Section 23.9) | Rolling window |
| Analytics retention reach | Derived from plan | — |
Clicks, scans and page views are not metered against any limit on any plan. There is no per-event pricing and no traffic cap. This is stated on the pricing page because it is a common competitor limitation, and it is the reason the analytics pipeline can be fire-and-forget (Section 17).
22.11.2 API #
GET /app/billing/usage
→ 200
{
"data": {
"plan": "pro",
"billing_period": { "start": "2026-08-14T00:00:00Z", "end": "2026-09-14T00:00:00Z" },
"meters": [
{ "key": "bio_pages", "kind": "count", "current": 8, "limit": 10,
"percent_used": 80, "state": "warning" },
{ "key": "short_links", "kind": "count", "current": 812, "limit": -1,
"percent_used": null, "state": "ok" },
{ "key": "short_links_created_per_period", "kind": "period", "current": 233,
"limit": 10000, "percent_used": 2, "state": "ok" },
{ "key": "qr_codes", "kind": "count", "current": 17, "limit": 100,
"percent_used": 17, "state": "ok" },
{ "key": "custom_domains", "kind": "count", "current": 1, "limit": 1,
"percent_used": 100, "state": "at_limit" },
{ "key": "seats", "kind": "count", "current": 1, "limit": 1,
"percent_used": 100, "state": "at_limit" }
]
},
"meta": { "computed_at": "2026-08-19T09:14:02Z" }
}state thresholds: ok < 80% ≤ warning < 100% ≤ at_limit. over_limit exists for the post-downgrade window where current > limit and archiving has not yet applied. Uncapped meters (limit: -1) always report state: "ok" and percent_used: null.
Available to Owner and Admin (Admin needs to know what capacity remains even without billing access). Editor and Viewer receive 403 billing_forbidden. Not exposed to API keys.
22.11.3 In-App Display #
- Billing page: a full meter list with bars, current/limit numbers, and per-meter actions ("Archive pages", "Upgrade to Business for 100").
- Contextual: the create button for any resource within 20% of its cap shows an inline counter ("8 of 10 pages"). At the cap the button remains enabled and opens the upgrade modal on click, rather than being disabled — a disabled control with no explanation is the worst possible answer, and it fails Section 24's error-identification requirements.
- Workspace switcher: workspaces at or over a limit carry a small indicator.
- Bars use both colour and a text label for state, never colour alone (Section 24.2, criterion 1.4.1).
22.11.4 Proactive Warnings #
| Threshold | Channel | Frequency cap |
|---|---|---|
| 80% of any stock meter | In-app inline counter + billing-page badge | Continuous while true |
| 90% of any stock meter | Email to Owner | Once per meter per billing period |
| 100% of any stock meter | Email to Owner + persistent billing-page banner | Once per meter per billing period |
| 80% of a fair-use period meter | Email to Owner | Once per period |
| 100% of a fair-use period meter | Email + in-app banner naming the reset date | Once per period |
Any refusal (plan_limit_reached) |
Contextual upgrade modal | Every time |
Warning emails are sent by a job (usage-threshold-scan) that runs hourly, evaluates crossings against a usage_notifications ledger (workspace, meter, threshold, period) to guarantee the frequency caps, and never sends more than one usage email per workspace per day regardless of how many meters cross. Owners can disable usage emails in notification settings; the in-app indicators are not disableable.
22.12 Entitlement Overrides #
22.12.1 Purpose and Constraints #
Overrides exist for support-granted exceptions: a customer migrating 40 pages onto Pro over a weekend, an agency piloting Business features, a goodwill remedy after an incident. They are deliberately narrow.
Hard constraints:
- An override may only be more generous than the plan (Section 22.2.6). There is no mechanism to reduce an entitlement below its plan value; punitive reduction is handled by suspension, which is a different, audited action.
- Overrides are workspace-scoped, never global and never account-scoped.
- Overrides must carry a reason and an expiry. An override with no expiry cannot be created; the maximum is 365 days, and the default offered in the tool is 30 days.
- Overrides are created only through the internal support tool by staff with the
billing_supportinternal role. There is no customer-facing path, no API endpoint, and no self-serve promotion code that grants one.
22.12.2 Storage invariants #
Overrides live in the entitlement_overrides table. Its columns, types, constraints and indexes are defined in Section 6, the sole schema authority; no DDL appears here. Four invariants are the reason the table exists in that shape, and they are stated here because the engine depends on them:
| Invariant | Enforced by | Why |
|---|---|---|
| At most one active override per (workspace, entitlement key) | A partial unique index over the pair, restricted to rows with no revocation timestamp | Two live overrides for the same key would make resolveEntitlement order-dependent. Granting a second revokes and replaces the first, and the replacement is itself audited |
entitlement_key is a real catalogue key |
A runtime assertion against ENTITLEMENT_KEYS (22.1.3) at grant time, not a database constraint — the key list is generated and changes with the catalogue |
A typo would create an override that silently never applies |
| Every override has a reason of 10 to 1,000 characters | A length check on the column | "Because support said so" is not a record; the weekly review in 22.12.4 reads these |
| Every override has an expiry | The column is not nullable | An override with no expiry is a permanent, invisible plan change. The maximum is 365 days and the tool defaults to 30 |
Revocation is a timestamp and an actor, never a delete: the history of what was granted, by whom, and when it ended is the point.
22.12.3 Lifecycle #
| Action | Effect |
|---|---|
| Grant | Row inserted; invalidateEntitlements(workspace_id, 'override_changed'); audit entry; email to the workspace Owner stating what was granted, why, and when it expires |
| Expire | Hourly job (override-expiry-sweep) finds rows past expires_at, invalidates entitlements, and runs the downgrade flow for any resource now over cap — including the guided selection and the 7-day window. An expiring override is a downgrade and gets the same protection |
| Pre-expiry warning | Email to the Owner at 7 days and 1 day before expiry, with the impact list |
| Revoke early | Same as expiry, with an immediate 7-day selection window |
| Plan upgrade above the override | Override becomes redundant; combine already takes the more generous value, so nothing breaks. The tool flags redundant overrides for cleanup |
22.12.4 Audit Requirement #
Every grant, edit, revocation and automatic expiry writes an immutable audit entry (Section 8) recording: acting staff user, workspace, entitlement key, before value, after value, reason text, ticket reference, expiry, and timestamp. These entries are additionally written to a separate internal audit stream that customers cannot see and staff cannot delete, retained for 7 years.
A weekly report lists every active override with its age, its grantor and its expiry, and is reviewed by the person accountable for billing. Overrides older than 90 days must be either converted into a plan change or revoked; carrying them indefinitely is how a pricing model quietly stops being real.
Customers see their own overrides on the billing page — "Your plan includes 3 custom domains (1 from Pro, 2 granted by support until 31 December 2026)". Hiding a granted exception from the customer produces a nasty surprise on expiry, so it is displayed and included in the usage meters.
22.13 Free-Tier Abuse Controls #
The Free plan is a genuine product, not a trial, and it will be abused for spam redirection, phishing and trial farming. Controls are layered so that no single bypass is sufficient.
22.13.1 Account-Level #
| Control | Rule | On trip |
|---|---|---|
| Email verification | Required before publishing any public surface (page, link, QR) — creation is allowed, publication is not | 403 email_not_verified |
| Disposable domains | Signup blocked against a maintained disposable-domain list, refreshed weekly | 400 validation_failed, details[0].issue = "disposable_email" |
| Signup velocity | 5 signups per IP per hour, 20 per /24 per hour | Arms the challenge escalation for that source, then 429 |
| Bot challenge | The one mechanism defined in Section 20.3.5 and nothing else: honeypot plus submission timing by default, escalating to Turnstile in managed mode only when a named trigger such as the velocity rule above has fired. No proof-of-work, no arithmetic question, no puzzle of any kind. The no-JavaScript signup path is never challenged; it is accepted and the resulting account is held for review before it may publish | Honeypot/timing silently; Turnstile only when armed |
| One Free workspace | workspaces entitlement is 1 on Free; creating a second returns plan_limit_reached |
403 |
| Trial reuse | Blocked by trial_used_at + payment-method fingerprint + verified email (Section 22.3.3) |
409 trial_already_used |
| Card fingerprint clustering | More than 3 billing accounts sharing a payment-method fingerprint flags all of them for review | Review task |
22.13.2 Resource-Level #
| Control | Rule |
|---|---|
| New-workspace ramp | For the first 7 days, a Free workspace may create at most 10 short links per day and 1 QR code per day, regardless of the stock caps. The stock caps (25 / 3) still bind. This blunts burst-and-abandon spam without affecting a real new user, who creates a handful of links in their first week |
| Destination safety on create | Scheme allow-list, DNS/SSRF checks and a Safe Browsing lookup on every destination at creation, per Section 23.6. Free destinations additionally get a same-day recheck rather than waiting for the weekly cycle |
| Slug blocklist | Reserved words, profanity, brand-impersonation terms, and homoglyph confusables (Section 23.6.7) |
| Bulk creation | The API is unavailable on Free (api_access: none), so bulk creation is limited to the dashboard's UI paths |
| Redirect chains | A destination that itself redirects more than 3 times is flagged for review; more than 5 is refused at creation with 422 destination_redirect_chain |
| Public-suffix targets | Destinations pointing at other URL shorteners on a maintained list require the account to have a verified payment method; on Free they are refused with 422 destination_shortener_blocked, because shortener-of-a-shortener is a near-universal abuse signal |
22.13.3 Traffic-Level #
| Signal | Threshold | Response |
|---|---|---|
| Sudden click spike on a Free workspace | > 10,000 clicks in 1 hour on a workspace with no prior traffic | Automated review task; no automatic block, because a legitimate post can go viral |
| Abuse reports (Section 23.6.8) | 3 upheld reports on one workspace | Workspace suspended pending review |
| Safe Browsing hit | Any destination flagged | Interstitial served immediately; owner emailed; repeat offences suspend the workspace |
| Phishing-pattern destinations | Heuristic match on brand-impersonation hostnames | Review task, interstitial applied |
22.13.4 Suspension #
Abuse suspension is distinct from every billing state and has its own flag (workspaces.abuse_suspended_at). Effects:
- All non-QR public surfaces serve a neutral "This content has been disabled" page. Short links: 200 OK, not a redirect. Bio pages: 404 with the same neutral body.
- The dashboard is read-only with an appeal form.
- QR codes continue to resolve unless the destination itself is confirmed malicious. Where a QR destination is confirmed malicious, resolution falls through to rung 3 or 4 of the chain in Section 22.6.2 — still 200 OK, never an error. The code stays alive; the harmful destination is what is removed. This is the one place where a QR's destination is overridden, and it is a safety decision, not a billing one.
- An appeal is answered within 2 business days. Wrongful suspensions are reversed with everything intact.
22.13.5 Rate-Limit Interaction #
Free-tier controls are enforced in the entitlement engine and in the rate limiter, and both surface distinct codes: plan_limit_reached (403) for entitlement caps, rate_limited (429) for velocity. The consolidated limit table is in Section 23.9; it is not duplicated here.
22.13.6 Branding Integrity #
The LinkHub badge on Free public surfaces is rendered server-side in the document, is not injected by JavaScript, carries no distinguishing class name that a copy-paste CSS snippet could target, and its container has a stable computed size. A user-supplied custom CSS block (available on paid plans only) cannot select it: custom CSS is parsed, and any rule whose selector matches the badge subtree is dropped at save time with a validation warning. This is checked in the same pass as the CSS sanitiser in Section 9.
22.14 Billing Error Codes and Support Runbook #
22.14.1 Error Codes #
All follow the canonical envelope in Section 21.3 and are listed in the Section 30 error-code appendix. Every code below is emitted on a dashboard route; none is reachable by an API key, because no /v1/billing/* path exists (Section 21.8.13).
| Code | HTTP | When | Client action |
|---|---|---|---|
plan_limit_reached |
403 | A numeric or period entitlement cap is exhausted. details[0].kind is count or period (22.2.7) |
Show the upgrade modal from details[0] |
plan_feature_unavailable |
403 | A boolean feature is not on the plan at all (22.2.7). The only other entitlement code; there is no feature_not_available |
Show the upgrade modal from details[0] |
billing_write_blocked |
403 | Workspace in read_only: past_due from day 8 only (22.7.4), unpaid, paused, or the post-cancellation selection window. Never emitted on days 0–7 of past due |
Show the pay-now action with details[0].invoice_url |
not_found |
404 | The workspace or object is not visible to this actor, including cross-workspace access | Do not distinguish from "deleted" in the UI |
totp_required |
401 | A billing action requiring step-up was attempted without a recent second factor (Section 3.3.11). 401, not 403 — the remedy is to authenticate again | Open the step-up challenge and replay |
billing_blocked |
409 | Open or lost dispute on the billing account | Direct to support |
subscription_not_found |
409 | Plan-change or portal request with no subscription | Redirect to Checkout |
subscription_already_active |
409 | Checkout requested while a subscription exists | Redirect to the plan-change flow |
plan_change_not_permitted |
409 | Target plan equals current, or the plan key is not purchasable. 409, never 403 — the request is refused because it conflicts with current subscription state, not because the actor lacks permission; a 403 would send the client to the wrong remedy |
Re-render the plan picker |
downgrade_selection_required |
409 | Cancel or downgrade confirmed without a valid preview token | Open the downgrade preview |
payment_requires_action |
409 | SCA/3DS needed to complete a charge | Open details[0].confirmation_url |
card_declined |
409 | Processor declined | Show the mapped decline message and the update-payment-method action |
trial_already_used |
409 | Trial requested by an ineligible account | Proceed to checkout without a trial |
billing_address_required |
409 | Tax calculation impossible without an address | Open the Customer Portal address form |
tax_id_invalid |
400 | Tax identifier failed format or registry validation | Inline field error |
refund_window_expired |
409 | Refund requested outside the policy window | Confirm the request was still sent to support |
invoice_not_found |
404 | Unknown or cross-workspace invoice id | — |
resource_archived |
409 | Mutation attempted on an archived resource | Offer details[0].restore_path |
seat_suspended |
403 | Suspended member accessing a workspace | Show the "contact the owner" screen |
billing_forbidden |
403 | Non-Owner accessing a billing endpoint | Hide billing navigation for the role |
payment_processor_unavailable |
503 | Stripe API unreachable or erroring after retries | Retry with backoff; show a transient-error state |
webhook_signature_invalid |
400 | Signature verification failed (never surfaced to customers) | — |
Decline-code mapping used in message and in dunning emails:
| Processor decline code | Customer-facing wording |
|---|---|
insufficient_funds |
"Your card was declined for insufficient funds." |
expired_card |
"Your card has expired. Please add a new one." |
incorrect_cvc |
"The security code was incorrect." |
card_velocity_exceeded |
"Your bank declined this as too many attempts. Try again later or use another card." |
do_not_honor, generic_decline |
"Your bank declined the payment. Contact your bank or try another card." |
lost_card, stolen_card |
"Your bank declined the payment. Please use a different card." — never reveals the reported reason |
processing_error |
"Something went wrong at the payment network. We'll retry automatically." |
| Anything else | "Your bank declined the payment. Please try another card." |
The fraud-related codes are deliberately vague to the customer, per the payment processor's guidance; the precise code is recorded internally and visible to support.
22.14.2 Support Runbook #
| Symptom | Diagnosis | Resolution |
|---|---|---|
| "I paid but I'm still on Free" | Check payment_events for the customer's events; check the projection |
Run the re-project command for the subscription id. If no event exists, run the checkout-reconcile command with the session id. Confirm entitlements via the internal workspace view |
| "My links stopped working after downgrade" | Check the link's archived_at, over_cap and fallback_stage |
Within 90 days the link should still redirect. If it is serving the landing page early, check the repaint job and the archive timestamp. Restoring the link fixes it immediately; a support override can raise the cap while the customer decides |
| "My QR code is broken" | This should be impossible. Resolve the slug directly and inspect which rung served | Any 404/410/5xx on a QR path is a Sev-1 incident (Section 23.17), not a support ticket. Page immediately. If the fallback chain served rung 3 or 4, the code is working as designed and the fix is to restore a destination |
| "I was charged after canceling" | Compare cancel_at_period_end, the cancellation audit entry and the invoice date |
If cancellation preceded the renewal, refund in full immediately under Section 22.9.1 and file a bug |
| "Tax was wrong on my invoice" | Check the address, the tax ID and its verification status | A tax ID added after issue does not retroactively change an invoice. Where LinkHub's data was wrong, void and reissue via the processor; where the customer's data was wrong, correct it for future invoices and explain |
| "I can't invite my team on Pro" | seats_per_workspace is 1 on Pro |
Correct behaviour. Explain and quote Business. Do not grant a seat override to work around plan design — this is the one override case that is refused by policy |
| Card declined repeatedly | Read the decline code on the internal invoice view | Apply the mapping above; suggest an alternative card; if card_velocity_exceeded, advise waiting 24 hours |
| Customer wants an extension | Verify no prior extension in the last 12 months | Grant an override on the affected entitlements with a 14-day expiry and a ticket reference, or ask billing to pause the subscription. Both are audited |
| Suspected fraudulent workspace | Check abuse signals, destinations and Safe Browsing status | Suspend per Section 22.13.4. Confirm QR resolution still works before closing the ticket |
| Duplicate charge | Two invoices, same period, same amount | Refund immediately, no approval needed, then file a bug — a duplicate charge is always a defect |
| Webhook backlog alert | payment_events rows in received/failed older than 10 minutes |
Check the worker and the processor status page; replay failed events by id; if the processor is down, the reconciliation job will repair state once it recovers |
| Reconciliation drift alert | billing.reconcile_drift metric non-zero |
Inspect the named fields on the internal view. Stripe wins; the job has already rewritten the projection. Investigate the handler that produced the wrong value |
Support tooling requirements: an internal workspace view showing plan, subscription status, enforcement mode, every meter, active overrides, the last 50 billing events, and the current fallback_stage and rung of any resource by id. Every action taken from that view writes an audit entry with the staff user's identity. Support can never read a card number, a full tax identifier, or a session token — those fields are redacted in the tool per Section 23.11.
23. Security, Privacy & Compliance #
23.1 Threat Model #
23.1.1 Assets #
| # | Asset | Sensitivity | Why an attacker wants it |
|---|---|---|---|
| A1 | Redirect destinations (short links, QR codes) | High integrity, low confidentiality | Repointing a printed QR code or a widely-shared link at a phishing page is the highest-value attack against this product |
| A2 | QR slug reservations and their permanence | Highest integrity | Losing or recycling a slug breaks physical media permanently and is unrecoverable |
| A3 | Account credentials and sessions | High | Full workspace takeover |
| A4 | Workspace content (pages, blocks, brand, domains) | Medium | Defacement, brand damage |
| A5 | Email-capture leads | High confidentiality | Personal data of the customer's audience; the highest-volume personal-data store in the product |
| A6 | Analytics data (aggregate, pseudonymous) | Medium | Competitive intelligence about a customer's traffic |
| A7 | Integration credentials (ESP API keys, webhook secrets, pixel IDs) | High | Lateral movement into the customer's other systems |
| A8 | API keys | High | Programmatic workspace control |
| A9 | Billing data (last four, address, tax ID; no PANs) | Medium | Fraud, identity data |
| A10 | Custom-domain TLS private keys | High | Impersonation of the customer's domain |
| A11 | The daily_salt and experiment_salt |
High | Reversing visitor hashes into address + user-agent pairs would convert pseudonymous analytics into personal data |
| A12 | Infrastructure secrets (database URLs, processor keys, signing secrets) | Highest | Total compromise |
| A13 | Audit log | High integrity | Covering tracks |
23.1.2 Actors #
| Actor | Capability | Motivation |
|---|---|---|
| Anonymous internet visitor | Requests to public surfaces | Usually benign; the volume source |
| Opportunistic scanner | Automated vulnerability probing | Commodity exploitation |
| Spammer / phisher | Free-tier account creation | Cheap, reputable redirect infrastructure |
| Malicious workspace member | Authenticated, in-tenant | Data theft on departure, sabotage |
| Cross-tenant attacker | Authenticated in tenant A, targeting tenant B | The classic SaaS breach |
| Credential-stuffing operator | Breached password lists | Account takeover at scale |
| Supply-chain attacker | Compromised npm package | Broad, indirect compromise |
| Insider (LinkHub staff) | Production access | Accidental exposure far more likely than malice |
| Competitor scraper | Public pages at volume | Content and traffic intelligence |
23.1.3 Trust Boundaries #
UNTRUSTED
┌────────────────────────────────────────────────────────────────────┐
│ Public internet: visitors, scanners, bots │
└───────────────┬─────────────────────────────────┬──────────────────┘
│ TB1: CDN / TLS edge │
┌───────────────▼──────────────┐ ┌──────────────▼───────────────┐
│ apps/edge (redirect) │ │ apps/web (SSR public pages) │
│ no session, no user input │ │ no session on public routes │
│ beyond host+path │ │ │
└───────────────┬──────────────┘ └──────────────┬───────────────┘
│ │
│ TB2: authenticated boundary (session / API key)
┌───────────────▼──────────────────────────────────▼───────────────┐
│ apps/web (dashboard) · apps/api (/v1) │
│ session cookie per-key scopes │
└───────────────┬──────────────────────────────────────────────────┘
│ TB3: tenancy boundary (workspace_id + RLS)
┌───────────────▼──────────────────────────────────────────────────┐
│ packages/core services ──► packages/db ──► PostgreSQL (RLS) │
└───────────────┬──────────────────────────────────────────────────┘
│ TB4: egress boundary (SSRF controls)
┌───────────────▼──────────────────────────────────────────────────┐
│ Outbound: destination checks, webhooks, ESP sync, ACME, pixels │
└──────────────────────────────────────────────────────────────────┘
│ TB5: third parties
┌───────────────▼──────────────────────────────────────────────────┐
│ Payment processor · email provider · Safe Browsing · CA · ESPs │
└──────────────────────────────────────────────────────────────────┘23.1.4 Ranked Threats and Controls #
Ranked by (likelihood × impact). Each threat names the control and the section that specifies it.
| # | Threat | Impact | Control |
|---|---|---|---|
| T1 | Cross-workspace data access — a request in tenant A reads or mutates tenant B | Critical | Mandatory WorkspaceContext, repository-level scoping, PostgreSQL row-level security on every tenant table, and the automated proof test — Section 23.3 |
| T2 | Destination tampering — an attacker repoints a printed QR or a shared link | Critical | Authentication + role checks; audit entry on every destination change (Section 8); email notification to Owner and Admins on any QR destination change; optional 2FA enforcement (Section 7) |
| T3 | Account takeover via credential stuffing | Critical | Argon2id hashing, breach-corpus check on set/change, per-account and per-IP login limits, TOTP 2FA, session invalidation on password change — Section 7 |
| T4 | Stored XSS on a public bio page | High | Strict nonce-based CSP with no unsafe-inline for scripts, no raw HTML block type, sanitised custom CSS, contextual output encoding — Sections 23.4 and 23.5 |
| T5 | SSRF via destination URL, webhook URL, or ESP callback | High | Scheme allow-list, DNS resolution checks against private ranges, re-resolution at connect time, no redirect following on server-side fetches — Section 23.6 |
| T6 | Open redirect abuse — LinkHub used as reputable cover for phishing | High | Safe Browsing on create and weekly recheck, interstitial, abuse reporting, shortener-of-shortener refusal, Free-tier ramp — Sections 23.6 and 22.13 |
| T7 | API key leakage (committed to a repo, pasted in a ticket) | High | Hash-at-rest with display prefix, scopes, per-key rate limits, last-used tracking, one-time display, revocation, automated secret-scanning of public sources for the key prefix — Section 21 and 23.7. The blast radius is additionally bounded by what a key may never reach: the audit log, member email addresses, lead records and billing (23.3.4) |
| T8 | Supply-chain compromise of a dependency | High | Lockfile enforcement, ignore-scripts in CI, automated updates with review, vulnerability scanning, patch SLA — Section 23.10 |
| T9 | Session hijacking | High | Secure/httpOnly/SameSite=Lax cookies, HSTS with preload, rotation on privilege change, absolute lifetime cap — Sections 7 and 23.8 |
| T10 | Salt disclosure enabling visitor re-identification | High | Salts in the managed secret store and Redis only, never logged, never in exports, 24-hour/7-day rotation, no historical salt retention — Sections 23.7.1 and 23.11.3 |
| T11 | Custom-domain takeover — dangling CNAME claimed by a third party | High | Ownership TXT verification before activation, hourly re-verification of active domains, immediate deactivation of a domain whose TXT/CNAME no longer resolves to LinkHub — Section 13 |
| T12 | Privilege escalation inside a workspace (Editor → Admin) | Medium | Server-side role checks on every mutation, role changes audited and Owner-notified, no client-trusted role — Sections 3 and 8 |
| T13 | Mass enumeration of slugs / handles | Medium | Negative-cache rate limiting, per-IP limits on the redirect path, non-sequential slug generation from a 32-character alphabet — Section 23.9 |
| T14 | Lead-data exfiltration by a departing member | Medium | Per-resource grants (Business), export audited, export rate-limited, exports delivered by expiring signed link, and no lead read or lead export path on the public API at all, so a leaked API key cannot reach lead data — Sections 20, 21, 23.3.4 and 23.14 |
| T15 | Denial of service on the redirect path | Medium | CDN in front, Redis-cached resolution, per-IP and per-slug rate limits, graceful degradation to the QR fallback chain rather than 5xx — Sections 11, 22.6.2 and 23.9 |
| T16 | Webhook forgery (inbound processor, outbound consumer) | Medium | HMAC signature verification with a replay window, both directions — Sections 22.10.2 and 19 |
| T17 | CSV injection in an export opened in a spreadsheet | Medium | Formula-prefix neutralisation on every exported cell — Section 23.4.5 |
| T18 | Clickjacking of the dashboard | Medium | frame-ancestors 'none', X-Frame-Options: DENY — Section 23.8 |
| T19 | Log-based personal-data leakage | Medium | Structured logging with a redaction allow-list and the absolute rule that raw addresses are never written — Section 23.11 |
| T20 | Insider access to production data | Medium | Least-privilege roles, no standing production database access, break-glass with audit, redacted support tooling — Sections 23.7 and 25 |
| T21 | Malicious file upload (logo, favicon, image block) | Medium | Content-type sniffing, re-encoding through the image pipeline, size and dimension caps, separate origin for user content, Content-Disposition on download — Section 23.4.6 |
| T22 | Brand impersonation via slug or handle | Low | Blocklist, homoglyph/confusable normalisation, trademark takedown process — Section 23.6.7 |
| T23 | Timing side channel on login/lookup | Low | Constant-time comparisons, uniform response timing on authentication failures — Section 7 |
Explicitly out of the launch threat model, with reasoning: nation-state adversaries with supply-chain implant capability (out of proportion to the asset value); hardware side channels on managed infrastructure (mitigated by the provider); DDoS above the CDN's absorbed capacity (escalated to the CDN provider's mitigation service, per Section 25).
23.2 Authentication and Session Security (Summary) #
The mechanism is owned by Section 7 and is not restated here. The security-relevant properties it guarantees, which the rest of this section depends on:
| Property | Guarantee |
|---|---|
| Password storage | Argon2id with the parameters fixed in Section 7; no reversible storage anywhere |
| Password quality | Minimum length, strength-score floor, and a k-anonymity breach-corpus check at set and change time |
| Session token | Opaque 256-bit random value; only its SHA-256 hash is stored; the raw value exists only in the cookie |
| Session cookie | httpOnly, Secure, SameSite=Lax, Path=/, rolling expiry with an absolute cap |
| Session invalidation | All sessions revoked on password change, on 2FA enrolment change, and on explicit "sign out everywhere" |
| Second factor | TOTP available to all users, enforceable per workspace by an Owner on Business, with single-use recovery codes |
| Login abuse | Per-account lockout and per-IP throttling with the thresholds in Section 7 |
| Email verification | Required before any public publication |
| OAuth | Google only; account linking requires a verified matching email; no implicit account creation on an unverified provider email |
Two rules that belong to this section rather than Section 7 because they are security invariants rather than mechanism:
- No authorization decision may be made from a client-supplied role, plan, or workspace identifier. Every one is re-derived server-side from the session or API key.
- A session cookie is never accepted on
/v1. The public API authenticates by API key only, so a CSRF against the API is structurally impossible. The dashboard's own mutations use same-origin server actions with an anti-CSRF token.
23.3 Authorization and Tenancy Isolation #
23.3.1 The Guarantee #
No request executing in the context of workspace A can read, infer the existence of, or mutate any row belonging to workspace B — including by direct object reference, by enumeration, by error-message differential, or by timing.
Cross-workspace access is prevented by construction at three independent layers. Any one of them failing does not produce a breach.
23.3.2 Layer 1 — The Type System Makes the Unscoped Query Unwritable #
Every tenant-owned table is reachable only through a repository whose constructor requires a WorkspaceContext:
// packages/db/src/scoped.ts
export function scopedRepo(ctx: WorkspaceContext) {
return {
bioPages: {
findById: (id: string) =>
ctx.tx.select().from(bioPages)
.where(and(eq(bioPages.id, id), eq(bioPages.workspaceId, ctx.workspace_id))),
// …every method injects the workspace predicate; none accepts a raw where clause
},
};
}- The unscoped Drizzle handles are exported only from
packages/db/src/internal.ts, whose import is restricted by an ESLint rule topackages/dbitself and to the migration/seed/retention utilities that are legitimately cross-tenant. - Repository methods take field values, never caller-supplied SQL fragments or predicate objects. There is no
findWhere(sql)escape hatch. - Every scoped method injects
workspace_idinto both theWHEREclause of reads and theVALUESof writes. A write cannot set a differentworkspace_idthan the context's; the column is not part of any update's assignable field set.
A developer wanting to query across tenants has to import from a restricted path, which fails lint, which fails CI. The unsafe thing is harder to write than the safe thing.
23.3.3 Layer 2 — PostgreSQL Row-Level Security #
Structural defence at the database, so that even a hand-written query in a worker cannot cross tenants:
ALTER TABLE bio_pages ENABLE ROW LEVEL SECURITY;
ALTER TABLE bio_pages FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON bio_pages
USING (workspace_id = current_setting('app.workspace_id', true)::uuid)
WITH CHECK (workspace_id = current_setting('app.workspace_id', true)::uuid);- The application connects as a role that is not the table owner and does not have
BYPASSRLS.FORCE ROW LEVEL SECURITYcloses the owner-bypass hole for migrations run under a different role. - Every transaction begins with
SELECT set_config('app.workspace_id', $1, true)— transaction-scoped, so a pooled connection cannot leak the setting into the next transaction.set_config(..., true)is mandatory; the local flag is asserted in the connection helper. - If
app.workspace_idis unset,current_setting('app.workspace_id', true)returnsNULL, the comparison isNULL, and zero rows are visible. The failure mode of forgetting to set the context is an empty result, never a cross-tenant result. - Cross-tenant workers (retention purge, rollup reconciliation, billing ingest, the erasure purge) run as a separate database role with
BYPASSRLS, are enumerated in one file, and each one is individually reviewed. That list is short and is part of the pre-launch checklist.
Which tables carry RLS is stated as a rule, not as a list. A hand-maintained list of table names drifts the moment a migration lands, and a drifted list is worse than none because it reads as an assurance. The rule, which the schema invariant test in 23.3.5 enforces against the live catalogue on every migration:
Every table in the application schema that carries a
workspace_idcolumn has row-level securityENABLEd andFORCEd, with atenant_isolationpolicy whoseUSINGandWITH CHECKclauses are bothworkspace_id = current_setting('app.workspace_id', true)::uuid— unless the table appears on exception list A below, with a written reason.
The authoritative set of tables and their columns is Section 6; this section does not restate it and cannot disagree with it. A table added by a future migration is covered automatically: if it has workspace_id and no policy, CI fails on that migration.
Exception list A — carries workspace_id, deliberately no tenant policy. Each of these would be broken by the policy, not protected by it:
| Table | Reason the tenant predicate must not apply |
|---|---|
sessions |
A session is resolved before any workspace context exists — the context is derived from the session. Under RLS the lookup would return zero rows and no one could ever sign in. Access is controlled by possession of the token hash, and the row is reachable by no other key |
qr_slug_reservations |
The global uniqueness namespace. It must be readable across tenants to refuse a slug another workspace holds, and it must survive the deletion of the workspace that created it, at which point its workspace_id is nulled (Sections 23.15.2 and 6) |
payment_events |
Processor ingest. Events arrive before the owning workspace is resolved, so workspace_id is nullable and a NULL never satisfies the predicate. The table is reachable only by the billing ingest worker's dedicated role; no application request path reads it |
feature_flags |
Global rows carry workspace_id IS NULL, and a NULL never matches the predicate, so RLS would hide exactly the rows that must always be visible. The table is read-only to applications and holds no customer content — flag keys and rollout percentages only |
jobs_dead_letter |
A job that failed before resolving its workspace has a NULL workspace_id, which is precisely the row an operator most needs to see. Read by operators through the redacted support tooling (23.7.6), never by a request path |
Exception list B — no workspace_id column, so no tenant predicate exists. These fall into four groups, and the invariant test requires every table without workspace_id to be registered in exactly one of them with a reason:
| Group | Why there is no tenant dimension | Control that replaces RLS |
|---|---|---|
| Identity and authentication (the user record, federated identities, the verification, reset, magic-link and email-change token tables, TOTP secrets, recovery codes, authentication attempts) | A person is global; they may belong to several workspaces or to none | Ownership by user_id, session ownership, and single-use tokens that are looked up by hash |
| Global reference data (the plan catalogue, the reserved-slug list, bot signatures, the user-agent parsing corpus of 23.12.4) | The same rows are true for every tenant | Read-only to applications; written only by migrations or a single maintenance job |
| Child rows reached only through an RLS-protected parent (version and attempt tables, certificate records, subscription line items, integration credentials, safe-browsing check results) | The parent row carries the tenancy, and the child is reachable only by its parent's foreign key | The parent's policy is the control: a query that cannot see the parent cannot obtain the child's key. Every such table is joined, never queried by a caller-supplied id |
| Operational records with no tenant (idempotency and infrastructure bookkeeping that predates workspace resolution) | Written before or outside a request context | Cross-tenant role only |
Because the parent-child group relies on an argument rather than on a database predicate, it is the one that gets audited: the cross-tenant sweep in 23.3.5 attempts to reach each of those tables by a foreign key belonging to workspace B while executing as workspace A, and asserts an empty result.
23.3.4 Layer 3 — Role and Grant Checks #
Tenancy answers which rows. Roles answer what may be done to them.
- Every service function declares its required capability (
links:write,members:manage,billing:read, …) and the middleware resolves the actor's role fromworkspace_memberson every request — never from the session payload, never from a client header. - Business per-resource grants intersect with the role: a scoped Editor's repository context carries an additional
resource_allow_list, injected as a second predicate, so a granted-scope violation is also an empty result rather than a 403 leak. - Not-found beats forbidden for cross-tenant references. Requesting a resource belonging to another workspace returns
404 not_found, never403 forbidden, because a 403 confirms the resource exists. Within the caller's own workspace, an insufficient-role failure correctly returns403 forbidden— there the existence is already known to the caller. - The full role matrix is owned by Section 3; the membership mechanics by Section 8.
An API key is bound to exactly one workspace, and the binding is checked first. The authorization routine's API-key branch compares the key's own workspace_id against the workspace named in the request before it evaluates any capability. A mismatch returns 404 not_found and stops; no scope is consulted, no membership is loaded, and no timing difference distinguishes "wrong workspace" from "does not exist". Only after that check passes is the key's scope set intersected with the capabilities of the membership that created it, so a key can never exceed the person who issued it, and a later demotion of that person immediately narrows the key. The scope catalogue itself is defined once, in Section 21; this section does not restate it or assert a count.
Four things an API key can never reach, each a deliberate reduction of what a leaked key is worth:
| Surface | Rule | Why |
|---|---|---|
| Audit log | There is no audit-read scope in the catalogue at all. The audit log is readable only by an Admin or Owner in an authenticated dashboard session | The audit log is the record of who did what, including who created the key. A credential that can read it is a credential that can be used to plan around detection |
| Member email addresses | The account-read scope returns member id, display name and role — never an email address | A workspace member list is otherwise a ready-made, role-annotated phishing target for the customer's own staff |
| Lead records and lead export | No lead read and no lead export endpoint exists on the public API. Leads are exported from the dashboard, which enforces the step-up re-authentication in Section 3.3.11 | Leads are the highest-volume personal-data store in the product (asset A5). Bulk retrieval of other people's personal data is exactly the operation that should require a human, a session and a second factor |
| Billing | No billing endpoint is exposed on the public API. Billing is dashboard-session-only, by design, so that a leaked API key can never reach payment state (Section 21) | Payment state changes are irreversible in the customer's eyes and are the highest-consequence write in the product |
The dashboard is not a way around these; it is the only way to them, and it carries session authentication, step-up where Section 3.3.11 requires it, and an audit entry on every access.
23.3.5 The Test That Proves It #
Four automated tests, all blocking in CI, coverage-gated at 95% on the authorization module (Section 26):
1. Schema invariant test. Enumerates every table in the live schema catalogue and partitions it by whether the table has a workspace_id column. Every table that has one, and is not on exception list A, must have RLS enabled, FORCE set, and at least one policy referencing app.workspace_id in both USING and WITH CHECK. Every table that does not have one must be registered in exception list B with a reason. A table matching neither condition fails the build on the migration that introduces it, so the rule cannot be outgrown and the exception lists cannot silently accumulate members. The test asserts against the catalogue, never against a checked-in list of names.
2. The cross-tenant sweep. Seeds two workspaces, A and B, each with one of every resource type. Then, for every route in the OpenAPI document (enumerated from the document, so a new route is automatically covered), it issues the request authenticated as A's Owner with B's resource identifiers substituted into every path parameter and body reference, and asserts:
- HTTP status is 404 for reads and for mutations targeting B's resources
- the response body contains no field value drawn from B's seed data
- B's rows are byte-identical before and after (checked by a table-wide checksum)
- the response time distribution for "B's id" is statistically indistinguishable
from "an id that does not exist at all" (guards the timing oracle)A route that returns 403, 200, or 500 for a foreign identifier fails the test. A new route added without scoping fails automatically because the sweep is generated, not hand-maintained.
3. The RLS escape test. Opens a raw connection as the application role, deliberately omits set_config('app.workspace_id', …), and asserts every tenant table returns zero rows for SELECT * and rejects every INSERT. This proves the failure mode of the forgotten context is closed, not open.
4. The lint-rule test. Asserts that a fixture file importing the unscoped handles from an application package produces the expected ESLint error, so the guard rail cannot be silently deleted.
23.4 Input Validation and Output Encoding #
23.4.1 The Rule #
Validate on the way in, encode on the way out, at every boundary, with no exceptions for "internal" data. Data that entered the system through any boundary is untrusted forever; there is no promotion to trusted.
Validation is centralised: every inbound payload is parsed by a Zod schema defined in packages/core/src/schemas. Handlers receive the parsed, typed output and never touch the raw body. A handler that reads req.body directly fails lint.
23.4.2 Inbound Boundaries #
| Boundary | Validation | Failure |
|---|---|---|
| Dashboard server actions | Zod schema per action; anti-CSRF token; session-derived actor | 400 validation_failed |
Public REST API /v1 |
Zod schema per endpoint; unknown keys rejected (.strict()), not stripped |
400 validation_failed with per-field details |
Redirect path (host, slug) |
Host matched against the registered-domain table; slug matched against ^[a-z0-9-]{1,64}$ before any lookup |
Fallback chain / negative cache; never an error page for QR |
Public page render (handle) |
Same character class as slug | Branded page at 200 or 404 per Section 11; a QR-reachable host always routes through the reservation check first |
| Inbound processor webhooks | HMAC signature, then a schema per event type | 400 validation_failed, no body |
| Inbound ESP callbacks | Signature or shared-secret verification, then schema | 400 validation_failed |
| File uploads | Section 23.4.6 | 400 validation_failed / 413 request_too_large / 415 unsupported_media_type |
Query parameters (limit, cursor, filters) |
limit integer 1–100; cursor base64 with an HMAC tag so a tampered cursor is rejected rather than decoded |
400 invalid_cursor |
| Import files (CSV bulk link creation) | Row-level schema; the whole import is rejected on any invalid row, with a per-row error report | 400 validation_failed |
| Request body size | Hard cap enforced at the proxy and again before parsing, so an oversized body is never buffered into memory | 413 request_too_large |
Universal input rules:
- Length caps on every string field, declared in the schema, never merely enforced by the database. Unbounded text is a denial-of-service vector before it is anything else.
- Reject, don't sanitise, on structured input. A malformed URL is an error, not something to "clean up". Silent normalisation of a destination URL is how an open redirect gets built.
- Normalise before comparing: NFC Unicode normalisation, lowercase, and confusable folding for slugs, handles and domains (Section 23.6.7).
- Numbers are bounded with explicit min/max; integers are parsed with
z.number().int(); no implicit coercion of strings to numbers on the API. nullandundefinedare distinguished on PATCH semantics: absent means "leave alone", explicitnullmeans "clear".
23.4.3 Outbound Encoding by Context #
| Output context | Encoding | Mechanism |
|---|---|---|
| HTML text | React's default escaping | dangerouslySetInnerHTML is banned by lint across the entire monorepo, with a single reviewed exception for the pre-sanitised theme <style> element, which carries a nonce and contains no user text |
| HTML attribute | React attribute binding | No string concatenation into attributes |
URL in href/src |
Scheme allow-list check, then encodeURI |
A URL failing the allow-list renders as inert text, never as a link |
Inline <style> (theme tokens) |
CSS value sanitiser: only the documented token grammar (hex/rgb/hsl colours, lengths with allowed units, font-family from an allow-list, enumerated keywords). Rejects url(), expression, @import, behavior, and any </> |
Section 9 |
| JSON API responses | JSON.stringify via the framework; Content-Type: application/json; charset=utf-8; X-Content-Type-Options: nosniff |
Never text/html for API responses |
| CSV / XLSX export | Formula-prefix neutralisation (23.4.5) + RFC 4180 quoting | Section 18 |
| Email (HTML and text) | Templating engine with autoescape on; user-supplied values escaped; no user HTML rendered in any email | — |
| Outbound webhook payloads | JSON only; user values carried as data, never interpolated into a URL or header | Section 19 |
| Log lines | Structured fields only; no string interpolation of user data into a message; newline and control characters stripped from field values (log-injection guard) | Section 23.11 |
| SQL | Parameterised statements exclusively through the query builder. Raw SQL is permitted only with bound parameters, and string interpolation into SQL fails lint | — |
| Shell | No user-derived value ever reaches a shell. There is no exec with interpolation in the codebase, and a lint rule enforces it |
— |
Redirect Location header |
Absolute URL re-validated against the scheme allow-list at emit time; CR/LF stripped (response-splitting guard) | Section 23.6 |
| SVG (QR output, uploaded logos) | Generated SVG is produced by the renderer, never user-supplied. Uploaded SVG is refused; only raster logo uploads are accepted, because SVG is an executable document format | Section 14 |
23.4.4 Rich Text and User-Controlled Markup #
There is no HTML block type on bio pages. Block text fields accept plain text plus a constrained inline markup subset (bold, italic, link) represented as structured JSON, not as markup. Rendering walks the structure and emits React elements. This removes the entire sanitiser-bypass class of vulnerability rather than trying to filter it.
Custom CSS (paid plans) is parsed into an AST, filtered against the property and value allow-list, re-serialised, and emitted in a nonce-carrying <style> element. Anything not on the allow-list is dropped with a save-time warning listing the dropped rules. @import, url() pointing off the allowed asset origins, and any selector matching the branding subtree are always dropped (Section 22.13.6).
23.4.5 CSV Injection #
Every exported cell whose value begins with =, +, -, @, a tab, or a carriage return is prefixed with a single quote (') before quoting. Applied to every export path — analytics, leads, audit log, and the GDPR bundle's CSV members. Tested with a fixture containing =cmd|'/c calc'!A1 and asserting the neutralised output.
23.4.6 File Uploads #
| Control | Value |
|---|---|
| Accepted types | image/png, image/jpeg, image/webp, image/avif, image/gif |
| Refused types | SVG, HTML, PDF, video, archives, everything else — with 415 unsupported_media_type |
| Detection | Magic-byte sniffing on the received bytes. The client-supplied Content-Type and file extension are advisory only and are never trusted |
| Size cap | 5 MB per file; 10 MB for a workspace logo set. Enforced at the proxy and again in the handler, and exceeded returns 413 request_too_large |
| Dimension cap | 8,000 × 8,000 px; larger is refused before decode to avoid decompression bombs |
| Processing | Every accepted image is re-encoded through the image pipeline, which strips all metadata including EXIF GPS. The original bytes are never served |
| Storage | S3-compatible bucket, private, served through the CDN on a separate origin from the app (cdn.linkhub.app), so a hypothetical stored-content vulnerability cannot reach app cookies |
| Response headers on user content | Content-Type from the re-encoded format, X-Content-Type-Options: nosniff, Content-Disposition: inline with a generated filename, Cache-Control: public, max-age=31536000, immutable on content-hashed paths |
| Filenames | Never used as storage keys. Keys are {workspace_id}/{uuidv7}.{ext}; the original filename is stored as metadata only and is escaped wherever displayed |
| Rate limit | 30 uploads per 10 minutes per workspace (Section 23.9) |
23.5 The Public Page Content Security Policy #
23.5.1 Scope #
This policy applies to public bio pages, public landing pages (including every QR fallback rung), and the interstitial warning page — everything served on a customer-visible surface. The dashboard has its own, simpler policy in Section 23.5.8.
This section is the single definition of the public Content Security Policy. No other section states a public policy, and the CI gate in 23.8.3 asserts the header set specified here. A second policy written elsewhere would be a policy that nothing enforces, and the failure mode of a wrong CSP is silent: the page still renders for the author, and a security control that was believed to be present is not.
23.5.2 The Directive Set #
Emitted per response, with a fresh nonce:
Content-Security-Policy:
default-src 'none';
base-uri 'none';
form-action 'self' https://app.linkhub.app;
frame-ancestors 'none';
script-src 'nonce-{NONCE}' 'strict-dynamic' 'unsafe-inline' https:;
style-src 'nonce-{NONCE}';
style-src-attr 'none';
img-src 'self' https://cdn.linkhub.app data:;
font-src 'self' https://cdn.linkhub.app;
connect-src 'self' https://api.linkhub.app;
media-src 'self' https://cdn.linkhub.app;
frame-src 'none';
object-src 'none';
worker-src 'none';
manifest-src 'self';
upgrade-insecure-requests;
report-uri /_/csp-report;
report-to cspDirective-by-directive reasoning:
| Directive | Value | Reason |
|---|---|---|
default-src |
'none' |
Deny by default; every resource type must be explicitly permitted. A future resource type is blocked until someone decides otherwise |
base-uri |
'none' |
Blocks <base> injection, which otherwise reroutes every relative URL on the page |
form-action |
'self' + dashboard origin |
The email-capture form posts same-origin; the sign-in affordance on fallback pages posts to the dashboard |
frame-ancestors |
'none' |
Public pages are not embeddable. Prevents clickjacking of the email-capture form. Customers wanting embedding are directed to sharing the URL |
script-src |
nonce + 'strict-dynamic' |
Only the nonce-carrying loader executes; anything it injects inherits trust. 'unsafe-inline' and https: are backwards-compatibility fallbacks that CSP Level 3 browsers ignore in the presence of a nonce; they exist so that a Level 2 browser degrades to host-based allow-listing rather than to no policy |
style-src |
nonce only | The theme <style> block and the critical inline CSS carry the nonce. No external stylesheets, no 'unsafe-inline' |
style-src-attr |
'none' |
Blocks inline style= attributes outright, closing a common injection sink. All styling comes from the nonced block or the compiled utility classes |
img-src |
self + CDN + data: |
data: is needed for the inline QR preview and 1×1 placeholder pixels. blob: is deliberately not allowed on public pages; it is only needed in the editor |
font-src |
self + CDN | Subset fonts are served from the CDN |
connect-src |
self + API host | The analytics beacon posts same-origin; the API host covers the email-capture submission path. Raised per response for an enabled pixel or an escalated bot challenge — see 23.5.5 |
media-src |
self + CDN | Self-hosted audio/video blocks |
frame-src |
'none' by default |
Raised per response only when the page contains an embed block, or when the bot challenge has escalated to Turnstile on that surface — see 23.5.4 |
object-src |
'none' |
No plugins, ever |
worker-src |
'none' |
Public pages have no workers; a service worker on a customer's custom domain would be a persistent-compromise vector |
manifest-src |
'self' |
— |
upgrade-insecure-requests |
— | Defence against a customer pasting an http:// asset URL |
report-uri / report-to |
Section 23.5.6 | Legacy and modern reporting, both emitted during the transition period |
23.5.3 Nonce Strategy #
- The nonce is 128 bits from a CSPRNG, base64-encoded, generated per response. Never derived from the request, never cached, never reused across responses.
- It is generated in the SSR request scope and injected into the header and every
<script>/<style>element by the framework's nonce integration. Templates never write a nonce literal. - Public bio pages are cacheable at the CDN, which conflicts with per-response nonces. The resolution: the cached HTML contains a placeholder token
__CSP_NONCE__, and the edge substitutes both the header value and the in-body occurrences on every response. Substitution is a single pass over the response body with a bounded replacement count, benchmarked inside the Section 11 budget. A response whose placeholder count does not match the expected count is not served from cache; it falls through to origin. This keeps caching and per-response nonces simultaneously true. - A boot-time assertion and a CI test both fail if any template emits a
<script>without a nonce binding. - Blocking JavaScript is zero bytes on bio pages (Section 11), so in the common case the only nonced elements are the theme
<style>and the deferred analytics beacon loader.
23.5.4 Per-Provider frame-src for Embeds #
Embeds are facade-first: the initial render is a static poster image with an accessible activation control and no iframe at all, so a page with three embeds still ships zero third-party frames until the visitor asks. frame-src is raised only for the providers actually present on the page, computed from the block list at render time:
| Provider | frame-src origins added |
Additional origins |
|---|---|---|
| YouTube | https://www.youtube-nocookie.com |
img-src https://i.ytimg.com for the poster |
| Vimeo | https://player.vimeo.com |
img-src https://i.vimeocdn.com |
| Spotify | https://open.spotify.com |
img-src https://i.scdn.co |
| Apple Music | https://embed.music.apple.com |
img-src https://is1-ssl.mzstatic.com https://is2-ssl.mzstatic.com https://is3-ssl.mzstatic.com |
| SoundCloud | https://w.soundcloud.com |
img-src https://i1.sndcdn.com |
https://www.instagram.com |
img-src https://*.cdninstagram.com |
|
| TikTok | https://www.tiktok.com |
img-src https://*.tiktokcdn.com |
| X | https://platform.twitter.com https://twitter.com https://x.com |
img-src https://pbs.twimg.com |
Rules:
- The provider list is a closed set defined in
packages/core/src/embeds/providers.ts. A user cannot supply an arbitrary embed URL; they supply a provider-recognised content URL, and the renderer constructs the frame URL itself from the extracted content id. An arbitraryiframe srcis never accepted from user input. - YouTube always uses the no-cookie origin.
- Origins are added only for providers present on the page. A page with one Spotify embed does not permit YouTube frames.
- Adding a provider is a code change with a security review, recorded in the repository's
DECISIONS.md. - Embeds are
sandbox="allow-scripts allow-same-origin allow-presentation allow-popups"— the minimum each provider needs, withallow-top-navigationdeliberately excluded so a hostile embed cannot navigate the parent page.allow-formsis excluded except for the two providers that require it, which are annotated in the provider table in code.
The one non-embed origin permitted in frame-src. The escalated bot challenge (23.6.10) renders in a provider-supplied frame, so its origin must be present or the challenge is blocked by this very policy and the protected form becomes unsubmittable:
| Reason the origin is added | frame-src origins added |
When |
|---|---|---|
| Bot challenge escalated to Turnstile on this surface | https://challenges.cloudflare.com |
Only on a response that actually renders the challenge — that is, only while the escalation of 23.6.10 is armed for that form or that address. The default state of every public page arms nothing and adds nothing |
This is not a convenience. A CSP that blocks the challenge produces a page whose form can never be submitted by anyone, on every public surface at once, and 23.11.6 raises a Sev-1 page for it. The rollout in 23.5.7 therefore exercises an armed challenge in the staging report-only stage, before the policy is ever enforced in production.
23.5.5 Permitting a User-Configured Pixel Without Opening the Policy #
This is the hard case: workspaces enable GA4, Meta Pixel or TikTok Pixel, all of which want to inject scripts, and the policy must not become a host allow-list that any injected script could hide behind.
The design:
- Users supply an identifier, never a URL. The pixel configuration accepts a GA4 measurement id (
^G-[A-Z0-9]{4,12}$), a Meta pixel id (^[0-9]{10,20}$), or a TikTok pixel id (^[A-Z0-9]{10,30}$). A URL field does not exist anywhere in the pixel configuration. This is the single most important property: the set of script origins is fixed at build time, not at configuration time. - One first-party loader. A single small script, served same-origin, carrying the response nonce, reads a JSON configuration block (also nonced,
type="application/json") and — only after consent where consent is required (Section 23.12) — injects the vendor tag for each enabled provider, building the vendor URL from the fixed template plus the validated id. 'strict-dynamic'propagates trust from the nonced loader to the scripts it injects, so the vendor script executes without any host being named in the policy. Because the loader only ever constructs URLs from a compiled-in template,'strict-dynamic'grants no more reach than the closed provider set.connect-srcandimg-srcare raised per provider, since the tags beacon to their own endpoints:
| Provider enabled | Added to connect-src |
Added to img-src |
|---|---|---|
| GA4 | https://www.google-analytics.com https://*.analytics.google.com https://*.google-analytics.com |
https://www.google-analytics.com |
| Meta Pixel | https://connect.facebook.net |
https://www.facebook.com |
| TikTok Pixel | https://analytics.tiktok.com |
https://analytics.tiktok.com |
Bot challenge escalated to Turnstile (23.6.10) — not a pixel, and listed here because this is the connect-src table |
https://challenges.cloudflare.com |
https://challenges.cloudflare.com |
The bot-challenge row is governed by the same rule as the rest of the table and by the frame-src entry in 23.5.4: the origin is added only on a response that actually renders the challenge, never as a standing allowance, and the two directives are raised together. Adding one without the other is the failure that looks like it works — the frame loads and then cannot talk to its own backend, so the challenge never resolves and the form never submits.
- Nothing is added when nothing is enabled. A workspace with no pixels and no armed challenge emits the base policy verbatim, which is the overwhelming majority of pages.
- Server-side forwarding is preferred. GA4 events are additionally sent from the server via the Measurement Protocol (Section 19), so a workspace that wants analytics without any client-side third-party script can disable the client tag entirely and still get data. That configuration emits the base policy and loads no vendor script at all.
The escalated bot challenge uses the identical mechanism: its script is injected by the same nonce-carrying first-party loader from a compiled-in URL template, so 'strict-dynamic' propagates trust to it and script-src never names a host. Only frame-src, connect-src and img-src are raised, and only on the responses that render it.
The net effect: a workspace can enable any supported pixel, and the policy grows by a small, fixed, reviewed set of origins that were decided by LinkHub, not by the customer. There is no configuration input that can add an origin to the policy.
23.5.6 Reporting #
- Violations post to
/_/csp-reporton the same origin, accepted as both the legacyapplication/csp-reportbody and the modern Reporting API batch. - The
Reporting-Endpoints: csp="https://api.linkhub.app/_/csp-report"header accompanies the policy. - The endpoint is unauthenticated and heavily rate-limited (Section 23.9): 60 reports per minute per IP, and 10,000 per hour globally, beyond which reports are counted but discarded. Browsers send these unauthenticated by design, and an unlimited endpoint is a free amplification target.
- Reports are normalised (blocked URI, violated directive, document URI reduced to its route pattern, disposition, user-agent family) and aggregated. The full document URI is reduced to a route pattern before storage, so a report cannot become a record of which visitor viewed which page.
- Noise from browser extensions is filtered by a maintained allow-list of extension schemes (
chrome-extension:,moz-extension:,safari-web-extension:) and known injected-script hosts; these are counted but not alerted. - Alert thresholds: a new blocked URI appearing more than 100 times in an hour raises a warning; any violation of
script-srcthat is not extension-attributable raises a page, because it is either an attack or a broken deployment.
23.5.7 Rollout Plan #
The policy ships in stages. Enforcing a strict CSP on day one with no telemetry breaks pages nobody is watching.
| Stage | Duration | Header | Exit criteria |
|---|---|---|---|
| 1. Local + preview | Development | Enforcing from the first commit | Development happens under the real policy, so violations are found while writing the code |
| 2. Staging report-only | 7 days | Content-Security-Policy-Report-Only with the full policy, against the seeded template corpus and synthetic traffic |
Zero non-extension violations across every public template, every embed provider, every pixel provider and an armed bot challenge, which is exercised deliberately in this stage rather than discovered in production |
| 3. Production report-only, 10% | 3 days | Report-only on a deterministic 10% slice of public responses, sliced by a hash of the workspace id so a given workspace has a consistent experience | Violation rate per 10,000 responses below 0.5, all non-extension violations triaged |
| 4. Production report-only, 100% | 7 days | Report-only on all public responses | Same threshold, sustained for 7 days |
| 5. Production enforcing, 10% | 3 days | Enforcing on the same 10% slice, report-only retained on the rest | No increase in client-error telemetry or bounce rate on the slice |
| 6. Production enforcing, 100% | Permanent | Enforcing, reporting retained permanently | — |
- The stage is controlled by one configuration value per environment, changeable without a deploy, so rollback is immediate.
- A regression in any later stage rolls back one stage and files an issue. Rolling back to "no policy" is not an available option.
- Both the enforcing and report-only headers may be emitted simultaneously during stages 5 and 6 when a new directive is being trialled: the enforced policy stays stable while the candidate policy is tested in report-only. This is the permanent mechanism for changing the policy after launch, and it is how any future embed provider is added.
23.5.8 The Dashboard Policy #
Simpler, because there is no untrusted content and no third-party embedding:
Content-Security-Policy:
default-src 'none';
base-uri 'none';
form-action 'self';
frame-ancestors 'none';
script-src 'nonce-{NONCE}' 'strict-dynamic' 'unsafe-inline' https:;
style-src 'nonce-{NONCE}' 'self';
img-src 'self' https://cdn.linkhub.app data: blob:;
font-src 'self' https://cdn.linkhub.app;
connect-src 'self' https://api.linkhub.app;
frame-src https://js.stripe.com https://hooks.stripe.com;
object-src 'none';
worker-src 'self' blob:;
upgrade-insecure-requests;
report-uri /_/csp-report; report-to cspDifferences and why: blob: in img-src and worker-src for client-side image cropping and the QR preview; frame-src for the payment processor's 3DS frames; style-src 'self' for the compiled stylesheet. frame-ancestors 'none' and object-src 'none' are identical — the dashboard is never embeddable.
23.6 Destination and Webhook Safety #
Every URL the system will either send a visitor to, or fetch on the server's behalf, passes the same pipeline. There is one implementation, in packages/core/src/safety/url-guard.ts, used by destinations, fallback URLs, outbound webhooks, ESP callbacks, image-by-URL imports, and OG-metadata fetches. Duplicating this logic per call site is how one path ends up unprotected.
23.6.1 Scheme Allow-List #
| Context | Allowed schemes | Reason |
|---|---|---|
| Short-link / QR destination, bio-page link block | http, https, mailto, tel, sms |
The five schemes a visitor-facing link legitimately needs |
| Paused fallback URL | http, https |
Must render a page |
| Outbound webhook URL | https only |
A plaintext webhook leaks its payload and its signature |
| ESP callback / integration URL | https only |
Same |
| Server-side fetch (OG metadata, image import) | https only |
— |
Everything else is refused at validation with 422 destination_scheme_not_allowed, including javascript:, data:, vbscript:, file:, ftp:, blob:, intent:, market: and any unrecognised scheme. The check is performed after WHATWG URL parsing and on the parsed protocol, never on the raw string — string prefix checks are trivially bypassed by whitespace, control characters and case tricks.
http destinations are permitted but flagged: the link editor shows "This destination isn't encrypted" and offers to upgrade it to https. They are not refused, because a meaningful number of legitimate small-business destinations remain plaintext.
23.6.2 Parsing and Normalisation #
- Parse with the WHATWG URL parser. Unparseable →
422 destination_invalid. - Reject any URL containing raw control characters (U+0000–U+001F, U+007F) or a newline anywhere, before or after parsing — response-splitting and header-injection guard.
- Reject embedded credentials (
https://user:pass@host/) with422 destination_credentials_not_allowed. They are almost exclusively a phishing device. - Normalise the host: lowercase, IDNA/punycode conversion, trailing-dot removal, NFC normalisation.
- Reject a host that is a bare IP literal in the public API and in Free workspaces; on paid plans a public IP literal is permitted with a warning, because some legitimate device dashboards use them. Private IP literals are always rejected by 23.6.3.
- Cap the total URL length at 2,048 characters.
- Reject a host whose punycode form differs from its input form in a way that indicates mixed-script confusables (23.6.7).
23.6.3 DNS Resolution Checks #
Every host is resolved, and every returned address — A and AAAA, all of them, not just the first — is checked against the deny list:
| Range | CIDR | Reason |
|---|---|---|
| Loopback v4 | 127.0.0.0/8 |
Local services |
| Loopback v6 | ::1/128 |
Local services |
| Private v4 | 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 |
Internal networks |
| Link-local v4 | 169.254.0.0/16 |
Includes the cloud metadata service |
| Metadata service | 169.254.169.254/32, fd00:ec2::254/128 |
Called out separately because it is the single highest-value SSRF target |
| Carrier-grade NAT | 100.64.0.0/10 |
Provider-internal |
| Unspecified | 0.0.0.0/8, ::/128 |
Resolves to local |
| Broadcast / reserved | 255.255.255.255/32, 192.0.0.0/24, 240.0.0.0/4 |
Reserved |
| Documentation | 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24, 2001:db8::/32 |
Non-routable |
| Benchmarking | 198.18.0.0/15 |
Non-routable |
| Unique local v6 | fc00::/7 |
Private |
| Link-local v6 | fe80::/10 |
Local |
| IPv4-mapped IPv6 | ::ffff:0:0/96 |
Unwrapped and re-checked against the v4 rules — a mapped ::ffff:127.0.0.1 must not slip through |
| NAT64 | 64:ff9b::/96 |
Unwrapped and re-checked |
| Multicast | 224.0.0.0/4, ff00::/8 |
Not a valid destination |
| LinkHub's own infrastructure ranges | Configured per environment | Prevents using the product to attack itself and prevents redirect loops through the edge |
Additional rules:
- Any address failing the check rejects the whole URL, even if other addresses pass. A host with one public and one private address is a rebinding attack in progress.
CNAMEchains are followed to a depth of 10; a longer chain is refused.- DNS resolution has a 2-second timeout; a timeout is a refusal (
422 destination_unresolvable), not a pass. NXDOMAINat creation time is a warning, not a refusal, for visitor-facing destinations — customers legitimately create links for domains that are not live yet — but a hard refusal for webhook and integration URLs, where there is no such use case.- The resolver is configured to a known public resolver set, not the host's resolver, so a compromised local resolver configuration cannot influence the decision.
23.6.4 Closing the DNS-Rebinding Window #
Checking DNS at validation time and connecting later is a well-known bypass: the attacker's authoritative server returns a public address to the checker and a private address to the fetcher, seconds later. Two mechanisms close it, and both are required:
For server-side fetches (webhooks, ESP calls, OG metadata, image import) — pin the address:
- Resolve the host once, obtaining the full address list.
- Validate every address.
- Connect to a validated address directly, using a custom
lookupfunction on the HTTP agent that returns only the pre-validated address for that hostname, so there is no second resolution between check and connect. TLS SNI and certificate verification still use the original hostname, so certificate validation is unaffected. - Do not follow redirects automatically.
redirect: 'manual'. Each hop is re-validated through the full pipeline before being followed, to a maximum of 3 hops. This is where naive implementations leak: the first URL is public and the 302 points at the metadata service. - Re-validate the peer address after connection (
socket.remoteAddress) and abort if it is not the pinned address — a belt-and-braces check against agent misconfiguration. - Enforce a 5-second connect timeout, a 10-second total timeout, and a 1 MB response cap.
For visitor-facing destinations — re-resolve on a schedule:
A visitor's browser resolves the destination itself, so pinning is impossible and irrelevant for SSRF; the risk is instead that a destination which was safe at creation becomes hostile later. Therefore:
- Every active destination host is re-resolved and re-checked every 24 hours, and immediately on any edit.
- A destination that begins resolving into a denied range is paused, the QR/link falls through to its fallback chain, and the Owner and Admins are emailed within 5 minutes.
- A destination whose host stops resolving entirely for 7 consecutive days raises a warning in the dashboard but is not paused — transient DNS outages are common and pausing would be worse than the risk.
23.6.5 Safe Browsing #
| Moment | Action |
|---|---|
| On create / on edit | Synchronous lookup against the Google Safe Browsing Update API v4 local database, plus a Lookup API call for entries not in the local set. Budget: 300 ms; on timeout the URL is accepted and queued for an immediate asynchronous recheck, because blocking creation on a third-party outage is unacceptable |
| Weekly recheck | Every active destination re-checked on a rolling 7-day cycle, spread evenly so the load is flat |
| Free-tier accelerated recheck | Free-workspace destinations additionally rechecked 24 hours after creation (Section 22.13.2) |
| On abuse report | Immediate recheck plus manual triage |
| On a hit | Destination set to safe_browsing_status = 'flagged'; the interstitial is served; Owner and Admins emailed; an audit entry written |
| On clearing | safe_browsing_status returns to safe automatically on the next recheck returning clean, plus a manual "request review" path |
There is exactly one safety column on a destination, safe_browsing_status, and it takes exactly four values. Its definition lives in Section 6; the meaning of each value, and what the delivery path does with it, is here:
safe_browsing_status |
Meaning | Delivery behaviour |
|---|---|---|
unchecked |
Created, and the synchronous lookup timed out or has not run yet | Resolves normally. A recheck is queued; blocking creation on a third-party outage is not acceptable |
safe |
Checked, clean | Resolves normally |
flagged |
A Safe Browsing hit, or an open abuse report awaiting triage | The interstitial of 23.6.6 is served at 200 in place of the redirect. The visitor may continue |
blocked |
Confirmed malicious, or an upheld abuse report | The destination is not served at any status. The resource falls through the fallback chain of Section 22.6.2 to rung 3 (workspace_unavailable) or rung 4 (generic), at 200. The visitor cannot continue |
Threat types consumed: MALWARE, SOCIAL_ENGINEERING, UNWANTED_SOFTWARE, POTENTIALLY_HARMFUL_APPLICATION. The local database is refreshed on the interval the API specifies, and a stale-database alarm fires if it has not updated in 6 hours.
23.6.6 The Interstitial Warning Page #
Served in place of the redirect when safe_browsing_status = 'flagged'. A blocked destination does not reach this page at all; it falls through the fallback chain as set out in 23.6.5.
| Property | Value |
|---|---|
| HTTP status | 200 OK — never a redirect, never an error |
| Cache | private, no-store |
| Robots | X-Robots-Tag: noindex, nofollow |
| Content | Warning heading, the reported threat category in plain language, the full destination URL shown as plain text, not as a link, the reporting source, and the date |
| Actions | "Go back" (primary), "Continue anyway" (secondary, requires an explicit click and is not focused by default), "Report a mistake" |
| Continue-anyway | Sets a short-lived same-origin token for that specific destination only; it is not a cookie that suppresses future warnings on other destinations, and it expires in 10 minutes |
| Accessibility | Meets WCAG 2.2 AA per Section 24; the warning is a heading and a role="alert" region, not colour alone |
| Analytics | Recorded with outcome = 'interstitial', plus whether the visitor continued, so the owner sees the impact |
| QR interaction | A flagged QR destination serves the interstitial. It is still a 200 and the code still resolves; the four-rung chain in Section 22.6.2 is not violated, because the interstitial is a served page, not an error. A blocked QR destination lands on rung 3 (workspace_unavailable) or rung 4 (generic), also at 200 |
23.6.7 Slug Blocklist and Confusables #
Applied to short-link slugs, QR slugs, bio-page handles and workspace slugs at creation and at rename.
| Category | Content | Behaviour |
|---|---|---|
| Reserved system words | api, app, admin, dashboard, login, logout, signin, signup, settings, billing, support, help, docs, status, blog, about, legal, privacy, terms, security, static, assets, cdn, www, mail, smtp, ftp, ns1, ns2, webhook, webhooks, oauth, auth, callback, health, healthz, metrics, robots, sitemap, favicon, manifest, sw, _next, _, plus every existing top-level route |
Refused, 409 slug_reserved |
| Well-known paths | Everything under .well-known/ |
Refused |
| Profanity | A maintained list, matched after confusable folding and leetspeak normalisation | Refused for auto-generated slugs unconditionally; for user-chosen slugs, refused with 422 slug_not_allowed |
| Brand impersonation | A maintained list of high-value brand names and payment/bank terms | User-chosen slugs matching require the workspace to have a verified payment method and are queued for review; matches on a Free workspace are refused |
| Homoglyph confusables | Unicode confusable folding (skeleton algorithm) against the reserved and brand lists — раypal with Cyrillic а and р folds to paypal |
Refused |
| Mixed-script | A slug mixing scripts (e.g. Latin + Cyrillic) in a single label | Refused, 422 slug_mixed_script |
| Character class | Anything outside [a-z0-9-] after normalisation; leading or trailing hyphen; consecutive hyphens; length outside 1–64 |
400 validation_failed |
| Existing QR reservation | Any slug present in the reservation table, regardless of which workspace created it, whether that workspace still exists, and whether the request is for a QR code or for a short link | 409 qr_slug_reserved — this is the permanence rule enforced at the namespace level |
The reservation check is deliberately namespace-wide, not resource-type-wide: QR codes and short links share one slug namespace on a given host (Section 14), so a QR reservation blocks the identical short-link slug on that host, and a short-link slug in use blocks the identical QR slug. Splitting the namespace by resource type would let a short link be created on a slug a printed QR code already owns, and the printed object would then resolve to somebody else's destination. That is the precise failure the permanence guarantee exists to prevent, so the check is performed on the namespace, before the resource type is even considered.
Auto-generated slugs are 7 characters from the Crockford base32 alphabet with look-alikes excluded (Section 5), giving roughly 3.4×10¹⁰ values; generation retries on collision up to 5 times and then widens to 8 characters. Generated slugs are additionally checked against the profanity list, and a match simply regenerates.
23.6.8 Abuse Reporting and Triage #
Endpoint, public and unauthenticated, linked from the footer of every public surface and from the interstitial:
POST /v1/abuse-reports
{
"url": "https://lnkhb.co/x7f2a9q",
"category": "phishing",
"description": "Impersonates a bank sign-in page",
"reporter_email": "reporter@example.com",
"website": "",
"form_token": "…"
}
→ 202
{ "data": { "report_id": "0192f5c2-…", "status": "received" }, "meta": {} }website is the honeypot field and must be empty; form_token is issued with the form and carries its render time. Both are the default bot protection of 23.6.10 and neither asks the reporter to do anything.
| Property | Value |
|---|---|
| Categories | phishing, malware, spam, copyright, impersonation, illegal_content, csam, other |
| Bot protection | The one mechanism in 23.6.10: honeypot plus submission timing by default; the Turnstile escalation only when armed by an abuse trigger. A report submitted without JavaScript is accepted and enters pending_review rather than being challenged |
| Rate limit | 10 per hour per IP, 100 per hour per reported URL (Section 23.9) |
| Reporter email | Optional; used only to send the outcome; deleted 90 days after the report is closed |
Auto-action on csam |
The resource is immediately disabled pending review — the only category with an automatic pre-review block — and escalated to the on-call responder within 15 minutes. Reporting to the relevant authority follows the legal escalation path in Section 23.17 |
Auto-action on phishing/malware |
Immediate Safe Browsing recheck; a confirmed hit applies the interstitial automatically |
| Triage SLA | csam 1 hour; phishing, malware 4 hours; everything else 2 business days |
| Triage outcomes | upheld → interstitial or disable, owner notified with the reason and an appeal path; rejected → no action, reporter notified; duplicate → merged |
| Owner notification | Always, on any upheld report, with the category and the appeal path. Owners are never told the reporter's identity |
| Escalation | 3 upheld reports on one workspace → workspace suspension (Section 22.13.4) |
| QR codes | A disabled QR destination still resolves through the four-rung chain in Section 22.6.2, landing on rung 3 (workspace_unavailable) or rung 4 (generic), at 200. The destination is neutralised; the code is not killed |
| Transparency | Aggregate counts by category and outcome are published quarterly; no individual reports are published |
23.6.9 Outbound Webhook Safety #
The single per-workspace webhook URL (Section 19) is subject to everything above, plus:
| Control | Value |
|---|---|
| Scheme | https only |
| Validation timing | Full pipeline at save time, and address-pinned re-validation on every delivery |
| Redirects | Never followed. A 3xx response is a delivery failure |
| Signature | X-LinkHub-Signature: t=<unix>,v1=<hex> over the raw body, per Section 19 |
| Timeouts | 5 s connect, 10 s total |
| Response cap | 64 KB read, then the connection is closed; the body is discarded |
| Retries | Per the schedule in Section 19, then dead-letter with UI visibility |
| Circuit breaker | 20 consecutive failures disables delivery, emails the Owner and requires a manual re-enable, so a dead endpoint does not consume worker capacity indefinitely |
| Port restriction | Only 443. Arbitrary ports are refused, which removes internal-service port scanning as a use of the webhook feature |
| Header injection | LinkHub sets every header; no customer-controlled header is sent |
23.6.10 The Bot Challenge — One Mechanism #
There is one bot-challenge mechanism in the product, and it is specified here. Every public surface that accepts a submission — sign-up, sign-in, password reset, the email-capture block, the abuse-report form and the support form — uses it and nothing else. Two mechanisms would mean two sets of CSP origins, two accessibility positions and two things to get wrong.
The default, on every one of those surfaces: a honeypot field plus a submission-timing heuristic.
| Property | Detail |
|---|---|
| Honeypot | A field rendered in the DOM, hidden from users by CSS and from assistive technology by aria-hidden="true" and tabindex="-1", with an autocomplete="off" and a name that looks attractive to a naive form-filler. A submission that fills it is discarded |
| Timing | The form carries a signed token encoding its render time. A submission arriving under 1.5 seconds after render, or more than 6 hours after it, is treated as automated |
| What the user experiences | Nothing. No box to tick, no image to identify, no puzzle, no delay, no third-party frame |
| JavaScript | Not required. Both signals work on a plain server-rendered form |
| CSP | Requires no origin at all. The base policy of 23.5.2 is emitted verbatim |
| Third parties | None. No data leaves LinkHub |
The escalation, and only the escalation, is Cloudflare Turnstile in managed mode. It is armed narrowly, never globally, and only by these named triggers:
| Trigger | Scope armed | Disarms |
|---|---|---|
| The per-IP limit on a sign-in, sign-up, password-reset, email-capture, abuse-report or support surface has been exceeded — the rows in 23.9 whose "on exceed" column says the escalation arms | That address, on that surface | 60 minutes after the last refused attempt |
| A single capture form has taken more than 50 submissions in an hour that the honeypot or timing heuristic discarded | That form | 6 hours after the rate returns to baseline |
| A workspace is under an active abuse investigation (23.6.8) | That workspace's public surfaces | When the investigation closes |
| A global signal: discarded submissions exceed 20% of all submissions for 15 minutes | Every public submission surface | When the rate returns below 10% for 30 minutes |
When armed, the challenge's origins are added to frame-src and connect-src per 23.5.4 and 23.5.5, on those responses only.
Three rules that hold without exception:
- The no-JavaScript path never receives a challenge of any kind. Turnstile requires JavaScript; a visitor without it cannot complete it, so presenting one would mean presenting a control that cannot be operated. Those submissions are accepted and written with a status of
pending_reviewinstead, where they are triaged by the same rules as any other flagged submission. A submission that a human made and a machine could not judge is held, not refused. - There is no cognitive-function test anywhere in the product — no arithmetic question, no puzzle, no word problem, no "type the characters", and no first-party proof-of-work presented to the user. WCAG 2.2 SC 3.3.8 treats a cognitive function test in an authentication process as a failure, Section 24.1.2 puts every one of these surfaces in scope for the conformance claim, and Section 24.9.4 records this as a conformance note. Turnstile in managed mode is used precisely because it is a behavioural check with a non-interactive path, not a puzzle.
- A challenge is never the only way through. Where a visitor cannot complete an armed challenge, the submission falls to
pending_reviewon the same path as the no-JavaScript case. The escalation raises the cost of automation; it never becomes a wall a person cannot get past.
23.7 Secrets, Key Rotation and Encryption at Rest #
23.7.1 Secret Inventory #
| Secret | Storage | Rotation | Blast radius if leaked |
|---|---|---|---|
| Database connection credentials | Managed secret store, injected as environment variables at container start | 90 days | Total data compromise |
| Redis credentials | Same | 90 days | Cache poisoning, session-cache read |
| Payment processor secret key | Same | 180 days, or immediately on suspicion | Financial access |
| Payment processor webhook signing secret | Same | 180 days | Forged billing events |
daily_salt |
Redis + secret store; generated, never typed by a human | 24 hours, automatic | Visitor re-identification for that day only |
experiment_salt |
Same | 7 days, automatic | Experiment bucket prediction |
| Session-cookie signing/derivation key | Secret store | 180 days, with an overlap window | Session forgery |
| API-key hashing pepper | Secret store | Not rotated (see 23.7.4) | Offline attack on stolen key hashes |
| Outbound webhook HMAC secret (per workspace) | Encrypted column | User-initiated, any time | Forged webhooks to that workspace's endpoint |
| Integration credentials (ESP API keys, Slack tokens) | Encrypted column | User-initiated | Access to that customer's third-party account |
| Custom-domain TLS private keys | Encrypted at rest in the certificate store, private key never leaves the edge tier's memory or its encrypted store | Per certificate renewal, ~60 days | Impersonation of the customer's domain |
| ACME account key | Secret store | 365 days | Certificate issuance for verified domains |
| Object-storage credentials | Secret store, scoped to one bucket with a least-privilege policy | 90 days | User-content read/write |
| Email provider API key | Secret store | 180 days | Outbound email as LinkHub |
| Safe Browsing API key | Secret store | 365 days | Quota abuse only |
| Export-link signing key | Secret store | 90 days, overlapping | Access to in-flight export bundles |
23.7.2 Handling Rules #
- No secret is committed to the repository, ever.
.envis git-ignored;.env.examplecontains keys with empty or obviously-fake values and is the documented source of the required variable list. - Secret scanning runs on every push (pre-receive and CI). A detected secret fails the build and triggers the rotation runbook; removing the commit is not sufficient, because the value must be treated as compromised.
- Secrets are injected as environment variables at container start from the managed store. They are never baked into an image, never written to disk, and never passed as command-line arguments (which are visible in the process table).
- A boot-time assertion validates that every required variable is present and non-empty, and fails startup rather than running degraded. The check reports which variables are missing by name, and never prints a value.
- Secrets are excluded from crash dumps, error reports and telemetry by an allow-list serialiser (Section 23.11).
- Local development uses a
.envgenerated from.env.examplewith development-only values against local containers. No developer holds production secrets; production access is via break-glass (23.7.6).
23.7.3 Rotation Procedure #
Every rotation follows a dual-key overlap so there is no outage window:
1. Generate the new secret in the managed store as version N+1; keep N active.
2. Deploy configuration that ACCEPTS both N and N+1 (verification tries both;
signing/encryption still uses N).
3. Switch the ACTIVE version to N+1. Signing and encryption now use N+1.
4. Wait for the longest lifetime of anything created under N
(session lifetime, signed-link TTL, webhook tolerance window).
5. Remove N from the accepted set. Delete N from the store.
6. Record the rotation in the operational log with operator, secret name and timestamp.Emergency rotation (suspected compromise) skips step 4, accepting the consequence — sessions terminate, in-flight signed links break — and is announced on the status page. The decision to skip is the incident commander's (Section 23.17).
Rotation is exercised at least once per quarter in staging as a drill. A rotation procedure that has never been run is not a procedure.
23.7.4 API Key Storage #
API keys are stored as SHA-256(pepper || key) with a 6-character display prefix, per Section 21. SHA-256 rather than a slow KDF is correct here: the key is 256 bits of CSPRNG output, so there is no dictionary to attack, and the redirect-adjacent verification path cannot afford a memory-hard hash on every request. The pepper is not rotated, because rotating it would invalidate every customer's keys simultaneously with no overlap mechanism; instead the pepper's blast radius is limited by the fact that hashes alone are useless against high-entropy keys.
Additional controls: keys are displayed once and never retrievable; the display prefix is used in the dashboard, logs and support tooling in place of the key; last_used_at and last_used_ip_country (country only, never the address) are tracked; revocation is immediate and propagates through the same invalidation channel as entitlements; automated scanning watches public code-hosting sources for the LinkHub key prefix format and auto-revokes any key found, notifying the Owner.
23.7.5 Encryption at Rest #
| Layer | Mechanism |
|---|---|
| Database volumes | Provider-managed AES-256 encryption at rest, enabled on every environment including preview |
| Object storage | Provider-managed AES-256, bucket-level enforcement, plus a bucket policy denying unencrypted PUT |
| Backups | Encrypted with a separate key from the live volumes, so a compromised live key does not decrypt history |
| Redis | Provider-managed encryption at rest and in transit. Redis holds only caches, buffered events and salts — never long-lived personal data |
| Integration credentials | Application-level envelope encryption, described below |
Application-level envelope encryption is applied to the stored integration credentials, because provider disk encryption does not protect against an application-level read. The table and its columns are defined in Section 6 and are not restated here; what follows is the cryptographic contract those columns implement.
- Algorithm: AES-256-GCM. The data key is unwrapped from the managed KMS at process start and held in memory only; the KMS master key never leaves the KMS.
- The initialisation vector is 96 bits from a CSPRNG and unique per encryption operation. Reuse is catastrophic for GCM, so a test asserts the generator is called per operation and never seeded deterministically.
- The owning workspace id and the integration type — read from the parent integration row — are bound as additional authenticated data. A ciphertext copied into another workspace's row therefore fails authentication rather than decrypting. This turns a database-level row swap into an error instead of a credential transplant, and it is the reason the credential row can be a child of an RLS-protected parent (23.3.3, exception list B) without weakening the boundary: even a successful cross-tenant read of the raw bytes yields nothing that will decrypt.
- The stored key version supports rolling re-encryption. A background job re-encrypts old versions after a key rotation; both versions decrypt during the overlap.
- Only a display hint (the last four characters) is stored in the clear, and only so support and the dashboard can identify which credential is which.
- Decryption happens only in the worker that needs the credential, at the moment of use. The plaintext is never returned by any API, never logged, never included in an export, and is redacted in support tooling to
last_four. - The GDPR export includes the existence and type of an integration, never its credential.
23.7.6 Production Access #
- No standing human access to the production database. Application roles are used by applications only.
- Break-glass access is granted through a time-boxed (maximum 4 hours) elevated role, requires a second person's approval, is announced in the operations channel, and writes an audit record. Every statement executed under it is logged.
- Support tooling reads through a redacted API, not through direct database access, so the ordinary support path requires no elevation at all (Section 22.14.2).
- Production console access to containers requires the same break-glass path.
- The break-glass procedure is drilled quarterly alongside the rotation drill.
23.8 Transport Security and HTTP Security Headers #
23.8.1 Transport #
| Control | Value |
|---|---|
| TLS versions | 1.2 and 1.3 only. TLS 1.0/1.1 and all SSL versions disabled |
| Cipher suites | TLS 1.3 defaults; for 1.2, ECDHE with AES-GCM or ChaCha20-Poly1305 only. No CBC, no RC4, no 3DES, no static RSA key exchange |
| Certificates | Let's Encrypt via ACME for customer domains (Section 13); the provider's managed certificates for LinkHub-owned hostnames |
| OCSP stapling | Enabled |
| HTTP → HTTPS | 301 at the edge for every hostname, including custom domains. It is permanent, cacheable, saves a round-trip on the highest-traffic surface, and pairs with HSTS. This is a transport upgrade to the identical URL, not a destination redirect; the never-301 rule in Section 12 governs destinations, whose targets are editable. It is the only 301 the system issues; every link and QR destination redirect is 302, always |
| Certificate transparency | All issued certificates are logged by the CA; a monitor watches CT logs for unexpected certificates on LinkHub-owned domains and alerts |
| Key strength | ECDSA P-256 preferred, RSA 2048 minimum |
| Renewal | Automatic at 30 days remaining, alert at 14, page at 7 (Section 13) |
23.8.2 Header Set #
Applied to every response from every application. Values are set in one shared middleware, not per route.
| Header | Value | Reason |
|---|---|---|
Strict-Transport-Security |
max-age=63072000; includeSubDomains; preload |
Two years, subdomains included, preload-eligible. Applied to LinkHub-owned hostnames unconditionally. On customer custom domains it is applied only after the domain has been active for 7 days and the customer has acknowledged the consequence, because HSTS on a customer's apex affects hostnames LinkHub does not serve |
X-Content-Type-Options |
nosniff |
Stops content-type sniffing turning an uploaded image into a script |
Referrer-Policy |
strict-origin-when-cross-origin |
Full URL to same-origin, origin only cross-origin, nothing on downgrade. Prevents a bio-page path leaking into a destination's referrer logs while preserving the referrer-host analytics dimension |
X-Frame-Options |
DENY |
Legacy companion to frame-ancestors 'none', for browsers that predate CSP Level 2 |
Permissions-Policy |
accelerometer=(), ambient-light-sensor=(), autoplay=(), battery=(), camera=(), display-capture=(), document-domain=(), encrypted-media=(), fullscreen=(self), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), midi=(), payment=(), publickey-credentials-get=(), screen-wake-lock=(), usb=(), xr-spatial-tracking=() |
Public pages need none of these. fullscreen=(self) is retained for video embeds; the embed iframe receives allow="fullscreen" explicitly |
Cross-Origin-Opener-Policy |
same-origin |
Isolates the browsing context; blocks cross-window references from an opener |
Cross-Origin-Resource-Policy |
same-site on app responses, cross-origin on CDN asset responses |
Assets must be loadable by customer domains; app responses must not |
Cross-Origin-Embedder-Policy |
Not set | Would break third-party embeds for no benefit at this threat level. This is a deliberate decision, not an omission |
Content-Security-Policy |
Section 23.5 | — |
Cache-Control |
private, no-store on every destination redirect, on all authenticated responses and on all API responses; public, max-age=… on public HTML and immutable assets. On the QR fallback chain specifically: rungs 1–2 (active, paused_fallback) are private, no-store because they resolve to an editable destination; rungs 3–4 (workspace_unavailable, generic) are public, max-age=60, because they are static pages that a flood of scans should be able to serve from the edge |
Prevents an intermediary caching a session-scoped or destination-scoped response, while letting the two terminal fallback pages absorb load |
X-Robots-Tag |
noindex, nofollow on the dashboard, the interstitial, archived-resource pages and every QR fallback page |
Keeps operational pages out of search results |
X-DNS-Prefetch-Control |
off on the dashboard |
Avoids leaking dashboard-linked hostnames to the resolver |
| Server identification | Server and X-Powered-By removed |
No free version disclosure |
Access-Control-Allow-Origin is not set on the dashboard or on public pages. On /v1 it is set to * for GET endpoints only, because API-key authentication is header-based and there are no ambient credentials to steal; write endpoints do not emit CORS headers, so a browser cannot be induced into a cross-origin mutation. Access-Control-Allow-Credentials is never true anywhere.
23.8.3 Verification #
- A CI test asserts the exact header set on a sample response from every application, so a middleware ordering change that drops a header fails the build.
- A synthetic monitor checks the production header set every 5 minutes on the dashboard, a public bio page, a redirect and an API endpoint, and alerts on any deviation (Section 25).
- TLS configuration is scanned weekly by an external scanner; a grade below A raises a warning, below B raises a page.
23.9 Rate Limiting and Abuse Prevention — Consolidated #
All limits use the sliding-window counters at rl:{scope}:{key} in Redis, whose canonical key set is owned by Section 4. Exceeding a limit returns 429 with code rate_limited and these headers:
HTTP/1.1 429 Too Many Requests
Retry-After: 37
RateLimit-Limit: 120
RateLimit-Remaining: 0
RateLimit-Reset: 37
Cache-Control: private, no-store{
"error": {
"code": "rate_limited",
"message": "Too many requests. Try again in 37 seconds.",
"details": [ { "field": "rate_limit", "issue": "exceeded", "scope": "api_key",
"limit": 120, "window_seconds": 60, "retry_after_seconds": 37 } ],
"request_id": "req_01JQ8ZR2K4M6P8S0U2W4Y6A8CD"
}
}Public API limits are not defined here. Section 21.7 is authoritative for every /v1 limit, ceiling, burst allowance and header. Rows 4 to 9 below reproduce that table exactly so this consolidated view is complete; if the two ever differ, Section 21.7 is right and this table is a bug. A single CI test reads the limiter's configuration and asserts both statements of it agree, so the duplication cannot rot.
| # | Surface | Scope | Limit | Window | On exceed |
|---|---|---|---|---|---|
| 1 | Redirect (apps/edge) |
IP | 6,000 | 60 s | 429 with a plain-text body. The limit is deliberately high: a shared corporate NAT, a campus network, a conference venue or a mobile carrier gateway routinely puts thousands of genuine visitors behind one address, and a limit tuned for a single browser turns them all away |
| 2 | Redirect | Slug | 20,000 | 60 s | Serve from cache only; never reject a legitimate viral link |
| 3 | Public bio page render | IP | 300 | 60 s | 429 |
| 4 | Public API /v1 — all requests |
API key (Pro) | 120 | 60 s | 429 (Section 21.7.1) |
| 5 | Public API /v1 — all requests |
API key (Business) | 600 | 60 s | 429 (Section 21.7.1) |
| 6 | Public API /v1 — mutating requests |
API key | 30 on Pro, 120 on Business | 60 s | 429, evaluated after the overall key limit (Section 21.7.1) |
| 7 | Public API /v1 — analytics queries |
API key | 30 on Pro, 60 on Business | 60 s | 429, its own counter (Section 21.7.1) |
| 8 | Public API /v1 — concurrent requests |
API key | 10 on Pro, 20 on Business | in flight | 429 concurrency_limit, so a client can distinguish "slow down" from "run fewer at once" (Section 21.7.4) |
| 9 | Public API /v1 |
IP (unauthenticated requests) | 60 | 60 s | 429, counted before authentication (Section 21.7.1) |
| 10 | Public API /v1 |
Workspace, all keys combined | 2× the per-key limit | 60 s | 429. One workspace with many keys cannot consume a multiple of its intended share (Section 21.7.1) |
| 11 | Sign-in | Account | 5 failures | 15 min | 15-minute lockout (Section 7) |
| 12 | Sign-in | IP | 20 | 60 min | 429; the bot-challenge escalation of 23.6.10 arms for that address on that surface |
| 13 | Sign-up | IP | 5 | 60 min | 429; escalation arms |
| 14 | Sign-up | /24 network | 20 | 60 min | 429; escalation arms |
| 15 | Password reset request | Email address | 3 | 60 min | 202 with a generic response — never reveals whether the address exists |
| 16 | Password reset request | IP | 10 | 60 min | 429; escalation arms |
| 17 | Magic-link request | Email address | 3 | 15 min | 202, generic |
| 18 | Email verification resend | Account | 5 | 60 min | 429 |
| 19 | Invitation send | Workspace | 50 | 24 h | 429 |
| 20 | Dashboard mutations | Session | 300 | 60 s | 429 |
| 21 | Resource creation (link/page/QR) | Workspace | 100 | 60 s | 429, separate from entitlement caps |
| 22 | New Free workspace ramp | Workspace | 10 links + 1 QR | 24 h, first 7 days | 429 (Section 22.13.2) |
| 23 | File upload | Workspace | 30 | 10 min | 429 |
| 24 | Analytics query — dashboard | Session | 60 | 60 s | 429. The API's analytics budget is row 7 and is a separate bucket |
| 25 | Export creation — analytics and leads combined | Workspace | 10 | 60 min | 429. Exports are expensive, so they have their own bucket and do not consume the general budget (Section 21.7.1) |
| 26 | Bulk endpoints | Workspace | 10 | 60 min | 429 (Section 21.7.1) |
| 27 | GDPR export request | Workspace | 2 | 24 h | 429 |
| 28 | QR render | API key | 60 | 60 s | Queued rather than rejected, with a position indicator. Cached renders do not count (Section 21.7.1) |
| 29 | Custom-domain add | Workspace | 10 | 24 h | 429 |
| 30 | Domain verification retry | Domain | 10 | 60 min | 429 |
| 31 | Email-capture submission | IP | 5 | 60 min | 429; escalation arms for that address on that form |
| 32 | Email-capture submission | Form | 500 | 60 min | 429; owner alerted to possible form abuse |
| 33 | Abuse report | IP | 10 | 60 min | 429; escalation arms |
| 34 | Abuse report | Reported URL | 100 | 60 min | Accepted but deduplicated |
| 35 | CSP report intake | IP | 60 | 60 s | Silently dropped, counted |
| 36 | Inbound processor webhook | Global | 1,000 | 60 s | 429; the processor retries |
| 37 | Billing invoice retry | Workspace | 3 | 60 min | 429 |
| 38 | Support/contact form | IP | 5 | 60 min | 429; escalation arms |
| 39 | Outbound webhook delivery | Workspace endpoint | 100 | 60 s | Queued, then shed with a warning to the owner |
Cross-cutting rules:
- The per-IP, per-workspace and per-key scopes are separate counters and are never conflated. An address exhausting row 1 has no effect on any workspace's row 21 budget, and a key exhausting row 4 has no effect on the dashboard's row 20. Merging scopes is how one noisy tenant, or one large office, silently throttles everyone else.
- Limits are enforced at the edge where possible, so a flood never reaches the origin.
- Distinct 429 and 403. A rate limit is
rate_limited(429, retry later); an entitlement cap isplan_limit_reached(403, retrying is pointless). Conflating them makes client behaviour wrong in both directions. - The redirect path degrades rather than fails. Under limiter unavailability the limiter fails open for redirects (availability of a printed code beats precision of a limit) and fails closed for authentication endpoints (security beats availability).
- QR resolution is never rate-limited to the point of failure. Above row 1's threshold the response is served from cache with no database access; it is never rejected, and it never becomes a 4xx or 5xx. This is a direct consequence of Section 22.6 and is the reason row 1's number is what it is.
- Exceeding a limit never arms a cognitive test. Where a row says the escalation arms, it arms the single mechanism in 23.6.10 and nothing else.
- Limits are configuration values per environment, changeable without a deploy, so an incident response can raise or lower them immediately.
- Every 429 is counted per scope and surfaced on the operations dashboard; a sustained 429 rate above 1% on any authenticated surface is a signal that the limit is wrong, not that customers are abusive.
23.10 Dependency and Supply-Chain Security #
23.10.1 Lockfiles and Install Discipline #
pnpm-lock.yamlis committed and authoritative. CI installs with--frozen-lockfile; an install that would modify the lockfile fails the build.ignore-scripts=trueis set for CI installs, so a maliciouspostinstallcannot execute in the build environment. The small number of packages genuinely requiring build scripts are allow-listed explicitly by name.- Registry access is pinned to the public registry over HTTPS with integrity hashes verified from the lockfile.
overridesin the workspace root pin transitive dependencies that have known advisories and no upstream fix, each with a comment naming the advisory.- The Node.js runtime version is pinned by
.nvmrcand by the container base image digest, so a runtime upgrade is an explicit, reviewed change. - Container base images are referenced by digest, not by tag, and are rebuilt weekly to pick up distribution patches.
23.10.2 Automated Updates #
| Stream | Cadence | Handling |
|---|---|---|
| Security advisories (any severity) | Immediate, on publication | Automated pull request, labelled security, jumps the review queue |
| Patch updates | Weekly, grouped | Automated pull request, auto-merged if all checks pass and the package is not in the critical set |
| Minor updates | Weekly, grouped by ecosystem | Automated pull request, human review |
| Major updates | Monthly | Individual pull requests with a migration note; never auto-merged |
| Container base images | Weekly | Automated rebuild and redeploy through the normal pipeline |
The critical set — packages whose compromise is directly exploitable and which therefore never auto-merge regardless of version bump size: the authentication library, the password-hashing library, the payment SDK, the ORM and database driver, the HTTP frameworks, the validation library, and the image-processing library. Updates to these require a named human approver.
23.10.3 Scanning #
| Scan | Tool class | Frequency | Failure behaviour |
|---|---|---|---|
| Dependency advisories | pnpm audit + the platform's advisory database |
Every pull request and nightly on the default branch | Critical or high fails the build; medium and low are reported |
| Container image CVEs | Image scanner | Every image build and nightly on published images | Critical fails the build; high fails after the SLA window in 23.10.4 elapses |
| Static analysis | The platform's code scanning with the security rule set | Every pull request | High-confidence security findings block merge |
| Secret scanning | Push protection + repository history scan | Every push, plus a full history scan weekly | Blocks the push; a detected historical secret triggers rotation |
| Licence compliance | Licence scanner | Every pull request | Copyleft licences incompatible with the product's distribution model block merge |
| SBOM | CycloneDX generation | Every release build | Attached to the release artefact and retained for 2 years |
23.10.4 Patch SLA by Severity #
Measured from the time the advisory becomes known to LinkHub (advisory publication or scanner detection, whichever is first) to the time the fix is deployed to production.
| Severity | Criterion | SLA | Escalation |
|---|---|---|---|
| Critical (CVSS ≥ 9.0, or any actively exploited advisory at any score) | Remote code execution, authentication bypass, or a public exploit | 24 hours | Page the on-call engineer; out-of-band deploy authorised; if no patch exists, mitigate by disabling the affected path or applying a virtual patch at the edge |
| High (CVSS 7.0–8.9) | Privilege escalation, significant data exposure | 72 hours | Normal deploy pipeline, prioritised over feature work |
| Medium (CVSS 4.0–6.9) | Limited exposure, or requires unusual preconditions | 14 days | Batched into the weekly update pull request |
| Low (CVSS < 4.0) | Minimal impact | 90 days | Batched into the monthly update pull request |
Modifiers:
- An advisory in a package that is not reachable from any production code path (a build-only or test-only dependency) drops one severity level, and the reachability judgement is recorded on the issue with the reasoning.
- An advisory in the critical set gains one severity level.
- An advisory affecting the redirect path or the QR fallback chain is treated as Critical regardless of CVSS, because those paths have no degraded mode a customer can accept.
- If an SLA will be missed, an explicit risk acceptance is recorded with a named owner, the compensating control, and a review date. Silently missing an SLA is not permitted.
23.10.5 Build Integrity #
- Builds run only in CI, from the default branch or a pull-request branch, never from a developer's machine.
- Build provenance attestations are generated for release artefacts and verified at deploy time; an artefact without a matching attestation is refused by the deploy pipeline.
- The default branch is protected: no direct pushes, at least one approving review, all status checks required, force-push disabled, and administrators are not exempt.
- Deploy credentials are short-lived, issued to the CI job via OIDC federation, never stored as long-lived secrets in the CI system.
23.11 Logging and Monitoring for Security #
23.11.1 Format and Transport #
All logs are structured JSON emitted by the shared logger. There are no free-text log lines and no string interpolation of user data into messages — a message is a stable constant, and every variable is a named field. This makes redaction enforceable and makes log injection impossible (a newline in a field value is escaped by the JSON encoder, and field values additionally have control characters stripped).
Standard fields on every line: timestamp, level, service, environment, request_id, trace_id, span_id, workspace_id (where applicable), actor_type, actor_id, route, status, duration_ms.
23.11.2 The Absolute Rule on Addresses #
A raw client IP address is never written to any log, any database column, any metric label, any trace attribute, any error report, any analytics record, or any export — in any environment, including local development.
The address exists only in memory, only for the duration of a request, and only for three purposes: deriving visitor_hash (Section 17.3), country/region lookup, and rate-limit keying. It is discarded when the request ends.
The same rule holds for the raw user-agent string, for the same reason and by the same mechanism: it is a high-entropy fingerprinting attribute, and stored beside a per-day visitor key it is the missing half of a re-identification attack. It is read into memory, parsed to a coarse family string, hashed to ua_hash under the same daily salt as visitor_hash, and then discarded. It is never written to a log, an event row, a metric label or an export. The one place a raw user-agent string is retained at all is the parser-maintenance corpus described in 23.12.4, which holds no visitor key, no workspace key and no timestamp finer than a day, and is therefore not linkable to any visit.
Enforcement:
- The logger's serialiser runs an allow-list: only declared fields are emitted. An undeclared field is dropped, not passed through. Adding a field is a code change that shows up in review.
- A dedicated
RedactedIptype wraps any address at the point it enters the process. It has notoString, notoJSON, and throws if serialised. Code that wants a loggable form must call.toCountry()or.toHashKey(salt), both of which return non-identifying values. Logging an address is a type error before it is a policy violation. - Rate-limit keys use
SHA-256(salt || ip)truncated to 128 bits, so even the Redis keyspace does not contain addresses. - A
RawUserAgenttype wraps the header value identically, exposing only.toFamily()and.toHash(salt). The two guards are one mechanism, so neither can be added without the other. - A CI test writes a log line with an address in every plausible position (message, field, nested object, error
cause, HTTP header dump) and asserts no dotted quad or IPv6 literal appears in the output. The same test asserts that a fixture user-agent string does not appear either. - The reverse proxy and CDN access logs are configured to hash the client address at the edge before the log leaves the edge. Where a provider cannot do this, its access logs are disabled.
23.11.3 Redaction List #
Never logged, in any form, at any level, in any environment:
| Category | Examples |
|---|---|
| Addresses | Client IP (v4/v6), X-Forwarded-For, X-Real-IP, CF-Connecting-IP, any header carrying an address |
| Credentials | Passwords, password hashes, reset tokens, magic-link tokens, TOTP secrets, TOTP codes, recovery codes |
| Session material | Session tokens, session cookie values, CSRF tokens, the lh_session cookie header |
| API material | Full API keys, the Authorization header, API-key hashes |
| Payment material | Card numbers, CVC, expiry, processor secret keys, webhook signing secrets, Stripe-Signature header |
| Salts | daily_salt, experiment_salt, and any derived intermediate |
| Integration secrets | ESP API keys, webhook HMAC secrets, OAuth access and refresh tokens, Slack tokens |
| Personal data of visitors | Email addresses submitted to capture forms, raw user-agent strings in any form (only user_agent_family is ever logged), precise geolocation |
| Bulk request bodies | Request and response bodies are not logged by default; on error, a body is logged only after passing the redaction serialiser and truncation to 2 KB |
| Cookie headers | The entire Cookie and Set-Cookie headers are dropped |
| Query strings | Logged as the route pattern plus an allow-list of parameter names; values are dropped except for enumerated safe parameters (limit, sort, range) |
| Full URLs of visited pages | Reduced to the route pattern; a bio-page handle is logged, a visitor's full referrer URL is reduced to its host |
Redaction is applied by the serialiser, not by call sites, so it holds for third-party libraries that log through the shared logger. Libraries that write to stdout directly are wrapped or configured to silence.
23.11.4 Security Events Logged #
These are logged deliberately and retained separately from application logs, at info or above, and feed the alerting rules:
| Event | Fields beyond the standard set |
|---|---|
| Authentication success / failure | user_id (on success), email_hash (on failure), method, ip_country, user_agent_family, failure_reason |
| Account lockout | user_id, attempt_count, unlock_at |
| Password change, 2FA enrol/disable, recovery-code use | user_id, mechanism |
| Session created / revoked / expired | session_id_hash, reason |
| Role granted / changed / revoked | workspace_id, target_user_id, from_role, to_role |
| Invitation sent / accepted / revoked | workspace_id, invitation_id |
| API key created / revoked / first use / auto-revoked-on-leak | key_prefix, scopes |
| Destination changed (link or QR) | resource_id, from_host, to_host — hosts only, never full URLs with query strings |
| Custom domain added / verified / removed | domain |
| Entitlement override granted / revoked | entitlement_key, staff_user_id, ticket_ref |
| Authorization failure (403) | required_capability, actual_role |
| Cross-tenant attempt (a 404 caused by the tenancy predicate) | attempted_resource_type — alerted, always, because this is either a bug or an attack |
| Rate limit exceeded | scope, limit, window |
| CSP violation | directive, blocked_uri_host, route_pattern |
| Safe Browsing hit | resource_id, threat_type |
| SSRF check rejection | reason, denied_range — never the resolved address itself |
| Abuse report received / triaged | report_id, category, outcome |
| Break-glass access granted / used / expired | operator, approver, duration, statements_executed |
| Webhook signature failure | source, reason |
| Export requested / generated / downloaded | export_id, scope, requested_by |
23.11.5 Retention and Access #
| Log class | Retention | Access |
|---|---|---|
| Application logs | 30 days hot, 90 days cold | Engineering, on-call |
| Security events | 400 days | Security responders; read-only, no delete permission |
| Customer-facing audit log | Per the plan table (Section 22.1.2) | Workspace Admins and Owner |
| Access logs (edge, address-hashed) | 30 days | Engineering |
| Break-glass statement logs | 7 years | Named accountable owner only |
Log storage is append-only where the provider supports it; deletion requires a separate permission that no engineer holds by default. This matters specifically for threat T13 in Section 23.1.4 — an attacker who reaches production should not be able to erase the evidence.
23.11.6 Security Alerting #
| Condition | Severity | Response |
|---|---|---|
| Any cross-tenant attempt | Page | Investigate immediately; assume a bug until proven otherwise |
Any script-src CSP violation not attributable to a browser extension |
Page | Possible stored XSS or a broken deploy |
| Authentication failure rate > 5× the 7-day baseline for 5 minutes | Page | Credential stuffing |
| Successful sign-in from a new country for an account with prior activity | Notify the user by email; log only | Standard account-security hygiene |
| More than 10 API keys created in a workspace in an hour | Warning | Possible compromise |
| Safe Browsing hits > 10 in an hour globally | Warning | Possible coordinated abuse campaign |
| Break-glass access granted | Notify the operations channel immediately | Awareness, not approval |
| Secret detected in a push | Page | Rotation runbook |
| Any 404/410/5xx on a QR resolution path | Page, Sev-1 | The permanence guarantee is broken; see Sections 22.6 and 23.17 |
| Webhook signature failures > 20 in 10 minutes | Warning | Misconfiguration or forgery attempt |
| TLS certificate within 7 days of expiry | Page | Renewal failure |
| Unexpected certificate in CT logs for a LinkHub domain | Page | Possible mis-issuance |
23.12 The Privacy Position #
23.12.1 Cookie-Free by Default #
LinkHub's first-party analytics sets no cookies and stores no device identifiers. It works like this, and this design is the entire basis of the privacy position:
- A click, scan or page view arrives at the edge.
- The client address and the raw user-agent string are read into memory only.
visitor_hash = base64(sha256(daily_salt || client_ip || user_agent || workspace_id)[0..15])is computed (Section 17.3).ua_hashis computed from the raw user-agent string under the samedaily_saltand the same rotation, anduser_agent_familyis parsed from it — a short label such asChrome on Android.- Country and region are resolved from the address.
- The address and the raw user-agent string are both discarded. Neither is written anywhere (Section 23.11.2).
- The event —
visitor_hash,ua_hash,user_agent_family,country_code,region_code, device family,os_family,browser_family, referrer host, UTM parameters, timestamp, resource id,is_bot— is pushed to the stream.
The raw user-agent string is never persisted. No event row, no session row, no audit row, no log line, no metric, no trace and no export contains it. What is stored is a coarse family label and a daily-salted hash, and the hash exists for one purpose only: clustering the signatures of automated traffic so that
is_botcan be set without keeping the string that would identify a browser exactly.
Why this is not a device identifier in any durable sense:
- The
daily_saltrotates every 24 hours at 00:00 UTC and no historical salt is retained. After rotation, yesterday's hashes cannot be linked to today's, even by LinkHub, even with the raw address. Cross-day tracking of an individual is not merely prohibited by policy; it is not computable. - The hash is scoped to
workspace_id, so the same visitor produces different hashes on two different customers' pages. Cross-customer profiling is not computable either. - Geolocation is country and region only. Never city, never coordinates, never postal code — a deliberate accuracy sacrifice, because city-level data is where geolocation becomes identifying in sparsely populated areas.
- The hash is truncated to 128 bits, sufficient to distinguish visitors within a day and insufficient to serve as a stable identifier across days.
- The exact user agent is not in the row.
visitor_hashcannot be tested against a candidate address without also knowing the exact user-agent string that produced it, and that string is not stored next to the hash — or anywhere else. An attacker holding a copy of the event table therefore holds neither of the two inputs, only two salted digests of them.
The consequence for the customer, stated plainly in the product: unique-visitor counts reset daily and cannot be deduplicated across days. A "unique visitors this month" figure is the sum of daily uniques and will over-count returning visitors. LinkHub displays it labelled as such rather than implying a precision it does not have. This is the honest cost of the design and it is not hidden.
23.12.2 The Lawful Basis, Argued #
Basis: legitimate interests, Article 6(1)(f) GDPR. The argument, in full, because a lawful basis asserted without reasoning is worthless in an audit:
Purpose. The customer (controller) needs to know how many people viewed their page, which links were clicked, roughly where those people were, and on what class of device, in order to operate the page at all. This is measurement of the customer's own service, not advertising, not profiling, and not enrichment.
Necessity. The purpose cannot be achieved with meaningfully less data. Aggregate counts alone cannot distinguish one visitor loading a page ten times from ten visitors loading it once, and that distinction is the basic unit of the measurement. A rotating, salted, workspace-scoped, daily-expiring hash is the minimum that supports it. Region-level geography is the coarsest granularity that supports the customer's actual decisions (which languages to publish, which time zone to post in). No alternative was rejected for convenience.
Balancing. Against the customer's interest sits the visitor's reasonable expectation. The processing: sets nothing on the visitor's device; stores no identifier that survives 24 hours; cannot follow the visitor to another customer's page; cannot follow them across days; stores neither the address nor the user-agent string at any point; retains of the device only a coarse family label that describes millions of people identically; and produces no output that identifies, contacts, targets or evaluates any individual. There is no automated decision-making and no profiling within the meaning of Article 22. The residual impact on a visitor is close to the theoretical minimum for any measurement at all, and the visitor suffers no consequence from it. The balance favours processing.
Transparency and objection. The privacy notice, linked from the footer of every public surface, describes the processing in the terms above. A visitor may object; the mechanism is the "Do not track my visit" control on that notice, which sets a strictly-necessary preference and suppresses event emission for that visitor. Global Privacy Control (Sec-GPC: 1) is honoured as an objection automatically, without the visitor having to find the control.
Where consent is used instead. Third-party pixels (GA4 client tag, Meta, TikTok) and the optional lh_ab stickiness cookie are not covered by legitimate interests. They require consent, because they involve storage on the device and disclosure to a third party for that third party's own purposes. Consent is obtained through the banner described in Section 23.13.3, and pixels never fire before it.
23.12.3 DPIA Summary #
A full DPIA is maintained as a living document by the accountable owner; this is its summary, and it is reviewed on every change that touches personal data.
| Element | Assessment |
|---|---|
| Is a DPIA mandatory? | Not strictly under Article 35(3). There is no systematic, extensive evaluation with legal or similarly significant effect, no large-scale special-category processing, and no systematic monitoring of a publicly accessible area. One is conducted anyway, because analytics at internet scale is the kind of processing where "we didn't think we needed one" ages badly |
| Nature | Collection of pseudonymous interaction events at the edge; buffering in a stream; batch write to a partitioned store; incremental aggregation into rollups; time-bounded retention with partition-drop purge |
| Scope | Every visitor to every public LinkHub surface; volume is potentially large, per-visitor data is minimal |
| Context | Visitors have no relationship with LinkHub and did not choose it; they interacted with the customer. This asymmetry is the core reason for the minimisation choices |
| Purpose | Traffic measurement for the customer; fraud and abuse detection; service operation |
| Necessity and proportionality | As argued in 23.12.2. Data minimisation is enforced structurally — neither the address nor the user-agent string can be logged, because both types refuse to serialise, and the salt cannot be un-rotated because history is not kept |
| Risk 1: re-identification via the hash | Likelihood: very low. Impact: medium. Confirming that a given visitor_hash belongs to a given person requires three things at once: the current day's salt, the candidate address, and the exact user-agent string that browser sent. None of the three is in the event store. The removal of the raw user-agent column is what changed this assessment: while that column existed, an attacker holding a database copy already had the third input in plaintext, sitting in the same row as the hash, and needed only to enumerate the candidate address space — which for a targeted individual is small. With it gone, the same attacker must guess a high-entropy string they have no copy of, for a hash that expires within 24 hours. Mitigated further by salt secrecy (Section 23.7), 24-hour rotation with no history, workspace scoping, and truncation |
Risk 1a: recovering the user agent from ua_hash |
Likelihood: low. Impact: low. Stated rather than glossed, because it is the one place the fix is not absolute: real user-agent strings come from a small, publicly enumerable set, so an attacker who already holds the current day's salt could dictionary-attack ua_hash back to a string. That attacker must have compromised the secret store to get the salt, at which point the salt itself is the incident. Against the far more likely attacker — one with a database copy and no salt — ua_hash discloses nothing at all, which is the whole reason it replaced the plaintext column. The residual is accepted, and ua_hash is used only for bot-signature clustering, never as a join key to anything |
| Risk 2: personal data in a free-text field | Likelihood: medium. Impact: medium. A visitor may type anything into an email-capture form's free-text field. Mitigated by field-length caps, the customer's own controller obligations, inclusion of leads in the export/erasure workflows, and a default form template that requests only an email address |
| Risk 3: address or user-agent leakage into a log | Likelihood: low. Impact: high. Mitigated by the two type-level guards, the allow-list serialiser, the CI test that asserts neither a dotted quad, an IPv6 literal nor a fixture user-agent string reaches the output, and edge-level log hashing (Section 23.11.2) |
| Risk 4: third-party pixel over-collection | Likelihood: medium. Impact: medium. The customer enables these, becoming the controller for them. Mitigated by consent gating, geo-gating, server-side forwarding as the preferred path, and the closed provider set in Section 23.5.5 |
| Risk 5: QR routing record surviving erasure | Likelihood: certain by design. Impact: negligible. The retained record contains no personal data — after erasure it does not even carry the workspace's display name, so the page a scanner reaches names nobody. See Section 23.15 for the full argument |
| Risk 6: excessive retention | Likelihood: low. Impact: medium. Mitigated by plan-bound retention, nightly partition-drop purge, and the fact that rollups contain no visitor-level rows |
| Residual risk | Low. Accepted by the accountable owner |
| Consultation | Not required; no high residual risk remains after mitigation |
| Review trigger | Any new personal-data category, any new sub-processor, any change to the salt or hash design, any new third-party integration, and in any case annually |
23.12.4 Data Inventory #
Every category of personal data the system holds. "Recipients" lists who receives it beyond LinkHub's own infrastructure.
| # | Category | Fields | Data subject | Purpose | Lawful basis | Retention | Recipients |
|---|---|---|---|---|---|---|---|
| 1 | Account identity | Email, display name, avatar URL, locale, timezone | LinkHub user | Provide the service, authenticate | Contract, Art. 6(1)(b) | Life of account + 30-day grace, then purge | Email provider (transactional email) |
| 2 | Credentials | Argon2id password hash, TOTP secret, recovery-code hashes | LinkHub user | Authenticate | Contract | Life of account | None |
| 3 | Sessions | Token hash, user-agent family, country, created/last-seen | LinkHub user | Session management, account security | Contract; legitimate interests for the security signal | 90 days maximum | None |
| 4 | Audit log | Actor, action, resource, before/after, country, user-agent family, timestamp | LinkHub user | Accountability, security, dispute resolution | Legitimate interests; legal obligation where applicable | Per plan (30 / 365 / unlimited days) | None |
| 5 | Billing identity | Billing name, billing address, tax ID, billing email, card brand/last four/expiry | LinkHub customer | Take payment, meet tax obligations | Contract; legal obligation for the invoice record | 7 years for invoices (legal obligation); other fields for the life of the account | Payment processor (processor→controller for its own compliance), tax authorities where required |
| 6 | Support correspondence | Message content, attachments, email | LinkHub user | Provide support | Contract; legitimate interests | 3 years from ticket closure | Support-desk provider |
| 7 | Visitor analytics events | visitor_hash, country_code, region_code, device family, os_family, browser_family, referrer host, UTM values, resource id, timestamp, is_bot. No address and no raw user-agent string |
Visitor to a customer's page | Traffic measurement for the customer | Legitimate interests, Art. 6(1)(f) — argued in 23.12.2 | Per plan: raw 30 days / 90 days / 24 months; rollups 30 days / 365 days / indefinite | None. Not sold, not shared, not used to train anything |
| 7a | user_agent_family — a column on the same event rows |
A short label such as Chrome on Android, capped at 100 characters |
Visitor to a customer's page | Report to the customer which browsers and platforms their audience uses, so they can decide what to test against. It describes a population, not a person: millions of visitors share one value | Legitimate interests, Art. 6(1)(f), on the same argument as row 7 | Identical to row 7 — it is a column of the same partition and is dropped with it | None |
| 7b | ua_hash — a column on the same event rows |
A 16-byte digest of the raw user-agent string, salted with the same daily_salt as visitor_hash and rotated on the same 24-hour schedule with no history kept |
Visitor to a customer's page | One purpose only: clustering the signatures of automated traffic so is_bot can be set. It is never used as a join key, never exposed in any API, chart or export, and never used to distinguish one human visitor from another |
Legitimate interests, Art. 6(1)(f) — fraud and abuse detection, the interest named in the DPIA's purpose row | Identical to row 7. It also ceases to be linkable to anything at the next salt rotation, 24 hours after it is written | None |
| 7c | User-agent parsing corpus | Distinct raw user-agent strings, a first-seen date, and an occurrence count | None — not personal data. It carries no visitor key, no workspace key, and no timestamp finer than a day, so no row in it can be related to a visit, a visitor or a customer | Keep the parser correct as browsers change. Without a corpus of real strings, user_agent_family silently degrades to "Other" as new versions ship |
Legitimate interests. Recorded here for completeness rather than because the Regulation requires it | Entries not seen for 18 months are deleted by the retention worker | None |
| 8 | Analytics rollups | Aggregated counts by dimension | None — no longer personal data | Charts and reporting | N/A once aggregated | Per plan | None |
| 9 | Email-capture leads | Email address, any custom fields the customer configured, submission timestamp, country, source resource | Visitor to a customer's page | Deliver the customer's list-building feature | Customer is controller and obtains consent at the form; LinkHub processes on the customer's documented instructions, Art. 28 | Until the customer deletes them or the workspace is deleted; 30-day grace then purge | ESP the customer connected (Mailchimp, ConvertKit, or their own webhook endpoint) |
| 10 | Consent records | Consent state per category, timestamp, policy version | Visitor | Demonstrate consent | Legal obligation, Art. 7(1) | 6 months (cookie lifetime) + 12 months of the server-side record | None |
| 11 | Abuse reports | Reporter email (optional), report content, reported URL | Reporter | Investigate abuse | Legitimate interests | 90 days after closure | None |
| 12 | Integration credentials | Encrypted third-party API keys and tokens | LinkHub customer | Operate the integration | Contract | Until removed by the customer, or workspace deletion | The third party the credential authenticates to |
| 13 | Security event logs | User id, email hash, country, user-agent family, action, outcome | LinkHub user | Detect and investigate security incidents | Legitimate interests | 400 days | None |
| 14 | QR routing records | Slug, host, creation and reservation timestamps, current fallback state, an erasure flag. Before erasure the record also carries the workspace reference used to render the branded rung-3 page; on erasure that reference and the display name are deleted, and the page becomes the neutral rung-4 platform text | Contains no personal data after erasure | Honour the permanence guarantee | Legitimate interests; see Section 23.15 | Permanent | None |
Categories explicitly not collected, stated so the absence is a commitment rather than an omission: precise geolocation; full IP addresses in any store; raw user-agent strings in any store that is linkable to a visit; device fingerprints beyond the daily hash; cross-site behavioural profiles; special-category data under Article 9 (the product has no field for it and the Terms prohibit collecting it through capture forms); children's data (the Terms set a minimum age of 16 and the product is not directed at children).
23.13 Cookies #
23.13.1 The Complete Cookie Table #
There are four. There will not be more without a documented decision, because every additional cookie is a new consent obligation and a new disclosure.
| Name | Set by | Purpose | Category | Lifetime | Attributes | Consent required |
|---|---|---|---|---|---|---|
lh_session |
Dashboard (app.linkhub.app) |
Authenticated session | Strictly necessary | 30-day rolling, 90-day absolute cap | httpOnly, Secure, SameSite=Lax, Path=/ |
No — necessary for a service the user explicitly requested |
lh_csrf |
Dashboard | Anti-CSRF token for same-origin form and action submissions | Strictly necessary | Session (browser session) | Secure, SameSite=Lax, Path=/, not httpOnly (the client must read it to echo it) |
No |
lh_consent |
Public surfaces | Records the visitor's consent choices per category and the policy version | Strictly necessary | 6 months | Secure, SameSite=Lax, Path=/, httpOnly |
No — a consent record is itself exempt |
lh_ab |
Public bio pages | Pins exact A/B assignment stickiness for a consenting visitor | Analytics | 90 days | httpOnly, Secure, SameSite=Lax, Path=/ |
Yes — only set after analytics consent is granted |
On the default cookie-free path there is no stickiness mechanism at all beyond visitor_hash, which rotates every 24 hours, so assignment stickiness is exact within a UTC day and is a fresh draw on the next one. This cookie is the only thing that extends it, it is only ever set with consent, and Section 16.2.5 owns the measurement consequences.
That is the complete list of cookies LinkHub sets. Specifically:
- A public bio page, short link or QR resolution sets no cookie at all unless the visitor has granted analytics consent (
lh_ab) or has interacted with the consent banner (lh_consent). A visitor who lands on a page in a non-gated region and never sees a banner receives zero cookies. - No
localStorage,sessionStorage, IndexedDB entry, or Cache Storage entry is written on public surfaces for tracking purposes. The only client storage used is the browser's HTTP cache for assets. - Third-party cookies may be set by an embed the visitor activates (a YouTube player, a Spotify widget) or by a pixel the customer enabled. Both are gated: embeds are facade-first and set nothing until the visitor clicks; pixels do not load before consent in a gated region. These cookies belong to the third party; they are named in the privacy notice by provider, and their lifetimes are the provider's.
- The dashboard sets no analytics cookie. Product analytics for the dashboard is first-party and cookie-free, using the same hash design as visitor analytics, scoped to the authenticated user id.
23.13.2 The Cookie Notice #
The public privacy notice contains this table verbatim, generated from the same source constant the code uses, so the disclosure cannot drift from the implementation. A CI test asserts that every cookie name set anywhere in the codebase appears in the table.
23.13.3 The Consent Banner #
Behaviour is owned by Section 19 for the pixel mechanics; the privacy rules are here.
| Rule | Value |
|---|---|
| When shown | Only when (a) the workspace has enabled at least one third-party pixel, or (b) the visitor is in the EEA, UK or Switzerland — whichever applies first. A workspace may force it globally |
| Region detection | The same country lookup as analytics; region only, no address stored |
| Categories | necessary (always on, not toggleable), analytics, marketing |
| Default state before a choice | All optional categories off. Nothing non-necessary loads |
| Accept / Reject symmetry | "Accept all" and "Reject all" are visually and positionally equivalent — same size, same prominence, same level. No dark pattern, no pre-ticked boxes, no "legitimate interest" toggles hidden behind a second screen |
| Granularity | A "Manage preferences" path with per-category toggles |
| Withdrawal | A persistent footer link on every public surface reopens the preference panel. Withdrawal is as easy as granting |
| Storage | lh_consent, 6 months. A server-side record of (consent id, categories, timestamp, policy version, country) is kept for 12 months to demonstrate consent under Article 7(1); it contains no address and no visitor hash |
| Re-prompt | On policy-version change, or after 6 months, whichever is first |
| Blocking | Pixels are not merely hidden before consent — the loader does not construct or inject any vendor script (Section 23.5.5). Server-side GA4 forwarding respects the same state |
| Accessibility | The banner is keyboard-operable, focus-trapped only while open, dismissible with Escape (equivalent to "Reject all"), announced as a dialog, and meets every criterion in Section 24. It never obscures focused content (criterion 2.4.11) |
| Performance | Rendered server-side as part of the initial HTML; no layout shift; counts against the bio-page HTML budget in Section 11 |
23.13.4 Global Privacy Control #
Sec-GPC: 1 is treated as: a valid objection to legitimate-interest analytics (event emission is suppressed for that request), a refusal of the analytics and marketing consent categories (the banner is not shown and nothing optional loads), and an opt-out of "sale/share" under CCPA/CPRA (Section 23.16.1). It is honoured on every public surface, without the visitor having to interact with anything.
23.14 GDPR Operations #
23.14.1 Controller / Processor Split #
| Data | Controller | Processor | Consequence |
|---|---|---|---|
| LinkHub account, billing, support data | LinkHub | Sub-processors listed in 23.14.3 | LinkHub answers data-subject requests from its own users directly |
| Visitor analytics for a customer's pages | The customer | LinkHub | LinkHub acts only on documented instructions; a visitor's request is routed to the customer, with LinkHub assisting |
| Email-capture leads | The customer | LinkHub | Same. The customer is responsible for the consent shown at their form |
| Payment data | LinkHub (as controller for its billing relationship); the payment processor is an independent controller for fraud and regulatory purposes | — | Disclosed in the privacy notice |
The customer is told this plainly at signup and in the Terms: "For the people who visit your pages, you are the data controller and we are your processor. You are responsible for telling them what you collect." LinkHub provides a default privacy-notice template the customer may adopt, hosted at their page's footer, precisely so that a small customer is not left with an unmet obligation.
23.14.2 The Data Processing Agreement #
- A DPA is offered to every customer on every plan, including Free, as a click-accept during signup and downloadable thereafter. Making it Business-only would leave the majority of customers non-compliant while using the product.
- Contents: subject matter and duration; nature and purpose; categories of data and data subjects; the controller's instructions; confidentiality obligations; Article 32 security measures (cross-referencing this section); sub-processor authorisation and the change-notification mechanism; assistance with data-subject requests and with Articles 32–36; deletion or return on termination; audit and information rights; international transfer terms.
- Sub-processor changes are notified 30 days in advance by email to the workspace Owner and on the public sub-processor page, with a right to object during that window.
- The DPA acceptance is recorded with the accepting user, timestamp and document version.
23.14.3 Sub-Processors #
Published at a public URL, with an email subscription for changes. Each entry states the processor, its purpose, the data categories it receives, and its location.
| Sub-processor category | Purpose | Data received | Location |
|---|---|---|---|
| Cloud infrastructure / container platform | Hosting | All operational data | Primary: EU or US region as configured per environment |
| Managed PostgreSQL | System of record | All operational data | Same region as the application |
| Managed Redis | Cache, queue buffer, salts | Ephemeral events, session cache, salts | Same region |
| Object storage | User-uploaded images, export bundles, QR renders | Uploaded media, exports | Same region |
| CDN | Public delivery | Requests, hashed addresses in edge logs | Global edge |
| Payment processor | Billing | Billing identity, card data (never seen by LinkHub) | US/EU |
| Transactional email provider | Account, billing and notification email | Recipient email, message content | US/EU |
| Support desk | Support | Correspondence | US/EU |
| Error and performance monitoring | Observability | Redacted telemetry, no personal data by configuration | US/EU |
| Safe Browsing provider | Destination safety | Destination URLs (hashed prefixes for the local database path) | US |
| Certificate authority | TLS for custom domains | Domain names | US/EU |
| Bot-protection provider | The escalation only of the bot challenge in 23.6.10 | Challenge tokens, hashed addresses. Receives nothing on the default honeypot-and-timing path, which is first-party and reaches no third party at all, and therefore receives nothing from the overwhelming majority of submissions | Global |
Customer-configured integrations (Mailchimp, ConvertKit, Slack, Zapier, GA4, Meta, TikTok, and the customer's own webhook endpoint) are not LinkHub sub-processors. The customer chooses them and instructs LinkHub to transmit to them; they are disclosed in the DPA as recipients under the customer's control.
23.14.4 International Transfers #
- The transfer mechanism for personal data leaving the EEA/UK/Switzerland is the EU Standard Contractual Clauses (2021/914), module 2 (controller→processor) or module 3 (processor→processor) as applicable, with the UK International Data Transfer Addendum and the Swiss adaptations.
- Where a sub-processor is certified under the EU–US Data Privacy Framework, that certification is relied on and the SCCs are retained as a fallback.
- A Transfer Impact Assessment is maintained per sub-processor located outside the EEA, covering the destination's surveillance law, the practical likelihood of access, and the supplementary measures applied (encryption in transit and at rest, key custody, data minimisation, and the fact that no raw addresses exist to be disclosed).
- EU data residency is offered as a deployment configuration: a workspace created under the EU configuration has its database, object storage and Redis in an EU region, and its analytics never leave it. This is an infrastructure configuration (Section 27), not a plan entitlement, and it is available on request on any plan.
23.14.5 Data Export #
| Property | Value |
|---|---|
| Scope | Per workspace, everything the workspace owns |
| Who may request | Owner; Admin may request with Owner notification |
| Availability | Every plan, including Free. Not gated by csv_export (Section 22.2.8) |
| Trigger | POST /v1/workspaces/{id}/exports → 202 Accepted with an export id |
| Processing | Asynchronous worker job; the requester is emailed on completion |
| SLA | Generated within 1 hour for workspaces under 1 million events; within 24 hours otherwise; the response returns an estimate |
| Format | A single ZIP containing both JSON (complete, canonical, machine-readable) and CSV (per entity, spreadsheet-friendly) |
| Contents | workspace.json, members.json, bio_pages.json (with blocks), links.json, qr_codes.json (with version history), custom_domains.json, experiments.json, leads.json + leads.csv, analytics_raw.csv (within the plan's retention, containing no address and no raw user-agent string because neither exists), analytics_rollups.csv, audit_log.csv, integrations.json (types and configuration, never credentials), invoices.json, and a README.md describing every file and column |
| Why this is the only bulk route to lead data | The public API exposes no lead read and no lead export endpoint (23.3.4). This bundle, and the dashboard's own lead export, are the two ways lead records leave the system, and both are session-authenticated, step-up gated where Section 3.3.11 requires it, rate-limited and audited |
| Delivery | A signed URL valid for 24 hours, delivered by email and shown in the dashboard. Requires an authenticated session to initiate the download, so a forwarded email alone is insufficient |
| Storage | The bundle is encrypted at rest and deleted 7 days after generation, regardless of whether it was downloaded |
| Rate limit | 2 per workspace per 24 hours (Section 23.9) |
| Audit | Request, generation and each download are audited (Section 8) |
| Personal data of others | The export includes lead data because the workspace is its controller. It does not include other users' credentials, sessions, or any data belonging to another workspace |
A separate account-level export covers the individual user's own personal data across every workspace they belong to — profile, sessions metadata, audit entries where they were the actor, and the list of workspaces — satisfying Article 15 for a LinkHub user in their own right.
23.14.6 Deletion #
| Stage | Timing | Behaviour |
|---|---|---|
| Request | T+0 | Owner requests workspace deletion, or a user requests account deletion. Typed confirmation required (the workspace slug or the account email). An audit entry is written |
| Immediate effects | T+0 | Workspace becomes inaccessible to all members; all public bio pages return the branded 404; short links serve the branded landing page at 200; QR codes fall to rung 3 of the fallback chain (workspace_unavailable) — the branded memorial page, still 200; API keys are revoked; integrations are disconnected; outbound webhooks stop; scheduled jobs are cancelled |
| Grace period | T+0 → T+30 days | Fully restorable by the Owner from a link in the confirmation email or by contacting support. A reminder is sent at T+23 days. Because the workspace record still exists, the memorial page during this window is rung 3 and may carry the workspace's display name and branding — which is useful, since a scanner in this window may be a customer of a business that is merely mid-cancellation |
| Purge | T+30 days | Irreversible hard delete of every row in every tenant table for that workspace, object-storage prefix deletion, Redis key deletion, and partition-scoped deletion of raw events. Executed by the retention worker, verified by a post-purge assertion that queries every tenant table for the workspace id and expects zero rows. At this moment every QR code drops from rung 3 to rung 4 (generic): the neutral platform landing page, still 200, carrying no display name, no logo, no brand colour and no text naming anybody |
| Retained after purge | Permanent / statutory | (a) QR slug reservations and their minimal routing records — Section 23.15; (b) billing invoices for 7 years — legal obligation, Art. 17(3)(b); (c) security event logs for 400 days with the user id replaced by a non-reversible pseudonym; (d) the audit record of the deletion itself, which is the evidence the deletion happened |
| Confirmation | T+30 days | An email confirming the purge, listing exactly what was retained and why. Never a bare "your data has been deleted" when something was kept |
Account deletion where the user is the sole Owner of a workspace requires either transferring ownership or deleting the workspace; the flow presents both options and will not proceed until one is chosen. A user who is a member (not Owner) of other workspaces has their membership removed and their authored content reattributed to "Removed user" while remaining intact — deleting one member must not destroy a team's content.
23.14.7 Data-Subject Requests #
| Right | Route | LinkHub's role |
|---|---|---|
| Access (Art. 15) | LinkHub user: in-app account export. Visitor: to the customer, with LinkHub assisting | Controller / processor respectively |
| Rectification (Art. 16) | In-app profile editing; support for anything not self-editable | Controller |
| Erasure (Art. 17) | In-app account or workspace deletion (23.14.6) | Controller / processor |
| Restriction (Art. 18) | Support request; implemented as a workspace freeze that suspends processing without deleting | Both |
| Portability (Art. 20) | The JSON export in 23.14.5 — structured, commonly used, machine-readable | Controller / processor |
| Objection (Art. 21) | The "Do not track my visit" control and GPC (Section 23.12.2) | Processor, on the customer's behalf |
| Automated decision-making (Art. 22) | Not applicable — none exists | — |
Workflow and clock:
Day 0 Request received at privacy@linkhub.app or through the in-app form.
Logged in the DSR register with a request id. Acknowledgement sent
the same business day.
Day 0–3 Identity verification. For a LinkHub user: authenticated session, or an
email challenge to the address on file. For a visitor: routed to the
customer, since LinkHub cannot identify a visitor from a rotating hash —
and this is explained honestly rather than treated as an evasion.
Day 3–25 Fulfilment. Data gathered, reviewed for third-party personal data,
redacted where another individual's data would otherwise be disclosed.
Day 30 **Statutory deadline: one month from receipt (Art. 12(3)).**
Response delivered.
Extension of up to two further months is permitted for complex or
numerous requests; if used, the data subject is informed WITHIN the
first month, with the reason. Extensions require the accountable
owner's sign-off and are recorded.The DSR register records: request id, date received, type, subject category (user / visitor / other), verification method and date, actions taken, date responded, whether extended, and the outcome. It is reviewed monthly. A request that cannot be fulfilled (for example, a visitor asking LinkHub to identify their own analytics events, which is not computable after salt rotation) receives a written explanation of why, including the technical reason, within the same deadline.
Processor requests. Where a visitor contacts LinkHub about a customer's page, LinkHub forwards the request to the customer within 3 business days, informs the visitor that it has done so and who the controller is, and assists the customer as required by Article 28(3)(e). LinkHub does not act unilaterally on a controller's data.
23.14.8 Breach Notification #
| Stage | Timeline | Action |
|---|---|---|
| Detection | T+0 | Any suspected personal-data breach is declared through the incident process (Section 23.17). The clock starts at the moment of awareness, not at the moment of confirmation |
| Assessment | T+0 → T+24 h | Incident commander determines: what data, whose, how many, what the likely consequences are, and whether the risk to rights and freedoms is unlikely / likely / high |
| Supervisory authority | Within 72 hours of awareness where LinkHub is controller and the risk is not "unlikely" | Notification under Art. 33 with the required content. A partial notification within 72 hours beats a complete one at 96 |
| Customer notification | Within 24 hours of awareness where LinkHub is processor | Art. 33(2) requires "without undue delay"; LinkHub commits contractually to 24 hours so the customer can meet their own 72-hour clock |
| Data-subject notification | Without undue delay, where the risk is high | Art. 34. Plain language, what happened, likely consequences, what LinkHub is doing, what the individual should do |
| Documentation | Always | Every breach, including those not notified, is recorded with the facts, effects and remedial action (Art. 33(5)) — including the reasoning for a decision not to notify |
| Post-incident | Within 5 business days of closure | Written post-mortem, blameless, with corrective actions and owners (Section 25) |
Pre-drafted notification templates for the authority, the customer and the data subject are maintained and reviewed annually, because 72 hours is not enough time to also be writing prose from scratch.
23.15 The QR Permanence Carve-Out in Privacy Terms #
This section restates, in data-protection language, the rule stated commercially in Section 22.6 and mechanically in Section 14. It is written to be quoted directly to a regulator, an auditor, or a customer's legal team.
23.15.1 The Restatement #
A dynamic QR slug survives erasure. When a workspace is deleted, or when a data subject exercises the right to erasure under Article 17, every other trace of that workspace is purged on the schedule in Section 23.14.6 — but the QR slug reservation and a minimal routing record remain, permanently, and the slug continues to resolve to a page with HTTP 200. It is never released, never recycled, never reassigned to another customer, and never made to return 404 or 410.
The page it resolves to is a rung of the four-rung fallback chain owned by Section 22.6.2, and which rung it is changes at erasure:
| Moment | Rung | fallback_stage |
What the page shows |
|---|---|---|---|
| Before erasure — the workspace exists, the code is unavailable | 3 | workspace_unavailable |
The workspace's branded unavailable page. It may carry the display name, logo and brand colours, because the workspace is a live entity and a scanner benefits from knowing whose code they scanned |
| After erasure — the workspace is gone | 4 | generic |
The neutral platform landing page. Neutral platform text only. No display name, no logo, no brand colour, no wording naming any person or organisation |
That transition is not cosmetic and it is not a nicety. It is the fact that makes the first limb of the argument in 23.15.3 true: a page that kept printing the erased workspace's display name would be a permanent, publicly-reachable disclosure of a name belonging to the very data subject who asked to be erased, and no amount of reasoning about namespace entries would save it. The display name is deleted with everything else, and the page it used to appear on says nothing about anybody.
23.15.2 Exactly What Is Removed #
On erasure, the memorial page and the routing record are stripped to the following. This is a precise list, not a summary.
| Data | Before erasure | After erasure |
|---|---|---|
| Destination URL | Stored, resolving | Deleted. The code no longer redirects anywhere; it serves the memorial page |
| Paused fallback URL | Stored | Deleted |
| Workspace display name | Shown on the rung-3 memorial page | Deleted, and never rendered again. The page becomes rung 4, whose text is the platform's own and names nobody |
| Workspace logo / brand colours | Shown on rung 3 | Deleted; the neutral rung-4 platform landing design is used |
| Creating user's identity (id, email, name) | Linked | Deleted. The routing record holds no user reference of any kind |
| Workspace id | Foreign key on the record | Nulled. The record becomes tenant-less |
| QR title, internal notes, tags, campaign labels | Stored | Deleted — these are customer content and may contain personal data |
| Styling configuration, logo overlay image | Stored | Deleted |
| Version history (destination changes over time) | Stored | Deleted |
| Scan analytics (raw events and rollups) | Stored per retention | Deleted with the rest of the workspace's analytics |
| Audit entries referencing the QR | Stored | Deleted with the workspace's audit log |
| Data | Retained permanently |
|---|---|
The slug string itself (e.g. x7f2a9q) |
Yes |
The host it was issued on (go.linkhub.app, or the custom domain) |
Yes |
created_at and reserved_at timestamps |
Yes |
A state value of memorial, which resolves the request to rung 4, fallback_stage = 'generic' |
Yes |
A boolean erased flag and the erasure timestamp |
Yes |
That is the entire retained record. Concretely:
{
"slug": "x7f2a9q",
"host": "go.linkhub.app",
"reserved_at": "2026-03-02T11:41:07Z",
"created_at": "2026-03-02T11:41:07Z",
"state": "memorial",
"erased": true,
"erased_at": "2026-08-19T00:12:44Z"
}There is no name, no email, no identifier of any person, no workspace reference, no destination, and no behavioural data — and nothing in the page that record resolves to displays any of those either. The record is a routing entry in a namespace, functionally equivalent to the fact that a particular street address exists. It does not relate to an identified or identifiable natural person, and therefore, after erasure, it is not personal data within the meaning of Article 4(1) — which means Article 17 has, in the strict sense, already been satisfied in full. The carve-out below is stated anyway, because relying solely on "it is no longer personal data" would be an argument rather than a commitment, and a customer deserves the commitment.
23.15.3 The Lawful Basis for the Retention #
Two independent grounds, either sufficient on its own.
Ground 1 — the retained record is not personal data, and neither is what it serves. As set out above, after stripping, the routing record contains no identifier of any natural person and cannot be linked to one by LinkHub or by any party using means reasonably likely to be used. This is true of the output as well as the storage: the page a scanner reaches is rung 4, neutral platform text, carrying no display name and no branding, so the retention is not merely private — it discloses nothing to anyone who scans the code. Recital 26 places anonymous information outside the Regulation's scope. Erasure of the personal data has been completed; what remains is a namespace entry.
Ground 2 — legitimate interests in service continuity, Article 6(1)(f), if Ground 1 is contested.
The interest. A dynamic QR code is, by design, printed onto physical objects that circulate independently of any account: menus, packaging, signage, vehicles, business cards, museum labels, medical-equipment tags. Those objects cannot be recalled, reprinted, or updated when an account is closed. The interest is in ensuring that a member of the public who scans such a code receives a comprehensible page rather than a browser error.
Whose interest. Not primarily LinkHub's, and not the erasing customer's. It is principally the interest of third parties — the scanning public, expressly contemplated by Article 6(1)(f), which extends to the legitimate interests of a third party. A person scanning a code on a product they have bought has a reasonable expectation of an intelligible response.
Necessity. The interest cannot be met with less data. Serving a page in response to a scan requires knowing that the slug was issued and must not be reissued. The retained record is the irreducible minimum for that: a string, a host, two timestamps and a state. Nothing in it could be removed without defeating the purpose, and nothing that could be removed has been kept.
Balancing. The residual impact on the erasing data subject is effectively nil: no data relating to them remains, nothing about them is disclosed, no one can learn who created the code, what it pointed at, or that it was ever associated with any person. A scanner sees a neutral platform page and learns nothing whatever about the workspace that once owned the slug. The countervailing harm from not retaining is concrete and falls on people who never had any relationship with LinkHub. The balance favours retention decisively.
Both grounds are stated and both are relied on. Ground 1 is the stronger argument and Ground 2 is the safer one, and a customer or a regulator is entitled to see the reasoning survive the loss of either. Neither is offered as a fallback for a weakness in the other.
Objection. A data subject may object under Article 21. LinkHub's response, stated in advance rather than improvised: the objection is upheld as to every element of personal data — all of which is deleted regardless of any objection — and is refused as to the bare slug reservation, on the compelling-legitimate-grounds limb of Article 21(1), for the reasons above. The refusal is given in writing with this reasoning and with information about the right to complain to a supervisory authority.
Retention period. Permanent, and honestly labelled as permanent. An indefinite retention justified by a continuing purpose is lawful; a purpose that has not ended does not create an obligation to erase. The purpose here does not end, because the printed object does not stop existing.
23.15.4 Disclosure at Signup #
The rule is disclosed before any QR code is created, not buried afterwards:
| Surface | Wording and placement |
|---|---|
| Signup | The Terms and privacy notice accepted at signup contain a section headed "QR codes are permanent", in plain language, before any acceptance action |
| First QR creation | An inline notice on the create form, always shown, never dismissible, above the create button: "QR slugs are permanent. Once created, this code's address is reserved forever and will keep working even if you delete this code, close this workspace, or delete your account. That's how we make sure printed codes never break." |
| QR settings | The same statement, permanently visible on every QR code's settings panel |
| Privacy notice | A dedicated section reproducing 23.15.2 and 23.15.3 substantially in full, so the reasoning is public rather than internal |
| Sub-processor / retention table | The data inventory in Section 23.12.4 lists QR routing records with retention "Permanent" |
23.15.5 Disclosure at Deletion #
The permanence is restated at the moment of erasure, when it actually matters, and the deletion cannot proceed without it being seen:
| Step | Content |
|---|---|
| Deletion confirmation dialog | A dedicated block, visually distinct, listing the number of QR codes affected: "{n} QR codes will keep resolving. We'll delete their destinations, titles, styling, scan history, your name and branding, and every link to you or your workspace. Anyone who scans one after that sees a plain LinkHub page that doesn't mention you. What remains is the code's address itself, so a printed code never turns into an error page for whoever scans it. This cannot be undone and cannot be opted out of." With a link to the full explanation |
| Typed confirmation | The user types the workspace slug (or account email) to proceed. The QR block sits directly above the input, so it cannot be scrolled past |
| Deletion confirmation email (T+0) | Repeats the block, with the count and the link |
| Purge confirmation email (T+30) | States exactly what was retained and why: the QR routing records, the invoice records for 7 years, and the pseudonymised security logs for 400 days. Never claims a complete erasure that did not occur |
| Data-subject-request response | Where erasure is requested as an Article 17 right rather than through the in-app flow, the written response reproduces 23.15.2 and 23.15.3 |
| Support tooling | The internal view flags workspaces with QR codes before a support-assisted deletion, so a support agent cannot omit the disclosure |
If a customer finds this unacceptable, the honest answer — given at signup, not after — is that they should not use the dynamic QR feature. Short links and bio pages carry no such permanence, and a workspace that never creates a QR code has nothing retained beyond the statutory invoice record.
23.16 Other Regimes and Honest Claims #
23.16.1 CCPA / CPRA (California) #
| Requirement | Position |
|---|---|
| Applicability | LinkHub is a service provider for customer visitor data, and a business for its own user and billing data |
| Sale / sharing of personal information | LinkHub does not sell or share personal information, as those terms are defined, and never has. There is no "Do Not Sell or Share My Personal Information" transaction to opt out of on LinkHub's own surfaces. Where a customer enables a third-party advertising pixel, that customer may be sharing under their own obligations; the consent banner and GPC handling give their visitors the control |
| GPC | Honoured as a valid opt-out signal (Section 23.13.4) |
| Right to know / access | The exports in Section 23.14.5 satisfy it |
| Right to delete | The deletion flow in Section 23.14.6 satisfies it, with the QR carve-out disclosed. CCPA's exception for retention necessary to "provide a good or service reasonably anticipated within the context of the business's ongoing relationship" plus the internal-use exception cover the routing record |
| Right to correct | In-app editing plus support |
| Right to limit use of sensitive personal information | Not applicable; no sensitive personal information is collected |
| Non-discrimination | No feature, price or service level differs based on the exercise of any privacy right. Explicitly: the GDPR export is available on Free (Section 22.2.8) |
| Notice at collection | In the privacy notice, linked from every public surface footer |
| Service-provider contract terms | Included in the DPA |
23.16.2 ePrivacy / PECR #
Cookie and storage rules are governed by ePrivacy independently of GDPR's lawful bases, and the two are not interchangeable — a legitimate-interests argument does not authorise storing something on a device.
- LinkHub's first-party analytics stores nothing on the device, so Article 5(3) is not engaged at all. This is the technical reason the design is cookie-free rather than merely low-cookie: it removes the consent requirement structurally instead of arguing an exemption.
lh_sessionandlh_csrfare strictly necessary for a service explicitly requested by the user — exempt.lh_consentstores the consent choice — exempt.lh_abis not exempt and is set only after consent.- Third-party pixels and activated embeds are not exempt and are consent-gated.
- Electronic marketing to LinkHub's own users follows the soft opt-in for existing customers, with an unsubscribe link in every non-transactional email. Transactional and security email is not marketing and is not unsubscribable — a fact stated in the notification settings so nobody is surprised.
23.16.3 What LinkHub Claims — and What It Does Not #
Stated plainly, because overclaiming a certification is both a commercial and a legal risk:
LinkHub does claim:
- GDPR and UK GDPR compliance as controller and as processor, with a DPA, sub-processor transparency, SCCs for transfers, and the operational processes in Section 23.14.
- CCPA/CPRA compliance in the roles described above.
- Encryption in transit (TLS 1.2+) and at rest for all data stores.
- The security controls specified throughout this section, verified by the automated gates in Section 26 and the pre-launch checklist in Section 23.18.
- PCI-DSS scope reduction to SAQ-A: card data is entered on the payment processor's hosted pages and never transits or is stored on LinkHub infrastructure.
- WCAG 2.2 Level AA conformance per Section 24.
LinkHub does not claim, at launch:
| Not claimed | Honest position |
|---|---|
| SOC 2 Type I or Type II | Not held at launch. The controls in this section are designed to map onto the Trust Services Criteria for Security and Availability, and evidence (audit logs, access records, change management, incident records) is retained accordingly. Roadmap position: begin a Type I readiness assessment once the product reaches sustained paying-customer volume, targeting Type II observation thereafter. No date is promised, and no "SOC 2 in progress" badge is displayed — that phrase is meaningless and customers know it |
| ISO 27001 | Not held. Not on the near-term roadmap |
| HIPAA / BAA | Not offered. LinkHub is not suitable for protected health information and the Terms prohibit it |
| FedRAMP / StateRAMP | Not applicable |
| PCI-DSS Level 1 as a service provider | Not applicable; SAQ-A only, as above |
| Penetration test report | An independent penetration test is commissioned before general availability (Section 23.18). Until it exists, no test is claimed. The report summary is available to customers under NDA once it does |
| 99.9% uptime SLA | An availability target is published in Section 25; a contractual SLA with credits is offered on Business only, and is not implied on other plans |
| Data residency guarantee by default | EU residency is available on request as a deployment configuration (Section 23.14.4); it is not the default and is not implied |
A public trust page carries this table verbatim. When a status changes, the table changes; nothing is anticipated in the present tense.
23.17 Security Incident Response #
23.17.1 Severity Levels #
| Level | Definition | Examples | Response start | Update cadence |
|---|---|---|---|---|
| Sev-1 | Confirmed or highly probable compromise of personal data or credentials; or total unavailability of a public surface; or any failure of the QR permanence guarantee | Cross-tenant data exposure, database compromise, credential-store exposure, a QR path returning 404/410/5xx, complete redirect outage | 15 minutes, 24/7 | Every 30 minutes |
| Sev-2 | Significant security weakness with no confirmed exposure, or major degradation | Exploitable vulnerability found in production, stored XSS on public pages, authentication bypass without evidence of use, sustained redirect latency breaching the budget | 1 hour during business hours, 4 hours otherwise | Every 2 hours |
| Sev-3 | Contained issue, limited scope | A single account compromised via a reused password, a workspace-scoped abuse incident, a dependency Critical advisory with no exploit | 1 business day | Daily |
| Sev-4 | Informational, no immediate risk | Low-severity advisory, a hardening gap, a policy deviation | 5 business days | At closure |
A QR-permanence failure is deliberately classified Sev-1 regardless of how few codes are affected, because the guarantee is binary and the affected artefacts are physical.
23.17.2 Roles #
| Role | Responsibility | Held by |
|---|---|---|
| Incident Commander | Owns the incident. Makes every call, including the call to skip a rotation overlap or to take a surface offline. Does not perform technical work | The on-call engineer initially; handed over explicitly if the incident escalates |
| Technical Lead | Investigation, containment, remediation | Assigned by the Commander |
| Communications Lead | Status page, customer email, internal updates, regulator liaison drafting | Assigned for Sev-1 and Sev-2 |
| Scribe | Maintains the timeline: every action, decision, time and person | Assigned for Sev-1 and Sev-2 |
| Privacy Owner | Determines whether a personal-data breach occurred and drives the Section 23.14.8 clocks | Named accountable individual |
| Executive Sponsor | Decisions with legal or commercial consequence: regulator notification, customer notification, refunds | Named individual |
One person may hold several roles in a small team; the Commander role is never combined with the Technical Lead role in a Sev-1, because the person doing the fixing cannot also be tracking the clock.
23.17.3 Phases and Timelines #
DETECT Alert, report, or discovery. Anyone may declare an incident;
no one needs permission. Over-declaring is explicitly encouraged.
T+0 Declare. Open the incident channel and the incident record.
Assign Commander. Start the Scribe timeline.
T+15m (Sev-1) Initial assessment: what, who, how many, still ongoing?
Status page updated if any customer-visible impact.
T+1h Containment. Preserve evidence BEFORE remediating: snapshot logs,
capture the state, then act. Revoke sessions/keys as needed.
T+4h Eradication and recovery underway. Root cause hypothesis recorded.
T+24h (Personal data involved) Privacy Owner's assessment complete;
processor notification to affected customers sent.
T+72h (Controller role, notifiable) Supervisory-authority notification
filed under Art. 33.
T+5 days Blameless post-mortem written and circulated: timeline, root cause,
contributing factors, what went well, corrective actions with named
owners and due dates.
T+30 days Corrective actions verified complete, or explicitly re-scheduled
with a reason. Verification is a separate step from completion.Evidence preservation is a named step because it is the one most often skipped under pressure, and destroying evidence during containment converts a recoverable incident into an unanswerable one.
23.17.4 Communications #
| Audience | Trigger | Channel | Content |
|---|---|---|---|
| Internal | Any Sev-1/2 | Incident channel | Full detail |
| Status page | Any customer-visible impact | Public status page | What is affected, what is not, what to do, next update time. Never a root cause before it is confirmed |
| Affected customers | Confirmed impact on their data or service | Email to the Owner | Plain language, specific scope, what LinkHub did, what they should do |
| All customers | Sev-1 with broad impact | Email + status page | Same |
| Supervisory authority | Per Section 23.14.8 | Authority's portal | Article 33 content |
| Data subjects | High risk per Art. 34 | Article 34 content | |
| Security researchers | Report received | security@linkhub.app |
Acknowledgement within 2 business days, triage within 5 |
A vulnerability disclosure policy is published at /.well-known/security.txt and at a public page: scope, safe-harbour commitment for good-faith research, out-of-scope items (denial of service, social engineering, physical), the reporting address, and the response commitment. No bug-bounty payments at launch; recognition is offered, and the roadmap position is a paid programme once the product has independent penetration-test coverage.
23.18 Pre-Launch Security Checklist #
Every item must be verified and signed off by a named person before general availability. Unchecked items block launch; there is no "launch with exceptions" path for anything marked BLOCKING.
Authentication and sessions
- BLOCKING Password hashing uses the specified Argon2id parameters; verified by a unit test asserting the parameters, not just the algorithm
- BLOCKING Session tokens are 256-bit CSPRNG output, stored only as SHA-256 hashes
- BLOCKING Session cookie carries
httpOnly,Secure,SameSite=Lax; verified by an integration test on the actualSet-Cookieheader - Sessions revoked on password change and on 2FA change; verified end-to-end
- Breach-corpus check active on password set and change
- Login and reset rate limits active and tested at the specified thresholds
- TOTP enrolment, verification and recovery codes tested, including recovery-code single use
- Email verification required before publishing; bypass attempted and refused in a test
Authorization and tenancy
- BLOCKING The cross-tenant sweep (Section 23.3.5, test 2) passes across every route in the OpenAPI document
- BLOCKING RLS enabled and
FORCEd on every table carryingworkspace_id; the schema invariant test passes against the live catalogue, and every member of exception lists A and B carries a written reason - BLOCKING The RLS escape test passes — no context yields zero rows, not all rows
- BLOCKING An API key presented against a workspace other than its own returns
404 not_foundbefore any capability is evaluated - The application database role lacks
BYPASSRLS; verified against the production role - Cross-tenant references return 404, never 403; asserted by the sweep
- Role matrix enforced server-side for every mutation; per-resource grants tested on Business
- No audit-read scope exists in the key catalogue; the account-read scope returns no member email address; no lead or billing endpoint is reachable by any key — each asserted by a test that attempts it
- The lint rule blocking unscoped imports is active and its own test passes
Input, output and content
- BLOCKING No
dangerouslySetInnerHTMLoutside the single reviewed theme exception; verified by lint on the whole monorepo - BLOCKING Destination URL guard applied on every write path — link, QR, fallback, bio-page link block, webhook, integration; verified by enumerating call sites
- CSV formula-prefix neutralisation applied on every export path, with the fixture test passing
- Upload magic-byte sniffing, size and dimension caps, re-encoding and metadata stripping verified with a malicious-file corpus
- SVG upload refused
- Custom CSS sanitiser rejects
url(),@import,expression, and branding-subtree selectors - Cursor tampering rejected with
400 invalid_cursor
Transport, headers and CSP
- BLOCKING HSTS with the specified max-age on all LinkHub hostnames; custom-domain policy implemented as specified
- BLOCKING The full header set present on every application's responses; the CI header test passes
- BLOCKING Public CSP reaches at least stage 4 (production report-only, 100%) before launch, with zero unexplained
script-srcviolations for 7 consecutive days - Nonce is per-response, CSPRNG-generated, and correctly substituted on CDN-cached responses; the placeholder-count guard tested
-
frame-srcraised only for providers present on the page; verified per provider - BLOCKING With the bot challenge armed, the challenge origin is present in both
frame-srcandconnect-src, and an end-to-end test completes a real challenge under the enforcing policy. A challenge blocked by the policy makes every protected form unsubmittable and pages Sev-1 under 23.11.6 - Pixel loader accepts identifiers only; no URL input exists anywhere in the configuration
- CSP report endpoint rate-limited and route-pattern reducing full URIs
- Only one public policy exists in the codebase; the header test asserts this section's set and there is no second definition to drift from it
- HTTP→HTTPS is 301 and every destination redirect is 302; a test asserts both, and asserts no destination path ever emits 301 or 308
- TLS 1.0/1.1 disabled; external scanner grade A or better
SSRF and destination safety
- BLOCKING The private-range deny list is complete, including the metadata service, IPv4-mapped IPv6 and NAT64 unwrapping; verified by a table-driven test over every listed range
- BLOCKING Address pinning implemented for every server-side fetch; the rebinding test (a resolver returning public then private) passes
- BLOCKING Redirects are not auto-followed on server-side fetches; each hop re-validated
- Safe Browsing lookup active on create, with the timeout fallback queueing a recheck
- Weekly recheck job scheduled and observed running
- Interstitial page serves 200 with the specified content and passes the accessibility gate
- Abuse-report endpoint live, rate-limited, protected by the default honeypot-and-timing mechanism, with the triage rota staffed
- BLOCKING There is exactly one bot-challenge mechanism in the codebase: no proof-of-work, no arithmetic question, no image or character puzzle anywhere; the no-JavaScript path receives no challenge and routes to
pending_review; verified by a test that submits every protected form with scripting disabled - Slug blocklist loaded, including confusable folding; the
раypalfixture is refused - A QR slug reservation refuses the identical short-link slug on the same host, and the reverse; asserted in both directions
- Rate limits match this section's table, and rows 4–10 match Section 21.7 exactly; the configuration-comparison test passes
- Webhook URLs restricted to
httpson port 443, with no redirect following
Secrets and keys
- BLOCKING No secret in the repository; full-history secret scan clean
- BLOCKING Every required environment variable asserted at boot, with startup failing on absence
- Secret scanning with push protection enabled on the repository
- Integration credentials encrypted with AES-256-GCM, unique IVs, and workspace-bound AAD; the row-swap test fails to decrypt
- Rotation drill completed in staging for the session key, the processor webhook secret and the export signing key
- No standing production database access; break-glass path tested and audited
Logging and privacy
- BLOCKING The address-leak test passes — no address appears in any log output from any position
- BLOCKING No raw user-agent string is persisted anywhere: not in an event row, a session row, an audit row, a log line, a metric label, a trace or an export. Verified by a schema scan for any unhashed user-agent column and by the leak test's user-agent fixture
-
ua_hashuses the same salt and rotation asvisitor_hash, and is used only for bot-signature clustering — no join, no API exposure, no export - BLOCKING
daily_saltandexperiment_saltrotation jobs verified running on schedule, with no historical salt retained - Redaction allow-list serialiser active; a fixture containing every redacted category produces clean output
- Edge/CDN access logs hash addresses at the edge, or are disabled
- Security event stream populated and retained separately, with delete permission held by nobody by default
- Cross-tenant-attempt alert wired and tested with a synthetic trigger
Privacy and compliance
- BLOCKING Cookie table matches the cookies actually set; the CI test comparing the two passes
- BLOCKING No cookie is set on a public surface for a visitor who has not consented and is not in a gated region
- Consent banner shows Accept and Reject with equal prominence; verified by a design review and a screenshot test
- GPC honoured on every public surface
- Privacy notice, cookie notice, DPA, sub-processor list and trust page published and linked
- Data export produces a complete bundle with every specified file; verified against a seeded workspace
- Deletion purge verified by the post-purge zero-row assertion across every tenant table
- BLOCKING QR permanence verified after a full workspace deletion and after an erasure request: the slug still resolves at 200, the page is rung 4 (
generic), and the routing record matches the exact field list in Section 23.15.2 - BLOCKING The post-erasure page renders no workspace display name, logo or brand colour — asserted by fetching the page and searching the response body for the deleted workspace's display name. This is the assertion that keeps the first limb of the 23.15.3 argument true, so it is tested rather than assumed
- DSR register in place with the acknowledgement and response templates
- Breach notification templates drafted for authority, customer and data subject
Supply chain and operations
- Lockfile enforced with
--frozen-lockfile;ignore-scriptsset in CI with the allow-list reviewed - Dependency, container, static-analysis, secret and licence scans all wired into CI and blocking at the specified severities
- SBOM generated and attached to the release artefact
- Default branch protected; administrators not exempt
- Deploy credentials issued via OIDC, not stored
- BLOCKING An independent penetration test completed, with all Critical and High findings remediated and retested
- Incident response roles assigned, on-call rota live, and one tabletop exercise completed
-
security.txtpublished and the disclosure policy page live - Backup restore tested end-to-end into a clean environment within the recovery objective in Section 25
24. Accessibility #
24.1 Standard, Scope and Conformance Claim #
24.1.1 The Standard #
WCAG 2.2, Level AA. Not 2.0, not 2.1, and not "AA where practical". WCAG 2.2 is chosen because its six new criteria — focus visibility, dragging alternatives, target size, consistent help and redundant entry — land precisely on this product's riskiest interactions: a drag-and-drop block editor, a colour-theming system, and a public page consumed overwhelmingly on touch devices.
The following are treated as Level AA requirements inside LinkHub even though the published Recommendation places them at Level AAA, because they are cheap here and materially improve the product:
| Criterion | Published level | LinkHub treatment | Reason |
|---|---|---|---|
| 2.4.13 Focus Appearance | AAA | Required | The focus indicator is defined once in the design tokens; meeting the stricter contrast and area requirement costs nothing after that |
| 2.5.5 Target Size (Enhanced), 44×44 CSS px | AAA | Required on public bio pages only; 24×24 (the AA minimum) elsewhere | Bio pages are thumb-operated on phones; 44 px is the size the interaction actually needs |
| 1.4.6 Contrast (Enhanced), 7:1 | AAA | Warned, not required | The theme editor recommends 7:1 and blocks below 4.5:1 (Section 24.5) |
24.1.2 Scope #
Every surface, with no carve-outs:
| Surface | In scope | Notes |
|---|---|---|
| Public bio pages, all templates and themes | Yes | The highest-traffic surface and the one whose visitors did not choose LinkHub |
Public landing pages: link-inactive, QR fallback rungs 3 (workspace_unavailable) and 4 (generic), the memorial page in both its pre- and post-erasure form, archived-page 404 |
Yes | Including every page in the four-rung QR fallback chain of Section 22.6.2. Rungs 1 and 2 are redirects and render nothing, so rungs 3 and 4 are the whole of the visible chain |
| Interstitial warning page | Yes | A safety warning that a screen-reader user cannot perceive is worse than no warning |
| Consent banner and preference panel | Yes | Section 23.13.3 |
| Email-capture forms | Yes | Section 24.9 |
| Every public form that carries a bot challenge — sign-up, sign-in, password reset, email capture, abuse report, support | Yes | This is the scope decision with the sharpest consequence: it puts SC 3.3.8 in force on the anti-abuse mechanism itself. See 24.9.4 |
| Marketing site and pricing page | Yes | — |
| Authenticated dashboard, every route | Yes | Including the bio page editor, the analytics dashboards and the billing pages |
| QR code visual output | Partially | A QR symbol is machine-readable, not human-readable; the accessible requirement is on the surrounding UI, the download controls and the alternative text (Section 24.8.3) |
| Transactional and notification emails | Yes | Semantic HTML, a text alternative part, and no meaning conveyed by colour alone |
| Generated PDF/EPS print assets | No | Print artefacts are outside the web content scope. The generating UI is in scope |
| Third-party embed content (a YouTube player's own controls) | Not controllable | Section 24.12 |
24.1.3 The Conformance Claim #
Published on the accessibility statement page (Section 24.11) in exactly this wording:
Conformance claim. LinkHub conforms to Web Content Accessibility Guidelines (WCAG) 2.2 at Level AA. This claim covers the LinkHub dashboard at
app.linkhub.app, the LinkHub marketing site atlinkhub.app, and all public pages generated by LinkHub — bio pages, link and QR landing pages, and the interstitial warning page — including those served on customers' own domains.The claim is based on automated testing on every release and manual testing of each surface with NVDA on Firefox, VoiceOver on Safari for iOS, and TalkBack on Chrome for Android.
Exclusions. The claim does not extend to (a) content embedded from third parties, such as video, music and social-post players, which is delivered by those providers and is outside our control; (b) content, images and colour themes supplied by our customers on their own pages, although our editor prevents themes that fail contrast requirements and requires alternative text for images; or (c) print files (PDF, EPS) generated for QR code production.
This claim was last reviewed on {review_date} and is reviewed at least every six months and after any significant release.
Rules governing the claim: it names an exact standard, level and date; it is never expressed as "we strive for" or "we are committed to", which are not conformance claims; and if a Level A or AA criterion is known to fail anywhere in scope, the claim is downgraded to "partially conforms" with the specific failure listed until it is fixed. A conformance claim that survives a known failure is a false statement, not a target.
24.2 Success-Criterion Conformance Table #
Every WCAG 2.2 Level A and AA criterion, how LinkHub satisfies it, and where in the product it is most at risk. "At risk" names the specific surface a reviewer should attack first.
24.2.1 Principle 1 — Perceivable #
| SC | Name | Level | How LinkHub satisfies it | Most at risk |
|---|---|---|---|---|
| 1.1.1 | Non-text Content | A | Every image block requires alternative text or an explicit decorative marking at upload (Section 24.8.1). Icon-only controls carry aria-label. Avatars use the profile name. The QR preview has a text alternative naming the code and its destination. Decorative dividers use aria-hidden="true" |
Customer-uploaded images on bio pages; the icon-only editor toolbar |
| 1.2.1 | Audio-only and Video-only (Prerecorded) | A | Self-hosted audio/video blocks require a text alternative field before publish; the field is mandatory, not advisory | The audio block |
| 1.2.2 | Captions (Prerecorded) | A | Self-hosted video requires a caption track (WebVTT) before publish. Third-party embeds inherit the provider's captions; the block editor warns when a linked video has none where the provider's API exposes that (Section 24.8.2) | Third-party video embeds |
| 1.2.3 | Audio Description or Media Alternative | A | The mandatory text alternative on self-hosted video satisfies the media-alternative option | Self-hosted video |
| 1.2.4 | Captions (Live) | AA | Not applicable — no live media exists in the product, and none is planned | — |
| 1.2.5 | Audio Description (Prerecorded) | AA | Self-hosted video: the publish gate accepts either an audio-description track or a full text alternative describing the visual content | Self-hosted video |
| 1.3.1 | Info and Relationships | A | Semantic HTML throughout. Blocks are an ordered list; headings follow a strict order (Section 24.3.2); dashboard tables use <th scope>; forms use <label for>; grouped controls use <fieldset>/<legend>; ARIA is used only where no native element exists |
The block list; the analytics tables |
| 1.3.2 | Meaningful Sequence | A | DOM order equals visual order on every surface; guaranteed structurally (Section 24.3.4) | Bio page block reordering; the two-column dashboard layouts |
| 1.3.3 | Sensory Characteristics | A | No instruction refers to shape, size or position alone. "Select the button below" is written as "select Continue" | Editor help text |
| 1.3.4 | Orientation | AA | No orientation lock anywhere. Bio pages and the dashboard are fully usable in portrait and landscape | The QR preview panel |
| 1.3.5 | Identify Input Purpose | AA | Every field collecting information about the user carries the correct autocomplete token: email, current-password, new-password, name, organization, street-address, address-level1, address-level2, postal-code, country, one-time-code |
Sign-up, sign-in, billing address, email capture |
| 1.4.1 | Use of Color | A | Colour is never the sole carrier of meaning. Analytics series carry distinct markers and direct labels; status uses an icon plus text; validation errors use an icon, text and aria-invalid; usage meters state the number as well as the bar colour |
Analytics charts; usage meters; link status badges |
| 1.4.2 | Audio Control | A | Nothing autoplays with sound. Embeds are facade-first and cannot autoplay because they do not exist until activated. Self-hosted audio has no autoplay option in the editor | The audio block |
| 1.4.3 | Contrast (Minimum) | AA | 4.5:1 for normal text, 3:1 for large text. Product UI is verified in CI against the design tokens. Customer themes are gated at save time — Section 24.5 | Customer-chosen theme colours |
| 1.4.4 | Resize Text | AA | Text scales to 200% with no loss of content or function. Layout uses relative units; no maximum-scale or user-scalable=no in any viewport meta tag |
The editor inspector panel |
| 1.4.5 | Images of Text | AA | No images of text in the product UI. Customers may upload one, so the alternative-text requirement (Section 24.8.1) prompts specifically for the text content when the image is detected as text-heavy | Customer-uploaded banner images |
| 1.4.10 | Reflow | AA | No horizontal scrolling at 320 CSS px width / 400% zoom. Verified in CI (Section 24.10.4). Wide analytics tables scroll horizontally within a labelled, keyboard-focusable region — the permitted exception for data tables — rather than forcing page-level scroll | Analytics tables; the editor's canvas + inspector layout |
| 1.4.11 | Non-text Contrast | AA | 3:1 against adjacent colours for: control borders, focus indicators, chart series lines and points, toggle states, the drag handle, and the QR preview frame | Chart series; the theme editor's own swatches |
| 1.4.12 | Text Spacing | AA | No content loss when line height is set to 1.5×, paragraph spacing to 2×, letter spacing to 0.12em and word spacing to 0.16em. No fixed-height text containers; verified by a CI stylesheet-injection test | Bio page button blocks; dashboard cards |
| 1.4.13 | Content on Hover or Focus | AA | Every tooltip and popover is dismissible with Escape without moving focus, hoverable (the pointer may move onto it), and persistent until dismissed or focus moves. Chart tooltips follow the same rule and are additionally reachable by keyboard | Chart tooltips; the truncated-URL tooltip |
24.2.2 Principle 2 — Operable #
| SC | Name | Level | How LinkHub satisfies it | Most at risk |
|---|---|---|---|---|
| 2.1.1 | Keyboard | A | Every function is keyboard-operable. Block reordering has a full keyboard path (Section 24.6.1); the colour picker has a typed hex input (Section 24.6.4); charts have a tabular equivalent (Section 24.7.2) | Drag-and-drop reordering; the colour picker |
| 2.1.2 | No Keyboard Trap | A | No trap anywhere. Modals trap focus deliberately and release it on Escape or close, which is the permitted dialog pattern; embed iframes are reachable and escapable with Tab | Third-party embed iframes |
| 2.1.4 | Character Key Shortcuts | A | Single-character shortcuts (n new link, / search, ? shortcuts help) are active only when focus is not in a text input, and every one is remappable or disableable in settings |
Dashboard shortcuts |
| 2.2.1 | Timing Adjustable | A | No content time limit. The session's 30-day rolling expiry is an exempt security exception. Autosave and toast timings are not content time limits; toasts persist until dismissed when they carry an error | Toasts |
| 2.2.2 | Pause, Stop, Hide | A | No auto-updating or moving content by default. The analytics live-view auto-refresh has a visible pause control and defaults to off. Skeleton shimmer stops after load and is suppressed entirely under prefers-reduced-motion |
The live analytics view |
| 2.3.1 | Three Flashes or Below | A | Nothing flashes. No animation exceeds 3 Hz. Enforced by design review; the token set contains no animation that could flash | Loading indicators |
| 2.4.1 | Bypass Blocks | A | A skip link is the first focusable element on every page — "Skip to content" on public pages, "Skip to main content" plus "Skip to navigation" on the dashboard. Landmarks provide the second bypass mechanism | Public bio pages |
| 2.4.2 | Page Titled | A | Every page has a unique, descriptive <title>. Bio pages: {page_title} · {display_name}. Dashboard: {view} · {workspace} · LinkHub. Fallback pages: a specific title, never "Untitled" |
QR fallback pages |
| 2.4.3 | Focus Order | A | Focus order matches meaning and visual order. No positive tabindex anywhere; a lint rule forbids it |
The editor's canvas/inspector split |
| 2.4.4 | Link Purpose (In Context) | A | Every link's purpose is clear from its text plus its list-item context. "Click here" and bare URLs are rejected at authoring time with a warning; icon-only social links carry the platform name as their accessible name | Customer-authored link labels |
| 2.4.5 | Multiple Ways | AA | Dashboard: navigation, global search, and breadcrumbs. Public: bio pages are single pages, and the criterion's exception for a page that is a step in a process does not apply — instead, every public page links to the workspace's other public content where the customer has enabled it | The dashboard's deep views |
| 2.4.6 | Headings and Labels | AA | Headings describe their section; labels describe their control. No placeholder-as-label anywhere; a lint rule flags an input without an associated <label> |
The editor's inspector fields |
| 2.4.7 | Focus Visible | AA | A visible focus indicator on every focusable element, from the design tokens, never removed. :focus-visible is used for pointer/keyboard distinction, but a :focus fallback guarantees an indicator in every browser |
Custom-themed buttons on bio pages |
| 2.4.11 | Focus Not Obscured (Minimum) | AA | See 24.2.4 | Sticky headers; the cookie banner; the editor's floating toolbar |
| 2.4.13 | Focus Appearance | AAA, required here | See 24.2.4 | Themed buttons |
| 2.5.1 | Pointer Gestures | A | No multipoint or path-based gesture is required anywhere. Pinch-zoom on the QR preview has +/− buttons; there is no swipe-only interaction | The QR preview |
| 2.5.2 | Pointer Cancellation | A | No function activates on down-event. Activation is on up-event, within the target, and every drag can be cancelled by returning to the origin or pressing Escape | Drag reordering |
| 2.5.3 | Label in Name | A | Every control's accessible name begins with its visible label text, so voice control works. Verified by an automated check comparing visible text to accessible name across the component library | Icon-plus-text buttons |
| 2.5.4 | Motion Actuation | A | No device-motion actuation exists | — |
| 2.5.7 | Dragging Movements | AA | See 24.2.4 | Block reordering; chart range brushing |
| 2.5.8 | Target Size (Minimum) | AA | See 24.2.4 | Dense analytics table row actions; the editor toolbar |
24.2.3 Principles 3 and 4 — Understandable, Robust #
| SC | Name | Level | How LinkHub satisfies it | Most at risk |
|---|---|---|---|---|
| 3.1.1 | Language of Page | A | <html lang> set on every page. Bio pages use the workspace's configured content language, defaulting to the account locale, and the editor exposes it as a per-page setting |
Bio pages whose content language differs from the account locale |
| 3.1.2 | Language of Parts | AA | The block editor exposes a per-block language attribute for multilingual pages, applied as lang on the block element |
Multilingual bio pages |
| 3.2.1 | On Focus | A | Focus never changes context. No focus handler navigates, submits or opens a modal | The editor's inspector auto-focus |
| 3.2.2 | On Input | A | No control submits or navigates on change. Select elements filter on change without moving focus; the plan picker's monthly/annual toggle updates prices in place | The billing period toggle |
| 3.2.3 | Consistent Navigation | AA | Navigation is in the same relative order on every dashboard route; the public page's footer order is identical across templates | New dashboard sections |
| 3.2.4 | Consistent Identification | AA | The same function carries the same name and icon everywhere: "Archive" is always Archive, never "Hide" in one place and "Deactivate" in another. Enforced by a shared component and a copy glossary | Billing versus content areas |
| 3.2.6 | Consistent Help | A | See 24.2.4 | The editor's full-screen mode |
| 3.3.1 | Error Identification | A | Errors are identified in text, associated with their field via aria-describedby, marked aria-invalid="true", announced in a live region, and summarised at the top of the form with links to each field |
The multi-step downgrade flow |
| 3.3.2 | Labels or Instructions | A | Every input has a persistent visible label. Format requirements are stated before input, not only after failure — the slug field states its character rules up front | The slug field; the tax-ID field |
| 3.3.3 | Error Suggestion | AA | Where the fix is known, it is offered: a taken slug suggests three available alternatives; an invalid destination scheme suggests the https:// prefix; a failing theme colour offers "fix for me" (Section 24.5.3) |
Slug and URL fields |
| 3.3.4 | Error Prevention (Legal, Financial, Data) | AA | Reversible, checked, and confirmed: plan changes show a preview and are reversible before the effective date; deletions have a 30-day restore window and a typed confirmation; downgrades show the full impact list; billing forms are reviewed on the processor's hosted page before submission | Cancellation; workspace deletion |
| 3.3.7 | Redundant Entry | A | See 24.2.4 | Signup → workspace creation; the invitation acceptance flow |
| 3.3.8 | Accessible Authentication (Minimum) | AA | No cognitive function test exists anywhere in the product. Password managers work — no paste blocking, correct autocomplete tokens, no field splitting. Magic-link sign-in is offered as a no-password path. TOTP fields accept paste and carry autocomplete="one-time-code". The bot challenge is honeypot plus timing by default, which asks the user for nothing at all; the escalation is a behavioural check, never a puzzle; and the no-JavaScript path receives no challenge whatever. See the conformance note in 24.9.4 |
The sign-up and sign-in bot challenge |
| 4.1.2 | Name, Role, Value | A | Native elements first; ARIA only where necessary. Every custom control (block list, colour picker, chart) exposes name, role, state and value, and is tested against the ARIA Authoring Practices patterns | The block list; the colour picker |
| 4.1.3 | Status Messages | AA | Status is announced without moving focus: autosave, validation results, filter results, copy-to-clipboard, and export readiness all use appropriately-scoped live regions (Section 24.6.3) | Autosave; the analytics filter |
4.1.1 Parsing is obsolete and removed in WCAG 2.2 and is therefore not claimed. Valid markup is still enforced by the framework and by a CI HTML validation step, because the reasons it was written have not gone away.
24.2.4 The WCAG 2.2 Additions — In Depth #
These six get their own treatment because they are new, because automated tools detect them poorly, and because this product's core interactions sit directly on top of them.
2.4.11 Focus Not Obscured (Minimum) — AA
Requirement: when a component receives keyboard focus, it is not entirely hidden by author-created content.
Where LinkHub is at risk: the dashboard's sticky top bar; the editor's floating block toolbar; the consent banner pinned to the bottom of public pages; the persistent billing banners in Section 22.7.3; the "unsaved changes" bar.
Implementation:
| Mechanism | Detail |
|---|---|
scroll-margin on every focusable element |
Set to calc(var(--sticky-header-height) + 8px) at the root, so browser scroll-into-view never parks a focused element under the header |
scroll-padding-block on the scroll container |
Set to the combined sticky header and footer heights, applied on :root and on the editor canvas |
| No fixed overlay without focus awareness | Sticky and fixed elements are enumerated in one module; each declares its occupied edge and its height as a CSS custom property, and the scroll padding is computed from that set. Adding a new sticky element without registering it fails a unit test |
| Banner placement | The consent banner and billing banners occupy the bottom and top edges respectively and are excluded from the content scroll region, so the focusable content area never extends beneath them |
| Modal focus | On open, focus moves inside the dialog; the underlying page does not scroll; on close, focus returns to the trigger, which is scrolled into view with the same margins |
| Verification | An automated test tabs through every focusable element on every route at three viewport heights (small phone, tablet, desktop) and asserts the focused element's bounding box intersects the viewport by at least 1 CSS px after any scroll settles. LinkHub applies the stricter "not even partially obscured" standard where achievable, and the test reports partial obscuring as a warning and total obscuring as a failure |
2.4.13 Focus Appearance — AAA, required here
Requirement: the focus indicator has an area at least equal to a 2 CSS px perimeter of the component, and a contrast ratio of at least 3:1 between focused and unfocused states.
Implementation:
:root {
--focus-ring-width: 3px; /* exceeds the 2px minimum */
--focus-ring-offset: 2px;
--focus-ring-color: <computed per theme, see below>;
}
:where(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
border-radius: inherit;
}| Rule | Detail |
|---|---|
| One definition | The indicator is defined once in the token layer. outline: none without a replacement indicator fails lint across the monorepo |
| Contrast | --focus-ring-color is computed per theme by picking, from the theme's own palette, the colour with the highest contrast against both the component's background and the page background, requiring ≥ 3:1 against each. If no theme colour qualifies, the ring falls back to a computed high-contrast neutral (near-black or near-white by page luminance). This runs in the same pass as the contrast gate in Section 24.5 |
| Area | A 3 px ring at 2 px offset around the full perimeter comfortably exceeds the required area for every control in the library, including the smallest 24×24 icon button |
| Never suppressed | Customer themes cannot alter or remove the focus ring. The theme token schema has no focus-ring property, and a custom CSS rule targeting the focus indicator is dropped by the sanitiser (Section 23.4.4) |
| Forced colors | Under forced-colors: active, the ring switches to outline: 3px solid Highlight and forced-color-adjust: none is not used anywhere |
| Verification | A visual-regression test captures the focused state of every component in the library across the light theme, the dark theme and forced-colors mode |
2.5.7 Dragging Movements — AA
Requirement: any function operated by a dragging movement has a single-pointer alternative that does not require dragging, unless dragging is essential.
Where LinkHub is at risk: bio page block reordering (the product's signature interaction), image crop-and-position, and the analytics chart's range brush.
Implementation:
| Drag interaction | Non-dragging alternative |
|---|---|
| Block reordering | (a) Move up / Move down buttons on every block, always present in the DOM, visible on focus or hover and permanently visible when "always show controls" is enabled in preferences; (b) a "Move to position…" menu item opening a numeric position input; (c) the full keyboard path in Section 24.6.1. All three are single-pointer, no dragging |
| Image crop and reposition | Numeric X/Y offset and zoom inputs alongside the drag surface, plus nine preset anchor buttons (top-left through bottom-right) |
| Analytics range brush | A date-range picker with typed start and end dates and preset ranges (7/30/90 days, this month, last month, custom). The brush is an enhancement; every range is reachable without it |
| Colour picker saturation/value area | A typed hex field and numeric H/S/L fields (Section 24.6.4) |
| Slider controls (QR module size, logo size) | Each slider is a native <input type="range"> — keyboard-operable by arrow keys, and paired with a numeric text input showing and accepting the same value |
No dragging interaction in LinkHub is essential, so no exception is claimed.
2.5.8 Target Size (Minimum) — AA
Requirement: pointer targets are at least 24×24 CSS px, unless an exception applies (spacing, inline, user agent control, essential, or an equivalent alternative exists).
| Surface | Minimum enforced | Notes |
|---|---|---|
| Public bio pages | 44×44 | Above the AA requirement, per Section 24.1.1. Link blocks are full-width with a minimum 48 px height; social icons are 44×44 with 8 px gaps |
| Dashboard, primary controls | 40×40 | — |
| Dashboard, dense table row actions | 24×24 minimum, with a 24 px spacing envelope | Where an icon button renders smaller for visual density, its hit area is expanded with padding or a pseudo-element so the target is ≥ 24×24 even when the glyph is 16×16. The spacing exception is relied on only where the envelope test passes |
| Editor block controls | 32×32 | Move up/down, duplicate, delete, settings |
| Inline text links within a paragraph | Exception applies | The inline exception; these are not enlarged, and the line height keeps them separated |
| Checkboxes and radios | 24×24 hit area minimum | The visual control may be 16×16 with an expanded target |
| Close buttons on modals and toasts | 40×40 | Frequently mis-hit; deliberately generous |
Verification: an automated check walks every interactive element on every route, computes its hit rectangle including padding and pseudo-element expansion, and fails the build on any element under 24×24 that does not match a coded exception. Exceptions must be declared in the component, not inferred by the test.
3.2.6 Consistent Help — A
Requirement: where a help mechanism is available on multiple pages, it appears in the same relative order on each.
| Surface | Help mechanism | Position |
|---|---|---|
| Dashboard, every route | "Help" item containing documentation search, keyboard-shortcuts reference, and "Contact support" | Last item in the primary navigation, identical on every route, including full-screen editor mode, the billing pages and every modal-heavy flow |
| Public bio pages | "Report this page" link | Last item in the footer, identical across every template and theme |
| Marketing site | "Help" | Last item in the header navigation |
| Error and fallback pages | "Contact support" | Last item, same relative position |
The editor's full-screen mode is the specific risk: it hides the primary navigation. The resolution is that full-screen mode retains a compact toolbar whose last item is the same Help control, preserving both presence and relative order. Support contact details are identical everywhere; there is no route-specific support channel.
3.3.7 Redundant Entry — A
Requirement: information previously entered by the user in the same process is auto-populated or available to select, rather than re-entered.
| Process | Redundancy removed |
|---|---|
| Signup → email verification → workspace creation | The workspace name field is pre-filled from the account display name; the email is never re-requested; the workspace slug is derived from the name and shown as editable |
| Invitation acceptance → account creation | The invited email address is pre-filled and read-only; the workspace is pre-selected; the role is displayed, not chosen again |
| Checkout → billing address | The account name pre-fills the billing name; the address is collected once by the processor and reused for every subsequent transaction and every plan change |
| Custom domain add → DNS verification → TLS | The domain is entered once and carried through all three steps; the verification token is displayed, never re-typed |
| Link create → UTM builder | Existing UTM values on the destination pre-populate the builder rather than being re-entered |
| Multi-step downgrade selection | Each step remembers its selection across navigation and across sessions until the effective date |
| Password re-entry | Not required. There is no "confirm password" field — a visibility toggle serves the same purpose without redundant entry, which is the permitted approach |
| 2FA code | Not auto-populated; this is the explicit security exception the criterion allows, and it is the only place the exception is claimed |
24.3 The Public Bio Page #
The bio page is the product's most-viewed surface, is consumed mostly on phones, and is authored by people who are not accessibility specialists. Accessibility therefore has to be a property of the renderer, not a discipline imposed on the author.
24.3.1 Document Structure and Landmarks #
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acme Studio · Links</title>
</head>
<body>
<a class="skip-link" href="#content">Skip to content</a>
<header role="banner">
<img src="…" alt="" width="96" height="96" class="avatar">
<h1>Acme Studio</h1>
<p class="bio">Design studio in Lisbon.</p>
</header>
<main id="content" role="main" tabindex="-1">
<ul class="blocks">
<li class="block block--link">
<a href="/r/x7f2a9q" class="link-block">Book a consultation</a>
</li>
<li class="block block--heading">
<h2>Recent work</h2>
</li>
<li class="block block--embed">
<figure>
<button type="button" class="embed-facade"
aria-label="Play video: Studio tour on YouTube">…</button>
<figcaption>Studio tour</figcaption>
</figure>
</li>
<li class="block block--form">
<form> … </form> <!-- Section 24.9 -->
</li>
</ul>
</main>
<nav role="navigation" aria-label="Social profiles">
<ul> <li><a href="…" aria-label="Acme Studio on Instagram">…</a></li> </ul>
</nav>
<footer role="contentinfo">
<a href="…">Privacy</a>
<a href="…">Report this page</a> <!-- Consistent Help, always last -->
</footer>
</body>
</html>| Landmark | Element | Content | Rule |
|---|---|---|---|
banner |
<header> |
Avatar, display name (<h1>), bio text |
Exactly one; always first |
main |
<main id="content"> |
The block list | Exactly one; the skip-link target; tabindex="-1" so focus can land on it |
navigation |
<nav aria-label="Social profiles"> |
Social icon links | Labelled, because a page may have more than one nav region |
contentinfo |
<footer> |
Legal links, the report link, the LinkHub badge on Free | Exactly one; always last |
Every element on the page is inside a landmark — no orphaned content. role attributes are written explicitly alongside the native elements as a redundancy for older assistive technology; a CI check asserts the landmark set is exactly this and that duplicates are labelled.
24.3.2 Blocks as a Semantic List, and Heading Order #
The block collection is a <ul> of <li> elements, one per block, in document order. This is not decorative: it lets a screen-reader user hear "list, 9 items" and know the shape of the page before traversing it, and it makes "item 4 of 9" available at every step.
- Blocks that are themselves lists (social rows, link groups) nest a
<ul>inside their<li>; nesting is never more than two deep. list-style: noneis accompanied byrole="list"on the<ul>, because some browser/screen-reader combinations strip list semantics when list styling is removed. This is applied by the renderer, not left to the theme.- Hidden blocks (scheduled, expired, unpublished) are removed from the DOM, never merely visually hidden. A screen-reader user must not encounter a link that a sighted user cannot see.
Heading rules, enforced by the renderer rather than trusted to the author:
| Rule | Enforcement |
|---|---|
The page has exactly one <h1> — the display name in the banner |
The renderer emits it; no block can produce an <h1> |
Heading blocks emit <h2> by default |
The block's level is a computed property, not a free choice |
A heading block nested inside a section started by another heading emits <h3> |
Computed by walking the block tree; the author picks "heading" and "subheading", and the renderer picks the tag |
| Levels never skip | Structurally impossible: only <h2> and <h3> are emitted, and <h3> only after an <h2> |
| Embed and form blocks carry accessible names, not headings | <figcaption> and <legend> respectively |
| The editor shows the computed outline | A "Page outline" panel lists the resulting heading structure, so an author sees what a screen-reader user will hear |
24.3.3 The Skip Link #
<a class="skip-link" href="#content">Skip to content</a>- The first focusable element in the DOM, before any header content.
- Visually hidden until focused, then rendered as a solid, high-contrast button at the top-left with the standard focus ring — never a 1×1 clipped element that appears off-screen when focused.
- Activation moves focus to
<main tabindex="-1">, not merely the scroll position, so the next Tab continues from inside the content. - Present on every public surface, including the QR fallback pages and the interstitial.
- Its contrast is theme-independent: it uses the fallback high-contrast pair, so a low-contrast theme cannot make the skip link invisible.
24.3.4 Screen-Reader Order Equals Visual Order — The Guarantee #
The DOM order of blocks is the visual order of blocks, at every viewport width, in every template, in every theme, and after every reorder.
This is guaranteed structurally rather than by convention:
| Mechanism | Detail |
|---|---|
| Single ordering source | Each block row carries an integer position. The renderer sorts by position and emits in that order. There is no second ordering input |
| No CSS reordering | flex-direction: row-reverse, column-reverse, flex-flow reversals, order, and grid-auto-flow: dense are banned in every public-page stylesheet, enforced by a stylelint rule that fails the build. Grid item placement via explicit grid-row/grid-column is likewise banned on the block list |
| No absolute positioning in the block flow | Blocks are in normal flow; only decorative pseudo-elements may be positioned |
| Reorder writes positions, not styles | A drag, a Move-up press, or a "move to position" entry all call the same reorderBlocks(page_id, ordered_ids[]) service, which rewrites position values in one transaction as a dense sequence starting at 1. Re-render therefore produces the new DOM order directly |
| Position integrity | A database constraint enforces uniqueness of (page_id, position) among non-deleted blocks; a repair job renormalises any gaps |
| Optimistic UI | The editor's optimistic reorder moves the actual DOM node; it never applies a transform to fake the move. The pre-save and post-save DOM orders are identical |
| Responsive layouts | Multi-column arrangements at wide viewports are produced with grid auto-placement in source order. A two-column layout reads left-to-right, top-to-bottom, matching the DOM |
| Verification | An automated test renders every template with a 12-block fixture at 320, 768 and 1280 px, extracts DOM order and computes visual order from bounding boxes (top, then left), and asserts they are identical. It runs again after a programmatic reorder |
24.3.5 Accessible Names #
| Element | Accessible name | Fallback when the author leaves it empty |
|---|---|---|
| Link block | The visible label text | The block cannot be saved without a label — the field is required |
| Icon-only social link | aria-label="{display_name} on {Platform}" |
Generated automatically from the platform and the page's display name; the author cannot produce an unnamed social link |
| Avatar image | alt="" — decorative, because the display name follows immediately as the <h1> |
Not applicable |
| Content image block | Author-supplied alternative text | Enforced at upload (Section 24.8.1) |
| Image used as a link | The alternative text describes the destination, not the picture, per the criterion's requirement | Enforced by the editor's prompt wording |
| Embed facade button | aria-label="Play {media_type}: {title} on {Provider}" |
Title fetched from the provider's oEmbed response; if unavailable, "Play video on YouTube" |
| Embedded iframe (after activation) | title="{title} — {Provider} player" |
Same source |
| Email-capture submit | The visible button text | Defaults to "Subscribe" |
| Download/file block | Link text plus file type and size: "Price list (PDF, 240 KB)" | Type and size computed from the file |
| QR display block | alt="QR code linking to {destination_label}" |
Section 24.8.3 |
| Share button | aria-label="Share this page" |
Fixed string |
| LinkHub badge | "Made with LinkHub" | Fixed string |
| "Report this page" | Visible text | Fixed string |
No public bio page can be published with an unnamed interactive element. The publish gate (Section 24.5.5) blocks it and names the offending block.
24.3.6 Reduced Motion #
prefers-reduced-motion: reduce is honoured on every public surface:
| Effect | Default | Under reduced motion |
|---|---|---|
| Block entrance animations | Fade + 8 px rise, 200 ms, staggered | Removed entirely. Content appears in place |
| Hover lift on link blocks | 2 px translate + shadow | Shadow change only, no movement |
| Button press | 1 px depress | Colour change only |
| Skeleton shimmer | Animated gradient | Static neutral block |
| Theme background gradients | Static by default | Static; any animated-gradient theme option is disabled |
| Embed facade → player transition | 150 ms cross-fade | Instant swap |
| Toast entrance | Slide + fade | Fade only, 100 ms |
| Smooth scrolling | scroll-behavior: smooth |
scroll-behavior: auto |
| Confetti / celebration effects | Present in the editor on first publish | Not rendered at all |
| Parallax | Not offered in any theme | — |
Implementation: a single @media (prefers-reduced-motion: reduce) block in the token layer sets --motion-duration: 0.01ms and --motion-distance: 0, and every animation is expressed in terms of those tokens. A stylelint rule fails any transition or animation declaration using a hard-coded duration outside the token system, so a new animation cannot escape the mechanism. Reduced motion is additionally exposed as an explicit per-page setting for authors who want it always on.
24.4 Keyboard Operability on the Public Page #
24.4.1 Principles #
- The public page requires no JavaScript for navigation. Every link is a real
<a href>server-rendered into the document (Section 11). Keyboard operability therefore survives a script failure, a slow network and an aggressive content blocker. - No custom key handlers on the public page, with one exception: Escape dismisses the consent banner (equivalent to "Reject all") and closes an activated embed's expanded state.
- No positive
tabindexvalues. No element is removed from the tab order except genuinely inert decorative content, which isaria-hiddenand non-focusable.
24.4.2 The Full Tab Order #
For a representative page containing every block type, in exact order:
| # | Element | Notes |
|---|---|---|
| 1 | Skip to content link | Visually hidden until focused |
| 2 | Consent banner — "Manage preferences" | Only when the banner is showing; the banner is at the end of the DOM but is given priority in the reading and focus order via aria-modal="false" plus programmatic focus on first render, so a keyboard user meets the choice before consuming the page. Escape dismisses it as "Reject all" |
| 3 | Consent banner — "Reject all" | — |
| 4 | Consent banner — "Accept all" | Equal prominence with Reject (Section 23.13.3) |
| 5 | Header avatar link | Only if the author made the avatar a link; otherwise skipped |
| 6 | Header bio inline links | In text order |
| 7 | Block 1 — link block anchor | The main content begins |
| 8 | Block 2 — heading | Not focusable; headings are landmarks for screen-reader navigation, not tab stops |
| 9 | Block 3 — image block, if linked | Skipped when unlinked |
| 10 | Block 4 — embed facade button | Activating it replaces the facade with the iframe and moves focus into the player region |
| 11 | Block 4 — embed iframe (after activation) | A single tab stop that hands off to the provider's internal focus order; Tab continues out of it, so there is no trap |
| 12 | Block 5 — email capture: each input in source order | Section 24.9 |
| 13 | Block 5 — email capture: consent checkbox | Where the form requires one |
| 14 | Block 5 — email capture: submit button | — |
| 15 | Block 6 — link group: each anchor in list order | — |
| 16 | Block 7 — file download link | — |
| 17 | Block 8 — QR display block: "Download" button | Only when the author enabled downloading |
| 18 | Block 9 — social row: each icon link in list order | Inside <nav aria-label="Social profiles"> |
| 19 | Share button | When enabled |
| 20 | Footer — Privacy | — |
| 21 | Footer — Report this page | Consistent Help; always the last footer link |
| 22 | Footer — LinkHub badge link | Free plan only; always the final tab stop on the page |
Rules that hold regardless of block composition:
- Blocks are traversed strictly in
positionorder; within a block, controls are traversed in source order. - A block with no interactive content contributes no tab stops.
- Hidden blocks contribute none, because they are not in the DOM.
- After the last element, Tab moves to the browser chrome. There is no wrap and no trap.
- Shift+Tab reverses the identical order.
- The interstitial page's order is: skip link, "Go back" (focused on load), "Continue anyway", "Report a mistake", footer. The destructive action is never the default focus.
- The two rendered QR fallback pages — rung 3 (
workspace_unavailable) and rung 4 (generic) — share the order: skip link, primary action, footer links. Rung 4 carries no workspace name or branding (Section 23.15.2), so its accessible names are drawn from fixed platform strings rather than from customer content, and it is the one public page whose accessibility is entirely under LinkHub's control.
24.4.3 Keyboard Activation #
| Control | Keys |
|---|---|
Link (<a href>) |
Enter |
Button (<button>) |
Enter and Space |
| Checkbox | Space |
| Embed facade | Enter or Space; focus then moves into the player |
| Consent banner | Tab/Shift+Tab within, Enter/Space to activate, Escape = Reject all |
| Form submit | Enter from any text input in the form |
| Share (where the native share sheet is unavailable) | Enter or Space copies the URL and announces "Link copied" in a live region |
There are no access keys, no single-character shortcuts and no modifier combinations on public pages. A visitor's assistive technology owns those keystrokes.
24.5 The Theme Contrast Gate #
24.5.1 The Rule #
A theme whose text/background or button pairs fail a 4.5:1 contrast ratio cannot be saved. The save request is rejected server-side with a validation error naming each failing pair. This is not a warning, not a badge, and not a lint suggestion — it is a hard gate.
Server-side enforcement is essential: the check runs in packages/core, so it applies identically to the editor, the public API (Section 21), template imports, and any future bulk-edit path. A client-only check would be bypassed by the first API user.
24.5.2 The Exact Pairs Checked #
Every pair is computed from the theme's resolved tokens using the WCAG relative-luminance formula, with alpha composited against the effective backdrop before measurement (a semi-transparent overlay is measured as what the eye sees, not as its declared colour).
| # | Foreground | Background | Required | Rationale |
|---|---|---|---|---|
| 1 | text.primary |
surface.page |
4.5:1 | Body text |
| 2 | text.secondary |
surface.page |
4.5:1 | Bio text, captions — treated as normal text regardless of size, because themes routinely render it small |
| 3 | text.onButton |
button.background |
4.5:1 | The single most-used pair on a bio page |
| 4 | text.onButtonHover |
button.backgroundHover |
4.5:1 | Hover states are frequently forgotten and frequently fail |
| 5 | text.onButtonActive |
button.backgroundActive |
4.5:1 | — |
| 6 | heading |
surface.page |
3:1 if the computed size is ≥ 24 px or ≥ 18.66 px bold; otherwise 4.5:1 | Large-text allowance, applied from the computed size, not the author's intent |
| 7 | link.inline |
surface.page |
4.5:1 | Inline links in bio text |
| 8 | link.inline |
text.primary |
3:1 | Links must be distinguishable from surrounding text by more than colour; if this fails, the renderer forces an underline rather than blocking the save (criterion 1.4.1) |
| 9 | button.border |
surface.page |
3:1 | Non-text contrast for outlined button styles |
| 10 | button.background |
surface.page |
3:1 | Required only when the button has no border, so its boundary is perceivable |
| 11 | text.onCard |
surface.card |
4.5:1 | Card-style blocks |
| 12 | surface.card |
surface.page |
3:1 | The card's edge must be perceivable when it has no border |
| 13 | focusRing |
surface.page |
3:1 | Criterion 2.4.13 |
| 14 | focusRing |
button.background |
3:1 | The ring must be visible against the control it surrounds, not only against the page |
| 15 | formField.text |
formField.background |
4.5:1 | Email capture |
| 16 | formField.border |
formField.background |
3:1 | Field boundary |
| 17 | formField.placeholder |
formField.background |
4.5:1 | Placeholders are frequently unreadable; they are checked at the same level as text |
| 18 | error.text |
surface.page |
4.5:1 | Validation messages |
| 19 | badge.text |
badge.background |
4.5:1 | The LinkHub badge and any status pill |
Every pair is checked in both light and dark variants where the theme defines both, and against every background image or gradient the theme applies, using the worst-case sampled luminance across a 5×5 grid of the composited backdrop. A gradient that is readable at the top and unreadable at the bottom fails.
An image background additionally requires a scrim (a solid or gradient overlay) sufficient to bring the worst-case sample into compliance. The editor applies one automatically and shows its opacity as an adjustable value with a live minimum.
24.5.3 The "Fix For Me" Algorithm #
When a pair fails, the editor offers a one-click correction. The algorithm is deterministic, hue-preserving, and identical on the client and the server so the preview and the saved result never differ.
fixPair(foreground, background, required_ratio):
1. Convert both colours to OKLCH (perceptually uniform; adjusting lightness
there does not shift apparent hue the way HSL does).
2. Decide which colour to move:
- If the background is a brand colour the user set most recently, move the
FOREGROUND. Brand colours are what the user cares about; text colour is not.
- If the foreground is the brand colour, move the BACKGROUND.
- Ties (both set in the same edit) move the foreground.
3. Binary-search the mutable colour's L (lightness) channel over [0, 1],
holding C (chroma) and H (hue) fixed, for the value nearest the ORIGINAL L
that satisfies the required ratio. 12 iterations gives sub-0.5% precision.
4. If no L in [0, 1] satisfies the ratio at the current chroma (a highly
saturated mid-tone against a mid-tone background), reduce C by 10% and
repeat step 3. Up to 5 chroma reductions.
5. If still failing, fall back to the theme's neutral pair
(near-black #111827 on light backgrounds, near-white #F9FAFB on dark),
chosen by the background's luminance.
6. Return { suggested_colour, achieved_ratio, delta_e } and, when step 4 or 5
was used, a plain-language note explaining what changed and why.Presentation of the suggestion:
- A side-by-side preview of current and suggested, rendered as an actual button with actual text, at actual size — never as two abstract swatches.
- The numbers are stated: "Contrast is 3.1:1. It needs 4.5:1. This change makes it 4.7:1."
- Two actions: "Use this colour" and "Pick my own", the second reopening the picker with the failing value retained so the user is not made to start over.
- When several pairs fail, "Fix all" applies the algorithm to each in dependency order (backgrounds before foregrounds) and re-verifies the whole set afterwards, because fixing one pair can break another.
- Suggestions are computed in under 5 ms, so the preview updates live as the user drags a picker.
- The suggested colour is always the nearest compliant colour, not a maximally-contrasting one. Users reject suggestions that look nothing like their brand, and a rejected fix helps nobody.
24.5.4 The Typed-Override Path #
An override exists because there are genuine cases the formula gets wrong — a logotype colour with a compensating background image, a decorative pairing on a non-text element, a customer with an accessibility specialist who has made an informed judgement. It is deliberately effortful.
| Step | Detail |
|---|---|
| Availability | Owner and Admin only. Editors cannot override; the option is not shown to them |
| Trigger | A "This doesn't apply to my page" link in the failure dialog, styled as a plain text link, never as a button |
| Screen 1 | Names the exact failing pairs, their ratios, and states in plain language: "People with low vision, and anyone reading in bright sunlight, may not be able to read this text. Around 1 in 12 men and 1 in 200 women have some form of colour vision deficiency." Shows a simulated preview of the page under protanopia, deuteranopia, tritanopia and reduced-contrast conditions |
| Screen 2 | A required free-text reason, minimum 20 characters, with the prompt "Why does this pairing work on your page?" |
| Screen 3 | A typed confirmation. The user types OVERRIDE CONTRAST exactly. Case-sensitive. Not a checkbox, not a copy-pastable button label — a phrase they have to read and reproduce |
| Result | The theme saves with contrast_override = true and the failing pairs recorded on the theme record |
| Persistence | The override is scoped to that theme, those pairs, that workspace. Changing either colour in an overridden pair clears the override and re-runs the gate |
| Visibility | A permanent, non-dismissible notice on the theme settings panel: "This theme has a contrast override. Some visitors may not be able to read it." Also shown on the page's publish panel and in the pre-publish report |
| Never overridable | The focus ring pairs (#13, #14) and the form-field text pair (#15) cannot be overridden. A page nobody can navigate or fill in is not a design choice. Attempting it returns 422 contrast_override_not_permitted |
| Reversal | One click, from the same panel, at any time |
Audit entry, written to the workspace audit log (Section 8) on every override:
{
"action": "theme.contrast_override_applied",
"workspace_id": "0192f3c1-8a4f-7b21-9e77-5c2b1a0d3e44",
"actor": { "type": "user", "id": "0192f3c0-…", "role": "owner" },
"resource": { "type": "theme", "id": "0192f6d3-…" },
"before": { "contrast_override": false },
"after": {
"contrast_override": true,
"failing_pairs": [
{ "pair": "text.onButton/button.background", "ratio": 3.12, "required": 4.5 },
{ "pair": "text.secondary/surface.page", "ratio": 4.02, "required": 4.5 }
],
"reason": "Logotype gold on charcoal; verified with our accessibility consultant.",
"confirmation_phrase_typed": true
},
"ip_country": "PT",
"user_agent_family": "Chrome",
"created_at": "2026-08-19T09:31:44Z"
}The entry is immutable and retained for the plan's audit-log window. Overrides are additionally counted on an internal metric, because a high override rate means the gate is mis-calibrated and should be examined rather than tolerated.
24.5.5 The Pre-Publish Accessibility Report #
Shown in the publish panel before the page goes live, and reachable at any time from the editor. It is not a gate for everything — only the items marked Blocking prevent publishing — but it is always shown, and it is never collapsed by default.
Accessibility check — 2 issues to fix, 1 warning
BLOCKING
✗ Image "hero-banner.jpg" has no alternative text
Block 3 · Add a description or mark it decorative [Fix]
✗ Link block "Click here" doesn't describe its destination
Block 6 · Use text that says where the link goes [Fix]
WARNING
! Body text contrast is 4.6:1
Meets the minimum (4.5:1). Consider 7:1 for easier
reading in sunlight [Improve]
PASSED
✓ All 12 links have descriptive text
✓ All colour pairs meet 4.5:1
✓ Heading order is correct (h1 → h2 → h3)
✓ All 4 images have alternative text
✓ Both embeds have accessible names
✓ Page language is set (English)
✓ Reading order matches visual order
✓ All targets are at least 44 × 44 pixels
✓ Reduced-motion preference is respected
Checked against WCAG 2.2 Level AA · What do these mean?| Check | Blocking | Detail |
|---|---|---|
| Contrast pairs | Yes (unless overridden per 24.5.4) | The full pair list in 24.5.2 |
| Image alternative text | Yes | Every non-decorative image; decorative marking is an explicit choice, not a default |
| Link text descriptiveness | Yes | Rejects empty text, bare URLs, and the phrases "click here", "here", "read more", "link", "this" when used alone |
| Interactive element without an accessible name | Yes | Any control the renderer cannot name |
| Heading order | Yes | Structurally guaranteed by 24.3.2, so a failure indicates a renderer bug and is reported as an internal error |
| Page language set | Yes | Defaults to the account locale; the author may change it |
| Video caption track (self-hosted) | Yes | Section 24.8.2 |
| Target size | Warning | Themes cannot produce targets under 44 px on public pages; a warning appears only for author-supplied inline content |
| Contrast above 4.5:1 but below 7:1 | Warning | An improvement suggestion, never a block |
| Embed without a provider-supplied title | Warning | A generated fallback name is used |
| Long link labels (> 60 characters) | Warning | Hard to scan by voice and by screen reader |
| More than 20 links on one page | Warning | Suggests grouping under headings |
| Reduced-motion respected | Informational | Always true; shown so the author knows it happens |
The report is generated by the same server-side evaluator that gates publishing, so what the author sees is exactly what is enforced. Each finding links directly to the offending block in the editor, with focus moved to the specific field that fixes it. The report is available through the API as GET /v1/bio-pages/{id}/accessibility-report, so a customer running their own checks gets the identical result.
24.6 The Editor's Own Accessibility #
The bio page editor is the most interaction-dense surface in the product and the one where accessibility is easiest to lose. It is held to the same Level AA bar as the public page.
24.6.1 The Keyboard Alternative to Drag-and-Drop Reordering — In Full #
Three independent paths exist, and all three call the same reorderBlocks service (Section 24.3.4), so no path can produce an ordering the others cannot.
Path A — Grab-and-move keyboard reorder (the primary path)
The block list is a composite widget implementing the ARIA "listbox with reorder" pattern with roving tabindex:
<ul role="listbox" aria-label="Page blocks" aria-orientation="vertical"
aria-describedby="reorder-instructions">
<li role="option" id="block-1" tabindex="0" aria-selected="false"
aria-posinset="1" aria-setsize="9">Link — Book a consultation</li>
<li role="option" id="block-2" tabindex="-1" aria-selected="false"
aria-posinset="2" aria-setsize="9">Heading — Recent work</li>
…
</ul>
<p id="reorder-instructions" class="sr-only">
Use the arrow keys to move between blocks. Press Space to pick up a block,
then use the arrow keys to move it and Space again to drop it.
Press Escape to cancel.
</p>| Key | State | Action |
|---|---|---|
Tab |
— | Enters the list at the last-focused block (roving tabindex); a second Tab leaves the list entirely |
↓ / ↑ |
Browsing | Move focus to the next / previous block. Does not wrap; the first and last announce "first block" / "last block" |
Home / End |
Browsing | Focus the first / last block |
Space |
Browsing | Pick up. aria-grabbed="true", aria-selected="true", the block gains a raised visual treatment, and the live region announces: "Book a consultation, grabbed. Position 1 of 9. Use arrow keys to move, Space to drop, Escape to cancel." |
↓ / ↑ |
Grabbed | Move the block one position. The DOM node moves; positions renumber optimistically. Announced after each move: "Position 3 of 9, now between Recent work and Studio tour." — naming the neighbours, because a bare number is not orientation |
Home / End |
Grabbed | Move to first / last position |
Space or Enter |
Grabbed | Drop. Persists via reorderBlocks. Announced: "Book a consultation dropped at position 3 of 9." Focus remains on the moved block |
Escape |
Grabbed | Cancel. The block returns to its original position, nothing is persisted, announced: "Move cancelled. Book a consultation returned to position 1." |
Enter |
Browsing | Open the block in the inspector |
Delete / Backspace |
Browsing | Delete with confirmation |
Ctrl/Cmd + D |
Browsing | Duplicate, inserted immediately after, focus moves to the copy |
Ctrl/Cmd + Z |
Any | Undo the last reorder, announced |
Rules: focus never leaves the moved block during a grab; a grab is cancelled automatically if focus leaves the list; the instructions element is referenced by aria-describedby so it is read on first focus and is also permanently visible as help text below the list; and the pattern is identical whether the user arrived by keyboard, by switch device or by voice control.
Path B — Move up / Move down buttons (the single-pointer path required by 2.5.7)
Every block row carries Move up and Move down buttons. They are:
- Always in the DOM and always in the tab order — never revealed only on hover, which is unreachable by touch and by keyboard.
- Visible on hover and on focus by default; a preference ("Always show block controls") pins them visible permanently, and that preference is on by default when the operating system reports a reduced-motion or increased-contrast preference.
- 32×32 CSS px, exceeding the target-size minimum.
- Named
aria-label="Move Book a consultation up"— naming the block, not just the direction, because a screen-reader user tabbing a long list otherwise hears "Move up, Move up, Move up". - Disabled with
aria-disabled="true"(notdisabled, so they remain focusable and their state is announced) at the list boundaries. - Announce the result in the live region after each press, identically to Path A.
Path C — "Move to position…"
Each block's overflow menu contains "Move to position…", opening a small dialog with a numeric input (1 to block count), the current position pre-filled, and a confirm action. This is the path that makes moving block 47 to position 2 a single operation instead of 45 keypresses. It announces the same completion message.
Path D — Drag and drop remains for pointer users, implemented with the HTML Drag and Drop API plus pointer-event fallback. It is an enhancement layered on the same service call. Dragging is never the only way to do anything.
24.6.2 Focus Management #
| Situation | Focus behaviour |
|---|---|
| Editor loads | Focus is on the document body; the skip link is the first tab stop; the block list is the second landmark |
| Block selected in the canvas | Focus moves to the inspector's heading (<h2 tabindex="-1">Link block settings</h2>), not to its first input — landing on an input makes a screen-reader user hear a field label with no idea which panel they are in |
| Inspector closed | Focus returns to the block in the list that was being edited |
| Block added | The new block is inserted, focus moves to it in the list, and the live region announces "Link block added at position 4 of 10." |
| Block deleted | Focus moves to the next block, or the previous one if the deleted block was last, or to the "Add block" button if the list is now empty. Focus is never lost to the body |
| Block duplicated | Focus moves to the duplicate |
| Modal opened | Focus moves to the modal's heading; focus is trapped inside; the background is inert |
| Modal closed (any route: confirm, cancel, Escape, backdrop click) | Focus returns to the exact element that opened it, scrolled into view with the sticky-header margin (Section 24.2.4) |
| Destructive confirmation modal | The safe action holds initial focus. The destructive action is never auto-focused |
| Save completes | Focus does not move. The result is announced (24.6.3) |
| Validation error on save | Focus moves to the first invalid field, after the error summary is rendered so the summary is available to jump back to |
| Panel or accordion expanded | Focus stays on the toggle; the expanded region is the next tab stop; aria-expanded is updated |
| Route change within the dashboard | Focus moves to the new page's <h1> (tabindex="-1"), and the page title is updated — a single-page-app navigation that does neither is silent to a screen-reader user |
| Toast appears | Focus does not move. Toasts are announced by their live region and are focusable via a keyboard shortcut (F6 cycles landmark regions including the toast region) |
| Undo | Focus moves to the restored element |
24.6.3 Live-Region Announcements #
Three live regions exist in the dashboard shell, present from first render (a live region injected at announcement time is frequently missed by screen readers):
| Region | aria-live |
role |
Used for |
|---|---|---|---|
| Status | polite |
status |
Autosave, filter results, copy confirmations, non-blocking progress |
| Alert | assertive |
alert |
Validation failures, save failures, connection loss, entitlement refusals |
| Log | polite |
log |
Reorder and structural-change announcements, which are sequential and benefit from log semantics |
| Event | Region | Announcement |
|---|---|---|
| Autosave started | Status | (silent — announcing every keystroke's save is intolerable) |
| Autosave succeeded | Status | "Saved." — debounced to at most one announcement every 20 seconds, and suppressed entirely if the user is typing |
| Autosave failed | Alert | "Couldn't save. We'll keep trying. Your changes are stored in this browser." |
| Autosave retrying | Status | "Reconnecting…" — once, not per attempt |
| Offline | Alert | "You're offline. Changes are saved in this browser and will sync when you reconnect." |
| Back online and synced | Status | "Back online. All changes saved." |
| Validation error | Alert | "3 problems with this block. Link URL is not valid. …" — the count first, so the listener knows the scope before the detail |
| Field corrected | Status | "Link URL is now valid." |
| Block added / deleted / duplicated / moved | Log | Per Section 24.6.1 |
| Publish succeeded | Status | "Page published. It's live at acme.linkhub.app." |
| Publish blocked by the accessibility report | Alert | "Can't publish. 2 accessibility issues need fixing. Moving to the first one." |
| Contrast check result while editing | Status | "Contrast is 4.7:1. Passes." — debounced 500 ms after the last colour change |
| Copy to clipboard | Status | "Link copied." |
| Export ready | Status | "Your export is ready to download." |
| Entitlement refusal | Alert | "You've reached 10 of 10 bio pages on Pro." |
Rules: announcements are complete sentences, never fragments; they never contain only a number; text is written for listening, not reading, so it never relies on visual context ("above", "on the right"); and no announcement is longer than roughly 15 words, because assistive technology cannot be interrupted mid-announcement without losing it.
24.6.4 The Colour Picker #
The colour picker is the single most common accessibility failure in visual editors, because it is fundamentally a two-dimensional pointer gesture over a continuous field. LinkHub's picker is a composite where the pointer surface is one input among several equals:
| Input | Detail |
|---|---|
| Hex text field | The primary input, always visible, always first in the tab order within the picker. Accepts #RGB, #RRGGBB, #RRGGBBAA, with or without the #. Validates on blur, not on keystroke. Invalid input is announced and the previous value is retained rather than silently reset. autocomplete="off", spellcheck="false", inputmode="text" |
| Numeric H / S / L fields | Three labelled number inputs with arrow-key increment (1 per press, 10 with Shift), full ranges, and live two-way sync with the hex field |
| Saturation/value surface | A pointer-operable 2D area that is also keyboard-operable when focused: arrows move by 1 unit, Shift+arrows by 10, Home/End jump to the extremes. It exposes role="slider" semantics on each axis via a paired implementation, with aria-valuetext announcing the resulting colour ("Hue 210, Saturation 68%, Lightness 42% — a medium blue") rather than raw numbers |
| Hue slider | A native <input type="range">, keyboard-operable by default |
| Alpha slider | Same, shown only where transparency is permitted |
| Swatches | The workspace's saved brand colours and a curated accessible default palette, as a radio group navigated by arrow keys. Each swatch's accessible name is its colour name plus its hex value plus its contrast against the current background: "Ocean blue, #1E5F8C, 6.2 to 1 against the page background" |
| Eyedropper | Offered where the browser supports the API; never the only path to any colour |
Additional requirements:
- Every colour value in the picker is always accompanied by its hex string as visible text. Colour is never the only representation of a colour — the criterion applies to the colour picker itself, which is a trap teams routinely fall into.
- The live contrast readout sits directly beneath the picker, updating as the value changes, stating the ratio, the pass/fail verdict, and the pair being measured: "4.7:1 — passes AA for button text on button background."
- The readout is inside the polite live region and debounced to 500 ms, so dragging does not produce a stream of announcements.
- The picker is a dialog: focus-trapped while open, Escape closes and reverts to the value on open, Enter confirms.
- A colour-blindness simulation toggle (protanopia, deuteranopia, tritanopia, achromatopsia) applies to the preview area, so a sighted author can see the consequence of their choice.
- Under
forced-colors: active, the picker's surfaces remain functional and the hex and numeric fields remain the authoritative inputs.
24.6.5 The Rest of the Editor #
| Component | Requirements |
|---|---|
| Canvas preview | An <iframe> with title="Page preview", rendering the actual public page. Reachable by keyboard as a single tab stop; interactive elements inside are inert so the preview is not a second, confusing tab order. A "Preview in a new tab" action opens the real page for genuine keyboard testing |
| Block palette ("Add block") | A menu button (aria-expanded, aria-haspopup="menu") opening a list navigated by arrow keys, with type-ahead. Each item's name is the block type plus a one-line description |
| Inspector fields | Every field has a persistent visible <label>; help text is linked by aria-describedby; errors are linked by aria-describedby and marked aria-invalid; grouped controls use <fieldset>/<legend> |
| Toggles | Native <input type="checkbox" role="switch"> with aria-checked, a visible on/off label, and never colour alone to convey state |
| Image upload | A <button> triggering the file input, plus a drop zone that is an enhancement only. Upload progress is announced at 0/50/100%, not continuously |
| Undo/redo | Keyboard shortcuts plus toolbar buttons; each action announces what was undone ("Undid: move block") |
| Autosave indicator | Visible text ("Saved", "Saving…", "Not saved") plus an icon, never an icon alone |
| Unsaved-changes bar | Announced when it appears; positioned so it never obscures focus (Section 24.2.4) |
| Full-screen mode | Retains the Help control as its last toolbar item (Consistent Help); Escape exits and returns focus to the trigger |
| Theme preview | Renders with the actual theme tokens; the contrast gate runs live against the preview, so the report and the preview cannot disagree |
24.7 The Dashboard #
24.7.1 Tables #
Every data table (links, pages, QR codes, members, invoices, leads, audit log) follows one pattern:
<table>
<caption>Short links — 812 total, showing 1 to 25</caption>
<thead>
<tr>
<th scope="col">
<button type="button" aria-sort="descending">
Created <span aria-hidden="true">▼</span>
</button>
</th>
<th scope="col">Slug</th>
<th scope="col">Destination</th>
<th scope="col">Clicks (30 days)</th>
<th scope="col">Status</th>
<th scope="col"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">19 Aug 2026</th>
<td>x7f2a9q</td>
<td><span title="https://example.com/very/long/path">example.com/very/…</span></td>
<td>1,284</td>
<td><span class="badge badge--active">● Active</span></td>
<td>…</td>
</tr>
</tbody>
</table>| Requirement | Detail |
|---|---|
<caption> |
Present on every table, stating what it contains and the current range. Not visually hidden — it is useful to everyone |
<th scope> |
col on headers, row on the first cell of each row where a natural row header exists |
| Sorting | Header buttons carry aria-sort (ascending/descending/none); the sort direction is also conveyed by an icon and by an announcement ("Sorted by created date, newest first, 812 results") |
No <div> grids |
Real <table> markup. A CSS grid pretending to be a table loses row/column relationships entirely |
| Row actions | Real <button> elements, minimum 24×24 hit area, each named with the row's subject ("Edit x7f2a9q", not "Edit") |
| Selection | Checkbox column with a header "select all" checkbox carrying aria-label="Select all links on this page" and an indeterminate state; the selection count is announced |
| Truncated cells | The full value is in a title attribute and reachable by a keyboard-focusable disclosure, since title is unreliable for keyboard and touch. The accessible name always carries the full value |
| Empty state | Inside the table region, announced, with a clear next action: "No links yet. Create your first short link." |
| Loading | A role="status" region announcing "Loading links…" then the result count; skeleton rows are aria-hidden |
| Pagination | "Next"/"Previous" buttons with aria-label including the range; the page's result range is announced on change |
| Horizontal overflow | The table sits in a tabindex="0" region with role="region" and an aria-label, making it scrollable by keyboard — the permitted 1.4.10 exception for data tables |
| Status badges | Icon + text + colour, never colour alone |
24.7.2 Charts and Their Tabular Equivalents #
Every chart in LinkHub has a tabular equivalent, reachable without leaving the page, containing the same data at the same granularity. This is a hard requirement, not a fallback: a line chart is fundamentally a visual encoding, and no amount of ARIA makes 90 data points comprehensible by audio.
| Requirement | Detail |
|---|---|
| The toggle | A "View as table" / "View as chart" control immediately before the chart in the DOM, so a screen-reader user meets it before the graphic. It is a real toggle button with aria-pressed, and its state persists per user across sessions |
| The table | A real <table> with a <caption> naming the metric and range, one row per time bucket or dimension value, one column per series, and totals in <tfoot> where meaningful |
| Granularity | Identical to the chart. A daily chart yields a daily table. Aggregating the table more coarsely than the chart is not permitted |
| The chart's own semantics | role="img" with an aria-label giving a one-sentence summary — "Clicks per day, 1 to 31 August. Rises from 120 to a peak of 1,284 on 19 August, then falls to 340." — generated from the data, not hand-written |
| Chart description | A longer aria-describedby text covering the trend, the maximum, the minimum and any notable change |
| Keyboard on the chart | The chart itself is a single tab stop. Arrow keys move a focused cursor across data points, each announcing "19 August, 1,284 clicks", so exploration is possible without switching to the table |
| Series identification | Distinct line styles or point markers in addition to colour; direct labels at the series end rather than a colour-keyed legend where space allows (criterion 1.4.1) |
| Series contrast | Every series line meets 3:1 against the plot background (criterion 1.4.11) |
| Tooltips | Reachable by keyboard, dismissible with Escape, hoverable, persistent (criterion 1.4.13) |
| Empty state | "No data for this range" as text within the chart region, announced, with a suggestion to widen the range |
| Colour-blind safety | The categorical palette is verified distinguishable under all three dichromacies; verification is part of the design-token test suite |
| Export | Every chart's underlying data is downloadable as CSV where the plan permits (Section 22.2.8), which is a third representation, not a substitute for the on-page table |
24.7.3 Forms #
| Pattern | Requirement |
|---|---|
| Labels | A persistent visible <label for> on every input. Placeholders are never labels; a lint rule fails an input whose only naming is a placeholder |
| Required fields | Marked with required and aria-required="true", plus visible text "(required)" — an asterisk alone is not sufficient |
| Instructions | Before the input, linked by aria-describedby. Format rules are stated up front, not only on failure (criterion 3.3.2) |
| Grouping | <fieldset> + <legend> for radio groups, checkbox groups and address blocks |
| Autocomplete | Correct tokens on every personal-data field (criterion 1.3.5) |
| Validation timing | On blur for individual fields, and on submit for the form. Never on every keystroke, which announces errors while the user is still typing |
| Error summary | On submit failure, a role="alert" region at the top of the form: "3 problems stopped this from saving", with a link to each invalid field. Focus moves to the summary |
| Per-field errors | Text below the field, linked by aria-describedby, with aria-invalid="true", and an icon plus text — never a red border alone |
| Error suggestion | Where the fix is computable, it is offered (criterion 3.3.3): available slug alternatives, a scheme prefix, a "fix for me" colour |
| Success | Announced in the polite region; focus stays put unless the form is destroyed by the success |
| Destructive confirmation | Typed confirmation for workspace deletion, plan cancellation and contrast override; the safe action holds focus |
| Multi-step flows | A visible step indicator with aria-current="step"; previously entered data is retained and never re-requested (criterion 3.3.7) |
| Disabled controls | Avoided. A control that cannot be used yet explains why on activation rather than being silently disabled. Where aria-disabled is used, the control stays focusable and its state is announced |
24.7.4 Modals, Menus and Other Patterns #
| Pattern | Implementation |
|---|---|
| Modal dialog | role="dialog" aria-modal="true", labelled by its heading; focus moves to the heading on open and returns to the trigger on close; focus trapped; Escape closes; background is inert; no nested modals anywhere |
| Non-modal popover | aria-expanded on the trigger; Escape closes and returns focus; clicking outside closes; focus is not trapped |
| Menu button | aria-haspopup="menu", aria-expanded; arrow-key navigation, type-ahead, Home/End, Escape to close, Tab closes and moves on |
| Tabs | The ARIA tabs pattern: arrow keys move between tabs, Tab moves into the panel, aria-selected and aria-controls wired, manual activation (arrow to move, Enter to select) so arrowing does not fire expensive panel loads |
| Accordion | Heading-wrapped <button aria-expanded aria-controls>; multiple panels may be open; state is announced |
| Toast | role="status" (or role="alert" for errors); auto-dismiss after 6 seconds for non-errors, never for errors; a dismiss button; reachable via the F6 region cycle |
| Combobox / search | The ARIA combobox pattern with aria-autocomplete="list", aria-activedescendant, and a result count announced politely ("7 results") |
| Tooltip | On focus as well as hover; Escape dismisses; hoverable and persistent; never the only source of an accessible name |
| Command palette | Focus trapped while open, Escape closes, results announced, every entry reachable through the ordinary navigation too — it is a shortcut, never the only path |
| Date range picker | A grid with full keyboard navigation (arrows, PageUp/PageDown for months, Home/End for week bounds), aria-current="date", and typed start/end inputs as an equal alternative (criterion 2.5.7) |
| Drag-reorderable lists elsewhere | Same three paths as Section 24.6.1 |
| Infinite scroll | Not used. Explicit "Load more" buttons or pagination only, because infinite scroll makes footers unreachable and progress unannounced |
24.8 Media #
24.8.1 Image Alternative Text — Enforcement at Upload #
Alternative text is required at the moment of upload, in the same dialog, before the image can be placed. Deferring it guarantees it is never supplied.
┌──────────────────────────────────────────────────────────┐
│ Describe this image │
│ ┌────────────────────────────────────────────────────┐ │
│ │ [ image preview ] │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ Alternative text (required) │
│ ┌────────────────────────────────────────────────────┐ │
│ │ │ │
│ └────────────────────────────────────────────────────┘ │
│ Describe what the image shows and why it's here. │
│ 0 / 250 characters │
│ │
│ ☐ This image is decorative and adds no information │
│ │
│ [ Cancel ] [ Add image ] │
└──────────────────────────────────────────────────────────┘| Rule | Detail |
|---|---|
| Required | "Add image" is inoperable until either the text field is non-empty or the decorative checkbox is ticked. Ticking decorative disables and clears the text field and writes alt="" |
| Length | 1–250 characters. Longer content belongs in a caption, and the dialog says so |
| Rejected values | The filename (IMG_4821.jpg), the file extension alone, "image", "photo", "picture", "graphic", "logo" used alone, and any string identical to the filename. Rejected inline with the reason and a suggestion |
| Linked images | When the image is a link, the prompt changes to "Describe where this link goes", because the alternative text must convey the link's purpose |
| Text-heavy images | A heuristic (edge density plus optional OCR where available) flags likely images of text and changes the prompt to "This image looks like it contains text. Include that text in your description." |
| Decorative marking | An explicit, deliberate choice, never a default and never pre-ticked |
| Editing | Alternative text is editable at any time from the block inspector and from a workspace-wide media library view |
| Bulk uploads | Each image gets its own prompt in a queue; the batch cannot be placed until all are described or marked decorative |
| API | POST /v1/bio-pages/{id}/blocks with an image block requires alt_text or decorative: true; omitting both returns 400 validation_failed with details[0].field = "alt_text" |
| Publish gate | Any image lacking either is a blocking finding in the pre-publish report (Section 24.5.5) |
| Avatar and favicon | Exempt — the avatar is decorative because the display name follows as the <h1>, and a favicon has no alternative-text mechanism |
| QR images | Generated automatically (Section 24.8.3) |
The policy is deliberately strict at the point of upload and permissive afterwards: the cost of describing an image is highest when it feels like an interruption, so the dialog explains why in one line rather than merely demanding.
24.8.2 Video and Audio #
| Content type | Policy |
|---|---|
| Self-hosted video (uploaded to a video block) | A caption track in WebVTT is required before publish. The editor accepts an uploaded .vtt file or a pasted transcript that it converts to a track. Audio description or a full text alternative is also required (criterion 1.2.5). Publishing without them is blocked |
| Self-hosted audio | A transcript is required before publish (criterion 1.2.1). The editor provides a plain-text field rendered beneath the player as a disclosure |
| Third-party video embeds (YouTube, Vimeo) | Captions are the provider's responsibility and the uploader's. Where the provider's oEmbed or metadata response exposes caption availability, the editor warns at insert time: "This video doesn't appear to have captions. Viewers who are deaf or hard of hearing won't be able to follow it. Consider adding captions on YouTube, or adding a summary below." It is a warning, not a block, because LinkHub cannot fix another platform's content and blocking would simply push authors to link out instead of embedding |
| Music embeds (Spotify, Apple Music, SoundCloud) | Music without speech has no caption obligation. The embed's accessible name identifies the track and artist |
| Social embeds (Instagram, TikTok, X) | The provider's content. The facade carries an accessible name including the author and, where the provider exposes it, the post text. A warning appears if the post contains video without captions |
| Player controls | For self-hosted media, the native <video>/<audio> controls are used, which are keyboard-operable and screen-reader-labelled by the browser. No custom player is built, because a custom player is a large accessibility surface for no product benefit |
| Autoplay | Not available for any media type, in any block, on any plan (criterion 1.4.2) |
| Facade-first | Every third-party embed renders as a static poster with an accessible activation button and loads nothing until activated (Section 23.5.4). This means no third-party player's accessibility failures affect a page the visitor did not choose to activate |
24.8.3 QR Codes and Embeds — The Accessibility Position #
A QR symbol is a machine-readable encoding. It is not perceivable to a screen-reader user, and no alternative text makes the symbol accessible. What is accessible is the information it encodes.
| Context | Requirement |
|---|---|
| QR displayed in a bio page block | alt="QR code linking to {destination_label}", where destination_label is the author's link label or the destination's hostname. Additionally, the block always renders a real <a href> to the same destination directly beneath the symbol, so a visitor who cannot scan can still follow it. This link is not optional and cannot be hidden by any theme |
| QR preview in the editor | role="img" with aria-label="QR code preview. Encodes {short_url}. Error correction level {level}. {status}" where status is the scannability validation result (Section 14) |
| QR download controls | Real buttons naming format and resolution: "Download SVG", "Download PNG at 300 DPI", "Download PDF for print" |
| QR styling controls | Every option (module shape, eye style, gradient, logo) has a text label; the live preview is accompanied by a text readout of the current configuration and the current contrast ratio |
| Scannability failure | Announced in the alert region with the specific cause: "This QR code can't be scanned reliably. The logo covers too much of the symbol. Reduce it below 22%." Never a bare "invalid" |
| Contrast in the QR itself | The 4.5:1 minimum foreground/background ratio (Section 14) is a scannability requirement that happens to align with a readability one; the editor states the ratio numerically |
| Printed material guidance | The physical size and scan-distance guidance in Section 14 is presented as a table, not as an image of a table |
Embed accessible names are covered in Section 24.3.5; the position on embed content LinkHub cannot control is in Section 24.12.
24.9 Forms, Including the Email Capture Block #
24.9.1 The Email Capture Block Markup #
<form action="/f/0192f7a1-…" method="post" novalidate>
<fieldset>
<legend>Join the newsletter</legend>
<p id="form-intro">Monthly updates. Unsubscribe any time.</p>
<div class="field">
<label for="ec-email">Email address (required)</label>
<input id="ec-email" name="email" type="email" required aria-required="true"
autocomplete="email" inputmode="email" spellcheck="false"
aria-describedby="ec-email-help ec-email-error">
<p id="ec-email-help" class="help">We'll only use this to send the newsletter.</p>
<p id="ec-email-error" class="error" hidden></p>
</div>
<div class="field">
<label for="ec-name">First name</label>
<input id="ec-name" name="first_name" type="text"
autocomplete="given-name" aria-describedby="ec-name-help">
<p id="ec-name-help" class="help">Optional.</p>
</div>
<div class="field field--checkbox">
<input id="ec-consent" name="consent" type="checkbox" required aria-required="true"
aria-describedby="ec-consent-error">
<label for="ec-consent">
I agree to receive emails from Acme Studio.
<a href="/privacy">Privacy notice</a>
</label>
<p id="ec-consent-error" class="error" hidden></p>
</div>
<button type="submit">Subscribe</button>
<div role="status" aria-live="polite" class="form-status"></div>
</fieldset>
</form>24.9.2 Requirements #
| Requirement | Detail |
|---|---|
| Works without JavaScript | A real <form action method="post"> that submits and returns a server-rendered confirmation page. Client-side enhancement replaces the form in place with the same confirmation text |
novalidate |
Set, so LinkHub's own error presentation is used consistently rather than the browser's inconsistent bubbles. Validation still runs server-side regardless |
| Visible labels | On every field. Placeholders are supplementary at most; the author cannot configure a placeholder-only field |
| Required marking | required + aria-required + visible "(required)" text |
| Autocomplete | email, given-name, family-name, tel, organization as appropriate (criterion 1.3.5) |
| Input types | type="email" with inputmode="email"; type="tel" with inputmode="tel" — correct mobile keyboards are an accessibility feature, not a nicety |
| Consent checkbox | Where the author enables it, it is required, unticked by default, with its own label containing the linked privacy notice. Never pre-ticked (Section 23.13.3) |
| Error presentation | Per-field text linked by aria-describedby, aria-invalid="true", an icon plus text, and a summary in the status region: "Couldn't subscribe. Enter a valid email address." |
| Error suggestion | A common typo in the domain (gmial.com, hotmial.com) offers "Did you mean acme@gmail.com?" as an accept-or-ignore suggestion, never an automatic correction |
| Focus on error | Moves to the first invalid field after the message is rendered |
| Success | The form is replaced by a confirmation heading with tabindex="-1" that receives focus, and the status region announces "Thanks — you're subscribed." Focus movement is correct here because the form no longer exists |
| Double submission | The submit button is disabled for the duration of the request with its label changed to "Subscribing…" and the change announced |
| Bot protection | The single mechanism of Section 23.6.10: honeypot plus submission-timing analysis by default — both invisible, both requiring no JavaScript, and both imposing nothing on any user. A visible challenge escalates only under the named abuse triggers, is never a puzzle, and is never presented on the no-JavaScript path at all. See the conformance note in 24.9.4 (criterion 3.3.8) |
| Target size | Submit button minimum 44×44 on public pages; the checkbox has a 44 px hit area |
| Contrast | Field text, borders and placeholders are gated by pairs 15–17 in Section 24.5.2 |
| Zoom | The form reflows to a single column at 320 px with no horizontal scrolling |
| Autofill contrast | The browser's autofill background is neutralised with a matching style so autofilled text does not become unreadable against a themed field — a common and rarely-noticed failure |
24.9.3 Dashboard Forms #
Every dashboard form follows Section 24.7.3. Two additional rules specific to this product:
- The slug field states its rules before input ("Lowercase letters, numbers and hyphens. 1 to 64 characters."), validates on blur, and on a conflict offers three available alternatives as buttons rather than only reporting the conflict (criterion 3.3.3).
- The destination URL field accepts input without a scheme and suggests
https://as an explicit, acceptable correction rather than silently prepending it — silent normalisation of a destination is both an accessibility problem (the user's input changed without notice) and a safety one (Section 23.4.2).
24.9.4 Conformance Note — The Bot Challenge and SC 3.3.8 #
This note is recorded explicitly because it is the one place where an anti-abuse decision and a conformance claim decide each other, and because the tempting design is the non-conformant one.
The scope decision that forces it. Section 24.1.2 places every public form in scope — sign-up, sign-in, password reset, email capture, abuse report and support — with no carve-out for anti-abuse controls. There is no reading of the conformance claim in 24.1.3 under which a bot challenge sits outside it. Whatever protects those forms is therefore held to Level AA, including SC 3.3.8 Accessible Authentication (Minimum), which is a Level AA criterion and which prohibits a cognitive function test — remembering, transcribing, solving a puzzle, or recognising objects — as a step in an authentication process, unless an alternative or a mechanism to assist is provided.
What follows, stated as rules rather than intentions:
| Rule | Consequence for the implementation |
|---|---|
| No arithmetic question, ever | "What is 3 + 4?" as a fallback challenge is a cognitive function test on a surface this section places in scope. It is a Level AA failure the moment it renders, and under 24.1.3 a known Level AA failure downgrades the published claim from "conforms" to "partially conforms". It is not offered as a fallback, an escalation, a no-JavaScript path, or a degraded mode. It does not exist in the product |
| No puzzle, image-recognition or character-transcription challenge | Same criterion, same outcome. "Select all the traffic lights" and "type the characters you see" are both excluded |
| No first-party proof-of-work presented to the user | A visible "solving…" gate that a user must wait through is a timed obstacle imposed on the person, not the machine, and it lands hardest on old and low-power devices — which correlate with disability and with low income far more than they correlate with abuse |
| The default challenge asks the user for nothing | Honeypot plus submission timing (Section 23.6.10) is invisible to every user, including screen-reader users: the honeypot field carries aria-hidden="true" and tabindex="-1", so it is neither announced nor reachable, and a user cannot fail it by accident |
| The no-JavaScript path is never challenged | It could not be. The escalated challenge requires JavaScript, so presenting it to a client without JavaScript would present a control that cannot be operated — the definition of a failure. Those submissions are accepted and held for review instead |
| A challenge is never the only way through | Where an armed challenge cannot be completed for any reason, the submission falls to the same held-for-review path. The mechanism raises the cost of automation; it never becomes a barrier a person cannot pass |
Why the escalation is acceptable under the criterion. Turnstile in managed mode is a behavioural and cryptographic check, not a test of the user's cognition or memory. In the common case it resolves with no interaction at all; where it presents an interaction, that interaction is a single confirmation control, not a problem to solve. It is operable by keyboard, exposes an accessible name, and is subject to the same focus, contrast and target-size rules as any other control on the page. The held-for-review path is the "mechanism to assist" the criterion contemplates, and it is available unconditionally rather than on request.
Verification. The keyboard-only script (24.10.3) and the screen-reader scenarios (24.10.2) are both run with the challenge armed as well as unarmed, and the automated sweep in 24.10.1 covers both states of every protected form. A build that introduces any cognitive-function-test challenge fails on a dedicated test that searches the rendered output of every public form for a question-and-answer control.
24.10 The Test Matrix #
Accessibility is verified continuously, not audited once. Every check below runs in CI or on a fixed schedule, and the pass criteria in Section 24.10.6 gate the release.
24.10.1 Automated — axe-core in CI #
| Target | Coverage | When |
|---|---|---|
| Every public bio page template | All templates × the light and dark variants of every bundled theme × a 12-block fixture containing every block type × 3 viewports (320, 768, 1280) | Every pull request |
| Every public landing page | Link-inactive, QR fallback rung 3 (workspace_unavailable) and rung 4 (generic), archived-page 404, interstitial, consent banner in both states, and every protected form with the bot challenge both unarmed and armed |
Every pull request |
| Every dashboard route | Enumerated from the router, so a new route is covered automatically and cannot be forgotten | Every pull request |
| Every component in the library | Component-level checks in the component test suite, including every interactive state (default, hover, focus, active, disabled, error, loading) | Every pull request |
| Editor states | Empty page, page with every block type, block selected, inspector open, modal open, colour picker open, drag in progress, full-screen mode | Every pull request |
| Email templates | Rendered to HTML and checked | Every pull request |
Configuration:
- Rule set:
wcag2a,wcag2aa,wcag21a,wcag21aa,wcag22aa, plusbest-practicereported as warnings. - Zero violations at
seriousorcriticalis a hard failure.moderateandminorare reported and tracked, and a PR may not increase their count. - No rule is globally disabled. A specific instance may be suppressed only with an inline annotation carrying a written justification and an owner, and the suppression list is reviewed monthly and reported in the release notes.
- Colour-contrast checks run against real rendered pixels, including background images and gradients.
- The runner uses the real browser engine, not a DOM emulation, so computed styles and stacking are accurate.
Automated tooling catches roughly a third of real issues. It is the floor, not the ceiling, and the matrix below exists because of the other two-thirds.
24.10.2 Manual Screen-Reader Matrix #
| # | Screen reader | Browser | Platform | Scope | Frequency |
|---|---|---|---|---|---|
| 1 | NVDA (latest stable) | Firefox (latest) | Windows 11 | Full: public pages, editor, dashboard, billing, analytics | Every release |
| 2 | VoiceOver | Safari | iOS (latest, phone) | Full public surface; dashboard core flows | Every release |
| 3 | TalkBack | Chrome | Android (latest, phone) | Full public surface; dashboard core flows | Every release |
| 4 | VoiceOver | Safari | macOS (latest) | Editor and dashboard | Every minor release |
| 5 | JAWS (latest) | Chrome | Windows 11 | Dashboard and editor spot check | Quarterly |
| 6 | Narrator | Edge | Windows 11 | Dashboard smoke test | Quarterly |
The first three are the committed matrix named in the conformance claim (Section 24.1.3). Pairs 4–6 are additional coverage and their findings are logged, but a pair-5 or pair-6 issue does not block a release unless it also reproduces on pairs 1–3.
Scripted scenarios, executed in full on pairs 1–3 every release:
- Land on a bio page, discover the page structure by landmarks and headings, hear the block count, traverse every block, activate a link.
- Traverse a bio page containing every block type; confirm every interactive element has a meaningful name and every image has alternative text.
- Activate an embed facade, enter the player, and escape it with Tab.
- Complete an email-capture form, including triggering and recovering from a validation error.
- Encounter the consent banner, understand the choices, reject all, then reopen preferences from the footer and accept analytics.
- Sign up, verify email, create a workspace, create a bio page — end to end, no sighted assistance.
- Add four blocks, reorder them using each of the three non-drag paths, confirm the announced positions match the saved order.
- Change a theme colour, hit the contrast gate, use "fix for me", save.
- Read the analytics dashboard: switch a chart to its table, change the date range, apply a filter, export.
- Complete a plan upgrade through checkout, then walk the downgrade preview and selection flow.
- Hit an entitlement refusal and understand what it says and what to do.
- Scan a QR fallback landing page — rung 3 and rung 4 in turn — and understand what happened and what to do next. Confirm rung 4 is comprehensible without naming any workspace.
- Submit a protected public form with the bot challenge armed, then submit the same form with scripting disabled and confirm the submission is accepted rather than challenged (Section 24.9.4).
Each scenario is scored pass / pass-with-friction / fail. A fail on any pair-1-to-3 scenario blocks the release. Friction is logged with a remediation owner and a due date.
24.10.3 Keyboard-Only Test Script #
Run on every release, on Windows and macOS, with the pointer physically disconnected — not merely unused.
1. Tab from the address bar. First stop must be the skip link. Activate it;
confirm focus lands inside main, not merely that the page scrolled.
2. Tab through the entire public page. Confirm the order matches Section 24.4.2
exactly, the focus indicator is visible on every stop, and no stop is
obscured by any sticky element at any scroll position.
3. Confirm no keyboard trap: reach the last element, then Shift+Tab back to
the first.
4. Enter and exit an activated embed iframe with Tab and Shift+Tab.
5. Complete the email-capture form using only the keyboard, including
recovering from a validation error.
6. Sign in with the keyboard only, including the 2FA code field (paste and type).
7. In the editor: add a block, open its inspector, edit a field, close the
inspector, confirm focus returns to the block.
8. Reorder blocks with Space-grab-move-drop. Confirm the announcement, then
cancel a move with Escape and confirm the block returns.
9. Reorder with the Move up / Move down buttons. Reorder with "Move to position".
Confirm all three produce identical saved orders.
10. Open the colour picker, set a colour by typing a hex value, by the H/S/L
fields, and by arrow keys on the saturation surface.
11. Trigger the contrast gate, apply "fix for me", save.
12. Open every modal type; confirm Escape closes each and focus returns to
the trigger.
13. Navigate a data table: sort by a column, select rows, page forward, open a
row action.
14. Switch a chart to its table and back; explore data points with arrow keys.
15. Walk the billing flow to the hosted checkout page and back.
16. Complete a downgrade selection across multiple steps; navigate backward and
confirm nothing was re-requested (criterion 3.3.7).
17. Confirm every single-character shortcut is inert while focus is in a text
input.
18. Confirm the Help control is the last item in the navigation on every route,
including full-screen editor mode.
19. Arm the bot challenge on a public form and complete the form by keyboard
alone. Confirm the challenge is reachable, named, operable and escapable,
and that no step asks a question the user must answer (Section 24.9.4).Any step that cannot be completed by keyboard alone is a release blocker, with no exceptions and no "we'll fix it next sprint".
24.10.4 Zoom and Reflow #
| Test | Method | Pass criterion |
|---|---|---|
| Reflow at 320 CSS px | Viewport set to 320 × 256 px | No horizontal scrolling of the page. No content or function lost. Data tables may scroll horizontally inside a labelled, keyboard-focusable region — nothing else may |
| 400% zoom | 1280 × 1024 viewport at 400% (equivalent to a 320 px viewport width) | Same criterion |
| 200% text-only zoom | Browser text-size increase without page zoom | No clipping, no overlap, no loss of function; verified against criterion 1.4.4 |
| Text spacing | Inject line-height 1.5, paragraph spacing 2em, letter spacing 0.12em, word spacing 0.16em | No clipping and no overlap; verified against criterion 1.4.12 |
| Vertical reflow | 1280 × 256 px viewport | Content remains reachable; sticky elements do not consume more than 30% of the viewport height |
| Forced colors | Windows High Contrast mode, both dark and light | All content and controls remain visible and distinguishable; the focus indicator is present; no essential information is conveyed by a removed background image |
| Dark mode | prefers-color-scheme: dark |
All contrast pairs still pass |
Routes covered by the automated zoom and reflow suite: every public template, the editor, the analytics dashboard, every settings page, the billing page, checkout entry, and the downgrade selection flow. Each is captured as a screenshot for visual regression, and a layout regression fails the build.
24.10.5 Other Automated Checks #
| Check | Detail |
|---|---|
| Focus-obscuring | The tab-and-measure test from Section 24.2.4, on every route at three viewport heights |
| Target size | The hit-rectangle walk from Section 24.2.4, on every route |
| Reading order | The DOM-order versus visual-order comparison from Section 24.3.4, on every template at three widths |
| Accessible name presence | Every interactive element on every route has a non-empty accessible name |
| Label-in-name | Every control's accessible name contains its visible label text (criterion 2.5.3) |
| Heading order | No skipped levels on any route |
| Landmark structure | Exactly one banner, one main, one contentinfo; duplicates labelled |
| Language attribute | Present and valid on every page |
| Positive tabindex | Lint rule; any occurrence fails the build |
outline: none without a replacement |
Lint rule; fails the build |
| Placeholder-only labelling | Lint rule; fails the build |
| Colour-only status | Design-token test asserting every status treatment includes a non-colour channel |
| Contrast of the token system | Every token pair in the design system verified on every build |
| HTML validity | Every rendered route validated; errors fail the build |
| Reduced motion | A test with prefers-reduced-motion: reduce asserting no animation exceeds 0.02 s |
24.10.6 Release Gates #
A release ships only when all of the following hold. These are gates, not goals.
| # | Gate | Threshold |
|---|---|---|
| 1 | axe-core violations at serious or critical |
Zero, on every target in Section 24.10.1 |
| 2 | axe-core violations at moderate or minor |
Not increased versus the previous release |
| 3 | Keyboard-only script (Section 24.10.3) | All 19 steps pass |
| 4 | Screen-reader scenarios on pairs 1–3 | All 13 scenarios pass; friction logged with an owner and a date |
| 5 | Reflow at 320 px and 400% zoom | Pass on every listed route |
| 6 | Text spacing and forced colors | Pass on every listed route |
| 7 | Focus-obscuring test | No focused element entirely obscured, on any route, at any tested viewport height |
| 8 | Target size | No interactive element under 24×24 without a declared, coded exception; none under 44×44 on public bio pages |
| 9 | Reading order | DOM order equals visual order on every template at every tested width |
| 10 | Contrast gate | The gate is active and rejects a known-failing theme fixture, server-side |
| 11 | Alternative text enforcement | The upload dialog and the API both reject an image with neither alt_text nor decorative |
| 12 | Open Level A or AA defects | Zero open blocker or critical accessibility defects |
| 13 | Conformance claim accuracy | If any Level A or AA criterion is known to fail in scope, the claim is downgraded to "partially conforms" before the release ships, with the failure listed |
| 14 | No cognitive function test | The rendered output of every public form contains no question-and-answer, puzzle, image-recognition or transcription challenge, armed or unarmed; and a scripting-disabled submission is accepted rather than challenged (Sections 24.9.4 and 23.6.10) |
Gates 1, 3, 4, 12, 13 and 14 have no override path. Gates 2 and 5–11 may be waived only by the named accessibility owner, in writing, with a remediation date inside the next release cycle, and the waiver appears in the release notes.
24.11 The Accessibility Statement, Feedback and Remediation #
24.11.1 The Statement Page #
Published at linkhub.app/accessibility, linked from the footer of the marketing site, the dashboard and every public page's "About LinkHub" path. It contains, in this order:
- The conformance claim, verbatim as in Section 24.1.3, including the standard, the level, the scope, the exclusions and the review date.
- What LinkHub has done — a plain-language summary: semantic structure, keyboard operability everywhere including block reordering, the contrast gate, alternative-text enforcement, reduced-motion support, and testing with real screen readers.
- How it was tested — the automated tooling, the screen-reader/browser pairs by name and platform, the manual scripts, and the date of the most recent assessment.
- Known limitations, reproduced from Section 24.12, each with its impact, its workaround, and what LinkHub is doing.
- Customer-controlled content — an honest explanation that pages are authored by customers, what LinkHub enforces (contrast gate, alternative text, link-text quality, structure), and what it cannot control (the words a customer writes, the images they choose).
- Feedback — the channels below and the response commitment.
- Formal complaints — the escalation path and, for EU and UK visitors, a note on national enforcement bodies.
- The technical specifications relied upon — HTML, CSS, JavaScript, WAI-ARIA — and the statement that the content is designed to work without JavaScript for navigation.
- The date the statement was prepared and last reviewed.
Written in plain language at roughly a 12-year-old reading level, without jargon beyond the standard's own names, and it is itself a model of the conformance it claims.
24.11.2 Feedback Channels #
| Channel | Detail |
|---|---|
accessibility@linkhub.app, monitored on business days, published on the statement page |
|
| In-app | A "Report an accessibility problem" item in the dashboard Help menu, pre-filling the current route, viewport, browser and assistive technology where detectable — never requiring the reporter to gather diagnostics |
| Public pages | The "Report this page" footer link includes an accessibility category |
| Phone / text relay | A published number for users who cannot use email, answered on business days |
| Anonymous | Reports without contact details are accepted and triaged; the reporter simply cannot receive a response |
Every report receives: an acknowledgement within 2 business days naming a person; an assessment within 5 business days stating the severity, the plan and the date; and progress updates at least every 10 business days until closure.
24.11.3 Remediation SLA #
| Severity | Definition | Fix or workaround | Full fix |
|---|---|---|---|
| Blocker | A user with a disability cannot complete a core task at all — sign in, create or edit content, publish, pay, or use a public page | 2 business days | 10 business days |
| Critical | A core task is completable but with severe difficulty; or any Level A failure | 5 business days | 20 business days |
| Major | A secondary task is affected; or any Level AA failure | 10 business days | 40 business days |
| Minor | Friction, inconsistency, or a Level AAA gap | Next scheduled release | 90 days |
Rules:
- The clock starts at report receipt, not at triage.
- Where a full fix will take longer than the workaround window, a documented workaround is published on the statement page and communicated to the reporter within the workaround SLA. A defect with no fix and no workaround is escalated to the accessibility owner daily.
- A blocker discovered internally is treated identically to one reported externally.
- Every accessibility defect carries the same priority weighting as a security defect of equivalent severity in planning; they are not traded against feature work.
- Remediation of a Level A or AA failure that cannot meet its SLA triggers the conformance-claim downgrade in Section 24.1.3 until it is resolved.
- Reports and their outcomes are counted and reviewed quarterly; a rising trend in any surface triggers a focused audit of that surface.
24.12 Known Limitations #
Stated honestly, published on the statement page, and reviewed every six months. Concealing a limitation is worse than having one, because it removes the user's ability to plan around it.
24.12.1 Third-Party Embed Content #
The limitation. When a visitor activates a YouTube, Vimeo, Spotify, Apple Music, SoundCloud, Instagram, TikTok or X embed, the resulting player is that provider's code inside an iframe. Its keyboard behaviour, its screen-reader labelling, its contrast, its target sizes and its caption availability are entirely the provider's. LinkHub cannot inspect it, restyle it, or repair it.
What LinkHub does anyway:
| Measure | Effect |
|---|---|
| Facade-first rendering | Nothing third-party loads until the visitor activates it. A visitor who never activates an embed never encounters its accessibility problems, and the page's own conformance is unaffected |
| Accessible activation control | The facade is a real <button> with a descriptive name — "Play video: Studio tour on YouTube" — so the visitor knows what will happen and which provider they are entering |
| A titled iframe | Every activated embed carries a descriptive title, which is the one thing LinkHub can set from outside |
| Minimum sandbox | Only the capabilities each provider needs; allow-top-navigation is never granted, so a hostile or broken embed cannot take over the page |
| Escape guarantee | Tab always exits the iframe. LinkHub tests this per provider on every release and reports any provider that traps focus |
| Caption warning at insert | Where the provider exposes caption availability, the editor warns the author at insert time (Section 24.8.2) |
| A documented fallback | Every embed block offers a plain-link fallback mode, rendering a normal accessible link to the content instead of an embed. Authors are told when to prefer it |
| Provider tracking | Each provider's embed accessibility is re-assessed every six months, and the findings are published on the statement page per provider |
What LinkHub does not do: claim conformance for content it does not control. The conformance claim in Section 24.1.3 excludes it explicitly.
24.12.2 Customer-Authored Content #
LinkHub enforces structure, contrast, alternative text, link-text quality, heading order, target size and reading order. It cannot enforce that an author writes a clear link label, chooses a meaningful image, describes it accurately, or writes at a readable reading level.
Mitigations: the pre-publish report (Section 24.5.5) blocks the mechanical failures; the editor rejects unhelpful alternative text and generic link labels; the contrast gate cannot be bypassed without an audited typed override; and an accessibility guide is linked from the editor. The residual gap is judgement, and it is disclosed on the statement page as the customer's responsibility, alongside the reminder that in most jurisdictions the customer, not LinkHub, carries the legal obligation for their own page's content.
24.12.3 The QR Symbol #
A QR symbol cannot be made perceivable to a screen-reader user, because it is an encoding rather than a graphic with meaning. LinkHub's response — an accessible name, and a mandatory real link to the same destination rendered beneath every QR display block (Section 24.8.3) — makes the information fully accessible. The symbol itself remains what it is, and the statement page says so rather than implying otherwise.
24.12.4 Print Output #
Generated PDF and EPS files for QR printing are print production artefacts, not web content, and are outside the conformance claim's scope. They contain a QR symbol and optional caption text; they are not tagged PDFs. The UI that generates them is fully in scope and fully conformant. A tagged-PDF pipeline is a roadmap item, not a launch commitment.
24.12.5 Analytics Charts on the Smallest Viewports #
At 320 CSS px, a 90-day line chart with several series is dense enough that the visual encoding becomes hard to read for everyone, not only for users with disabilities. The tabular equivalent (Section 24.7.2) is fully available and is the default view below 480 px, with the chart available on request. This is stated as a deliberate design decision rather than presented as a limitation to be fixed.
24.12.6 Assistive Technology Coverage #
The committed matrix is NVDA/Firefox, VoiceOver/Safari on iOS, and TalkBack/Chrome on Android (Section 24.10.2). JAWS, Narrator and Dragon are tested less frequently, and less common combinations are not tested at all. Reports from any assistive technology are triaged and remediated under the same SLA regardless of whether that technology is in the matrix; the matrix describes what is proactively tested, not what is supported.
24.12.7 Cognitive Accessibility #
WCAG 2.2 Level AA addresses cognitive accessibility only partially, and LinkHub does not claim more than the standard. Beyond the criteria, the product deliberately adopts: plain-language interface copy; consistent naming for identical functions; no time limits on any task; undo on every destructive action; a 30-day restore window on every deletion; explicit confirmation with a stated consequence before anything irreversible; and error messages that state what to do rather than only what went wrong. These are commitments, not conformance claims, and they are listed on the statement page as such.
25. Observability, Operations & Runbooks #
25.1 Observability strategy #
Observability exists to answer three questions in this order: is the redirect path serving?, is the public page fast?, is data being lost? Everything else is secondary. The instrumentation budget is spent accordingly — the redirect resolver is measured heavily but traced sparsely, because tracing overhead on that path would cost more than the insight it buys (Section 25.4).
The three pillars are used for distinct purposes and are not interchangeable:
| Pillar | Purpose | Cardinality discipline |
|---|---|---|
| Metrics | Continuous health, SLO computation, alerting | Bounded labels only. workspace_id is never a metric label. |
| Logs | Per-event forensics, audit correlation, error detail | Structured JSON, sampled on the hot path |
| Traces | Cross-service causality for slow or failed requests | Sampled; redirect hot path excluded by default |
Instrumentation per deployable:
| Deployable | Metrics | Logs | Traces |
|---|---|---|---|
| Web application (dashboard, marketing, SSR public pages) | HTTP request rate/latency/status by route group, SSR render duration, cache hit ratio for page payloads, Core Web Vitals received from the field beacon, auth events | Full request log for dashboard routes; 1-in-20 sample for public page renders; always log errors | Sampled at 10% for dashboard routes, 1% for public page renders, 100% for requests that error |
| Redirect resolver | Redirect latency histogram, throughput, resolution outcome counter, cache hit/miss/negative-hit, stream publish failures, fallback-stage counter, per-host request rate | Errors always, and every response whose fallback_stage is anything other than active (rungs 2, 3 and 4) always; successful rung-1 redirects sampled at 1-in-1000. This threshold is stated identically in 25.2.3 and the two must not drift |
Off by default. Enabled only via the runtime flag described in Section 25.4 |
| Public API | Request rate/latency/status by endpoint and API-key scope, rate-limit rejections, idempotency replays, payload size | Full request log (excluding bodies), always | Sampled at 25%, 100% on error |
| Worker fleet | Job counters by queue and outcome, job duration histogram, queue depth, consumer lag, batch size, retry and dead-letter counters, reconciliation delta | One structured line per job completion; full detail on failure | 100% of jobs traced, because volume is low relative to value |
All four deployables emit a startup log line carrying service name, deployment environment, release identifier and resolved configuration checksum (never the configuration values themselves). All four expose a metrics endpoint on a private port and health endpoints as specified in Section 27.10.
25.2 Structured logging #
25.2.1 Format and required fields #
Logs are newline-delimited JSON on stdout, one object per line, emitted through pino. The container platform collects stdout; the application never writes log files and never manages rotation.
Every log line carries this field set. Lines missing a required field fail the logging contract test in Section 26.
| Field | Type | Required | Description |
|---|---|---|---|
ts |
string (RFC 3339, UTC, ms precision) | yes | Event time |
level |
string | yes | trace, debug, info, warn, error, fatal |
service |
string | yes | web, edge, api, worker |
env |
string | yes | local, preview, staging, production |
release |
string | yes | Git SHA of the running build |
msg |
string | yes | Human-readable, lower-case, no interpolated identifiers (identifiers go in fields) |
request_id |
string | yes for request-scoped lines | req_ + UUIDv7; generated at ingress if absent |
trace_id |
string | when tracing active | W3C trace id, enables log↔trace correlation |
span_id |
string | when tracing active | W3C span id |
workspace_id |
string (uuid) | when known | Tenancy scoping for forensics |
actor_type |
string | when authenticated | user, api_key, system, support |
actor_id |
string | when authenticated | User id or API key id — never the key itself |
route |
string | for HTTP lines | Route template, never the interpolated path |
method |
string | for HTTP lines | HTTP method |
status |
integer | for HTTP lines | Response status |
duration_ms |
number | for HTTP and job lines | Wall-clock duration |
queue |
string | for worker lines | Queue name |
job_id |
string | for worker lines | BullMQ job id |
attempt |
integer | for worker lines | 1-based attempt number |
error_code |
string | on error lines | Canonical code from Section 30.2 |
err |
object | on error lines | { type, message, stack }; stack omitted in production for expected errors |
25.2.2 Level semantics #
| Level | Use for | Production default |
|---|---|---|
trace |
Per-iteration detail inside a loop | Disabled |
debug |
Decision branches, cache lookups, resolution rungs | Disabled |
info |
Request completion, job completion, lifecycle events | Enabled |
warn |
Recoverable degradation: cache miss storm, retry, fallback rung reached, entitlement rejection | Enabled |
error |
Request or job failed and the user is affected | Enabled |
fatal |
Process cannot continue; emitted immediately before exit | Enabled |
Production log level is info, set by configuration (Section 27.4). Raising a single service to debug is a supported operational action and must be reverted within 60 minutes; a metric tracks how long any service has been running above info.
25.2.3 Sampling on the redirect path #
The redirect path serves orders of magnitude more requests than any other. Unsampled logging there is the single largest avoidable cost in the system.
The rung vocabulary used here is the canonical four-rung chain defined in Section 14.8, carried on every event and log line as the fallback_stage enum. Rung numbers and enum values are always written side by side so neither can be read alone:
| Rung | fallback_stage |
What served |
|---|---|---|
| 1 | active |
The active destination |
| 2 | paused_fallback |
The paused/expiry fallback URL |
| 3 | workspace_unavailable |
The workspace-branded unavailable page |
| 4 | generic |
The neutral platform landing page |
| Condition | Sampling |
|---|---|
Successful redirect, rung 1 (active), cache hit |
1 in 1000, deterministic on request_id |
Successful redirect, rung 1 (active), cache miss (database read occurred) |
1 in 100 |
Any response whose fallback_stage is not active — that is, rung 2 (paused_fallback), rung 3 (workspace_unavailable) or rung 4 (generic) |
100% |
| Safety interstitial served | 100% |
| Any non-2xx/3xx response | 100% |
| Stream publish failure | 100% |
| Duration above the p99 budget | 100% |
| Requests carrying the debug header with a valid operator token | 100%, at debug level |
The threshold is stated once, explicitly, and repeated identically in the instrumentation table in 25.1: anything above rung 1 is logged in full. There is no "rung ≥ N" shorthand anywhere in this document, because the numbering has one off-by-one trap and the enum value removes it.
Sampling is deterministic (hash of request_id), never random, so that a sampled request logs consistently at every hop.
25.2.4 Correlation #
request_idis generated at the first ingress that sees the request and propagated on theX-Request-Idheader to every downstream call. An inboundX-Request-Idfrom an untrusted client is not trusted: it is recorded asclient_request_idand a fresh server-side id is generated.- The
request_idis returned to the caller in theX-Request-Idresponse header and inside the error envelope'srequest_idfield (Section 21). A support ticket quoting that value resolves to the exact log lines. - Jobs enqueued during a request inherit
request_idin the job payload, so an asynchronous webhook delivery can be traced back to the click that caused it. - Analytics events carry
request_idinto the stream but not intoclick_events; the durable analytics record is deliberately not joinable to request logs.
25.2.5 Retention #
| Log class | Hot (searchable) | Cold (archived) | Total |
|---|---|---|---|
| Application logs, all services | 14 days | 90 days | 104 days |
| Access logs (redirect path, sampled) | 7 days | 30 days | 37 days |
| Security-relevant logs (auth, permission denials, API key use, support impersonation) | 90 days | 365 days | 455 days |
| Audit log (in PostgreSQL, not the log pipeline) | Per plan, Section 22 | — | Per plan |
25.2.6 Redaction #
The following are never written to a log line at any level, in any environment. A redaction middleware strips them from serialized objects by key name before emission, and a unit test asserts the deny-list is applied.
| Item | Reason | Substitute that may be logged |
|---|---|---|
| Raw client IP address | Product invariant: raw IP is never persisted | Country code, is_private boolean |
Raw User-Agent string, at any level, in any environment |
Product invariant: the raw user-agent string is never persisted to a log line or to any durable store. It exists in worker memory only, long enough to parse, and is then discarded | user_agent_family (a short label such as Chrome on Android) and ua_hash, the daily-salted digest defined in Section 6 |
| Password, in any form | — | Nothing |
| Session token, session cookie value | Account takeover | SHA-256 prefix, 8 hex chars |
| API key plaintext | Account takeover | Key id and 6-char display prefix |
| Magic-link, invitation, verification, password-reset tokens | Account takeover | Token id |
| TOTP secrets, recovery codes | 2FA bypass | Nothing |
| Payment-processor secret keys, webhook signing secrets | Financial | Last 4 of the key id |
| Object-storage credentials, database URLs, Redis URLs | Infrastructure | Host name only |
| ACME account key | Certificate forgery | Nothing |
| Analytics daily salt, experiment salt | De-anonymisation of visitor hashes | Salt generation number |
| Lead email addresses and form field values | Personal data of the customer's customer | Lead id, workspace id |
| Full destination URLs on the redirect path | May contain tokens in query strings | Destination host, path length |
| Authorization / Cookie / Set-Cookie headers | Credentials | Header presence boolean |
25.3 Metrics catalogue #
Metrics are exported in Prometheus exposition format on a private port and scraped by the platform's collector; the OpenTelemetry SDK is the instrumentation API so that the collector is replaceable. Histogram buckets are stated where the default is wrong for the budget being measured.
Naming: linkhub_<subsystem>_<name>_<unit>, counters end _total, histograms end in a base unit (_seconds, _bytes).
25.3.1 Redirect path #
| Metric | Type | Labels | Purpose |
|---|---|---|---|
linkhub_redirect_requests_total |
counter | outcome (active,paused_fallback,workspace_unavailable,generic,interstitial,unknown_host,error), resource_type (link,qr), status |
Throughput and outcome mix. The first four values are exactly the fallback_stage enum, so this counter and the rung counter below can never disagree |
linkhub_redirect_duration_seconds |
histogram | resource_type, cache (hit,miss,negative) |
SLO source. Buckets: 0.005, 0.01, 0.02, 0.035, 0.05, 0.08, 0.12, 0.25, 0.5, 1 |
linkhub_redirect_fallback_rung_total |
counter | rung (1–4), fallback_stage (active,paused_fallback,workspace_unavailable,generic), resource_type |
Detects codes silently degrading toward the neutral landing page. Rung and stage are both labels so a dashboard query is unambiguous |
linkhub_redirect_cache_operations_total |
counter | op (hit,miss,negative_hit,write,invalidate), result |
Cache health |
linkhub_redirect_cache_hit_ratio |
gauge | — | Recorded rule over the counter; alert source |
linkhub_redirect_db_fallback_duration_seconds |
histogram | — | Latency of the database read taken on cache miss |
linkhub_redirect_host_requests_total |
counter | host_class (default,custom,unknown) |
Custom-domain traffic share; host itself is not a label (cardinality) |
linkhub_redirect_targeting_evaluations_total |
counter | rule_type, matched |
Targeting rule usage (Section 15) |
linkhub_redirect_safety_interstitials_total |
counter | reason |
Flagged-destination interstitials served |
25.3.2 Analytics ingest and rollups #
| Metric | Type | Labels | Purpose |
|---|---|---|---|
linkhub_ingest_published_total |
counter | event_type (click,scan,view), result (ok,failed) |
Events handed to the stream at the edge |
linkhub_ingest_publish_failures_total |
counter | reason |
The drop counter. Any non-zero rate is a defect |
linkhub_ingest_consumed_total |
counter | event_type, result (written,duplicate,invalid,dead_lettered) |
Consumer throughput and idempotency behaviour |
linkhub_ingest_batch_size |
histogram | — | Batch efficiency. Buckets: 1, 10, 50, 100, 250, 500, 1000 |
linkhub_ingest_lag_seconds |
gauge | consumer_group |
Age of the oldest unacknowledged stream entry. Primary staleness signal |
linkhub_ingest_stream_length |
gauge | — | Stream backlog in entries |
linkhub_ingest_pending_entries |
gauge | consumer_group |
Claimed-but-unacked entries; detects a dead consumer |
linkhub_rollup_upserts_total |
counter | granularity (hourly,daily), result |
Rollup write volume |
linkhub_rollup_reconciliation_delta_total |
counter | granularity, direction (added,removed) |
Rows corrected by the nightly recompute. Sustained non-zero means the incremental path is wrong |
linkhub_rollup_reconciliation_duration_seconds |
histogram | granularity |
Nightly job cost |
linkhub_bot_events_total |
counter | reason (ua,asn,heuristic) |
Bot classification volume |
linkhub_partition_maintenance_total |
counter | action (created,dropped), result |
Partition lifecycle for the event table |
linkhub_retention_purged_rows_total |
counter | table, method (partition_drop,delete) |
Retention enforcement evidence |
25.3.3 Queues and jobs #
| Metric | Type | Labels | Purpose |
|---|---|---|---|
linkhub_queue_depth |
gauge | queue, state (waiting,active,delayed,failed) |
Backlog |
linkhub_queue_oldest_waiting_seconds |
gauge | queue |
Queue lag — a better alert signal than depth alone |
linkhub_job_duration_seconds |
histogram | queue, outcome |
Job cost |
linkhub_job_total |
counter | queue, outcome (completed,failed,retried,dead_lettered) |
Reliability |
linkhub_job_retries_total |
counter | queue, attempt |
Retry storms |
linkhub_worker_concurrency_in_use |
gauge | queue |
Saturation against configured concurrency |
25.3.4 Domains, TLS and certificates #
| Metric | Type | Labels | Purpose |
|---|---|---|---|
linkhub_domain_state_count |
gauge | state |
Population per lifecycle state (Section 13) |
linkhub_domain_verification_attempts_total |
counter | result |
DNS verification success rate |
linkhub_tls_certificate_expiry_seconds |
gauge | domain_class (system,customer) |
Minimum remaining lifetime across the class. Alert source |
linkhub_tls_certificates_expiring_count |
gauge | bucket (lt_30d,lt_14d,lt_7d) |
Renewal pipeline health |
linkhub_acme_orders_total |
counter | challenge (http01,dns01), result |
ACME success rate |
linkhub_acme_rate_limit_remaining |
gauge | — | Distance from the certificate authority's issuance limits |
25.3.5 API, auth and abuse #
| Metric | Type | Labels | Purpose |
|---|---|---|---|
linkhub_http_requests_total |
counter | service, route, method, status |
General traffic |
linkhub_http_request_duration_seconds |
histogram | service, route, method |
General latency |
linkhub_rate_limit_rejections_total |
counter | scope (api_key,ip,account,workspace,form), route_group |
429 volume; distinguishes abuse from a customer needing a higher plan |
linkhub_auth_events_total |
counter | event (login_success,login_failure,lockout,magic_link_sent,oauth_success,totp_failure,recovery_code_used), method |
Credential-stuffing detection |
linkhub_api_key_usage_total |
counter | scope |
Which API scopes are actually used |
linkhub_idempotency_replays_total |
counter | route |
Client retry behaviour |
linkhub_ssrf_rejections_total |
counter | surface (destination,webhook,esp_callback), reason |
Security control efficacy |
linkhub_safe_browsing_lookups_total |
counter | verdict, phase (create,recheck) |
Malicious-destination pipeline |
25.3.6 QR rendering #
| Metric | Type | Labels | Purpose |
|---|---|---|---|
linkhub_qr_renders_total |
counter | format (svg,png300,png600,pdf,eps), result |
Render volume |
linkhub_qr_render_duration_seconds |
histogram | format |
Render cost |
linkhub_qr_decode_validation_total |
counter | condition (clean,downscale,degraded), result (pass,fail) |
Scannability pipeline health (Section 14) |
linkhub_qr_ec_escalations_total |
counter | from_level, to_level |
How often styling forces error-correction upgrades |
linkhub_qr_rejected_total |
counter | reason (unscannable,contrast,logo_size) |
Editor friction |
25.3.7 Webhooks and integrations #
| Metric | Type | Labels | Purpose |
|---|---|---|---|
linkhub_webhook_deliveries_total |
counter | event_type, result (success,retry,dead_letter), status_class |
Delivery success rate |
linkhub_webhook_delivery_duration_seconds |
histogram | result |
Customer endpoint latency |
linkhub_webhook_dead_letter_depth |
gauge | — | Undelivered backlog visible in the UI |
linkhub_integration_sync_total |
counter | provider, result |
ESP and pixel forwarding health |
linkhub_pixel_forward_total |
counter | provider, consent_state, result |
Server-side forwarding, consent-gated |
25.3.8 Billing and business metrics #
| Metric | Type | Labels | Purpose |
|---|---|---|---|
linkhub_billing_webhook_total |
counter | event, result |
Payment processor event handling |
linkhub_subscription_state_count |
gauge | plan, state (active,trialing,past_due,canceled) |
Revenue-at-risk visibility |
linkhub_entitlement_rejections_total |
counter | entitlement, plan |
Which limit customers hit — a product signal, not just an ops one |
linkhub_signups_total |
counter | source |
Growth |
linkhub_workspaces_active |
gauge | plan |
Workspaces with ≥1 public surface published |
linkhub_resources_count |
gauge | resource_type, plan |
Scale planning inputs |
linkhub_public_events_total |
counter | event_type |
Total clicks, scans and views served — the headline business number |
linkhub_leads_captured_total |
counter | destination |
Lead capture volume (Section 20) |
Cardinality rule. No metric carries workspace_id, link_id, qr_id, domain, slug, user_id, ip or a full URL as a label. Per-workspace numbers come from the analytics rollups, not from the metrics system. A CI check parses metric registrations and fails the build if a forbidden label name appears.
25.4 Tracing #
Tracing uses the OpenTelemetry SDK with W3C trace context propagation, exported over OTLP to the platform's collector.
25.4.1 Spans #
| Span | Service | Attributes |
|---|---|---|
http.server |
all | route template, method, status, request_id, workspace id when known |
page.render |
web | template id, block count, cache state |
db.query |
all | statement name (never the interpolated SQL), row count, duration |
cache.get / cache.set |
all | key namespace (never the full key), hit/miss |
resolve.link |
edge, web | resource type, rung number (1–4) and fallback_stage together, targeting rules evaluated |
qr.render |
worker | format, error-correction level, escalation count |
qr.decode_validate |
worker | condition, result |
job.execute |
worker | queue, job id, attempt, outcome |
ingest.batch |
worker | batch size, written, duplicates |
rollup.upsert |
worker | granularity, row count |
acme.order |
worker | challenge type, domain class |
webhook.deliver |
worker | event type, attempt, response status class |
billing.api_call |
web, worker | operation name, result |
external.http |
all | destination host, status, duration |
25.4.2 Sampling policy #
| Path | Sample rate |
|---|---|
| Dashboard HTTP routes | 10% head-based, parent-respecting |
| Public bio page SSR | 1% |
| Public API | 25% |
| Worker jobs | 100% |
| Any request that produces a 5xx or an unexpected 4xx | 100% (tail sampling in the collector retains the full trace) |
| Any request exceeding twice its route's latency budget | 100% (tail sampling) |
| Redirect resolver | 0% by default — see below |
25.4.3 The deliberate redirect exception #
The redirect resolver is not traced in normal operation. Context extraction, span creation, attribute serialisation and export queueing add measurable per-request cost and allocation pressure to a path whose entire server-side budget is 50 ms at p95 and which is the busiest surface in the product. Trading a percentage of that budget for traces of a request that touches at most two systems (Redis, then PostgreSQL on a miss) is a bad trade: the metrics in Section 25.3.1 already decompose that path completely, and the fallback-rung counter answers the only interesting causal question.
The exception is bounded and reversible:
- Tracing on the redirect path is controlled by a runtime flag, defaulting to off.
- An operator may enable it at a 1-in-10,000 sample rate for a maximum of 30 minutes; the flag self-expires.
- A request carrying a valid operator debug header is always traced regardless of the flag, which gives on-call a way to trace one specific reproduction without touching global configuration.
- Trace context is still propagated: if an upstream CDN or load balancer supplies
traceparent, the header is passed through untouched so external systems keep their causality. - While the flag is on,
linkhub_redirect_duration_secondsis compared against its pre-flag baseline; a regression above 5% at p95 auto-disables the flag.
25.5 SLIs, SLOs and error budgets #
Measurement window is a rolling 28 days unless stated. Availability SLIs are computed from server-side metrics, not from an external prober alone; a synthetic prober runs in parallel and disagreement between the two is itself alerted.
| # | SLI (how it is measured) | SLO target | Window | Error budget | Consequence of exhaustion |
|---|---|---|---|---|---|
| 1 | Redirect availability — share of redirect requests returning a 3xx or an intentional fallback response rather than 5xx or timeout | 99.95% | 28 days | 20 min | Headline. Feature work on all services stops; the next sprint is reliability-only until the budget recovers above 50%. Incident review is mandatory. |
| 2 | Redirect latency — share of redirect requests with server processing under 50 ms | 99.0% at p95 ≤ 50 ms, p99 ≤ 120 ms | 28 days | 1% of requests | Headline. No change may ship to the redirect resolver except latency fixes until p95 is back under budget for 7 consecutive days. |
| 3 | QR resolution continuity — share of QR scan requests that return a resolvable destination or a branded fallback page (never 404/410) | 100.00% | 28 days | Zero | Any single occurrence is a Sev-1 incident regardless of volume. This is a product invariant, not a target. |
| 4 | Public bio page availability — share of public page requests returning 2xx | 99.9% | 28 days | 43 min | Feature freeze on the web deployable; reliability work prioritised. |
| 5 | Public bio page latency — share of page responses with server render under 200 ms | 99% | 28 days | 1% | Render-path optimisation is prioritised over new blocks. |
| 6 | Field Core Web Vitals — share of real-user bio page loads meeting LCP < 1.2 s | 90% | 28 days | 10% | The public-path budget gate in Section 26.7 is tightened and the offending template is rolled back. |
| 7 | Analytics completeness — 1 − (publish_failures / published) |
99.99% | 28 days | 0.01% | Ingest path is the top priority; the customer-facing "analytics may be incomplete" banner is enabled for the affected window. |
| 8 | Analytics freshness — share of 5-minute intervals where ingest lag < 120 s | 99% | 28 days | 1% | Consumer scaling review; dashboards display a staleness indicator. |
| 9 | Dashboard API availability — share of authenticated dashboard requests returning non-5xx | 99.9% | 28 days | 43 min | Standard incident review. |
| 10 | Public API availability — share of API requests returning non-5xx | 99.9% | 28 days | 43 min | Standard incident review; affected customers notified if > 15 min. |
| 11 | Custom-domain TLS validity — share of active customer domains serving a certificate with > 0 s remaining | 100.00% | 28 days | Zero | Any expiry reaching production is a Sev-1; the renewal pipeline is audited end to end. |
| 12 | Webhook delivery — share of webhook events delivered within the 6-attempt ladder defined in Section 19.7 | 99% | 28 days | 1% | Delivery pipeline review; dead-letter backlog cleared and root-caused. |
| 13 | Job success — share of jobs completing without exhausting retries | 99.5% | 28 days | 0.5% | Per-queue review; the worst queue gets a dedicated fix. |
| 14 | Export delivery — share of export requests completing within 15 minutes | 99% | 28 days | 1% | Export worker scaling review. |
Error-budget policy is enforced by a monthly review. Budget consumption above 50% at the halfway point of a window triggers a written mitigation plan; exhaustion triggers the consequence in the table without discussion.
25.6 Alerting #
Every alert has a condition, a severity, a route and exactly one runbook. An alert without a runbook is deleted, not tolerated. Paging alerts wake a human; ticketing alerts create work for the next business day.
Severity definitions are in Section 25.9.1.
25.6.1 Paging alerts #
| Alert | Condition | Sev | Routes to | Runbook |
|---|---|---|---|---|
RedirectErrorRateHigh |
5xx rate on redirect path > 1% over 5 min | Sev-1 | Page primary | 25.8.11 if a deploy is in flight, else 25.8.1/25.8.2 |
RedirectLatencyBudgetBurn |
p95 redirect duration > 50 ms for 10 min, or > 120 ms for 3 min | Sev-2 | Page primary | 25.8.1, 25.8.3 |
QRResolutionFailure |
Any redirect request for resource_type=qr returning any 4xx or any 5xx — threshold is 1 occurrence, not a rate |
Sev-1 | Page primary + secondary immediately | 25.8.7 |
RedirectCacheHitRatioCollapse |
Cache hit ratio < 80% for 5 min (normal ≥ 97%) | Sev-2 | Page primary | 25.8.1 |
RedisUnavailable |
Redis connection failures > 10/min, or health probe failing 3 consecutive checks | Sev-1 | Page primary | 25.8.1 |
PostgresPrimaryDown |
Write probe failing for 60 s | Sev-1 | Page primary + secondary | 25.8.2 |
PostgresConnectionSaturation |
Pool utilisation > 90% for 5 min on any service | Sev-2 | Page primary | 25.8.2 |
PostgresReplicationLagHigh |
Replica lag > 30 s for 5 min | Sev-2 | Page primary | 25.8.2 |
IngestConsumerStalled |
linkhub_ingest_lag_seconds > 600, or consumed rate = 0 while stream length > 0 for 5 min |
Sev-2 | Page primary | 25.8.4 |
IngestDropRate |
linkhub_ingest_publish_failures_total rate > 0.1% over 10 min |
Sev-2 | Page primary | 25.8.4 |
QueueBacklogCritical |
linkhub_queue_oldest_waiting_seconds > 900 on any queue |
Sev-2 | Page primary | 25.8.3 |
TLSCertificateExpiryCritical |
Any active domain certificate with < 7 days remaining | Sev-2 | Page primary | 25.8.5 |
SystemDomainCertificateExpiry |
Any LinkHub-owned host certificate with < 7 days remaining | Sev-1 | Page primary | 25.8.5 |
BillingProcessorDown |
Payment API error rate > 25% over 10 min, or 3 consecutive webhook signature validation subsystem failures | Sev-2 | Page primary | 25.8.9 |
TrafficSpikeSaturation |
Redirect RPS > 3× the 7-day rolling p99 and CPU > 80% on the resolver fleet | Sev-2 | Page primary | 25.8.10 |
DeployHealthRegression |
Post-deploy error rate or p95 latency worse than the pre-deploy baseline by > 25% for 5 min | Sev-2 | Page the deployer | 25.8.11 |
SecurityBreachSuspected |
Manual trigger, or anomalous mass data access detected by the access-pattern monitor | Sev-1 | Page primary + security lead + leadership | 25.8.12 |
ObjectStorageUnavailable |
Asset or export storage error rate > 10% over 5 min | Sev-2 | Page primary | 25.8.3 |
25.6.2 Ticketing alerts #
| Alert | Condition | Sev | Routes to | Runbook |
|---|---|---|---|---|
TLSRenewalFailing |
ACME order failed twice for the same domain | Sev-3 | Ticket, domains queue | 25.8.5 |
TLSCertificateExpiryWarning |
Certificate with < 14 days remaining | Sev-3 | Ticket | 25.8.5 |
CustomerDomainMisconfigured |
Domain in dns_failed for > 24 h, or an active domain failing hourly re-check twice |
Sev-4 | Ticket, support queue | 25.8.6 |
DestinationFlaggedMalicious |
Safe-browsing verdict flips to malicious on an active destination | Sev-3 | Ticket, trust queue | 25.8.8 |
RollupReconciliationDelta |
Nightly reconciliation corrects > 0.5% of rows for 2 consecutive nights | Sev-3 | Ticket | 25.8.4 |
WebhookDeadLetterGrowing |
Dead-letter depth > 100, or growth > 20/hour | Sev-3 | Ticket | 25.8.3 |
RateLimitRejectionSpike |
429 rate on the public API > 5% of requests for 30 min | Sev-4 | Ticket | Capacity review, Section 25.11 |
AuthFailureAnomaly |
Login failure rate > 10× the 7-day baseline for 15 min | Sev-3 | Ticket, security queue | 25.8.12 (assess only) |
PartitionMaintenanceFailed |
Daily partition creation job failed | Sev-3 | Ticket | 25.8.3 |
RetentionPurgeFailed |
Nightly purge job failed twice | Sev-3 | Ticket | 25.8.3 |
ExportStuck |
Any export in processing for > 60 min |
Sev-4 | Ticket | 25.8.13 |
LogLevelElevated |
Any service above info for > 60 min |
Sev-4 | Ticket | Revert configuration |
DependencyVulnerability |
Scanner reports a high or critical advisory | Sev-3 | Ticket, security queue | Section 26.9 |
QRUnscannableRateHigh |
qr_rejected_total{reason="unscannable"} > 5% of renders over 24 h |
Sev-4 | Ticket, product queue | Review styling defaults, Section 14 |
SubscriptionPastDueSpike |
Past-due count grows > 3× the 7-day baseline | Sev-4 | Ticket, billing queue | 25.8.9 |
Anti-noise rules. Every alert has a minimum duration before firing; single-scrape spikes never page. Alerts are grouped by service so one incident produces one page. RedisUnavailable and PostgresPrimaryDown inhibit the downstream alerts they obviously cause. An alert that fires more than 3 times in 30 days without producing a corrective action is either re-tuned or deleted at the monthly review.
25.7 Dashboards #
| Dashboard | Primary reader | Contents |
|---|---|---|
| Redirect Health (default on-call screen) | On-call engineer | RPS by outcome; latency p50/p95/p99 against budget lines; cache hit ratio; fallback rung distribution; error rate by status; stream publish failure rate; resolver instance count and CPU; per-host-class traffic split |
| Service Overview | On-call engineer | Per-deployable request rate, error rate, latency, saturation (the four golden signals) for all four deployables on one screen; deploy markers overlaid |
| Analytics Pipeline | On-call engineer, data owner | Publish vs consume rates; ingest lag; stream length; pending entries; batch size distribution; duplicate rate; rollup upsert rate; reconciliation delta trend; partition inventory; retention purge results |
| Queues | On-call engineer | Depth, oldest-waiting age, throughput, failure and dead-letter counts per queue; worker concurrency utilisation; retry heat map |
| Domains & TLS | On-call engineer, support lead | Domain population by state; verification attempt success rate; certificate expiry histogram; ACME order outcomes; ACME rate-limit headroom; list of domains needing attention |
| Database | On-call engineer | Connections by service and pool utilisation; slow query top-N by statement name; transaction rate; replication lag; table and index bloat; partition sizes; disk headroom and days-to-full projection |
| SLO & Error Budget | Engineering lead, on-call | Each SLO from Section 25.5 with current attainment, budget remaining, burn rate over 1 h and 6 h, and a projection to window end |
| Security | Security lead | Auth failure and lockout rates; SSRF rejections by surface; safe-browsing verdict flips; permission-denial rate by role; API key creation and revocation; support impersonation sessions opened, with actor and target |
| Public API | API owner, support | Requests by endpoint, status and scope; rate-limit rejections by plan; idempotency replay rate; top consumers by key id; latency by endpoint |
| Business | Product lead, leadership | Signups, activation (first public surface published), workspaces active by plan; total clicks/scans/views; entitlement rejection counts by entitlement — read as an upgrade-intent signal; subscription state mix; leads captured |
| Cost | Engineering lead | Compute, database, cache, storage, egress and third-party spend, trended weekly, with cost per million redirects as the efficiency metric |
Dashboards are defined as code in the repository and reviewed like any other change; nobody edits them only in a UI.
25.8 Runbooks #
Every runbook is written to be executed by an on-call engineer who did not build the feature. Each step states its verification. Where a step is destructive it says so before the command.
25.8.1 Redis unavailable #
Symptoms: RedisUnavailable, RedirectCacheHitRatioCollapse, elevated redirect latency, ingest publish failures rising.
Impact: Redirects still work — the resolver falls back to a direct database read. Latency rises. Rate limiting, session cache and analytics buffering are degraded.
- Confirm the blast radius. Check the Redirect Health and Analytics Pipeline dashboards. Verify: you can state whether the failure is total (all operations failing) or partial (one Redis role or one command class).
- Confirm redirects are still serving. Request a known-good short link and a known-good QR slug against the resolver. Verify: both return 302 with the correct
Location. If they do not, escalate to Sev-1 and go to step 8. - Check the managed Redis provider's health and the instance's memory, connection count, evicted-keys and blocked-clients metrics. Verify: you have identified one of: provider incident, memory exhaustion, connection exhaustion, network partition.
- If memory exhaustion: confirm the eviction policy is
allkeys-lrufor the cache database andnoevictionfor the stream database.noevictionon the stream is not a tuning preference — under any other policy Redis silently discards unconsumed stream entries under memory pressure, which is undetectable data loss on the analytics path. Every worker asserts this at startup and refuses to boot if it is wrong (Section 26.5.5); if the assertion has been disabled, re-enable it before closing the incident. If the stream database is full, jump to 25.8.4 step 5 to drain the stream. Verify: used-memory falls below 80% of maximum and the stream database reportsmaxmemory-policy noeviction. - If connection exhaustion: check pool sizes per service against the instance's client limit; reduce the worker fleet's replica count temporarily to free connections. Verify: connected-clients drops below 80% of the limit.
- If the provider is in an incident: enable cache-bypass mode on the resolver (runtime flag). In this mode the resolver reads directly from the read replica with a 5-second in-process memory cache. Verify: redirect error rate returns to baseline; expect p95 latency in the 60–120 ms range and accept it.
- Scale the read replica up one size while cache-bypass mode is active. Verify: replica CPU below 70%.
- If redirects are failing outright: fail the resolver over to the standby region or, if none is healthy, serve the static generic fallback page for QR paths so that no scan returns an error. Verify: a QR request returns 200 with the fallback page, never 404. This preserves the invariant in Section 25.5 SLO 3.
- On recovery: disable cache-bypass mode; the cache repopulates lazily on demand. Do not attempt a bulk warm — it will overwhelm the database. Verify: cache hit ratio climbs above 90% within 15 minutes.
- Post-incident: check
linkhub_ingest_publish_failures_totalfor the outage window and record the estimated lost-event count in the incident report. Enable the customer-facing analytics-gap banner for that window if losses exceed 0.01%.
25.8.2 PostgreSQL primary failover #
Symptoms: PostgresPrimaryDown, write errors across services, PostgresConnectionSaturation.
Impact: Reads may continue from replicas. All writes fail: no new links, no dashboard edits, no analytics persistence. Redirects continue from cache.
- Confirm the primary is genuinely down rather than saturated: attempt a trivial write from an operator shell. Verify: you can distinguish "connection refused / not accepting writes" from "slow".
- If saturated rather than down, go to step 9.
- Check whether the managed provider has already initiated automatic failover. Verify: provider console shows failover in progress or complete.
- If automatic failover has not started after 60 seconds, trigger a manual failover to the standby. This is disruptive: in-flight transactions are lost. Verify: the new primary accepts a write.
- Confirm every service has reconnected. Services connect through the provider's endpoint name, so no configuration change should be needed. Verify: per-service database error rate returns to zero; if a service is stuck on a stale DNS answer, restart that service's replicas one at a time.
- Confirm the connection pools have re-established. Verify: pool utilisation is normal on the Database dashboard.
- Confirm the analytics consumer resumed. Because the stream retains unacknowledged entries, events buffered during the outage are consumed on resume. Verify: ingest lag falls back under 120 s within 15 minutes; if not, go to 25.8.4.
- Verify no data loss beyond the failover window: compare the last
created_atin the audit log before the incident with the incident start. Record the replication-lag-at-failover figure in the incident report as the loss bound. - If saturated: identify the top statements on the Database dashboard, then terminate the worst offenders with a statement-timeout-based cancellation rather than a blanket restart. Verify: active connection count and CPU fall. Then reduce worker concurrency temporarily and scale read-heavy traffic onto the replica.
- After recovery: confirm a new standby has been provisioned and is replicating before closing the incident. Verify: replication lag < 5 s on the new standby.
25.8.3 Queue backlog growing #
Symptoms: QueueBacklogCritical, rising linkhub_queue_oldest_waiting_seconds, delayed exports, delayed webhooks, delayed QR renders.
- Identify which queue. The Queues dashboard shows depth and oldest-waiting per queue. Verify: you can name the single worst queue.
- Determine whether the cause is inflow or throughput: compare enqueue rate against completion rate over the last hour. Verify: you can state "inflow spike" or "throughput collapse".
- If throughput collapse, check
linkhub_job_total{outcome="failed"}and the retry counter for that queue. A retry storm consumes concurrency without progress. Verify: you know whether jobs are failing or merely slow. - If jobs are failing on a common error, read one failed job's log line, identify the
error_code, and decide: fix forward, or pause the queue. Pausing is preferable to a retry storm. Verify: after pausing, other queues' throughput recovers. - If jobs are slow because of an external dependency (customer webhook endpoints, ESP APIs, ACME), confirm the per-job timeout is being enforced. Reduce the timeout temporarily if a slow third party is the bottleneck. Verify: p95 job duration falls.
- If inflow spike and jobs are healthy, scale worker replicas for that queue. Queues are scaled independently. Verify: completion rate exceeds enqueue rate; oldest-waiting age begins to fall.
- If the backlog is in a queue whose work is safely droppable (pixel forwarding for events older than 24 h), drop the stale portion rather than processing it late. Destructive. State the dropped count in the incident report. Verify: depth falls and the remaining jobs are within their useful window.
- Never drop from
analytics-ingest,webhook-deliver,qr-render,domain-verifyortls-renew. These have downstream contractual or physical consequences. - After recovery, review the queue's concurrency configuration against its observed steady-state rate and adjust the baseline. Verify: oldest-waiting age stays below 60 s for 24 hours.
25.8.4 Analytics consumer stalled #
Symptoms: IngestConsumerStalled, IngestDropRate, dashboards showing stale numbers, stream length growing.
Impact: Analytics are delayed, not lost, as long as the stream retains the entries. Redirects and pages are unaffected — the ingest path is fire-and-forget by design.
- Confirm the redirect path is unaffected. Verify: redirect error rate and latency at baseline. If they are not, this is a different incident.
- Read the stream state: length, consumer-group lag, pending entry count, and the idle time of each consumer. Verify: you can say whether consumers are absent, idle-but-connected, or crash-looping.
- If consumers are absent or crash-looping, read the worker logs for the ingest queue. Verify: you have the
error_codeand stack of the first failure. - If a single poison event is blocking the batch, identify the entry by id from the log, move it to the dead-letter list, and acknowledge it. Destructive for that one event. Verify: the consumer advances past that id and lag begins to fall.
- If the stream is approaching its configured maximum length and is about to evict unconsumed entries, scale ingest consumers immediately and, if still at risk, temporarily raise the stream's maximum length. Evicting unconsumed entries is real data loss. Verify: stream length stops growing.
- If consumers are idle-but-connected with pending entries claimed by a dead consumer, run the claim-reassignment procedure (
XAUTOCLAIMwith a 5-minute idle threshold) so a live consumer takes ownership. Verify: pending entry count falls to near zero. - Scale ingest worker replicas until consume rate exceeds publish rate by at least 20%. Verify: ingest lag falls monotonically.
- Once lag is under 120 s, verify correctness rather than just throughput: run the reconciliation job for the affected period and inspect
linkhub_rollup_reconciliation_delta_total. Verify: the delta is consistent with the outage window and returns to its normal near-zero rate the following night. - If any events were evicted or dead-lettered, mark the affected time range in the analytics metadata so dashboards display the "incomplete data" indicator for that window. Verify: the indicator appears on a test workspace's dashboard for the affected range.
- Record the drop count. Analytics completeness is SLO 7 and this consumes its budget.
25.8.5 Customer TLS renewal failing #
Symptoms: TLSRenewalFailing, TLSCertificateExpiryWarning, or TLSCertificateExpiryCritical.
Escalation clock: renewal begins at 30 days remaining, warns at 14, pages at 7. If a certificate reaches 7 days, treat it as a service-affecting incident for that customer.
- Identify the domain and read its last ACME order record: challenge type, error returned, attempt count. Verify: you have the certificate authority's exact error string.
- Confirm the domain still points at the platform. Resolve its CNAME (or A/AAAA for an apex) and compare against expected values. Verify: records match Section 13's expected configuration. If they do not, this is a customer misconfiguration — go to 25.8.6.
- If the challenge type is HTTP-01, confirm the challenge path is reachable from the public internet for that host. Verify: an external request to the challenge path returns the expected token. Common causes: the customer placed a proxy in front that intercepts the path, or a redirect-to-HTTPS rule breaks the plaintext challenge.
- If HTTP-01 is being intercepted, switch that domain to DNS-01 only if the customer has delegated the challenge record; otherwise contact the customer with the exact record to add. Verify: order state advances past
pending. - Check ACME rate-limit headroom on the Domains & TLS dashboard. If the account is rate limited, back off and schedule retries beyond the limit window rather than retrying immediately. Verify:
linkhub_acme_rate_limit_remainingrecovers. - If the failure is CAA-related, read the domain's CAA records. Verify: the certificate authority is permitted; if not, send the customer the exact CAA record to add.
- Trigger a manual renewal for the domain. Verify: a new certificate is issued and installed; the expiry gauge for that domain resets to its full lifetime.
- Confirm the edge is serving the new certificate. Verify: an external TLS handshake against the host returns a certificate whose expiry matches the new one.
- If the certificate cannot be renewed before expiry, notify the customer with a clear deadline and the exact fix. Do not allow the domain to serve an expired certificate: at expiry minus 24 h, mark the domain
tls_failedand fail traffic back to the default host so that links keep resolving on the LinkHub domain rather than presenting a browser security error. Verify: the fallback host serves the destination correctly. QR codes on that domain continue to resolve — confirm this explicitly. - Record the root cause against the domain so support has history.
25.8.6 Customer-misconfigured domain #
Symptoms: CustomerDomainMisconfigured, a support ticket, or a domain stuck in pending_dns / dns_failed.
- Open the domain's diagnostic view, which shows expected records against currently resolved records (Section 13). Verify: you can state the exact mismatch.
- Resolve the records independently from at least two public resolvers to rule out propagation lag versus a wrong record. Verify: both resolvers agree.
- If the records are correct but recently changed, check the authoritative TTL and wait accordingly; the verification job retries every 30 s for 15 minutes then every 5 minutes for 72 hours. Verify: time remaining in the verification window is sufficient.
- Common cause — proxying CDN in front of the customer's DNS: the CNAME resolves to the proxy rather than the platform target. Instruct the customer to disable proxying for that hostname. Verify: the CNAME resolves to the expected target.
- Common cause — apex domain on a provider without ALIAS/ANAME support: instruct the customer to use the published A/AAAA records, or to use a subdomain instead. Verify: apex resolves to the published addresses.
- Common cause — the ownership TXT record placed at the wrong name (at the apex instead of the challenge label, or with the domain appended twice by the provider's UI). Verify: the challenge label resolves to the issued token exactly.
- Common cause — the domain is already claimed by another workspace. Verify: check the claim record; if the claim is stale (the other workspace never completed verification and the record has been absent for 7 days), release it and log the action to the audit log with the operator as actor.
- Once records are correct, trigger a manual re-verification rather than waiting for the schedule. Verify: state advances to
verifying, thenprovisioning_tls, thenactive. - If the customer cannot control DNS, tell them plainly that a custom domain is not possible and that their links continue to work on the default host. Do not leave the domain in a retrying state indefinitely — after 72 hours it moves to
dns_failedand stops consuming verification capacity. - Reply to the ticket with the specific record that was wrong and the corrected value. Generic instructions generate a second ticket.
25.8.7 QR code resolving to the wrong destination #
This is the highest-urgency runbook in the product. A QR code is printed on physical material. A wrong destination may be sending scans to a competitor, a defunct page, or a malicious site, and the customer cannot recall the print run. Treat as Sev-1 on report, before confirmation.
Target: correct destination restored within 15 minutes of report.
- Capture the report precisely: the QR slug or short URL, the observed destination, the expected destination, when it was first observed, and the reporter. Verify: you can reproduce the wrong destination yourself with a plain request to the QR URL. Record the exact
Locationheader and therequest_id. - Determine the current resolution outcome. Query the resolver's diagnostic endpoint for that slug, which returns: matched QR record, active version id, destination, rung number and
fallback_stage, targeting rules evaluated, experiment assignment if any, and whether the answer came from cache or database. Verify: you know which rung and which mechanism produced the wrong answer. - Classify the cause from the diagnostic:
- (a) Wrong destination stored — the record itself is wrong (a human edit, an API call, a bulk import).
- (b) Stale cache — the cached payload disagrees with the database.
- (c) Targeting rule — a geo/device/schedule rule matched unexpectedly (Section 15).
- (d) Experiment variant — an A/B variant is serving (Section 16).
- (e) Fallback rung above 1 — the code is paused or its workspace is unavailable, and rung 2 (
paused_fallback), 3 (workspace_unavailable) or 4 (generic) is serving (Section 14.8). Billing state is never a cause here: no billing state moves a QR off rung 1. - (f) Slug collision or wrong host — the request matched a different resource on a different host.
- Stop the bleeding first, diagnose second. If cause (a), (c) or (d): use the emergency override — set the QR's destination to the expected URL with targeting and experiments suspended for that resource. This is a single action in the support console and requires a reason string. Verify: re-request the QR URL from two networks; both return the expected
Location. - If cause (b), purge the cache key for that host and slug and re-request. Verify: the diagnostic reports
cache=missthencache=hitwith the correct payload. Then investigate why write-through invalidation failed — this is a defect, not a one-off. - If cause (e), read the workspace's resource and deletion state. Resolution must never return a 4xx; a fallback page is correct behaviour but may still be the wrong product answer. Restore the active destination if the underlying cause is resolvable (for example a pause that was applied in error, or a workspace restored inside its soft-delete window). Verify: the diagnostic reports rung 1 (
active). - If cause (f), this is a serious defect: confirm the slug reservation table and the host-matching logic. Escalate immediately and freeze deploys.
- Audit-log trace. Query the audit log for this resource, ordered by time descending, filtered to
qr.destination_changed,qr.styling_changed,qr.paused,qr.archived,experiment.promoted,targeting_rule.changed. Each entry carries actor (user or API key), before and after values, IP country, user-agent family and timestamp. Verify: you can name the actor and the exact change that introduced the wrong destination, or state positively that no audit entry explains it — which reclassifies this as a software defect rather than a user action. - Rollback. Every QR destination change writes a version record. Restore the prior version by id through the support console's rollback action, which: writes the previous destination back, creates a new version marking it a rollback, invalidates the cache write-through, and writes an audit entry with the operator as actor and the incident id as the reason. That audit entry is a QR destination change and is therefore written with
retention_expires_at = NULLso no purge can ever remove it (Sections 6 and 8). Verify: the diagnostic shows the restored destination, rung 1 (active), cache hit; and two independent scans from real phones return the correct page. - If the wrong destination was malicious or reputationally harmful, follow 25.8.8 in parallel and preserve evidence before any change: snapshot the record, the audit entries and the resolver logs into the incident record first.
- If the cause was an API-key-driven change, check whether that key made other changes in the same window. Verify: you have a list of every resource that key modified in the previous 24 hours; review each. If the key is suspected compromised, revoke it and notify the workspace Owner.
- Notify the customer with: what happened, the window during which scans went to the wrong place, the estimated affected scan count (from the analytics rollups for that resource and window), and what has changed to prevent recurrence.
- Post-incident, mandatory: add a regression test reproducing the exact cause to the QR fallback and resolution suites in Section 26.5.
25.8.8 Destination flagged as malicious #
Symptoms: DestinationFlaggedMalicious, an abuse report, or a certificate-authority/registrar complaint.
- Confirm the verdict independently: re-run the safe-browsing lookup and inspect the destination from a sandboxed environment. Never open a suspected malicious URL on a workstation. Verify: you have a second-source verdict.
- If confirmed malicious, immediately switch the affected links to the interstitial warning page (Section 23) rather than deleting them. Verify: requesting the short URL returns the interstitial, not a redirect.
- Determine the blast radius: every link, QR and bio page block in the platform pointing at the same host. Verify: you have the full list and the owning workspaces.
- For QR codes: the interstitial still resolves — this satisfies the permanence invariant. Never delete or 404 a QR. Verify: each affected QR returns 200 with the interstitial.
- Assess whether the owning workspace is a victim (a hacked destination site) or the perpetrator (an abuse account). Check account age, signup source, resource creation velocity and payment status. Verify: you have classified the account.
- If a victim: notify the Owner with the evidence and a 48-hour window to change the destination before suspension. Verify: notification sent and recorded.
- If a perpetrator: suspend the workspace under the abuse policy. Suspension disables editing and serves the interstitial on all links. QR codes still resolve to the interstitial. Verify: no destination on that workspace resolves to the malicious host.
- Record the host on the internal deny-list so it cannot be re-added from any workspace. Verify: attempting to create a link to that host returns 422
link_destination_unsafe. - If the platform's default short domain has been reported to a blocklist provider, file a review request with evidence of the remediation. Verify: domain reputation restored.
- Log every action to the audit log with the operator as actor.
25.8.9 Payment processor outage #
Symptoms: BillingProcessorDown, checkout failures, webhook events not arriving.
Principle: billing degradation must never affect public delivery. No link, page or QR changes behaviour because a payment API is down.
- Confirm against the processor's own status page and the platform's own error rate. Verify: you can distinguish a processor incident from a credential or configuration problem on our side.
- Enable billing read-only mode: checkout, plan change and payment-method update surfaces show a clear, honest message and disable submission. Verify: the dashboard shows the notice; no request is sent to the processor from those surfaces.
- Freeze all entitlement downgrades driven by billing state. Dunning transitions to
past_dueand any downgrade automation are suspended for the duration. Verify: the downgrade job is paused; no workspace loses entitlements during the outage. - Confirm public delivery is unaffected: test a redirect, a QR scan and a bio page on a workspace in every subscription state. Verify: all resolve normally.
- Queue rather than drop: inbound processor webhooks that fail signature verification because the processor is degraded are stored raw for later reprocessing, not discarded. Verify: the raw event store is receiving entries.
- When the processor recovers, replay stored webhooks in timestamp order through the idempotent handler. Verify: subscription states converge with the processor's records; run the reconciliation report comparing local subscription state against the processor's.
- Resume dunning and downgrade automation only after reconciliation is clean. Verify: the reconciliation report shows zero mismatches.
- Extend any grace period by the outage duration so no customer is penalised for a failure that was not theirs. Verify: affected subscriptions' grace deadlines have moved.
- Disable billing read-only mode. Verify: a test checkout completes end to end in a non-production account.
25.8.10 Viral traffic spike #
Symptoms: TrafficSpikeSaturation, redirect RPS far above baseline, one workspace dominating traffic.
This is a success case. The goal is to serve it, not to throttle it.
- Identify the shape: total RPS, and whether it concentrates on one workspace, one link, one QR or one bio page. Use the Redirect Health dashboard's outcome and host-class panels. Verify: you can name the concentration.
- Confirm cache behaviour. A single hot slug should be a near-100% cache hit and cost almost nothing. Verify: cache hit ratio is above 97%. If it is not, that is the problem — go to step 6.
- Scale the resolver fleet immediately. It is stateless; scale aggressively rather than incrementally. Verify: CPU per instance below 60% and latency back within budget.
- Scale the ingest consumers proportionally — the spike produces the same multiple of analytics events. Verify: ingest lag stays under 120 s.
- Confirm the database is not being touched on the hot path. If cache misses are elevated, extend the cache TTL for the hot key temporarily and pre-warm it explicitly. Verify: database read rate from the resolver returns to baseline.
- If a hot slug is missing from cache repeatedly, check for a cache stampede: the resolver must use single-flight so that concurrent misses on the same key produce one database read, not thousands. Verify: database reads per second for that key are in single digits.
- If the bio page is the hot surface, confirm the CDN is absorbing it. Public page responses are cacheable at the edge per Section 11. Verify: CDN origin request rate is a small fraction of edge request rate.
- Do not apply per-workspace rate limiting to public delivery. Public surfaces are never rate limited by plan; that would punish the customer's success. Abuse protection remains per-IP and per-form only.
- Check plan fair-use counters. If the workspace has crossed a fair-use threshold on creation volume, that is a sales conversation, not an operational throttle. Verify: no automated restriction has been applied to delivery.
- After the spike, hold the scaled capacity for 24 hours before reducing it, then reset baselines on the Capacity dashboard. Verify: autoscaling minimums reflect the new normal if traffic has stepped up permanently.
25.8.11 Bad deploy requiring rollback #
Symptoms: DeployHealthRegression, error rate or latency regression correlated with a deploy marker.
Decision rule: if you are debating whether to roll back, roll back. Diagnosis is cheaper on a healthy system.
- Confirm correlation with a deploy marker on the Service Overview dashboard. Verify: the regression starts within 5 minutes of a deploy.
- Identify the deployable. Rollback is per-deployable; do not roll back all four for one service's fault. Verify: you know which of the four is affected.
- For the redirect resolver, the deploy is blue/green: shift traffic back to the previous colour. This is near-instant and does not require a rebuild. Verify: redirect error rate and latency return to the pre-deploy baseline within 2 minutes.
- For the other deployables, redeploy the previous release identifier. Verify: the running release identifier matches the previous one on every replica and the regression clears.
- Check whether a database migration shipped with the release. If it did, consult Section 27.6 before rolling back. Under the expand/contract discipline every migration is backward-compatible with the previous release, so a code-only rollback is always safe; a migration rollback is a separate, explicit decision and is only taken when the migration itself is the fault.
- If the migration is at fault and it is additive, prefer to roll forward with a corrective migration. Reverting a destructive migration risks data loss. Destructive: any revert that drops a column or table requires a second engineer's confirmation and a fresh backup snapshot first. Verify: the snapshot exists and its identifier is recorded in the incident.
- Purge caches that may hold payloads shaped by the bad release. Verify: diagnostic endpoint reports fresh payloads.
- Freeze deploys for that deployable until the fault is understood. Verify: the deploy freeze flag is set and visible to the team.
- Add a test that reproduces the failure before the fix ships. A rollback with no new test is an incomplete incident.
25.8.12 Suspected data breach #
Symptoms: SecurityBreachSuspected, AuthFailureAnomaly with successful logins, anomalous bulk data access, external report.
Do not touch anything before step 2. Evidence preservation precedes remediation.
- Declare Sev-1 and open a dedicated incident channel. Notify the security lead and leadership immediately. All communication about the incident happens in that channel and is retained. Verify: the security lead has acknowledged.
- Preserve evidence. Snapshot the relevant logs, database point-in-time state and access records to a separate, access-controlled store before any remediation. Verify: snapshot identifiers are recorded in the incident.
- Scope the access. Determine which data classes may have been reached: user credentials, session tokens, API keys, lead data, analytics, billing metadata. Verify: you have a written scope statement with the evidence for each inclusion and exclusion.
- Contain. In order: revoke the suspected credential (API key, session, service credential); rotate any secret that may have been exposed per Section 27.5; block the source if a network origin is identifiable. Verify: the credential no longer authenticates.
- If session compromise is suspected, invalidate sessions for the affected scope. Global invalidation logs everyone out — this is acceptable and preferable to leaving a compromised session live. Verify: the session store no longer holds the affected entries and the next request re-authenticates.
- If credential compromise is suspected, force password resets for the affected accounts and require re-enrolment of second factors where relevant. Verify: affected accounts cannot authenticate with old credentials.
- Determine whether personal data was affected. Note that raw IPs are never stored and analytics identifiers are salted rotating hashes — this materially reduces the personal-data surface, and the incident record should state that explicitly with evidence. Verify: you can state which personal data fields were in scope.
- Assess notification obligations. Where the platform is processor, the affected customers (controllers) must be notified without undue delay so they can meet their own obligations; the target is within 24 hours of scope confirmation. Regulatory notification follows the 72-hour obligation where applicable. Verify: the legal owner has the scope statement and the clock start time.
- Remediate the root cause. Do not close the incident on containment alone. Verify: a test exists that would have caught it.
- Publish a post-incident report to affected customers with timeline, scope, remediation and prevention. Verify: published and archived.
25.8.13 Stuck data-export request #
Symptoms: ExportStuck, a customer reporting an export that never arrived.
- Locate the export record by id: state, requested-by, workspace, requested range, row estimate, job id, attempt count. Verify: you have the state and the job id.
- If the state is
queued, check the export queue depth — this is a backlog, not a stuck job. Go to 25.8.3. Verify: oldest-waiting age on the export queue. - If the state is
processingbeyond 60 minutes, read the job's log lines. Typical causes: a range spanning more raw partitions than expected, an object-storage failure on upload, or a worker restart mid-job. Verify: you have the failing step. - If the range is genuinely too large, the row cap applies: the job should have failed fast with 422
export_too_largerather than running long. If it did not, that is a defect — record it. Verify: the cap is enforced on retry. - If object storage failed, confirm storage health and retry the job. Exports are idempotent on the export id and overwrite their object. Verify: the object exists and its size is plausible for the row count.
- If the worker restarted mid-job, confirm the job was returned to the queue rather than left claimed. Re-queue manually if it was orphaned. Verify: the job appears as active with a new attempt number.
- Once complete, confirm the signed download link is generated with the correct expiry and that the notification email was sent. Verify: fetch the link's metadata (do not download the customer's data unless the customer has explicitly asked for verification and consented).
- If the export cannot be completed, fail it explicitly with a reason visible in the UI rather than leaving it processing. A stuck-forever state is worse than a clear failure. Verify: the customer sees a failed state with a retry action.
- If the request was a GDPR data-subject export, escalate: these carry a statutory deadline. Notify the privacy owner with the remaining time. Verify: privacy owner acknowledged.
25.9 On-call #
25.9.1 Severity definitions and response targets #
| Severity | Definition | Acknowledge | Mitigate | Communication |
|---|---|---|---|---|
| Sev-1 | Public delivery is broken or wrong for many customers: redirects failing, a QR resolving incorrectly or not at all, data loss, suspected breach | 5 min, 24/7 | 60 min | Status page within 15 min; updates every 30 min |
| Sev-2 | Significant degradation without total failure: latency budget breached, analytics stalled, dashboard down, billing broken, one customer's domain down | 15 min, business hours; 30 min out of hours | 4 h | Status page if customer-visible; updates every 2 h |
| Sev-3 | Contained problem with a workaround, or a single-customer issue | Next business day | 3 business days | Direct to affected customer |
| Sev-4 | Cosmetic, low-impact, or a hygiene task surfaced by an alert | Next business day | Next sprint | None |
25.9.2 Rotation #
- One primary and one secondary, weekly rotation, handover at a fixed time on the same weekday each week.
- Minimum three engineers in the rotation. Below three, on-call is outsourced to a managed provider for out-of-hours coverage rather than burning two people.
- Handover is a written note: open incidents, deploy freezes in force, error-budget status, anything degraded, anything expected during the week.
- The primary carries the pager. The secondary is the escalation and the reviewer for any destructive action.
- Compensation and time-in-lieu policy is a business decision recorded outside this document, but on-call weeks do not carry sprint commitments.
25.9.3 Escalation #
- Primary acknowledges within the severity's target.
- No acknowledgement within the target → automatic escalation to secondary.
- No acknowledgement from secondary within a further 10 minutes → escalation to the engineering lead.
- Any Sev-1 escalates to the engineering lead at 30 minutes unmitigated, and to leadership at 60 minutes.
- Suspected breach escalates to the security lead and leadership immediately, in parallel with technical response.
- Any destructive action (data deletion, migration revert, forced failover) requires a second engineer's explicit confirmation, recorded in the incident channel.
25.9.4 Incident review #
Every Sev-1 and Sev-2 gets a written review within 5 business days: timeline, impact quantified from metrics, contributing causes, what worked, what did not, and dated action items with owners. Reviews are blameless and are read at the monthly operations review. An action item without a date and an owner is not an action item.
25.10 Backup and disaster recovery #
25.10.1 What is backed up #
| Asset | Method | Frequency | Retention | Encrypted |
|---|---|---|---|---|
| PostgreSQL — full | Managed provider snapshot | Daily | 35 days | Yes, at rest |
| PostgreSQL — incremental / WAL | Continuous archiving | Continuous | 7 days of point-in-time recovery | Yes |
| PostgreSQL — logical export of non-partitioned tables | pg_dump of the core schema, excluding event partitions |
Weekly | 90 days, stored in a separate account/region | Yes |
| Object storage — uploaded assets | Cross-region replication + versioning | Continuous | 30-day version history | Yes |
| Object storage — generated QR renders | Not backed up | — | — | Regenerable deterministically from the QR version record; backing them up is waste |
| Object storage — exports | Not backed up | — | — | Regenerable on request; expire in 24 h by design |
| Redis | Not backed up | — | — | Deliberate. Redis holds only cache, rate-limit counters, session cache and the in-flight event stream. Losing it costs a cache warm-up and, at worst, the un-consumed event window, which is bounded by SLO 7. Backing it up would imply it is authoritative, which it is not. |
| Secrets | Managed secret store's own versioned backup | Continuous | 90 days of versions | Yes |
| Infrastructure and dashboard definitions | Git repository | Per commit | Indefinite | — |
| Audit log | Included in the PostgreSQL backups; additionally exported monthly to write-once storage | Monthly | 7 years | Yes |
25.10.2 Targets #
| Scenario | RPO | RTO |
|---|---|---|
| Single service failure | 0 | 5 min (automatic replacement) |
| Database primary failure with standby available | < 30 s (replication lag) | 15 min |
| Database corruption requiring point-in-time recovery | < 5 min | 4 h |
| Complete region loss | < 5 min | 8 h |
| Object storage loss in one region | 0 (replicated) | 1 h |
| Accidental deletion of a workspace's data | 0 (30-day soft delete restores instantly) | 15 min |
| Total account compromise requiring rebuild from backups | 24 h | 24 h |
For a region-loss scenario, the redirect path is restored first and separately: the resolver is stateless and its data set (resolved payloads) can be rebuilt from a database restore, so the practical objective is to get redirects serving within 2 hours even if the dashboard takes longer. QR permanence makes this the correct priority order.
25.10.3 Restore procedure #
- Declare the incident and record the target recovery point. Verify: the recovery point is written down before anything is restored.
- Provision a new database instance from the snapshot or point-in-time target. Do not restore over the existing instance. Verify: the new instance is available and its recovery point matches the target.
- Run schema validation against the expected migration state. Verify: the migration table's head matches the release being restored.
- Run the data integrity checks: row counts on core tables against the last known-good figures; referential integrity spot checks; confirm
qr_slug_reservationscount is greater than or equal to its pre-incident value. A reduction in QR slug reservations is a stop-the-line condition — restore a different point rather than proceed. - Point a non-production copy of the application at the restored database and exercise the smoke suite. Verify: the smoke suite passes.
- Cut over: update the connection endpoint, restart services in the order worker → API → web → resolver, so that the highest-traffic surface changes last. Verify: each tier reports healthy before the next is restarted.
- Flush Redis caches so no payload from the pre-restore state survives. Verify: cache hit ratio starts near zero and climbs.
- Re-run the analytics reconciliation for the period between the recovery point and now. Verify: rollup deltas settle.
- Publish the data-loss window to affected customers with the exact time range. Verify: notification sent.
25.10.4 Restore drills #
Restore drills are mandatory and are not optional exercises that get skipped when busy.
| Drill | Cadence | Success criterion |
|---|---|---|
| Restore the latest snapshot into an isolated environment and run the smoke suite | Monthly | Completes within RTO; smoke suite green; result recorded |
| Point-in-time recovery to a timestamp chosen at random within the PITR window | Quarterly | Data at that timestamp verified against a known marker row |
| Full region-loss simulation, including DNS cutover and resolver-first restore ordering | Twice yearly | Redirects serving within 2 h; full service within RTO |
| Secret rotation drill (Section 27.5) | Quarterly | All services healthy after rotation with no downtime |
| Restore-from-logical-export drill (proves the backup is independent of the provider) | Yearly | Schema and core data reconstructed in a clean account |
A failed drill opens a Sev-3 ticket that must be closed before the next drill.
25.11 Capacity planning and scaling playbook #
Capacity is reviewed monthly against the Cost and Capacity dashboards, and immediately after any traffic step-change.
Planning inputs: redirect RPS (peak and p99 of daily peaks), public page RPS, events per second, workspaces by plan, resources by type, database size growth per week, and the projected days-to-full on database storage.
Headroom policy: every component runs with capacity for 3× current peak without human intervention, and a documented path to 10× within one hour.
| Component | Scaling dimension | Signal to scale | Action | Ceiling and next step |
|---|---|---|---|---|
| Redirect resolver | Horizontal, stateless | CPU > 60% for 5 min, or p95 latency > 35 ms | Add replicas; autoscale on CPU and RPS | At fleet maximum, add a second region and split by anycast |
| Web application | Horizontal | CPU > 65%, or SSR p95 > 150 ms | Add replicas | Increase CDN cache coverage for public pages before adding compute |
| Public API | Horizontal | CPU > 65%, or p95 > 300 ms | Add replicas | Tighten per-key rate limits only as a last resort |
| Worker fleet | Horizontal, per queue | Oldest-waiting > 60 s | Add replicas for that queue only | Split the queue by workload class |
| PostgreSQL | Vertical first, then read replicas | CPU > 70%, or connection utilisation > 80% | Increase instance size; add a read replica for analytics reads | Partition-aware sharding by workspace is the documented next step, not required at launch |
| PostgreSQL storage | Vertical | Days-to-full < 60 | Increase storage; verify retention purge is running | Move raw events to columnar cold storage |
| Redis | Vertical, then split by role | Memory > 70%, or CPU > 60% | Increase size; then split cache / stream / rate-limit into separate instances | Cluster mode with hash-tagged keys |
| Object storage | Managed | — | None | — |
| CDN | Managed | Origin request ratio rising | Increase cache TTLs, add cache keys per Section 27.12 | — |
Growth model for sizing: each 1,000 redirect requests per second produces approximately 1,000 analytics events per second, roughly 200 KB/s of raw event rows before compression, and one additional daily partition of predictable size. Size ingest consumers and database storage from redirect RPS, not from workspace count.
25.12 Support toolset #
Support exists to resolve customer problems without engineering, and without unnecessary access to customer data.
25.12.1 What support can see #
| Surface | Visible to support |
|---|---|
| Account | Email, name, created date, verification state, 2FA enrolled (yes/no), last login time, workspace memberships and roles |
| Workspace | Name, slug, plan, subscription state, seat count, resource counts, created date, deletion/restore state |
| Billing | Plan, price, billing interval, invoice history with amounts and statuses, payment method brand and last four digits, dunning state |
| Resources | Link/QR/page lists with slugs, destinations, states, created/updated timestamps, and version history |
| Diagnostics | Redirect diagnostic for any slug (matched record, rung, cache state, rules evaluated), domain DNS diagnostic, QR render validation results |
| Delivery | Email delivery log per address: template, sent time, delivery status. Not message bodies containing tokens |
| Analytics | Aggregate counts per resource. Not individual event rows |
| Audit log | Full audit log for the workspace, read-only |
| Jobs | Job status for that workspace's exports, renders, verifications and webhook deliveries, including error codes |
25.12.2 What support can do #
| Action | Requires |
|---|---|
| Resend verification, invitation, magic-link or password-reset email | Ticket reference |
| Unlock an account after failed-login lockout | Identity verification per the support policy |
| Re-run domain verification; re-trigger TLS renewal | — |
| Re-queue a failed export, render or webhook delivery | — |
| Purge a cache key for one slug | Reason string |
| Restore a soft-deleted resource within the 30-day window | Owner or Admin request on the ticket |
| Roll back a QR or link destination to a prior version | Owner or Admin request, reason string, audit entry |
| Apply a billing credit or extend a grace period | Support lead approval |
| Suspend a workspace for abuse | Trust lead approval, evidence attached |
| Release a stale domain claim | Second approver |
25.12.3 Impersonation policy #
Impersonation ("view as this user") exists because some problems cannot be diagnosed from records alone. It is tightly bounded.
- Consent is required and is per-session. The customer must grant access from their own account settings, producing a time-boxed grant. The only exception is a suspected-abuse investigation authorised by the trust lead, which is logged identically and additionally flagged for legal review.
- Read-only by default. An impersonation session cannot mutate anything. A write-enabled session is a separate grant, requires the customer to select "allow changes", expires in 60 minutes, and requires a support lead's approval.
- Time-boxed. Grants default to 60 minutes and cap at 24 hours. Sessions end automatically at expiry with no extension — a new grant is required.
- Visible. A persistent banner is shown inside the impersonated session. The customer receives an email when a session starts and when it ends, and sees an entry in their workspace's audit log.
- Fully audited. Every request made during impersonation is logged with
actor_type=support, the support user's id, the target user id, the grant id and the ticket reference. These logs are retained for 455 days (Section 25.2.5) and are reviewed monthly by the security lead. - Never for convenience. Impersonation is not used to perform routine actions on a customer's behalf; those go through the support console actions in Section 25.12.2, which are individually audited.
25.12.4 The boundary support cannot cross #
Support cannot, under any grant, with any approval short of a formal legal process handled by the privacy owner:
- Read or export a workspace's individual lead records or lead form submissions.
- Read individual raw analytics event rows.
- View, decrypt or retrieve any password, session token, API key, TOTP secret or recovery code — these are not retrievable by anyone, by construction.
- Change a user's email address or password directly; they can only trigger the customer-driven flows.
- Alter, delete or suppress an audit log entry. The audit log is append-only and there is no code path that mutates it.
- Modify a subscription's plan or price outside the approved credit/grace actions, or issue a refund without finance approval.
- Delete a workspace, a user account, or any resource. Deletion is customer-initiated only.
- Delete, reassign or recycle a QR slug reservation. No role in the system, including engineering, has this capability — the table has no delete path in the application at all.
- Access production databases, Redis, object storage or logs directly. Support works exclusively through the support console.
Attempts to exceed these boundaries fail closed and raise a security alert.
26. Testing Strategy & Quality Gates #
26.1 Philosophy and the shape of the pyramid #
Tests exist to let the team change this system quickly without breaking the three product promises. Coverage percentage is a hygiene indicator, not a goal. The goal is that the paths where a bug is expensive are exhaustively tested, and the paths where a bug is cheap are tested lightly and quickly.
The pyramid, as applied here:
| Layer | Share of suite | Runtime target | What lives here |
|---|---|---|---|
| Unit | ~65% | < 60 s total | Pure domain logic: entitlement evaluation, slug validation and normalisation, URL safety checks, redirect resolution decision tree, QR styling constraint checks, A/B bucketing, UTM composition, targeting-rule matching, permission predicates, envelope and error mapping |
| Integration | ~25% | < 5 min total | Anything touching PostgreSQL or Redis: repositories, migrations, transactional behaviour, cache write-through and invalidation, stream publish and consume, rate limiters, job handlers, tenancy scoping |
| End-to-end | ~10% | < 12 min total | The journeys a customer actually performs, in a real browser, against a real stack |
Worth testing heavily, without apology for the effort:
- The QR fallback chain. Every rung, every trigger, every billing and deletion state. A miss here is a physical-world failure that cannot be recalled.
- Entitlement evaluation. A miss either gives away the product or blocks a paying customer.
- The permission matrix. A miss is a tenancy or privilege bug.
- Redirect resolution, including targeting, scheduling, expiry and experiment interaction.
- Analytics ingest idempotency and at-least-once semantics.
- Tenancy isolation on every query path.
- URL safety: SSRF, open redirect, scheme allow-listing.
Not worth testing heavily, stated honestly so nobody wastes a week:
- Presentational React components with no logic. A snapshot of a
<Button>proves nothing and breaks on every design change. Visual regression (Section 26.11) covers appearance better. - Framework behaviour — routing, ORM query building, validation library internals. Test our schemas, not the library that runs them.
- Third-party SDK internals. Test our adapter against a contract double, not the vendor's client.
- Exhaustive CRUD permutations on low-risk settings. One happy path plus one validation failure is enough.
- Generated code and type-level guarantees the compiler already enforces.
- Marketing pages, beyond a link-integrity crawl and accessibility checks.
26.2 Unit testing #
Framework: Vitest, in the workspace-root configuration with per-package projects.
Scope: functions with no I/O. If a test needs a database or a network, it belongs in Section 26.3.
Structure and naming:
- Test files sit beside the source:
src/entitlements/evaluate.ts→src/entitlements/evaluate.test.ts. - One top-level
describeper exported symbol. - Test names are assertions in plain English, present tense:
returns plan_limit_reached when link count equals the cap, nottest entitlement 3. - Arrange/act/assert, visually separated. No shared mutable state between tests.
- No conditional logic inside a test body. A branching test is two tests.
Factories and fixtures. Every domain entity has a factory in a shared test package: makeWorkspace(), makeUser(), makeMembership(), makeLink(), makeQrCode(), makeQrVersion(), makeBioPage(), makeBlock(), makeDomain(), makeExperiment(), makeSubscription(), makeApiKey(), makeClickEvent().
// Factories return a complete, valid entity; overrides are shallow-merged.
const workspace = makeWorkspace({ plan: 'free' })
const link = makeLink({ workspace_id: workspace.id, slug: 'spring-sale' })Rules for factories:
- Defaults are always valid — a factory with no arguments produces an entity that would pass every validation rule.
- Identifiers are deterministic per test via a seeded sequence, so failures are reproducible.
- Timestamps come from an injected clock, never from the ambient system clock. Every time-dependent behaviour (scheduling, expiry, salt rotation, session absolute cap, trial end) is tested by advancing the injected clock.
- Factories never write to a database. Persisting factories live in the integration test package and are named
createWorkspace()etc. to make the distinction obvious. - No test may depend on the ordering or contents of another test's data.
26.3 Integration testing #
Real dependencies, in containers. Integration tests run against a real PostgreSQL and a real Redis started via Testcontainers. No in-memory substitutes, no SQLite, no fake Redis — the product depends on partitioning, upserts, advisory locks, streams and consumer groups, and a substitute would test something the product does not use.
Database lifecycle per test:
- A single container starts once per test process and is reused across all test files in that process.
- Migrations run once against a template database at process start. Every test file gets its own database created from that template (
CREATE DATABASE ... TEMPLATE ...), which is far faster than re-running migrations. - Each individual test runs inside a transaction that is rolled back at teardown. Tests that must commit (anything asserting on triggers, partition routing, or cross-connection visibility) opt out and instead truncate the tables they touched, in dependency order, in an
afterEach. - Tests run in parallel across files, serially within a file.
- Redis is isolated per test file by database index, and flushed in
beforeEach.
Seeding: three named seed sets, composable:
| Seed set | Contents | Used by |
|---|---|---|
minimal |
One user, one Free workspace, one membership | Most tests |
standard |
Three workspaces on Free/Pro/Business, four users covering all four roles including a per-resource-scoped Business member, one verified custom domain, links, QR codes, a published bio page | Permission and entitlement suites, E2E |
analytics |
standard plus 90 days of synthetic events across two partitions, with pre-computed rollups |
Analytics, dashboard, export and reconciliation tests |
Seeds are deterministic: same seed set, same identifiers, same counts, every run.
What integration tests must cover: every repository function, every migration's up path against the previous release's data shape, cache write-through and invalidation on every mutation, rate limiter behaviour at the boundary, stream publish/consume/ack/claim, job handlers with their retry and dead-letter behaviour, the worker startup assertions (including the Redis noeviction check in 26.5.5), and tenancy scoping on every query that accepts a workspace id.
26.4 End-to-end testing #
Framework: Playwright.
Browser matrix for E2E:
| Browser | Viewport | Runs on |
|---|---|---|
| Chromium desktop | 1440×900 | Every pull request |
| WebKit desktop | 1440×900 | Every pull request |
| Firefox desktop | 1440×900 | Nightly and pre-release |
| Chromium mobile emulation (Pixel-class) | 393×851 | Every pull request |
| WebKit mobile emulation (iPhone-class) | 390×844 | Every pull request |
Environment: E2E runs against a full stack composed for the test run — all four deployables, PostgreSQL, Redis, an SMTP capture service, an object-storage emulator, a local ACME server for certificate issuance, and a stub payment processor. No test ever contacts a real third party. Custom-domain behaviour is exercised by resolving test hostnames to the local edge via the browser context's host mapping.
Test data management:
- Every E2E spec creates its own workspace and user through the API with a unique identifier. No spec depends on the seed set or on another spec.
- No spec asserts on global counts.
- Cleanup is by workspace deletion at teardown, with the QR reservation carve-out asserted rather than cleaned.
- Specs never use fixed sleeps. They wait on a specific condition — a network response, an element state, a job's completion signalled through a test-only status endpoint.
Flake policy:
- A spec that fails and then passes on retry is recorded as flaky with its spec id and failure signature. CI retries once, and once only.
- Quarantine rule: a spec that flakes 3 times in a rolling 14 days is automatically moved to a quarantined project. Quarantined specs still run and still report, but do not fail the build. Moving a spec to quarantine opens a ticket assigned to the owning team with a 10 working day deadline.
- A quarantined spec not fixed within the deadline is deleted, and the deletion is announced. A permanently quarantined test is worse than no test because it creates false confidence.
- A spec covering anything in Section 26.5 may never be quarantined. If one of those flakes, the build stays red and the flake is treated as a product defect until proven otherwise — a non-deterministic redirect or entitlement check is a real bug.
- Total flake rate above 2% of runs over a week triggers a dedicated stabilisation task before new E2E specs are added.
26.5 Critical test suites #
These suites carry the elevated coverage floor from Section 26.12 and cannot be quarantined, skipped or marked pending. Each case below is written as an assertion to implement.
26.5.1 Entitlement matrix #
For each plan (Free, Pro, Business) and each entitlement in the plan table in Section 22:
- At
limit − 1resources, creating one more succeeds and returns 201. - At
limit, creating one more fails with 403 anderror.code = "plan_limit_reached".error.detailsis an array whose single entry carriesfield(the entitlement key),issue: "limit_reached",limit,current,planandkind, matching the plan table in Section 22.1.2 exactly. Assert the shape as well as the values — a bare object instead of an array is a contract failure. - An unlimited entitlement permits creation past any tested count and never returns
plan_limit_reached. - A fair-use period ceiling returns 403
plan_limit_reachedwithkind: "period", distinguishing it from the stored-count refusal, which carrieskind: "count". Assert bothkindvalues are produced by the right conditions; a period ceiling is not a rate limit and must not return 429. - A binary feature gate that is off for the plan rejects the feature endpoint with 403
plan_feature_unavailable,issue: "feature_unavailable"and nolimit/current, before any validation runs — so an invalid payload on a forbidden feature returns 403, never 400. - Upgrading a workspace immediately lifts the limit within the same request cycle; no cache staleness permits or denies incorrectly.
- Downgrading with resources over the new cap does not delete anything: assert the resource rows still exist, and that the newest-created above the cap carry the archived state.
- During the guided downgrade, the user's explicit keep-selection is honoured and everything else above the cap is archived, never removed.
- Archived short links continue to resolve for 90 days from the downgrade date, then serve the branded landing page — assert both boundaries, and assert neither returns 404.
- QR codes are exempt from every downgrade effect: after downgrading a workspace from Business to Free with 500 QR codes, all 500 still resolve to their active destinations. Assert count and resolution. Assert additionally that the link pinned to each QR code is neither archived nor counted toward the link cap (Sections 12.9, 22.2 and 22.5): create QR codes until the Free link cap would be exceeded by their backing links alone, and assert an ordinary link can still be created.
- Seat limits: inviting past the seat cap returns 403
seat_limit_reached; an existing member above the cap after a downgrade retains access read-only and is never removed. - Entitlement checks are evaluated server-side on every mutating endpoint, including the public API — assert by calling the API directly with a key on a plan that lacks the feature.
- Analytics retention is clamped, never errored: a Free workspace requesting a 90-day range receives the 30 days it is entitled to, with
meta.clamped_fromnaming the requested start and a visible indication in the UI. Assert that no error code is returned for a partially-overlapping range, and that a range entirely outside the window returns an empty result set with the same clamp metadata rather than a 4xx. - Branding removal: a Free workspace's public page contains the LinkHub branding element; a Pro workspace's does not. Assert on rendered HTML, not on a flag.
- Archived resources do not count toward a cap (Section 22.2.5): archive a resource on a workspace sitting exactly at its cap and assert that creating a new one now succeeds with 201, not 403.
- Past-due write blocking follows the Section 22.7 schedule exactly: on days 0–7 of past due every write succeeds; from day 8 a write returns 403
billing_write_blocked; and at every point on that schedule a QR code and a short link still resolve normally. Assert each boundary against an injected clock.
26.5.2 Permission matrix #
For every role in Section 3 (Owner, Admin, Editor, Viewer) crossed with every guarded action, assert both the allow and the deny. The deny assertions matter more.
- Owner — every action succeeds, including billing operations and workspace deletion.
- Admin — member management, invitations, custom domains, brand settings, all content operations and all analytics succeed. Every billing endpoint returns 403. Workspace deletion returns 403.
- Editor — create/update/delete on bio pages, links, QR codes and experiments succeeds; analytics read succeeds. Member, invitation, domain, brand, billing and workspace-settings endpoints return 403.
- Viewer — every read succeeds; every mutating endpoint in the product returns 403. This is asserted by enumerating the route table and calling each mutating route as a Viewer, so a newly added route without a permission check fails the suite by default.
- Exactly one Owner exists per workspace: attempting to assign a second Owner directly fails; ownership transfer requires confirmation from both parties and, on completion, demotes the previous Owner to Admin.
- An Owner cannot remove themselves while sole Owner: 403.
- An Admin cannot modify or remove the Owner, and cannot modify another Admin: 403
insufficient_role. - Role changes take effect on the next request without requiring re-login; a Viewer promoted to Editor can immediately mutate, and an Editor demoted to Viewer immediately cannot.
- Per-resource grants (Business only): a scoped Editor can edit granted resources and receives 404
not_found— never 403 — on non-granted ones; a scoped Viewer sees only granted resources in list endpoints and likewise receives 404 for non-granted resource ids. Both directions matter: a 403 here confirms the resource exists to a member who was deliberately not shown it, which is the same enumeration oracle the cross-workspace rule exists to close (Section 3.5 step 6). - Requesting per-resource grants on Free or Pro returns 403
plan_feature_unavailablewithrequired_plan: "business". - Grants intersect with, never widen, the role: a scoped Viewer with a grant on a link still cannot edit it.
- API keys are subject to the same matrix through their scopes: a
links:readkey receives 403insufficient_scopeon any write, and a key cannot exceed the permissions of the membership it was created under. Assert additionally that no API key can reach billing, member management, audit or GDPR operations at all — those actions return 403action_not_available_to_api_keyregardless of scope, and no billing endpoint is exposed on the public API surface (Sections 21 and 22). Assert there is no scope namedaudit:readin the catalogue. - Removing a member immediately invalidates their access: an in-flight session for that workspace receives 404
workspace_not_found— not 403 — on the next request, because a former member and a stranger must be indistinguishable. - Every route in the application's route table appears in this suite. A CI check enumerates routes and fails if any mutating route is untested here.
26.5.3 Redirect resolution algorithm #
Cases run against the resolver with both a cold and a warm cache, and assert status, Location, Cache-Control and the analytics event emitted.
- Known slug on the default host resolves to the active destination with 302 and
Cache-Control: private, no-store. - Known slug on a verified custom domain resolves identically.
- The same slug on two different hosts resolves independently — host is part of the identity.
- Unknown slug on a known host returns 404 with the branded not-found page for short links — provided the slug carries no permanent reservation. If a reservation exists, the request is a QR path and never returns 4xx; see 26.5.4.
- Unknown host: the request is routed through the slug-reservation check before any host decision. A reserved slug on an unknown, removed or not-yet-active host resolves to rung 3 (
workspace_unavailable) or rung 4 (generic) with 200 — never 404. Only an unreserved slug on an unknown host produces the platform's host-not-configured 404, and an unknown host is never silently treated as the default host. 5a. HTTP to HTTPS. A plain-HTTP request to any platform-owned or verified customer host returns 301 to the identical URL over HTTPS, with HSTS on the HTTPS response. Assert theLocationdiffers from the request only in scheme. This is a transport upgrade to the identical URL, not a destination redirect; the never-301 rule below governs destinations, whose targets are editable. - Slug matching is case-insensitive on lookup and normalised to lower case;
Spring-Saleandspring-saleresolve to the same record. - Trailing-slash and trailing-punctuation tolerance:
/spring-sale/and/spring-sale.resolve; the resolution is logged as normalised. - A scheduled link before its start time returns 200 with the branded "not available yet" page (Section 12); after its end time returns the expiry behaviour in case 9; inside the window resolves normally with 302. Assert all three against an injected clock, and assert that none of the three returns a 4xx.
- An expired link returns 200 with the branded expired page, and — if an expiry redirect URL is configured — 302 to that URL instead. Never 410. The rationale is asserted rather than assumed: any of these URLs may be QR-backed, and a QR-backed URL must never return a 4xx, so the same 200 is used uniformly rather than relying on the resolver to know which surface printed it. 9a. A password-protected link or page returns 200 with the password form defined in Section 12.7, on the initial GET and on a failed submission alike; a correct submission sets the cookie described there and redirects to the destination. Never 401. The cookie name, HMAC inputs and attempt limits are Section 12.7's and are asserted against it, not restated here.
- Targeting rules evaluate in priority order; the first match wins; a rule that matches nothing falls through to the default destination.
- Geo targeting uses country and region only; a request with no resolvable geography falls through to the default rather than failing.
- Device targeting classifies from the user-agent family; an unknown user agent falls through to the default.
- A link inside a running experiment serves a variant deterministically for a given visitor hash; the same visitor hash returns the same variant on repeated requests within the salt window.
- UTM parameters configured on the link are appended to the destination, existing parameters on the destination are preserved, and a parameter present in both is resolved with the link's value winning. Fragment handling: an existing fragment on the destination is preserved and remains last in the URL.
- Variant identity is never appended to the outbound URL. Assert the
Locationheader contains no experiment or variant parameter. - A destination flagged malicious serves the interstitial with 200, not a redirect.
- A destination whose scheme is not in the allow-list cannot exist — assert at creation, and assert the resolver rejects a record that somehow holds one, serving the branded error rather than redirecting.
- Cache write-through: updating a destination causes the very next request to return the new destination. Assert with no sleep — invalidation is synchronous with the write.
- Negative cache: two requests for an unknown slug produce exactly one database lookup within the negative TTL.
- Single-flight: 100 concurrent requests for one uncached slug produce exactly one database read.
- Resolution completes within the latency budget under the load conditions in Section 26.7 — asserted there, referenced here.
- Analytics publication is fire-and-forget: with the stream deliberately failing, the redirect still returns 302 with the correct
Location, and the publish-failure counter increments by exactly one. - A soft-deleted link returns the branded not-found page rather than resolving; restoring it within the 30-day window makes it resolve again. If the slug carries a permanent reservation the QR rules in 26.5.4 apply instead and no 4xx is produced.
- An archived (over-cap) link resolves for 90 days then serves the branded landing page with 200 — never 404, never 410.
- No destination redirect is ever 301 or 308. Enumerate every case above that produces a redirect and assert the status is exactly 302 and the header set is
Cache-Control: private, no-store. The scheme upgrade in case 5a is the single 301 in the product and is asserted separately, by its own test, against a request that differs from itsLocationonly in scheme.
26.5.4 QR fallback chain — no path returns 404 #
This is the highest-value suite in the product. Its thesis is a single assertion repeated under every hostile condition: a request to a QR slug never returns any 4xx and never returns any 5xx, and always returns something a human can act on. 404 and 410 are the two the suite was originally written against, but the assertion is deliberately wider than those two, because the failure that matters is "the person holding the printed material got an error page", and the status number they got is beside the point.
The chain has exactly four rungs and no fifth. The vocabulary below is Section 14.8's and is used verbatim by the suite, the resolver, the analytics event and the metrics, so a test name, a log line and a dashboard panel all say the same word:
| Rung | fallback_stage |
HTTP | What is served |
|---|---|---|---|
| 1 | active |
302 | The active destination |
| 2 | paused_fallback |
302 | The paused / expiry fallback URL configured on the code |
| 3 | workspace_unavailable |
200 | The workspace-branded unavailable page, including the pre-erasure memorial |
| 4 | generic |
200 | The neutral platform landing page, including the post-erasure memorial. Carries no workspace display name |
No rung is ever 404, 410 or 5xx.
Structure the suite as a matrix. For every state below, assert (i) the HTTP status is 302 or 200 — never any 4xx, never any 5xx; (ii) the resolved rung number and fallback_stage match the expected pair; (iii) the response body or destination is the expected one; (iv) an analytics event is still recorded, carrying the same fallback_stage; (v) the response carries Cache-Control: private, no-store on rungs 1–2 and public, max-age=60 on rungs 3–4.
| # | Workspace / resource state | Expected rung | Expected result |
|---|---|---|---|
| 1 | Active, paid, destination set | 1 active |
302 to the active destination |
| 2 | QR paused, fallback URL set | 2 paused_fallback |
302 to the paused fallback URL |
| 3 | QR paused, no fallback URL, workspace unavailable page configured | 3 workspace_unavailable |
200, workspace-branded unavailable page |
| 4 | QR paused, nothing configured | 4 generic |
200, neutral platform landing page |
| 5 | Subscription past_due, any day of the dunning schedule |
1 active |
302 to the active destination — billing state never stops resolution |
| 6 | Subscription canceled, plan reverted to Free |
1 active |
302 to the active destination |
| 7 | Downgrade Business → Free with QR count far above the Free cap | 1 active |
Every QR still resolves; assert all of them, not a sample |
| 8 | Payment failed and dunning exhausted | 1 active |
Still resolves; editing is disabled, resolution is not |
| 9 | Free-plan branding applied after downgrade | 1 active |
Resolves; the rung 3 and 4 pages carry LinkHub branding |
| 10 | Workspace soft-deleted (within 30-day window) | 3 workspace_unavailable |
200, workspace-branded unavailable page — this is the pre-erasure memorial and it may show the workspace display name |
| 11 | Workspace hard-purged after the window | 4 generic |
200, neutral platform page. Assert the response body contains no workspace display name, no handle and no logo |
| 12 | User account deleted, sole owner | 4 generic |
200, neutral platform page, no personal data |
| 13 | GDPR erasure executed on the workspace | 4 generic |
200, neutral platform page; assert by scanning the rendered HTML for the erased workspace's name, handle and owner email and finding none. The slug still resolves |
| 14 | QR record soft-deleted by the customer | 3 or 4 | Rung 3 while the workspace still exists, rung 4 once it does not; never a 4xx |
| 15 | QR destination flagged malicious | — | 200, safety interstitial, private, no-store. The chain is not entered; the interstitial is the response |
| 16 | Custom domain removed from the workspace | 1 active |
The QR's canonical URL on the default host still resolves. If the QR was printed on the custom domain, that host is routed through the reservation check and falls to rung 3 or 4 rather than returning 404, for as long as the domain remains claimed |
| 17 | Custom domain TLS failed | 1 active |
Falls back to the default host per Section 25.8.5 step 9; resolution continues |
| 18 | Redis unavailable | 1 active |
Resolves from the database; assert with the cache client forced to fail |
| 19 | Database unavailable, cache warm | 1 active |
Resolves from cache |
| 20 | Database unavailable, cache cold | 4 generic |
200, neutral platform page — degraded, but never an error page |
| 21 | Experiment running on the QR, then deleted mid-flight | 1 active |
Falls back to the control destination |
| 22 | Slug reservation exists but the QR record was purged | 4 generic |
200, neutral platform page |
Additional structural assertions:
qr_slug_reservationshas nodeleted_atcolumn. Assert against the live schema, not the model file.- There is no code path in the application that issues a
DELETEagainstqr_slug_reservations. Assert with a static check over the repository layer. - Creating a QR whose slug matches an existing reservation returns 409
qr_slug_reservedregardless of whether the original QR, workspace or account still exists. - The reservation is one namespace. Creating a short link whose slug matches an existing QR reservation on that host returns the same 409
qr_slug_reserved, and creating a QR whose slug matches an existing short-link slug on that host is refused in the same way. Assert both directions; a QR reservation that protected only QR slugs would be reissued as a short link and break the printed code. - Hard-purging a workspace preserves its QR reservations: assert the reservation count before and after purge is unchanged.
- There is no rung 5, and no code path produces a
fallback_stageoutside the four-value enum. Assert by enumerating the enum in the live schema and by a static check that the resolver's chain has exactly four terminal branches. - A property-based test generates random combinations of the states above and asserts the invariant holds for every combination, not just the enumerated rows.
26.5.5 Analytics ingest idempotency and at-least-once delivery #
- An event published once is written exactly once.
- The same event published twice with the same event id is written once; the duplicate counter increments.
- A batch acknowledged after a partial write, then redelivered, produces no duplicate rows — the idempotency key is the event id and the write is an upsert with conflict-do-nothing.
- A consumer that crashes after writing but before acknowledging causes redelivery; assert no duplicate rows after redelivery.
- A consumer that crashes before writing causes redelivery; assert the event is written after recovery. No event is lost.
- Entries claimed by a dead consumer are reclaimed after the idle threshold and processed.
- Events arriving out of order across a partition boundary are routed to the correct daily partition by their event timestamp, not by arrival time.
- An event with a malformed payload is dead-lettered and does not block the batch; the remaining events in the batch are written.
- Publish failure at the edge increments the drop counter and does not affect the response — asserted in 26.5.3 case 22, and here for the counter's accuracy.
- Rollups computed incrementally from a batch equal rollups recomputed from raw for the same period: seed 10,000 events, run incremental rollups, run the reconciliation, assert the delta is exactly zero.
- Deliberately corrupt one rollup row, run reconciliation, assert it is corrected and the reconciliation delta counter increments by exactly the number of corrected rows.
- Bot-classified events are written with the bot flag set and are excluded from default dashboard queries but present when the toggle is on.
- Raw IP addresses appear nowhere: after ingesting events, assert no column in any table contains a value matching an IPv4 or IPv6 literal. Run this as a schema-wide scan, not a targeted column check.
- Raw user-agent strings appear nowhere either. Ingest events whose user-agent is a long, distinctive sentinel string, then run the same schema-wide scan for that sentinel and assert zero matches in every table, including the event tables, the audit log and any job payload retained after completion. Assert that what is stored is
user_agent_family(a short label, length ≤ 100) andua_hash(16 bytes, daily-salted with the same salt and rotation as the visitor hash). Assert the parser-maintenance corpus holds no visitor key, no workspace key and no timestamp finer than a day. - The visitor hash for the same IP/user-agent/workspace differs across a daily salt rotation boundary, and is identical within one 24-hour salt window. Assert the same property for
ua_hash, which rotates on the same salt. - The stream database refuses to run under an eviction policy. At startup every worker reads the stream database's
maxmemory-policyand asserts it isnoeviction; under any other policy the process logs a fatal line naming the observed policy and exits non-zero rather than starting. Assert both branches against a container configured each way. This is a correctness assertion, not a tuning preference: any eviction policy allows Redis to discard unconsumed stream entries under memory pressure, which is silent, unrecoverable analytics loss with no counter to detect it. The cache database, by contrast, is expected to beallkeys-lruand is asserted to be so at startup with a warning rather than a refusal, because evicting cache entries is correct behaviour. - Retention: after the plan's raw window elapses, the partition is dropped and the rollups within the rollup window remain queryable.
26.5.6 A/B assignment stickiness #
- The same visitor hash and experiment produce the same variant on repeated evaluation within one visitor-hash window.
- Assignment distribution across 100,000 synthetic visitor hashes is within ±1.5% of configured weights for a 50/50 split and within ±2% for an uneven split.
- Two different experiments produce independent assignments for the same visitor — assert no correlation above chance.
- Stickiness on the no-consent path is bounded at 24 hours, and the suite asserts that bound rather than a longer one. The assignment function mixes the weekly experiment salt with the visitor hash, and the visitor hash rotates daily (Section 17.3), so the composite input changes every 24 hours no matter how long the experiment epoch is. Assert that (a) assignment is stable across repeated evaluations for the full 24-hour visitor-hash window, (b) at the daily rotation boundary the visitor is re-bucketed deterministically under the new hash and the assignment may change, (c) a test that asserts stability beyond 24 hours on this path fails, and is expected to fail — the suite contains that negative case explicitly so nobody re-introduces a 7-day claim, and (d) the minimum-sample guard therefore requires both the visitor-count threshold and the elapsed-duration threshold, because the duration threshold is what makes a multi-window experiment interpretable.
- Consented path: with the assignment cookie present, the assignment is stable across both the daily visitor-hash rotation and the weekly experiment-salt epoch. Assert stability across a simulated rotation of each, with the cookie retained. This is the only path on which stickiness exceeds 24 hours.
- Consent granted mid-experiment pins the visitor's current assignment into the cookie rather than re-rolling it.
- Cross-surface stickiness: a page view and a subsequent click from the same visitor hash join to the same assignment record server-side.
- No outbound URL contains variant identity — assert on every
Locationheader and every rendered anchor href in an experiment. - Promote-winner is disabled until both guard conditions pass; the endpoint returns 409
experiment_guard_not_metbefore then, withfailing_conditions[]naming which threshold blocked it. - Force-promote requires the exact typed confirmation string; a wrong string returns 422
experiment_force_promote_confirmation_invalid, and a successful force-promote writes an audit entry recording that the guard was bypassed. - After promotion, all traffic serves the winner and new assignments cease being recorded.
- Deleting an experiment mid-flight reverts traffic to the control destination without error (also asserted in 26.5.4 case 21).
26.5.7 Tenancy isolation #
- Every table carrying a workspace id is enumerated by a test that reads the live schema; for each, a query executed in workspace A's context returns zero rows belonging to workspace B.
- Fetching any resource by id from another workspace returns 404
not_found, not 403 — existence is not disclosed. Assert the same for an API key presented against a workspace it does not belong to: the workspace check runs before any capability evaluation, so the response is 404 and never a scope or role error, which would confirm the workspace exists. - List endpoints are scoped: creating 10 resources in B and 1 in A returns exactly 1 for A.
- Analytics queries are scoped: rollups and raw drill-downs for A never include B's events, asserted with identical resource slugs in both workspaces.
- Export bundles contain only the requesting workspace's data; assert by generating an export in a database seeded with two workspaces and scanning the output for the other's identifiers.
- Cache keys are namespaced by host and slug, and payloads carry the workspace id; a cache-poisoning attempt with a crafted host header cannot cause A's slug to serve B's destination.
- Redis rate-limit counters are namespaced per scope; exhausting A's API limit does not affect B.
- Webhook deliveries carry only the owning workspace's events.
- A user belonging to A and B with different roles gets the correct role in each; asserted by performing an action allowed in one and denied in the other in the same session.
- Background jobs carry the workspace id and are asserted to scope their queries; a job enqueued for A never reads B's rows.
- A fuzz test issues requests with random valid UUIDs in path parameters and asserts every response is 404 and no response body contains data from another workspace.
26.6 Accessibility testing #
Accessibility requirements, the conformance target and the manual screen-reader matrix are defined in Section 24; they are not restated here. This section specifies only how they are enforced.
Automated gate:
- axe-core runs in the E2E job against every public template, every block type in the catalog rendered in isolation and in combination, the not-found, expired, interstitial, unavailable and memorial pages, and every top-level dashboard route.
- The gate fails the pull request on any violation of impact
seriousorcritical.moderateandminorviolations are reported as annotations and tracked, not blocking. - There is an allow-list of known-accepted findings. Each entry requires an expiry date and a justification; an expired entry fails the build. The allow-list may not contain any rule relating to contrast, keyboard operability, focus visibility or accessible names.
- Keyboard-only traversal is asserted in E2E for the editor: every action reachable, focus order equal to visual order, no trap, block reordering achievable without a pointer.
- The theme editor's contrast enforcement is asserted: a failing theme cannot be saved, the suggested fix passes, and the typed-confirmation override records an audit entry.
- Reflow is asserted at 320 CSS px width with no horizontal scrolling on every public template.
- Target size is asserted on interactive controls in the public templates.
prefers-reduced-motionis asserted to suppress animation on public surfaces.
Manual matrix: executed per the schedule in Section 24 before every release candidate; results attached to the release record. A release cannot be qualified with an unresolved serious or critical manual finding.
26.7 Performance testing #
Load testing uses k6 against a production-shaped staging environment with a representative data set (at minimum 1 million links, 100,000 QR codes, 50,000 bio pages, 200 million events across partitions).
| Scenario | Profile | Target | Fails the build if |
|---|---|---|---|
| Redirect steady state | 5,000 rps sustained, 10 min, 90% cache hit | p50 < 20 ms, p95 < 50 ms, p99 < 120 ms, error rate < 0.01% | Any threshold missed |
| Redirect cold cache | 5,000 rps, cache flushed at start | p95 < 120 ms during warm-up, recovering under 50 ms within 60 s | Not recovered in 120 s |
| Redirect single hot slug | 5,000 rps against one slug | p95 < 30 ms, database reads for that key < 10/s | Database read rate exceeds 50/s (stampede) |
| Bio page render | 500 rps, mixed templates | p95 server render < 200 ms, error rate < 0.05% | Any threshold missed |
| Public API | 600 rps across endpoints | p95 < 300 ms, correct 429 behaviour at the limit | p95 exceeded or limits not enforced |
| Analytics ingest | 10,000 events/s for 5 min | Zero publish failures; ingest lag < 60 s at the end | Any publish failure, or lag over 120 s |
| Dashboard analytics query | 50 concurrent users, 90-day ranges | p95 < 1.5 s | Exceeded |
| Export | 20 concurrent exports of 1 million rows | All complete < 15 min | Any exceeds |
Soak test: 1,000 rps mixed traffic for 8 hours, run nightly against staging. Pass criteria: no memory growth trend above 5% after the first 30 minutes on any service; no file-descriptor or connection leak; no unbounded queue growth; p95 latency at hour 8 within 10% of hour 1; zero unexpected restarts.
Spike test (viral post model): baseline 200 rps, then a step to 6,000 rps within 30 seconds, held for 10 minutes, then decay over 20 minutes. Pass criteria: error rate stays below 0.1% throughout; p95 recovers under 50 ms within 90 seconds of the step; autoscaling adds capacity within 120 seconds; ingest lag peaks under 300 s and returns under 60 s within 10 minutes of decay; no manual intervention required.
Lighthouse CI budget gate. Runs on every pull request that touches the web deployable, against a production build, on the reference device profile from Section 11 (mid-range Android, 4G throttling, 4× CPU throttle), three runs with the median taken. The gate asserts the Section 11 budgets: FCP, LCP, CLS, INP, gzipped HTML size, critical inline CSS size and zero blocking JavaScript. Any breach fails the pull request. A bundle-size gate additionally asserts per-entrypoint JavaScript and CSS byte budgets and fails on a regression above 2% without an explicit, reviewed budget update in the same commit.
26.8 QR decode conformance #
The scannability validation pipeline is defined in Section 14. This suite proves the pipeline is correct and that the shipped styling options are all decodable.
Corpus. The full cross-product of:
| Axis | Values |
|---|---|
| Content length | Short slug URL (~25 chars), medium (~60), long custom-domain URL (~120) |
| Error correction | M, Q, H |
| Module shape | Square, dot, rounded, classy |
| Eye shape | Square, rounded, leaf, circle |
| Colour scheme | Black on white; brand solid on white; light on dark inverted; linear gradient; radial gradient |
| Logo overlay | None; 10% width; 18% width; 22% width (the maximum) |
| Quiet zone | 4 modules (minimum enforced) |
| Output | SVG, PNG 300 DPI, PNG 600 DPI, PDF, EPS |
Combinations that the product refuses to generate (for example gradient at error correction M, which is auto-upgraded to H) are asserted to be refused or upgraded, not merely absent from the corpus.
Decode conditions. Every corpus member is rasterised and decoded under the three conditions specified in Section 14: clean at 100% scale; downscaled to 50% with bilinear resampling then upscaled; and 30% contrast reduction with 3% Gaussian noise and 2° rotation. Two additional conditions are exercised in this suite beyond the runtime pipeline, because CI can afford them: 5° rotation with 2% perspective skew, and JPEG re-compression at quality 60 (simulating a screenshot shared through a messaging app).
Pass criteria:
- Every corpus member decodes to exactly the expected URL under all three runtime conditions. 100%, no tolerance.
- Under the two extended CI-only conditions, at least 95% of the corpus decodes; failures must be confined to the lowest-margin combinations and are recorded as the known styling risk surface.
- Contrast below 4.5:1 is rejected at render time with
qr_contrast_too_low— assert with a deliberately low-contrast pair. - A logo above 22% is rejected with
qr_logo_too_large. - Applying a logo, gradient or custom module shape at error correction M results in an automatic upgrade to H; assert the stored error-correction level on the version record.
- A combination that fails validation triggers at most two automatic escalations and then rejects with
qr_unscannable, and the rejection identifies the specific styling choice responsible. - Quiet zone is present and at least 4 modules in every output format — asserted by measuring the rendered output, not by reading configuration.
- The same input produces byte-identical SVG output across runs (deterministic rendering), so a regenerated code always matches the printed one.
- Two independent decoder implementations are used; agreement between them is required for a pass, which prevents the suite from encoding one decoder's leniency.
26.9 Security testing #
| Control | Tool class | Runs | Gate |
|---|---|---|---|
| Static analysis | Language-aware SAST plus lint rules banning dangerous patterns (raw SQL interpolation, dangerouslySetInnerHTML without a sanitiser, unbounded regex) |
Every pull request | Fails on high or critical |
| Dependency scanning | Advisory database scan of the lockfile | Every pull request and nightly | Fails on high or critical with a fix available; a 7-day grace with an approved exception when no fix exists |
| Secret scanning | Repository and diff scan | Every pull request and pre-commit hook | Fails on any match; a detected secret is treated as compromised and rotated per Section 27.5 |
| Container scanning | Image vulnerability scan | Every image build | Fails on high or critical in the base image or installed packages |
| Infrastructure config scan | Policy-as-code checks on deployment manifests | Every pull request | Fails on public storage buckets, permissive security groups, missing encryption |
SSRF suite. Against every surface that fetches or resolves a user-supplied URL — link destinations, QR destinations, bio page block URLs, webhook endpoints, ESP callback URLs, embed URL validation, and image import:
- Reject loopback (
127.0.0.0/8,::1) and all forms of localhost. - Reject private ranges (
10/8,172.16/12,192.168/16,fc00::/7) and link-local (169.254/16,fe80::/10), including the cloud metadata address. - Reject after DNS resolution, not only on the literal string — a hostname resolving to a private address is rejected.
- Reject on redirect: a public URL that 302s to a private address is rejected at fetch time, and each hop is re-checked.
- Reject DNS-rebinding attempts by pinning the resolved address used for the connection to the address that was validated.
- Reject non-allow-listed schemes (
file,gopher,ftp,data,javascript) withlink_destination_scheme_not_allowed. - Reject encoded and obfuscated forms: decimal, octal and hexadecimal IP notation, IPv4-mapped IPv6, userinfo-prefixed hosts (
https://evil.com@127.0.0.1), and unicode homoglyph hostnames. - Reject any outbound webhook endpoint that is not
httpson port 443. Plain HTTP is refused withwebhook_url_scheme_invalidand any other port withwebhook_url_port_invalid— there is no exception for port 80, for a private network, or for a customer who asks. Assert both refusals at save time and again immediately before each delivery attempt, because DNS and configuration can change between the two.
Open-redirect suite. Against every endpoint accepting a return or continuation URL — sign-in return, OAuth callback, invitation acceptance, post-checkout return, and the interstitial's continue action:
- An absolute URL to an external origin is rejected or forced to the default landing route.
- A protocol-relative URL (
//evil.com) is rejected. - Backslash, encoded-slash and mixed-encoding variants are rejected.
- A path-only relative URL is accepted and normalised.
- The interstitial's continue action requires a signed token bound to the specific destination and a minimum dwell time, and cannot be used as a general-purpose redirector.
Authorization fuzzing. An automated pass enumerates every route from the route table and, for each, issues requests as: unauthenticated, a user with no membership, each of the four roles, a scoped Business member without a grant, and API keys holding each scope. Every response is compared against the expected matrix; any deviation fails. New routes are picked up automatically, which is the point — the suite fails on a route added without an authorization decision.
Penetration testing cadence: an external test before the public launch, annually thereafter, and additionally after any change to authentication, the permission model, the public API surface, or the custom-domain/TLS path. Findings are triaged within 5 business days; critical and high findings block the next release.
26.10 Contract testing #
The OpenAPI document is generated from the Zod schemas that the public API actually validates with, so the document cannot describe an endpoint the code does not implement.
- Generation check: CI regenerates the document and fails if the committed file differs. The document is committed so that consumers can diff it in review.
- Request contract: for every operation, valid examples pass validation and each documented error case produces the documented status and error code.
- Response contract: every response is validated against its schema at test time. An undocumented field in a response fails the suite — additive drift is drift.
- Envelope conformance: every success response matches the canonical success envelope and every error response matches the canonical error envelope from Section 21, including the presence and format of
request_id. - Pagination conformance: every collection endpoint accepts the documented limit and cursor parameters, enforces the maximum limit, returns the documented meta fields, and returns a stable ordering such that paging through a mutating collection never silently skips a record.
- Breaking-change detection: the generated document is diffed against the previously released version. Removing an endpoint or field, narrowing a type, adding a required request field, or changing a status code fails the build unless the change is accompanied by an API version increment.
- Error-code registry drift: every code emitted anywhere in the codebase must exist in the registry that Section 30.2 is generated from, and every registry entry must be emitted by at least one code path or covered by a test. Both directions are checked, and both are hard failures. Two consequences follow and are enforced: the registry is generated from a single constant in the shared core package, never hand-maintained; and the "deliberately absent names" list in 30.2.16 is explicitly excluded from both directions of the check, because it exists precisely to name codes that must not be emitted — a third check asserts that no identifier on that list appears anywhere in the source.
- Consumer smoke: a generated client is exercised against the running API in CI for the primary operations, proving the document is usable, not merely valid.
26.11 Visual regression #
- Every public bio page template is rendered with a fixed content fixture and captured at 393 px, 768 px and 1440 px widths, in light and dark theme, and with the two extreme brand colour configurations.
- Every block type is captured in isolation with short, long and empty content, and in its error and loading states where they exist.
- The not-found, expired, interstitial, unavailable and memorial pages are captured. The memorial page is captured with and without personal data stripped.
- QR code renders are captured for a representative styling subset; pixel comparison here is a deterministic-rendering check complementing Section 26.8.
- Captures use a fixed seed, frozen clock, disabled animation, and preloaded fonts so that only real changes produce diffs.
- Threshold: 0.1% pixel difference. Any diff above threshold blocks the pull request and must be explicitly accepted, with the accepted baseline committed in the same change.
- Rendering is done in a single containerised browser build so that baselines are portable across machines.
- Dashboard screens are not in visual regression. They change often, and the cost of maintaining baselines exceeds the value.
26.12 Coverage requirements #
| Scope | Line coverage floor | Branch coverage floor |
|---|---|---|
| Overall (all packages, unit + integration combined) | 80% | 70% |
| Entitlement evaluation | 95% | 90% |
| Redirect resolution | 95% | 90% |
| QR fallback chain and slug reservation | 95% | 95% |
| Analytics ingest and rollup | 95% | 90% |
| Permission and tenancy scoping | 95% | 90% |
| URL safety and SSRF guards | 95% | 95% |
| Presentational UI packages | 50% | — |
Gate behaviour on a miss:
- The coverage job fails the pull request and posts the delta with the specific uncovered lines.
- A drop in overall coverage of more than 0.5% relative to the base branch fails even if the absolute floor is met — this prevents slow erosion.
- The elevated-floor paths have no ratchet tolerance: below the floor is a hard failure, full stop.
- Coverage exclusions are declared in one configuration file, each with a comment justifying it. Generated code, migrations and type-only files are excluded. Excluding a file in the elevated-floor set requires a reviewer's explicit approval on the pull request.
- Coverage is measured on the merged unit and integration runs; E2E coverage is not counted, because instrumented E2E coverage rewards breadth over assertion quality.
26.13 CI pipeline #
Every stage runs on every pull request unless marked otherwise. Stages within a phase run in parallel.
| # | Stage | Gate | Expected duration |
|---|---|---|---|
| 1 | Checkout, dependency install with a warm cache | Install must succeed with a frozen lockfile | 45 s |
| 2 | Lint (code, formatting, import rules) | Any error fails | 60 s |
| 3 | Type check across all packages | Any error fails | 90 s |
| 4 | Secret scan on the diff | Any match fails | 15 s |
| 5 | Unit tests with coverage | Failure or coverage floor miss fails | 60 s |
| 6 | Build all four deployables and shared packages | Build failure, or bundle-size budget breach, fails | 3 min |
| 7 | Integration tests (containers for PostgreSQL and Redis) | Any failure fails | 5 min |
| 8 | Migration check: apply all migrations to an empty database, then to a database seeded at the previous release, then assert the schema matches the model definitions | Any drift fails | 90 s |
| 9 | Contract tests and OpenAPI drift check | Difference or breaking change without version increment fails | 60 s |
| 9a | Reference integrity. Every Section N.M citation — in this specification, in README.md, in DECISIONS.md and in code comments — is resolved against the actual set of headings. A citation naming a section that does not exist, or a subsection number that no heading carries, fails the build. The check also reports citations that resolve to a heading whose title is unrelated to the citing sentence's subject, as warnings for review rather than failures, because that judgement is not mechanical |
Any unresolvable citation fails | 20 s |
| 10 | Dependency and container vulnerability scan | High or critical with an available fix fails | 90 s |
| 11 | Static application security testing | High or critical fails | 2 min |
| 12 | E2E, sharded across 4 runners, PR browser matrix | Any non-quarantined failure fails | 12 min |
| 13 | Accessibility (axe-core) across public templates and dashboard routes | Serious or critical fails | 3 min |
| 14 | Visual regression | Diff above threshold fails | 4 min |
| 15 | Lighthouse CI on the reference device profile | Any Section 11 budget breach fails | 4 min |
| 16 | QR decode conformance | Any runtime-condition failure fails | 5 min |
| 17 | Deploy to a per-pull-request preview environment | Deploy failure fails | 3 min |
| 18 | Smoke suite against the preview environment | Any failure fails | 90 s |
Wall-clock target for the full pull-request pipeline with parallelism: under 20 minutes. A pipeline consistently exceeding 25 minutes is treated as a defect and gets a dedicated task.
Nightly, against staging: full E2E across the extended browser matrix; the soak test; the load suite; the spike test; the extended QR conformance conditions; a restore drill smoke check; and a full dependency audit including moderate advisories.
On merge to the main branch: the pipeline above, plus image publication, staging deployment, post-deploy smoke, and a synthetic journey check.
26.14 Release qualification #
A release candidate is qualified by the automated gates plus a short manual script. Both must be recorded against the release identifier before production promotion.
Automated preconditions — all must be green on the candidate commit:
- Full pull-request pipeline green, including every gate in Section 26.13.
- Nightly suite green on the candidate: soak, load, spike, extended E2E, extended QR conformance.
- Coverage floors met, including all elevated paths.
- No open high or critical security findings.
- No open
seriousorcriticalaccessibility findings. - Migration plan reviewed and confirmed backward-compatible for one release (Section 27.6).
- Error-budget status reviewed: a headline SLO in breach blocks any release that is not itself a fix for that breach.
- Rollback plan written into the release record, naming the previous release identifier and whether a migration is involved.
Manual test script — executed on staging by a human, approximately 30 minutes:
- Sign up with a new email; confirm the verification email arrives and the link works; confirm publishing is blocked before verification and permitted after.
- Sign in with a magic link; sign in with Google; enrol TOTP; sign out; sign in with TOTP; use a recovery code.
- Create a bio page, add one block of each of at least six types including an embed and an email-capture form, reorder blocks using only the keyboard, publish it.
- Load the published page on a real mid-range Android phone over a real mobile connection. Confirm it feels immediate. Disable JavaScript in the browser and confirm every link still navigates.
- Submit the email-capture form; confirm the lead appears in the dashboard and reaches the configured destination.
- Create a short link with UTM parameters, a schedule and an expiry; confirm each behaves at its boundary using the clock override on staging.
- Create a dynamic QR code with a logo and a gradient; download the SVG and the 300 DPI PNG; print the PNG at 2 cm and scan it with two different phones; confirm it resolves.
- Change the QR's destination; re-scan the same printed code; confirm it resolves to the new destination without reprinting. This is the product's core promise and is verified physically every release.
- Add a custom domain against the staging DNS fixture; walk the full lifecycle to active with TLS; confirm a link resolves on it.
- Run an A/B experiment on a bio page; confirm sticky assignment across reloads; confirm promote is blocked before the guard passes.
- Open analytics; confirm today's events appear within the freshness target; apply filters; export a CSV and open it.
- Upgrade to Pro through the stub processor; confirm branding disappears and gated features unlock immediately. Downgrade; confirm the guided flow, confirm nothing is deleted, and confirm every QR still resolves.
- Create an API key; call three endpoints; exceed the rate limit and confirm a correct 429; revoke the key and confirm 401.
- Trigger a webhook; confirm the signature validates against the documented scheme; force a failure and confirm retry then dead-letter with UI visibility.
- Exercise support impersonation on a consenting test account; confirm the banner, the emails and the audit entries.
- Confirm the status page, the on-call rotation and the alert routing are correct for the release window.
The release is signed off in the release record by the engineer who ran the script, naming any deviation observed. A deviation without a written decision blocks promotion.
27. Deployment, Environments & Configuration #
27.1 Environments and the promotion path #
| Environment | Purpose | Data | Third parties | Who deploys |
|---|---|---|---|---|
local |
Development on an engineer's machine | Seeded, disposable | All stubbed | Anyone |
preview |
One ephemeral stack per pull request | Seeded, disposable, destroyed on merge or after 72 h idle | All stubbed except the payment processor's test mode | CI, automatically |
staging |
Release qualification, load and soak tests, manual script | Production-shaped synthetic data at production scale; never production personal data | Test/sandbox modes of real providers; a local ACME server for TLS | CI on merge to the main branch |
production |
Customers | Real | Live | CI, on an explicit promotion action |
Promotion path: feature branch → pull request → preview → merge to main → automatic deploy to staging → nightly qualification → manual promotion of a qualified release identifier to production.
Rules: the same immutable container images are promoted from staging to production — images are never rebuilt for production. A release identifier is the git SHA. Production deploys require a green Section 26.14 qualification. Production never runs a build that has not run on staging. Deploys are blocked automatically while a headline SLO is in breach unless the deploy is tagged as the fix.
27.2 Infrastructure requirements #
These are capability requirements, not vendor selections. Any provider meeting the capability satisfies the requirement. Vendors named below are examples to make the capability concrete and must not be treated as dependencies; the system contains no vendor-specific API calls outside the payment processor and the certificate authority protocol, both of which are behind adapters.
| Capability | Requirement | Sizing at launch | Example providers |
|---|---|---|---|
| Container compute — web | Runs OCI images, horizontal autoscaling, health checks, rolling deploys | 2 instances × 1 vCPU / 2 GB, autoscale to 10 | Any managed container platform |
| Container compute — redirect resolver | As above, plus blue/green traffic shifting and fast scale-out | 3 instances × 1 vCPU / 1 GB, autoscale to 30 | As above |
| Container compute — public API | As above | 2 instances × 1 vCPU / 1 GB, autoscale to 8 | As above |
| Container compute — worker | As above, plus per-queue replica groups and graceful shutdown with drain | 2 instances × 1 vCPU / 2 GB per queue group, autoscale to 12 | As above |
| Managed PostgreSQL | Declarative partitioning, logical replication, point-in-time recovery, automated failover with a standby, read replicas, encryption at rest, private networking | 4 vCPU / 16 GB, 200 GB storage with autogrow, 1 standby, 1 read replica | Any managed PostgreSQL meeting the major line in Section 4 |
| Managed Redis | Streams with consumer groups, configurable eviction per database, TLS, private networking, automated failover | 4 GB, 1 replica | Any managed Redis meeting the major line in Section 4 |
| S3-compatible object storage | Presigned URLs, object versioning, lifecycle rules, cross-region replication, server-side encryption | 2 buckets (assets, exports) | Any S3-compatible service |
| CDN | Custom origins, custom domains with SNI, cache-key control including host and query, stale-while-revalidate, cache purge API, Brotli, HTTP/2 and HTTP/3 | Public bio pages and static assets | Any CDN meeting the capability |
| Email delivery | SMTP or HTTP API, DKIM/SPF/DMARC alignment, delivery webhooks, suppression list, sandbox mode | ~50k messages/month at launch | Any transactional email provider |
| DNS-capable edge for custom domains | Terminate TLS for arbitrary customer hostnames, SNI-based certificate selection, ACME HTTP-01 challenge serving, anycast addresses for apex A/AAAA, dynamic certificate installation without restart | All customer domains | Any edge/proxy layer with dynamic certificate loading |
| Secret store | Versioned secrets, per-service access policies, audited reads, rotation API | All secrets in Section 27.4 marked secret | Any managed secret manager |
| Observability backend | Prometheus-compatible metric ingestion, log ingestion with structured search, OTLP trace ingestion, alert routing with paging | All four deployables | Any provider meeting the ingestion protocols |
| Geo database | Country and subdivision resolution from IP, offline/embedded, updatable | Embedded in the resolver image or mounted | Any IP-to-country/region database |
| URL reputation API | Malicious-URL lookup with a batch mode | Destination checks | Any reputation service behind the adapter |
Portability rule: every capability above is accessed through a thin adapter in the shared core package. Swapping a provider must require changing one adapter and configuration only. A pull request introducing a vendor SDK call outside an adapter is rejected in review.
27.3 Containerisation #
- One image per deployable, four in total, built from the same monorepo with a shared base.
- Multi-stage builds: stage 1 installs dependencies with a frozen lockfile against a cache mount; stage 2 builds the workspace and prunes to the target deployable's production dependency closure; stage 3 copies only the built output and production node_modules into a minimal runtime base.
- Base image policy: a slim official runtime base pinned by digest, rebuilt weekly by an automated job so that base security patches land without a code change. No
latesttags anywhere. The rebuild job runs the full test suite before publishing. - Runtime hardening: non-root user with a fixed UID, read-only root filesystem, a writable
tmpfsfor scratch only, no shell in the final stage where the runtime permits it, dropped Linux capabilities, and no build tools in the final image. - Metadata: every image carries labels for the git SHA, build timestamp, source repository and deployable name. The running service exposes the git SHA on its health endpoint so a deployed version is verifiable without inspecting the platform.
- Image size targets: redirect resolver ≤ 150 MB, public API ≤ 150 MB, worker ≤ 250 MB (image processing and rendering dependencies), web ≤ 300 MB (framework build output). A build exceeding its target by more than 15% fails CI; the resolver's target is the strictest because scale-out speed during a spike is a function of pull time.
- Reproducibility: builds are deterministic given a lockfile and a base digest. Images are signed at publication and signatures verified at deploy.
27.4 Complete configuration reference #
Every environment variable in the system is listed here. This is the only place they are documented; other sections refer here. Variables marked secret are never in the repository, never in a plain file in production, and are injected from the secret store. The repository contains .env.example carrying every non-secret variable with its default and every secret variable with an empty placeholder.
Conventions: booleans are true/false; durations are explicit in the name's unit suffix; lists are comma-separated with no spaces; URLs include the scheme and no trailing slash. Every variable is validated at process start by a schema; a missing required variable or a value failing validation aborts startup with a message naming the variable. There is no silent fallback for a required secret.
27.4.1 Runtime and identity #
| Variable | Services | Type | Req | Default | Example | Description |
|---|---|---|---|---|---|---|
NODE_ENV |
all | enum | yes | production |
production |
Runtime mode; development in local only |
DEPLOY_ENV |
all | enum | yes | — | staging |
local, preview, staging, production; drives logging, sampling and safety rails |
SERVICE_NAME |
all | enum | yes | — | edge |
web, edge, api, worker; used as the service log field and metric label |
RELEASE_SHA |
all | string | yes | — | a1b2c3d |
Git SHA of the build; surfaced on the health endpoint |
PORT |
all | int | yes | 3000 |
3000 |
HTTP listen port |
METRICS_PORT |
all | int | no | 9090 |
9090 |
Private metrics endpoint port |
LOG_LEVEL |
all | enum | no | info |
info |
trace…fatal; see Section 25.2.2 |
LOG_SAMPLE_REDIRECT_SUCCESS |
edge | int | no | 1000 |
1000 |
Log 1 in N successful redirects |
SHUTDOWN_GRACE_SECONDS |
all | int | no | 30 |
30 |
Drain window before forced exit |
TRUSTED_PROXY_HOPS |
web, edge, api | int | no | 1 |
1 |
Number of trusted proxies when deriving the client address |
27.4.2 Database #
| Variable | Services | Type | Req | Default | Example | Description |
|---|---|---|---|---|---|---|
DATABASE_URL |
all | url | yes | — | postgres://app@db.internal:5432/linkhub |
Primary connection string. secret |
DATABASE_REPLICA_URL |
edge, web, worker | url | no | unset | postgres://app@replica.internal:5432/linkhub |
Read replica; analytics reads and resolver cache-miss reads use it when set. secret |
DATABASE_POOL_MAX |
all | int | no | 10 (web/api), 20 (edge), 5 (worker per process) |
20 |
Maximum pool connections per process |
DATABASE_POOL_IDLE_TIMEOUT_MS |
all | int | no | 30000 |
30000 |
Idle connection reclamation |
DATABASE_CONNECT_TIMEOUT_MS |
all | int | no | 5000 |
5000 |
Connection acquisition timeout |
DATABASE_STATEMENT_TIMEOUT_MS |
all | int | no | 10000 (2000 on edge) |
2000 |
Server-side statement timeout; deliberately tight on the redirect path |
DATABASE_SSL_MODE |
all | enum | no | require |
require |
disable permitted in local only |
DATABASE_MIGRATION_LOCK_TIMEOUT_MS |
worker | int | no | 60000 |
60000 |
Advisory lock wait for the migration runner |
27.4.3 Redis #
| Variable | Services | Type | Req | Default | Example | Description |
|---|---|---|---|---|---|---|
REDIS_URL |
all | url | yes | — | rediss://cache.internal:6379/0 |
Cache and rate-limit database. secret |
REDIS_STREAM_URL |
edge, worker | url | no | value of REDIS_URL |
rediss://stream.internal:6379/1 |
Event stream; split onto its own instance at scale. secret |
REDIS_QUEUE_URL |
web, api, worker | url | no | value of REDIS_URL |
rediss://queue.internal:6379/2 |
Job queues. secret |
REDIS_TLS_REJECT_UNAUTHORIZED |
all | bool | no | true |
true |
false only in local |
CACHE_REDIRECT_TTL_SECONDS |
edge, web, api, worker | int | no | 3600 |
3600 |
Resolved redirect payload TTL |
CACHE_NEGATIVE_TTL_SECONDS |
edge | int | no | 60 |
60 |
Negative (miss) cache TTL |
CACHE_BYPASS_MODE |
edge | bool | no | false |
false |
Operational flag used in runbook 25.8.1 |
STREAM_MAXLEN |
edge | int | no | 5000000 |
5000000 |
Approximate stream cap; raise during runbook 25.8.4 |
REDIS_STREAM_REQUIRE_NOEVICTION |
edge, worker | bool | no | true |
true |
Startup assertion. At boot the process reads the stream database's maxmemory-policy and refuses to start unless it reports noeviction, logging a fatal line that names the observed policy. Any other policy lets Redis silently discard unconsumed stream entries under memory pressure, which is undetectable analytics loss. May be set false only in local; the assertion itself is exercised in both directions by the test in Section 26.5.5 |
REDIS_CACHE_EXPECT_LRU |
all | bool | no | true |
true |
Startup check that the cache database reports allkeys-lru. A mismatch logs at warn and continues — evicting cache entries is correct behaviour, so this is an advisory, not a refusal |
27.4.4 Sessions, tokens and authentication #
| Variable | Services | Type | Req | Default | Example | Description |
|---|---|---|---|---|---|---|
AUTH_SECRET |
web, api | string ≥ 64 chars | yes | — | <64+ random chars> |
Root signing secret for sessions and short-lived tokens. secret |
AUTH_SECRET_PREVIOUS |
web, api | string | no | unset | <64+ random chars> |
Previous value during rotation; accepted for verification only. secret |
SESSION_COOKIE_DOMAIN |
web | string | yes | — | .linkhub.app |
Cookie domain for the session cookie |
SESSION_ROLLING_DAYS |
web, api | int | no | 30 |
30 |
Rolling session lifetime |
SESSION_ABSOLUTE_DAYS |
web, api | int | no | 90 |
90 |
Hard cap regardless of activity |
INVITATION_EXPIRY_DAYS |
web, worker | int | no | 7 |
7 |
Invitation validity |
MAGIC_LINK_EXPIRY_MINUTES |
web | int | no | 15 |
15 |
Magic-link validity |
PASSWORD_RESET_EXPIRY_MINUTES |
web | int | no | 60 |
60 |
Reset token validity |
GOOGLE_OAUTH_CLIENT_ID |
web | string | no | unset | 1234.apps.googleusercontent.com |
Google sign-in; feature disabled when unset |
GOOGLE_OAUTH_CLIENT_SECRET |
web | string | no | unset | — | secret |
TOTP_ISSUER |
web | string | no | LinkHub |
LinkHub |
Label shown in authenticator apps |
HIBP_ENABLED |
web | bool | no | true |
true |
Breach check on password set/change |
AUTH_LOGIN_MAX_FAILURES |
web, api | int | no | 5 |
5 |
Failed logins per account per 15 min before lockout |
AUTH_LOGIN_LOCKOUT_MINUTES |
web, api | int | no | 15 |
15 |
Lockout duration |
AUTH_LOGIN_MAX_PER_IP_HOUR |
web, api | int | no | 20 |
20 |
Failed logins per IP per hour |
27.4.5 Analytics, privacy and geography #
| Variable | Services | Type | Req | Default | Example | Description |
|---|---|---|---|---|---|---|
ANALYTICS_SALT_SEED |
edge, web, worker | string ≥ 32 chars | yes | — | <32+ random chars> |
Seed from which the rotating daily visitor salt is derived. secret, never logged |
ANALYTICS_SALT_ROTATION_HOUR_UTC |
worker | int | no | 0 |
0 |
Hour of day at which the daily salt rotates |
EXPERIMENT_SALT_SEED |
edge, web, worker | string ≥ 32 chars | yes | — | <32+ random chars> |
Seed for the 7-day experiment salt. secret |
EXPERIMENT_SALT_ROTATION_DAYS |
worker | int | no | 7 |
7 |
Experiment salt window |
GEO_DB_PATH |
edge, web | path | yes | /srv/geo/geo.mmdb |
/srv/geo/geo.mmdb |
Embedded country/region database |
GEO_DB_UPDATE_URL |
worker | url | no | unset | https://example.com/geo.mmdb |
Source for the weekly geo database refresh |
GEO_DB_LICENSE_KEY |
worker | string | no | unset | — | Credential for the geo database source. secret |
BOT_ASN_LIST_PATH |
worker | path | no | /srv/geo/datacenter-asn.txt |
— | Datacenter ASN list for bot classification |
INGEST_BATCH_SIZE |
worker | int | no | 1000 |
1000 |
Maximum events per ingest batch |
INGEST_BATCH_WINDOW_MS |
worker | int | no | 2000 |
2000 |
Maximum wait before flushing a partial batch |
INGEST_CLAIM_IDLE_MS |
worker | int | no | 300000 |
300000 |
Idle threshold before reclaiming a dead consumer's entries |
ROLLUP_RECONCILE_DAYS |
worker | int | no | 3 |
3 |
Days recomputed nightly from raw |
RETENTION_PURGE_HOUR_UTC |
worker | int | no | 3 |
3 |
Nightly purge start hour |
SOFT_DELETE_RESTORE_DAYS |
worker, web | int | no | 30 |
30 |
Soft-delete restore window before hard purge |
27.4.6 Object storage and assets #
| Variable | Services | Type | Req | Default | Example | Description |
|---|---|---|---|---|---|---|
S3_ENDPOINT |
web, api, worker | url | yes | — | https://s3.example-region.com |
S3-compatible endpoint |
S3_REGION |
web, api, worker | string | yes | — | eu-west-1 |
Region identifier |
S3_ACCESS_KEY_ID |
web, api, worker | string | yes | — | — | secret |
S3_SECRET_ACCESS_KEY |
web, api, worker | string | yes | — | — | secret |
S3_BUCKET_ASSETS |
web, api, worker | string | yes | — | linkhub-assets |
Uploaded images, avatars, QR renders |
S3_BUCKET_EXPORTS |
web, worker | string | yes | — | linkhub-exports |
Generated exports; 24-hour lifecycle rule |
S3_FORCE_PATH_STYLE |
web, api, worker | bool | no | false |
true |
Required by some S3-compatible services and the local emulator |
ASSET_CDN_BASE_URL |
web, worker | url | yes | — | https://cdn.linkhub.app |
Public base URL for asset delivery |
UPLOAD_MAX_BYTES |
web, api | int | no | 10485760 |
10485760 |
Per-file upload cap (10 MB) |
EXPORT_SIGNED_URL_TTL_SECONDS |
web, worker | int | no | 86400 |
86400 |
Export download link validity |
EXPORT_MAX_ROWS |
web, worker | int | no | 1000000 |
1000000 |
Row cap per export before 422 export_too_large. The value matches the cap Sections 18 and 21 publish to customers; changing it here without changing it there is a contract break |
27.4.7 Billing #
| Variable | Services | Type | Req | Default | Example | Description |
|---|---|---|---|---|---|---|
PAYMENTS_SECRET_KEY |
web, worker | string | yes | — | — | Payment processor server-side key. secret |
PAYMENTS_PUBLISHABLE_KEY |
web | string | yes | — | pk_live_… |
Client-side key; not secret but environment-specific |
PAYMENTS_WEBHOOK_SECRET |
web | string | yes | — | — | Signature verification for inbound billing events. secret |
PAYMENTS_PRICE_PRO_MONTHLY |
web | string | yes | — | price_pro_m |
Price identifier for Pro monthly |
PAYMENTS_PRICE_PRO_YEARLY |
web | string | yes | — | price_pro_y |
Price identifier for Pro yearly |
PAYMENTS_PRICE_BUSINESS_MONTHLY |
web | string | yes | — | price_biz_m |
Price identifier for Business monthly |
PAYMENTS_PRICE_BUSINESS_YEARLY |
web | string | yes | — | price_biz_y |
Price identifier for Business yearly |
PAYMENTS_PORTAL_CONFIG_ID |
web | string | no | unset | bpc_123 |
Hosted billing portal configuration |
BILLING_GRACE_PERIOD_DAYS |
worker | int | no | 14 |
14 |
Days past due before entitlement downgrade |
BILLING_READ_ONLY_MODE |
web | bool | no | false |
false |
Operational flag used in runbook 25.8.9 |
BILLING_WRITE_BLOCK_AFTER_DAYS |
web, worker | int | no | 8 |
8 |
Day of the past-due schedule on which writes begin returning 403 billing_write_blocked. Writes are unrestricted on days 0–7; the schedule itself is Section 22.7's and this variable must match it |
The four PAYMENTS_PRICE_* names above are canonical. They are the only names by which a price identifier is referred to anywhere in the system; Section 22 uses these names and no others. If a fifth price is ever introduced it follows the same PAYMENTS_PRICE_<PLAN>_<INTERVAL> shape.
Note that no billing capability is reachable from the public API by any credential the product issues, by design (Section 21). These variables are consumed by the web deployable and the worker only; the redirect resolver and the public API cannot read them, which the per-service secret policy in 27.5 enforces rather than merely documents.
27.4.8 Email #
| Variable | Services | Type | Req | Default | Example | Description |
|---|---|---|---|---|---|---|
EMAIL_TRANSPORT |
web, worker | enum | no | smtp |
smtp |
smtp or http |
SMTP_URL |
web, worker | url | conditional | — | smtps://user:pass@smtp.example.com:465 |
Required when transport is smtp. secret |
EMAIL_API_KEY |
web, worker | string | conditional | — | — | Required when transport is http. secret |
EMAIL_FROM_ADDRESS |
web, worker | yes | — | no-reply@linkhub.app |
Envelope and header sender | |
EMAIL_FROM_NAME |
web, worker | string | no | LinkHub |
LinkHub |
Display name |
EMAIL_REPLY_TO |
web, worker | no | support@linkhub.app |
support@linkhub.app |
Reply-to header | |
EMAIL_SANDBOX_MODE |
web, worker | bool | no | false |
true |
Captures instead of sending; forced true in local and preview |
EMAIL_RATE_PER_ADDRESS_HOUR |
web | int | no | 10 |
10 |
Anti-abuse cap on transactional sends per address |
27.4.9 Domains, TLS and edge #
| Variable | Services | Type | Req | Default | Example | Description |
|---|---|---|---|---|---|---|
ACME_DIRECTORY_URL |
worker | url | yes | — | https://acme-v02.api.letsencrypt.org/directory |
Certificate authority directory |
ACME_ACCOUNT_EMAIL |
worker | yes | — | ops@linkhub.app |
Account contact for expiry notices | |
ACME_ACCOUNT_KEY |
worker | string (PEM) | yes | — | — | ACME account private key. secret |
ACME_RENEW_DAYS_BEFORE_EXPIRY |
worker | int | no | 30 |
30 |
Renewal trigger |
ACME_DNS01_PROVIDER |
worker | enum | no | none |
none |
DNS-01 fallback provider adapter; none disables the fallback |
ACME_DNS01_CREDENTIALS |
worker | string (JSON) | no | unset | — | Credentials for the DNS-01 adapter. secret |
CNAME_TARGET_HOST |
web, worker | hostname | yes | — | cname.linkhub.app |
Value customers point subdomains at |
EDGE_ANYCAST_IPV4 |
web, worker | list | yes | — | 203.0.113.10,203.0.113.11 |
Published A records for apex domains |
EDGE_ANYCAST_IPV6 |
web, worker | list | yes | — | 2001:db8::10,2001:db8::11 |
Published AAAA records for apex domains |
DOMAIN_CHALLENGE_LABEL |
web, worker | string | no | _linkhub-challenge |
_linkhub-challenge |
TXT record label for ownership verification |
DOMAIN_VERIFY_FAST_INTERVAL_SECONDS |
worker | int | no | 30 |
30 |
Poll interval for the first 15 minutes |
DOMAIN_VERIFY_SLOW_INTERVAL_SECONDS |
worker | int | no | 300 |
300 |
Poll interval thereafter |
DOMAIN_VERIFY_TIMEOUT_HOURS |
worker | int | no | 72 |
72 |
Deadline before dns_failed |
CERT_STORE_PATH |
edge | path | no | /srv/certs |
/srv/certs |
Local certificate cache directory |
27.4.10 Public URLs and hosts #
| Variable | Services | Type | Req | Default | Example | Description |
|---|---|---|---|---|---|---|
PUBLIC_APP_URL |
all | url | yes | — | https://app.linkhub.app |
Dashboard base URL; used in emails and redirects |
PUBLIC_API_URL |
all | url | yes | — | https://api.linkhub.app |
Public API base URL |
PUBLIC_EDGE_URL |
all | url | yes | — | https://go.linkhub.app |
Default redirect host base URL |
PUBLIC_MARKETING_URL |
web | url | yes | — | https://linkhub.app |
Marketing site base URL |
PUBLIC_BIO_HOST |
web, edge | hostname | yes | — | linkhub.app |
Host serving bio pages at /<handle> |
DEFAULT_SHORT_DOMAIN |
web, api, edge | hostname | yes | — | lnkhb.co |
Default short-link domain offered to all plans |
QR_MEMORIAL_BASE_URL |
edge | url | no | value of PUBLIC_EDGE_URL |
https://go.linkhub.app |
Base for the memorial and generic fallback pages |
STATUS_PAGE_URL |
web | url | no | https://status.linkhub.app |
https://status.linkhub.app |
Linked from error pages |
27.4.11 Security and abuse #
| Variable | Services | Type | Req | Default | Example | Description |
|---|---|---|---|---|---|---|
SAFE_BROWSING_API_KEY |
web, api, worker | string | yes | — | — | URL reputation lookups. secret |
SAFE_BROWSING_ENABLED |
web, api, worker | bool | no | true |
true |
Disabling is permitted only in local |
SAFE_BROWSING_RECHECK_DAYS |
worker | int | no | 7 |
7 |
Weekly recheck cadence for active destinations |
DESTINATION_DNS_CHECK_ENABLED |
web, api, worker | bool | no | true |
true |
SSRF/private-range resolution guard |
DESTINATION_BLOCKED_HOSTS_PATH |
web, api, worker | path | no | /srv/security/blocked-hosts.txt |
— | Internal deny-list from runbook 25.8.8 |
SLUG_BLOCKLIST_PATH |
web, api | path | no | /srv/security/slug-blocklist.txt |
— | Reserved, profane and impersonation slug terms |
WEBHOOK_SIGNING_PEPPER |
worker | string ≥ 32 chars | yes | — | — | Additional entropy mixed into per-workspace webhook secrets. secret |
WEBHOOK_TIMEOUT_MS |
worker | int | no | 10000 |
10000 |
Per-attempt delivery timeout. Matches the 10-second budget Section 19.7 publishes to customers |
WEBHOOK_MAX_ATTEMPTS |
worker | int | no | 6 |
6 |
Total attempts before dead-letter — one immediate plus the five-step retry ladder in Section 19.7 |
BOT_CHALLENGE_TURNSTILE_SITE_KEY |
web | string | no | unset | — | Public site key for the managed bot challenge. The default protection is a honeypot field plus a submission-timing heuristic, which needs no key and no JavaScript; this key is required only for the escalation described in Section 10, and when it is unset the escalation is unavailable rather than silently skipped |
BOT_CHALLENGE_TURNSTILE_SECRET_KEY |
web | string | no | unset | — | Server-side verification key for the same. secret |
RATE_LIMIT_ENABLED |
web, api, edge | bool | no | true |
true |
Disabling permitted only in local |
SUPPORT_IMPERSONATION_MAX_MINUTES |
web | int | no | 60 |
60 |
Default grant duration ceiling per session |
CSP_REPORT_URI |
web | url | no | unset | https://csp.example.com/report |
Content Security Policy violation reporting endpoint |
27.4.12 Integrations #
| Variable | Services | Type | Req | Default | Example | Description |
|---|---|---|---|---|---|---|
GA4_MEASUREMENT_PROTOCOL_ENABLED |
worker | bool | no | true |
true |
Server-side forwarding master switch |
SLACK_CLIENT_ID |
web | string | no | unset | — | Slack app for milestone alerts |
SLACK_CLIENT_SECRET |
web | string | no | unset | — | secret |
MAILCHIMP_CLIENT_ID |
web | string | no | unset | — | ESP OAuth application |
MAILCHIMP_CLIENT_SECRET |
web | string | no | unset | — | secret |
CONVERTKIT_CLIENT_ID |
web | string | no | unset | — | ESP OAuth application |
CONVERTKIT_CLIENT_SECRET |
web | string | no | unset | — | secret |
ZAPIER_SHARED_SECRET |
api | string | no | unset | — | Validates Zapier REST-hook subscription calls. secret |
EMBED_ALLOWED_PROVIDERS |
web | list | no | all catalogued providers | youtube,vimeo,spotify |
Restricts embed providers. The Content Security Policy itself is owned by Section 23.5; this variable narrows the provider set that policy admits, and can never widen it |
27.4.13 Observability #
| Variable | Services | Type | Req | Default | Example | Description |
|---|---|---|---|---|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT |
all | url | no | unset | https://otlp.example.com |
Trace and metric export target; tracing disabled when unset |
OTEL_EXPORTER_OTLP_HEADERS |
all | string | no | unset | — | Export authentication headers. secret |
OTEL_TRACES_SAMPLER_RATIO |
web, api, worker | float | no | 0.1 (web), 0.25 (api), 1.0 (worker) |
0.1 |
Head sampling ratio |
EDGE_TRACING_ENABLED |
edge | bool | no | false |
false |
The deliberate exception in Section 25.4.3; self-expires after 30 minutes |
EDGE_TRACING_SAMPLER_RATIO |
edge | float | no | 0.0001 |
0.0001 |
Applies only while the flag above is on |
ERROR_TRACKING_DSN |
all | url | no | unset | — | Error aggregation endpoint. secret |
OPERATOR_DEBUG_TOKEN |
edge, web, api | string | no | unset | — | Enables per-request forced logging and tracing. secret |
27.4.14 Workers and queues #
| Variable | Services | Type | Req | Default | Example | Description |
|---|---|---|---|---|---|---|
WORKER_QUEUES |
worker | list | yes | — | analytics-ingest,rollup |
Queues this replica group consumes; enables per-queue scaling |
WORKER_CONCURRENCY_DEFAULT |
worker | int | no | 5 |
5 |
Default per-queue concurrency |
WORKER_CONCURRENCY_OVERRIDES |
worker | string | no | unset | qr-render=2,webhook-deliver=20 |
Per-queue overrides |
WORKER_JOB_TIMEOUT_MS |
worker | int | no | 120000 |
120000 |
Default per-job timeout |
WORKER_MAX_STALLED_COUNT |
worker | int | no | 2 |
2 |
Stalled reclaims before failing a job |
MIGRATION_RUN_ON_START |
worker | bool | no | false |
false |
Migrations run as a separate step, not on boot; see Section 27.6 |
27.4.15 Feature flags #
Flags are boolean environment variables, evaluated at startup, defaulting to the launch state. They exist to de-risk rollout and to disable a subsystem during an incident — not to branch product behaviour long-term. Any flag still present 90 days after its feature reaches general availability is removed.
| Variable | Services | Default | Purpose |
|---|---|---|---|
FEATURE_SIGNUP_ENABLED |
web | true |
Close registration during an incident |
FEATURE_CUSTOM_DOMAINS |
web, api, worker | true |
Disable domain onboarding without disabling existing domains |
FEATURE_AB_TESTING |
web, api, edge | true |
Disable experiment evaluation; traffic falls back to control |
FEATURE_PUBLIC_API |
api | true |
Master switch for the public API |
FEATURE_LEAD_CAPTURE |
web, worker | true |
Disable form submission acceptance |
FEATURE_QR_ADVANCED_STYLING |
web, api, worker | true |
Restrict to the safe styling subset if a decode regression appears |
FEATURE_SUPPORT_IMPERSONATION |
web | true |
Emergency kill switch for impersonation |
FEATURE_EXPORTS |
web, worker | true |
Disable export generation during a storage incident |
FEATURE_PIXEL_FORWARDING |
worker | true |
Disable server-side pixel forwarding |
27.5 Secrets management and rotation #
Storage. Every value marked secret in Section 27.4 lives in the managed secret store, referenced by name in the deployment manifest and injected as an environment variable at container start. No secret is baked into an image, committed to the repository, present in a CI log, or stored in a platform's plaintext variable field. CI reads secrets from the same store through a short-lived workload identity, never a long-lived key.
Access. Per-service policies: the resolver cannot read billing or email secrets; only the worker reads the ACME account key; only the web service reads OAuth client secrets. Every read is audited. Humans do not read production secrets in normal operation; a break-glass read requires a second approver and raises a security alert.
Rotation cadence.
| Secret | Cadence | Method |
|---|---|---|
AUTH_SECRET |
180 days, or immediately on suspicion | Dual-secret: set AUTH_SECRET_PREVIOUS to the old value, deploy, wait one full session lifetime, remove. No forced logout |
ANALYTICS_SALT_SEED |
365 days | Rotate at a day boundary; historical visitor hashes intentionally stop correlating, which is a privacy feature |
EXPERIMENT_SALT_SEED |
365 days | Rotate only when no experiment is within its minimum-sample window |
| Database and Redis credentials | 90 days | Create a second credential, roll services, retire the first |
S3_* credentials |
90 days | Same dual-credential pattern |
PAYMENTS_SECRET_KEY, PAYMENTS_WEBHOOK_SECRET |
180 days | Processor supports overlapping keys; roll then retire |
| Email transport credential | 180 days | Dual-credential |
WEBHOOK_SIGNING_PEPPER |
365 days | Per-workspace secrets are re-derived; customers are notified 30 days ahead because their verification code is affected |
ACME_ACCOUNT_KEY |
365 days | New account, re-register, retain the old for existing order cleanup |
SAFE_BROWSING_API_KEY, integration client secrets |
365 days | Provider-native rotation |
OPERATOR_DEBUG_TOKEN |
30 days | Regenerate; no overlap needed |
Compromise procedure: rotate immediately without waiting for a window; invalidate anything derived from the secret; follow runbook 25.8.12. A secret that has appeared in a log, a screenshot, a ticket or a repository is compromised by definition, regardless of how briefly.
Verification: a quarterly drill (Section 25.10.4) rotates one non-critical secret end to end and proves zero downtime.
27.6 Database migrations in deployment #
Migrations are generated and applied by the ORM's migration tooling and are plain SQL files committed to the repository, reviewed like code.
Ordering relative to the application rollout:
- Migrations run as a separate deployment step, before the new application version is rolled out.
MIGRATION_RUN_ON_STARTis false everywhere: application containers never migrate on boot, because N replicas booting simultaneously must not race. - The migration step acquires a PostgreSQL advisory lock, so concurrent runners serialise rather than conflict.
- Only after the migration step reports success does the application rollout begin.
- The rollout is gradual (blue/green or rolling per Section 27.7), so the old and new application versions run concurrently against the migrated schema. This is the constraint that dictates everything below.
Expand/contract discipline. Every schema change is decomposed into an expand phase and a contract phase, shipped in different releases:
| Change | Expand (release N) | Application (release N) | Contract (release N+1 or later) |
|---|---|---|---|
| Add a column | Add nullable, or with a default | New code writes it; old code ignores it | Add NOT NULL once backfilled |
| Rename a column | Add the new column; dual-write via application code | Read new, fall back to old | Drop the old column |
| Change a type | Add a new column of the new type; backfill; dual-write | Read new | Drop the old |
| Drop a column | — | Stop writing and reading it | Drop it |
| Add an index | Create CONCURRENTLY, outside a transaction |
— | — |
| Add a constraint | Add NOT VALID, then VALIDATE CONSTRAINT separately |
— | — |
| Rename a table | Create the new table; dual-write; backfill | Read new | Drop the old |
| Backfill a large table | Batched background job, not a migration statement | — | — |
The backward-compatibility-for-one-release rule. Every migration must leave the schema working for the immediately previous application release. This is not a guideline; it is checked in CI by stage 8 of Section 26.13, which applies the migration to a database seeded at the previous release and runs the previous release's integration tests against it. A migration failing that check cannot merge.
Consequences, stated plainly: no migration ever drops or renames a column that the previous release reads or writes; no migration ever adds a NOT NULL column without a default in the same release that starts writing it; no migration takes a lock that blocks writes for more than 1 second on a table on the redirect path. Long-running data changes are jobs, not migrations.
Rollback when a migration is involved.
- Because every migration is backward-compatible for one release, rolling back application code is always safe and requires no schema change. This is the default and covers almost every incident.
- Reverting a migration is a separate decision, taken only when the migration itself is the fault. Every migration ships with a tested down script, and the down script is exercised in CI.
- Additive migrations (new column, new table, new index) are reverted safely at any time.
- Destructive migrations (drop, narrowing type change) are not reverted; they are rolled forward with a corrective migration. Reverting them loses data.
- Any revert requires: a fresh backup snapshot taken first, a second engineer's confirmation, and the snapshot identifier recorded in the incident. See runbook 25.8.11 step 6.
- Contract-phase migrations are never shipped in the same release as an incident-prone change; they are scheduled deliberately, at least one release after their expand phase has been running in production without issue.
27.7 Deployment strategy per deployable #
| Deployable | Strategy | Rationale |
|---|---|---|
| Redirect resolver | Blue/green with traffic shift | It is the highest-traffic, lowest-latency, highest-consequence surface. Blue/green gives an instant, complete rollback by shifting traffic back to a fleet that is already warm — no rebuild, no container start, no cache cold-start. A rolling deploy would mix versions across a fleet whose behaviour must be uniform (a resolution bug on 20% of instances is harder to diagnose than one on 100%), and would leave every instance cold at exactly the moment traffic arrives. The extra cost of running two fleets during a deploy is trivially justified by SLO 1 and SLO 3 |
| Web application | Rolling, 25% at a time, with health gates between batches | Sessions are stateless and server-rendered pages tolerate mixed versions; rolling is cheaper and adequate |
| Public API | Rolling, 50% at a time | Contract-stable by design (Section 26.10); mixed versions are safe |
| Worker | Rolling per queue group, one replica at a time, with drain | Jobs must finish or be safely returned to the queue; draining one replica at a time bounds the in-flight work |
Blue/green procedure for the resolver:
- Deploy the new colour alongside the current one, receiving no production traffic.
- Wait for readiness on every instance, then run the resolver smoke suite directly against the new colour's internal endpoint: known link, known QR, unknown slug, custom-host resolution, and one request that terminates at rung 4 (
generic). - Warm the new colour with a replay of 60 seconds of recent resolution keys, so it does not start cold.
- Shift 5% of traffic. Hold 3 minutes. Compare error rate and p95 latency against the current colour.
- Shift 50%. Hold 5 minutes. Compare again.
- Shift 100%. Hold 15 minutes with the old colour still running.
- Terminate the old colour.
- Abort at any step by shifting traffic back to the old colour — a single action, effective in seconds.
27.8 Zero-downtime requirements #
Every deploy is zero-downtime. Concretely, for all four deployables: no request is dropped, no in-flight request is terminated, no job is lost, and no customer-visible error is produced by the act of deploying.
Requirements common to all services: readiness fails before the process stops accepting connections; the platform removes an instance from rotation and waits for connection drain before sending the termination signal; the shutdown handler stops accepting new work, finishes in-flight work within the grace window, closes pools cleanly, and exits; the grace window exceeds the longest expected request or job.
Specific care for the redirect path:
- It is deployed blue/green precisely so that no instance is ever both serving and shutting down under load.
- The resolver is warmed before receiving traffic. A cold instance taking database reads on every request would breach the latency budget within seconds of a traffic shift.
- Its shutdown drain is 30 seconds — long enough for in-flight requests (which take milliseconds), short enough to release capacity quickly during scale-in.
- Connection reuse is preserved across the shift by draining at the load-balancer layer rather than closing keep-alive connections abruptly.
- During the shift, both colours use the same cache, so a shifted request is a cache hit regardless of which colour serves it.
- Deploys of the resolver are not performed during a traffic spike (runbook 25.8.10). The deploy pipeline checks current RPS against the 7-day p99 and requires explicit confirmation above 2×.
- Certificate state is externalised: the resolver loads customer certificates from shared storage and can install a new certificate without a restart, so TLS renewal never requires a deploy.
27.9 Rollback procedures #
Decision criteria — roll back immediately if any of these hold:
| Signal | Threshold |
|---|---|
| Redirect error rate | Above 0.1% for 2 minutes |
| Redirect p95 latency | Above 50 ms for 5 minutes |
| Any QR request returning any 4xx or any 5xx | One occurrence |
| Any destination redirect returning 301 or 308 | One occurrence |
| Web or API 5xx rate | More than 2× the pre-deploy baseline for 5 minutes |
| Job failure rate | More than 5× the pre-deploy baseline |
| A data-integrity error appears in logs | One occurrence |
| A security control is failing open | One occurrence |
| Any doubt after 10 minutes of investigation | — |
Procedure: identify the deployable (Section 25.8.11); for the resolver shift traffic back to the previous colour; for the others redeploy the previous release identifier; verify the signal recovers; purge caches if payload shape changed; freeze deploys for that deployable; open an incident. Migrations are handled per Section 27.6.
Rollback targets: resolver, under 60 seconds from decision to full traffic restored. Others, under 5 minutes. These are exercised in the region-loss drill.
27.10 Health checks, readiness, liveness and shutdown #
Every service exposes three endpoints on the application port. None require authentication, and none disclose configuration values.
| Endpoint | Purpose | Response |
|---|---|---|
/healthz |
Liveness. Is the process functioning? | 200 with {"status":"ok","service":…,"release":…}. Never checks dependencies — a dependency outage must not cause a restart loop |
/readyz |
Readiness. Should this instance receive traffic? | 200 when dependencies required to serve are usable; 503 otherwise, with a body naming the failing check |
/startupz |
Startup completion, for platforms that distinguish it | 200 once initialisation completes |
Readiness criteria per service:
| Service | Ready when |
|---|---|
| Web | Database reachable, Redis reachable, build assets loaded |
| Redirect resolver | Redis reachable or database reachable (either is sufficient — the resolver serves from whichever is available, and readiness must not fail when only the cache is down), geo database loaded, certificate store readable, warm-up replay complete |
| Public API | Database reachable, Redis reachable |
| Worker | Queue Redis reachable, database reachable, its queues subscribed |
Shutdown and drain, in order: receive the termination signal → /readyz returns 503 immediately → continue serving in-flight requests → wait the platform's deregistration delay (5 s) → stop accepting new connections → wait for in-flight completion up to the grace window → for workers, stop pulling new jobs, finish or return in-flight jobs to the queue, acknowledge stream entries already written → close database, Redis and stream connections → flush pending logs, metrics and traces → exit 0. If the grace window expires, log the count of abandoned units of work and exit 1 so the abandonment is visible.
27.11 Scaling configuration and autoscaling signals #
| Service | Min | Max | Scale-out signal | Scale-in signal | Stabilisation |
|---|---|---|---|---|---|
| Redirect resolver | 3 | 30 | CPU > 55%, or requests per instance above 60% of tested capacity, or p95 latency > 35 ms | CPU < 25% for 15 min | Out: 30 s. In: 10 min. Deliberately fast out, slow in — under-provisioning this service is far more costly than over-provisioning it |
| Web | 2 | 10 | CPU > 65%, or p95 render > 150 ms | CPU < 30% for 15 min | Out 60 s, in 10 min |
| Public API | 2 | 8 | CPU > 65%, or p95 > 300 ms | CPU < 30% for 15 min | Out 60 s, in 10 min |
| Worker — ingest | 2 | 12 | Ingest lag > 60 s, or stream length growing for 3 min | Lag < 10 s for 15 min | Out 60 s, in 10 min |
| Worker — general | 1 | 6 | Oldest-waiting > 60 s on any of its queues | All queues empty for 15 min | Out 60 s, in 10 min |
| Worker — render/export | 1 | 6 | Queue depth > 20 | Depth 0 for 15 min | Out 60 s, in 10 min |
Scheduled floors: the resolver's minimum is raised to 6 during the daily peak window and during any customer-announced campaign, because autoscaling reacts after the spike has already been felt. Cross-service protection: worker scale-out is capped so that total database connections across all services stay below 70% of the instance's limit; the cap is enforced in configuration, not by convention.
27.12 CDN and edge configuration #
| Path class | Cacheable | TTL | Cache key | Notes |
|---|---|---|---|---|
| Static assets (hashed filenames) | Yes | 1 year, immutable | Path | Content-hashed, safe to cache forever |
| Uploaded images | Yes | 30 days | Path + transformation parameters | Purged on replacement |
| QR renders | Yes | 1 year, immutable | Path (includes version id) | A new version is a new path |
| Public bio page HTML | Yes | 60 s, stale-while-revalidate 600 s |
Host + path + device class + language | Purged on publish; short TTL bounds staleness while absorbing a spike |
| Bio page for a workspace with an active experiment | No | — | — | Assignment is server-side and per-visitor; caching would break stickiness |
Redirect responses on rungs 1 and 2 (/{slug}) |
Never | — | — | Cache-Control: private, no-store. Destinations are editable at any time; a cached redirect is a product defect |
| Dashboard and API | Never | — | — | Authenticated |
Rung 3 (workspace_unavailable) and rung 4 (generic) pages |
Yes | 60 s | Host + path | Cache-Control: public, max-age=60. Long enough to absorb a scan burst on a printed code, short enough that restoring the underlying resource is visible within a minute |
| Branded not-found, expired and "not available yet" pages for short links | Yes | 60 s | Host + path | Same reasoning; all three are 200 responses (Section 12) |
| Safety interstitial | Never | — | — | private, no-store. Safety state can change in seconds and a cached warning is as wrong as a cached all-clear |
Edge requirements: HTTP/2 and HTTP/3, Brotli with gzip fallback, TLS 1.2 minimum with 1.3 preferred, HSTS on all platform-owned hosts, and the security headers from Section 23 applied at origin (never only at the edge, so they survive a CDN bypass). A plain-HTTP request is answered with a 301 to the identical URL over HTTPS — a transport upgrade to the same URL, not a destination redirect; the never-301 rule in Section 12 governs destinations, whose targets are editable. Purge is by surrogate key: publishing a bio page purges only that page's key; a template change purges the template's key across pages.
The redirect host is deliberately not behind a caching CDN. It may sit behind an anycast TCP/TLS-terminating layer for latency and DDoS absorption, but no HTTP response caching. Origin shielding is enabled for the bio page origin to reduce origin load during a viral spike.
27.13 Local development setup #
Target: a new engineer clones the repository and has the full product running, seeded and testable in under 30 minutes, with no manual account creation at any third party.
Prerequisites: a container runtime with Compose support; Node.js at the major line in Section 4, installed via a version manager that reads the repository's version file; the pnpm package manager; make; and a POSIX shell. Nothing else is required — no database, Redis, or image library installed on the host.
First run:
git clone <repository-url> && cd linkhub
cp .env.example .env # every value already works for local; no secrets needed
make up # starts containers, waits for health, migrates, seeds
make dev # runs all four deployables in watch modeWhat docker-compose.yml provides:
| Service | Purpose | Local address |
|---|---|---|
postgres |
Database at the major line in Section 4, with a persistent volume and a tuned local configuration | localhost:5432 |
redis |
Cache, streams and queues | localhost:6379 |
minio |
S3-compatible object storage, with the assets and exports buckets created by an init container | localhost:9000, console localhost:9001 |
mailpit |
SMTP capture with a web inbox; every outbound email is viewable and clickable | localhost:8025 |
pebble |
A local ACME server for exercising real certificate issuance without contacting a public authority | localhost:14000 |
caddy |
Local TLS-terminating edge that serves the wildcard development domains and proxies to the running services | localhost:443 |
payments-stub |
Implements the payment processor's checkout, subscription and webhook surface used by the billing adapter | localhost:4242 |
reputation-stub |
Returns configurable verdicts for the URL reputation adapter | localhost:4243 |
otel-collector + grafana |
Optional profile (make up-observability) for metrics, logs and traces locally |
localhost:3001 |
Seeding. make seed loads the standard seed set from Section 26.3 and prints a credentials table: an Owner, an Admin, an Editor, a Viewer and a scoped Business member, each with a known password, plus the URLs of the seeded bio page, short link and QR code. make seed-analytics additionally loads 90 days of synthetic events so dashboards are populated. make reset destroys volumes and starts clean.
Custom domains and TLS locally. The development environment resolves *.localtest.me (and any hostname mapped in the compose network) to the local edge, so custom-domain behaviour is exercised end to end without editing the host file:
- Add
shop.localtest.meas a custom domain in the dashboard. - The domain verification worker resolves DNS against the compose network's resolver, which is seeded with the expected TXT and CNAME records by
make domain-fixtures. The domain advances throughverifying. - TLS provisioning runs against the local ACME server over HTTP-01 through the local edge, issuing a real certificate from a local authority whose root is trusted inside the containers and installed into the developer's browser by
make trust-local-ca. - The domain reaches
active, andhttps://shop.localtest.me/<slug>resolves through the real resolver code path with real TLS.
Failure modes are simulated deliberately: make domain-fixtures FAIL=dns removes the TXT record to exercise dns_failed; FAIL=tls blocks the challenge path to exercise tls_failed.
Other local affordances. make test, make test:integration, make test:e2e run the suites against the compose stack. make clock ADVANCE=8d advances the injected clock to exercise scheduling, expiry, salt rotation and trial boundaries. make lint, make typecheck and make check (everything CI runs) let an engineer reproduce a red pipeline locally. The repository's README.md contains this section's commands and nothing that contradicts them.
27.14 Cost model #
Order-of-magnitude monthly figures in USD at three traffic tiers, for the infrastructure described in Section 27.2. These are planning figures for capacity decisions, not a quotation; actual pricing varies by provider and region by roughly a factor of two in either direction.
| Component | Tier 1: 1M redirects/mo, ~1k workspaces | Tier 2: 50M redirects/mo, ~25k workspaces | Tier 3: 500M redirects/mo, ~150k workspaces |
|---|---|---|---|
| Compute — redirect resolver | $60 | $350 | $2,400 |
| Compute — web | $60 | $300 | $1,500 |
| Compute — public API | $30 | $120 | $600 |
| Compute — workers | $60 | $400 | $2,200 |
| Managed PostgreSQL (primary + standby + replica) | $150 | $900 | $4,500 |
| Database storage and backups | $25 | $250 | $1,800 |
| Managed Redis | $40 | $250 | $1,200 |
| Object storage + requests | $10 | $80 | $500 |
| CDN delivery and egress | $20 | $400 | $3,000 |
| Email delivery | $15 | $150 | $800 |
| URL reputation and geo data | $0 | $50 | $200 |
| Observability (metric, log and trace ingestion) | $50 | $400 | $2,500 |
| Secret store, registry, CI minutes | $40 | $150 | $500 |
| Approximate total | ~$560/mo | ~$3,800/mo | ~$21,700/mo |
| Cost per million redirects | ~$560 | ~$76 | ~$43 |
Main cost drivers, in order of leverage:
- Observability ingestion. At tier 3 it rivals the database. This is precisely why redirect success logs are sampled at 1-in-1000 and why the redirect path is not traced (Sections 25.2.3 and 25.4.3). Unsampled logging on that path would multiply this line by roughly 1000.
- Database storage and IOPS, driven by raw event volume. Daily partitioning with whole-partition drops keeps purging cheap; retention tiering by plan (Section 22) is the main control. Moving Business-tier raw events beyond 12 months to columnar cold storage is the documented next step.
- CDN egress on public bio pages. The 40 KB HTML budget is a cost control as much as a performance one — halving page weight halves this line.
- Redirect compute, which scales linearly with traffic and is dominated by cache hit ratio. Every percentage point below 97% moves load onto the database, which is roughly an order of magnitude more expensive per request.
- Redis memory, driven by the resolved-payload cache. Payloads are kept minimal (destination, rules, flags — never full records) for this reason.
Costs not in the table because they are not infrastructure: payment processing (a percentage of revenue), certificate issuance ($0 with an ACME authority), and domain registration for the platform's own hosts.
28. Milestones & Execution Plan #
Thirteen milestones in dependency order. Each produces something demonstrable — a thing you can open, click, scan or measure — not a layer of plumbing that has to wait for another layer to be visible. Exit criteria are written as assertions a reviewer can verify without asking the author what they meant.
Effort is stated in engineer-weeks for a single competent full-stack engineer (or an AI agent working continuously with review). They are planning estimates, not commitments.
The dependency rule, enforced rather than assumed: no milestone's exit criteria may depend on a milestone that comes after it. A plan that violates this is not a plan, because the earlier milestone can never be signed off and the whole sequence silently becomes one big-bang delivery. Two structural decisions follow from applying that rule honestly, and both are load-bearing for everything below:
- The entitlement seam ships in M2, not M10. Step 7 of the authorization algorithm in Section 3.5 evaluates entitlements, so every guarded route from M2 onward already calls it. M2 therefore ships the evaluation function reading the static plan catalogue in Section 22.1.2, with each workspace pinned to a plan by configuration. M10 does not build that function; it replaces its source with live subscription state and adds the commercial flows around it. Without this split, M6, M7 and M8 would each depend on M10, which comes after all three.
- Every milestone that touches a state in the Section 26.5.4 QR matrix extends that matrix rather than deferring it. M6 owns the rows it can reach, M7 adds the analytics limb, M10 adds the billing rows. Each milestone's exit criteria name exactly the rows it is accountable for, so no milestone is asked to prove a behaviour whose preconditions do not yet exist, and no row goes unproven.
28.1 M1 — Foundations, schema and CI #
Goal. A monorepo that builds, tests, migrates and deploys a trivial endpoint through the full pipeline, with the complete database schema in place.
In scope. Monorepo and workspace configuration; shared config, core and UI packages; the complete schema from Section 6 with migrations and the seed sets from Section 26.3; UUIDv7 generation in application code; the four deployable skeletons with health endpoints; structured logging and the metrics endpoint; the CI pipeline from Section 26.13 with every stage wired (some trivially passing); container images meeting the size targets; preview, staging and production environments; the configuration schema and startup validation from Section 27.4.
Out of scope. Any product feature. Authentication. Any user interface beyond a health page.
Implements. Sections 4, 5, 6, 25.2, 25.3 (framework), 26.13, 27.1–27.4, 27.5 (secret store wiring and per-service access policies; the rotation drill is M13), 27.6 (the migration runner, the advisory lock and the expand/contract CI check), 27.10, 27.13.
Depends on. Nothing.
Deliverables. Repository with four buildable images; migration set; seed commands; green pipeline; README.md and .env.example; deployed staging environment.
Exit criteria.
make up && make devon a clean machine produces all four services running and seeded in under 30 minutes.- Every table, column, index, constraint and partition in Section 6 exists; a schema dump matches the model definitions with zero drift.
- Migrations apply cleanly to an empty database and to a database seeded at the previous state; the CI migration check passes.
qr_slug_reservationsexists and has nodeleted_atcolumn.- All four services return 200 on
/healthzand correct dependency-aware behaviour on/readyz, and expose the running git SHA. - Every stage in Section 26.13 executes; the full pipeline completes in under 20 minutes.
- Starting any service with a required variable missing aborts with a message naming that variable. Starting a worker against a stream database whose
maxmemory-policyis notnoevictionaborts with a message naming the observed policy. - A commit merged to main deploys automatically to staging.
- Every secret in Section 27.4 marked secret is read from the secret store at container start; no secret appears in an image, a repository file, a CI log or a platform plaintext variable field, and the per-service access policies are in force — demonstrated by showing the resolver failing to read a billing secret.
- Migrations run as a separate deployment step holding the advisory lock, never on container boot, and the backward-compatibility-for-one-release check is wired and passing.
Effort. 2 weeks.
28.2 M2 — Authentication, accounts and workspaces #
Goal. A person can sign up, verify, sign in by three methods, create a workspace, invite teammates and see the audit log.
In scope. Email/password with the specified hashing and strength rules; email verification gating publication; magic-link sign-in; Google OAuth; sessions with the specified cookie, storage and caching; TOTP with recovery codes and the Business-level workspace enforcement; account settings, email change, password change, account deletion with grace; workspaces, membership, the four roles, per-resource grants (data model and enforcement, Business-gated); invitations with the full lifecycle; the append-only audit log; the permission middleware every later route uses.
Also in scope, and the reason M6, M7 and M8 do not depend on M10:
- The entitlement evaluation seam required by step 7 of the Section 3.5 algorithm, reading the static plan catalogue in Section 22.1.2, with a workspace's plan pinned by configuration. It emits the canonical
plan_limit_reachedandplan_feature_unavailableresponses in their final shape. M10 replaces its source with live subscription state; it does not rewrite it. - The batch capability endpoint the dashboard's render-authorisation model depends on (Section 21), authenticated by the dashboard session. It is a thin, pure wrapper over the same
authorize()function and performs no writes. M12 extends it to API-key credentials; the dashboard cannot wait until M12 for it, because every screen built from M2 onward decides what to render from its answer.
Out of scope. Any public surface. Live billing state, the commercial flows and the payment processor (M10). Until M10, a workspace's plan is configuration, not a subscription.
Implements. Sections 7, 8, 3.
Depends on. M1.
Deliverables. Auth flows; workspace and member management UI; invitation emails; audit log view; the entitlement evaluation function and the capability endpoint; the permission and tenancy test suites from Sections 26.5.2 and 26.5.7.
Exit criteria.
- All three sign-in methods work end to end; email verification is required before any publish action and the block is enforced server-side.
- Password rules are enforced: minimum length, strength score, and breach check rejection.
- Rate limits produce lockout at the specified thresholds per account and per IP.
- TOTP enrolment, verification, recovery-code use and per-workspace enforcement all work; recovery codes are single-use.
- The permission matrix suite passes for all four roles including the enumerated-route Viewer denial test.
- The tenancy isolation suite passes, including the 404-not-403 cross-workspace assertion.
- Invitations expire at 7 days, are single-use, can be resent and revoked, and work for both new and existing accounts.
- Every event in the Section 8 catalogue writes an audit entry with actor, before/after values, IP country (never the IP), user-agent family (never the raw user-agent string) and timestamp.
- No code path updates or deletes an audit row; asserted by static check.
- The entitlement seam refuses at the caps in the static plan catalogue and returns the canonical
plan_limit_reached/plan_feature_unavailableshapes, includingkind— asserted by the parts of Section 26.5.1 that do not require a live subscription (cases 1–5 and 15). - The capability endpoint answers a batch of (action, resource_type, resource_id) with allow/deny plus the deny code per entry, agrees with the enforcing route in every case in the permission matrix, and writes nothing — asserted by running the matrix twice, once through the endpoint and once through the real routes, and diffing.
Effort. 3 weeks.
28.3 M3 — Redirect service and branded short links #
Goal. A short link created in the dashboard resolves through the production resolver within the latency budget.
In scope. The resolver service; host and slug resolution; slug generation, validation, normalisation, reserved-word and confusable checks; the Redis payload cache with write-through invalidation, negative caching and single-flight; destination URL safety (scheme allow-list, DNS/private-range rejection, reputation lookup, interstitial); link CRUD in the dashboard; bulk creation; the default short domain; the 302 semantics and cache headers; branded not-found and expired pages; k6 load harness.
Out of scope. Custom domains (M9). Analytics beyond a counter (M7). QR codes (M6). Targeting and scheduling (M8 subset ships here only as data columns).
Implements. Sections 12, 11 (redirect portion), 23 (URL safety), 25.3.1, 27.7, 27.8, 27.9.
Depends on. M2.
Deliverables. Resolver deployed blue/green; link management UI; safety pipeline; load-test results.
Exit criteria.
Every case in Section 26.5.3 passes.
The load scenario sustains 5,000 rps with p50 < 20 ms, p95 < 50 ms, p99 < 120 ms and error rate < 0.01%.
Cache hit ratio exceeds 97% in the steady-state scenario; 100 concurrent misses on one key produce exactly one database read.
Every destination redirect returns 302 with
Cache-Control: private, no-store, and the check that enforces it does not forbid the one correct 301. The launch check is scoped, not blanket: it walks the resolver's destination-resolution modules — the host and slug lookup, the cache payload path, the targeting and experiment evaluators, and every branch that emits aLocationderived from a stored destination — and fails the build if any of them can emit301or308. Exactly one module is exempt, named explicitly in the check's configuration: the scheme-upgrade handler, which answers a plain-HTTP request with a301to the identical URL over HTTPS. That exemption is not a hole. It is asserted positively by its own test — the response must be 301, and theLocationmust differ from the request URL in scheme and in nothing else — so the exempted module cannot quietly grow a destination redirect.A blanket "fail on any 301" grep is specifically rejected here, and the reason is worth stating because the naive version is so tempting: the scheme upgrade must be permanent. It is a transport upgrade to the identical URL, it pairs with HSTS, and it saves a round-trip on the highest-traffic surface in the product. The never-301 rule in Section 12 governs destinations, whose targets are editable and therefore must never be cached permanently by a browser. A check that cannot tell those two apart forbids the correct behaviour and fails the build on the compliant implementation.
Every SSRF and open-redirect case in Section 26.9 is rejected.
Updating a destination changes the resolved answer on the very next request, with no sleep in the test.
A blue/green deploy completes with zero dropped requests under load, and a rollback restores the previous colour in under 60 seconds.
Effort. 3 weeks.
28.4 M4 — Bio page renderer and the public path budget #
Goal. A seeded bio page renders publicly, meets every performance budget on the reference device, and works with JavaScript disabled.
In scope. Server-rendered public page route; the base template and theming system with the contrast-blocking rule; critical inline CSS; font, image and asset strategy; the no-JavaScript navigation guarantee; the analytics beacon as progressive enhancement only; CDN caching and purge-on-publish; the branded not-found page; Lighthouse CI and bundle-size gates; visual regression baselines.
Out of scope. The editor (M5) — pages are seeded or API-created at this stage. Blocks beyond a minimal set (M5).
Implements. Sections 11, 24 (public surfaces), 26.7 gates, 26.11, 27.12.
Depends on. M2.
Deliverables. Public renderer; theming with contrast enforcement; CI budget gates that fail a pull request; visual regression baselines; axe-core gate on public templates.
Exit criteria.
- On the reference device profile, FCP < 0.8 s, LCP < 1.2 s, CLS < 0.02, INP < 200 ms.
- Gzipped HTML ≤ 40 KB; critical inline CSS ≤ 14 KB; zero bytes of render-blocking JavaScript.
- With JavaScript disabled, every link on the page navigates correctly.
- A pull request that deliberately regresses any budget fails CI; demonstrated in the milestone review.
- axe-core reports zero serious or critical violations on every public template.
- Reflow at 320 px produces no horizontal scrolling; target sizes meet the minimum; focus is visible and never obscured.
- A theme failing 4.5:1 contrast cannot be saved; the "fix for me" suggestion passes; the typed-confirmation override writes an audit entry.
- Publishing purges the CDN key for that page only.
Effort. 3 weeks.
28.5 M5 — Bio page editor and block catalog #
Goal. A non-technical user builds and publishes a complete page without help.
In scope. The editor experience: block list, add, edit, reorder, duplicate, delete, hide; live preview; autosave and draft/publish separation; the full block catalog; media upload and processing; embed blocks as lazy facades with accessible names; keyboard-operable reordering; per-block validation and empty states; handle selection and validation.
Out of scope. Email capture destinations beyond storing the lead (M12). A/B page variants (M11).
Implements. Sections 9, 10, 24 (editor).
Depends on. M4.
Deliverables. Editor; complete block catalog; upload pipeline; embed facades; editor E2E and visual regression coverage.
Exit criteria.
- Every block type in Section 10 can be added, configured, reordered, duplicated, hidden and deleted, and renders correctly on the public page.
- Block reordering is fully achievable using only the keyboard, and this is asserted in E2E.
- Every embed renders as a click-to-load facade with an accessible name; no embed loads a third-party script before interaction; the page budget from M4 still passes with the maximum supported number of embeds.
- Autosave never loses more than 5 seconds of work; publishing is explicit and the draft/published distinction is visible.
- Every block has a defined empty state and a defined validation failure state, both exercised in visual regression.
- Uploads are format- and size-validated, converted to AVIF with WebP fallback, and served with explicit intrinsic dimensions.
- Publishing a page with any block combination keeps all Section 11 budgets green.
Effort. 4 weeks.
28.6 M6 — Dynamic QR codes and the scannability pipeline #
Goal. A printed QR code scans, and its destination can be changed afterwards without reprinting.
In scope. QR entity, versions and the permanent slug reservation table; the styling system; the encoding and error-correction rules with automatic escalation; logo, quiet zone and contrast constraints; the three-condition decode validation on every render; SVG, PNG at both densities, PDF and EPS output; the physical sizing guidance; the complete fallback chain with all four rungs; the branded unavailable, memorial and generic pages; the destination-change flow with versioning and audit entries; the support rollback action.
Out of scope. QR-specific analytics breakdowns, and the analytics limb of the fallback matrix (both M7). The billing-driven rows of the fallback matrix (M10) — the mechanism that makes them pass is built here, because the resolver never consults billing state at all; what M10 adds is a live subscription to point at it.
Implements. Sections 14, 25.8.7, 30.6.
Depends on. M3. Nothing later.
Deliverables. QR creation and styling UI; render pipeline; validation pipeline; the four fallback rungs and their pages; decode conformance suite; resolver diagnostic endpoint.
Exit criteria.
- Rows 1–4 and 10–22 of the Section 26.5.4 matrix pass, on assertions (i), (ii), (iii) and (v) — status is never a 4xx or a 5xx, the rung number and
fallback_stagematch, the body or destination is correct, and the cache headers are right for the rung. Rows 5–9 are the billing rows and are M10's; assertion (iv), the analytics limb, is M7's. The property-based test runs here over the rows this milestone owns and is widened at each later milestone. - The chain has exactly four rungs.
fallback_stageis a four-value enum, there is no rung 5, and the resolver's chain has exactly four terminal branches — asserted by 26.5.4 case 28. - The decode conformance corpus in Section 26.8 passes at 100% under the three runtime conditions, using two independent decoders.
- A logo above the maximum is rejected with 422
qr_logo_too_large; contrast below 4.5:1 with 422qr_contrast_too_low; an unresolvable styling combination with 422qr_unscannablenaming the offending choice. - Applying a logo, gradient or custom module shape at error correction M auto-upgrades to H, verified on the stored version record.
- Identical input produces byte-identical SVG output across runs.
- A physically printed 2 cm code scans from 20 cm on two different phone models — verified by hand and recorded.
- Changing the destination causes the same printed code to resolve to the new target on the next scan, with an audit entry naming actor and before/after values. That entry is written with
retention_expires_at = NULL, and the purge job's condition excludes null values, so no retention window can ever remove it — asserted by running the purge with the clock advanced past every plan's window and re-reading the entry. - Rolling back to a prior version restores the destination, invalidates the cache and writes an audit entry.
- No application code path deletes a row from the slug reservation table; asserted by static check. A reservation blocks the identical short-link slug on that host and vice versa — asserted in both directions.
Effort. 4 weeks.
28.7 M7 — Analytics ingestion pipeline #
Goal. Every click, scan and view is captured, attributed and rolled up, without ever slowing or breaking delivery.
In scope. Fire-and-forget publication from the resolver and the renderer; the visitor hash with the rotating daily salt; country and region resolution with immediate discard of the raw address; the stream, consumer group and batch consumer; daily range partitioning with automated partition maintenance; hourly and daily rollups with incremental upsert; the nightly reconciliation; bot classification; the retention purge honouring plan windows; the ingest and rollup metrics from Section 25.3.2; runbook 25.8.4.
Out of scope. Dashboards and export (M8). Billing-driven changes to a workspace's retention window (M10) — the purge honours whatever window the entitlement seam reports, and at this milestone that seam reads the static plan catalogue M2 shipped.
Implements. Sections 17, 23 (privacy positions), 25.3.2, 25.8.4.
Depends on. M3, M6. Nothing later.
Deliverables. Ingest pipeline; partition maintenance; rollup and reconciliation jobs; retention purge; ingest test suite.
Exit criteria.
- Every case in Section 26.5.5 passes.
- At 10,000 events/second for 5 minutes, zero publish failures and ingest lag under 60 s at the end.
- With the stream forced to fail, redirects still return the correct 302 and the drop counter increments by exactly the number of failures.
- A schema-wide scan finds no stored value matching an IPv4 or IPv6 literal, and none matching the raw user-agent sentinel. What is stored instead is
user_agent_familyand the daily-saltedua_hash. - Geographic data is limited to country and region; no column stores city, coordinates or postal code.
- Rollups computed incrementally match a full recomputation exactly over a 10,000-event fixture.
- Killing the consumer mid-batch and restarting produces no duplicate and no lost rows. A worker refuses to start against a stream database that is not
noeviction. - Retention purge drops whole partitions where possible and honours the raw and rollup windows independently for each plan in the static catalogue. The purge's own condition never touches a row whose retention timestamp is null, which is what preserves QR destination-change audit entries indefinitely — asserted here as well as at M6, because this is the milestone that ships the purge.
- The analytics limb of the QR fallback matrix — assertion (iv) of Section 26.5.4 — now passes for every row M6 signed off. Each rung emits an analytics event carrying the same
fallback_stagethe response carried, so a code silently degrading to rung 4 is visible in the data and not only in the logs. - Unique-visitor counts are available at resource-total grain only. A dimension breakdown reports events, not uniques, and the API and the stored aggregates make that distinction explicit rather than leaving it to the reader.
Effort. 3 weeks.
28.8 M8 — Dashboards, reporting, export, and link controls #
Goal. A customer can answer "what is working?" and take the data with them; and links gain the controls Pro is sold on.
In scope. Analytics dashboards for workspace, page, link and QR scopes; time-range selection with retention-aware limits; every rolled-up dimension; the bot toggle; comparison periods; the UTM builder; scheduling, expiry and expiry-redirect behaviour; targeting rules with priority evaluation; CSV export with async generation, signed links and row caps; scheduled email reports; the export runbook.
Out of scope. A/B testing (M11). Live billing state (M10): retention reach, export gating and schedule caps are read from the entitlement seam, whose source at this milestone is still the static plan catalogue.
Implements. Sections 18, 15, 25.8.13.
Depends on. M7. Nothing later.
Deliverables. Dashboards; UTM builder; scheduling, expiry and targeting; export pipeline; report emails.
Exit criteria.
- Dashboard queries over a 90-day range return p95 under 1.5 s with 50 concurrent users on the production-shaped data set.
- A range beyond the plan's retention is clamped, never errored: the response carries the permitted window's data plus
meta.clamped_from, and the UI shows the clamp. A range entirely outside the window returns an empty result with the same metadata. No 4xx is produced for a retention shortfall on a read. - Every rolled-up dimension is filterable and the numbers reconcile with a raw query for the same window. Dimension breakdowns are labelled as event counts, not unique-visitor counts, and a unique count is offered only at resource-total grain.
- The bot toggle changes results; bot events are excluded by default.
- Scheduling, expiry and expiry-redirect behave correctly at each boundary under an injected clock, and each boundary returns 200 with the branded page rather than a 4xx — the "not available yet", expired and password surfaces are all 200, because any of them may be QR-backed.
- Targeting rules evaluate in priority order, first match wins, and unmatched requests fall through to the default destination.
- UTM parameters merge correctly with an existing query string and preserve fragments.
- Twenty concurrent one-million-row exports complete within 15 minutes; a request above the row cap fails fast with 422
export_too_largerather than running long; download links expire on schedule; a stuck export fails visibly rather than hanging.
Effort. 3 weeks.
28.9 M9 — Custom domains and TLS provisioning #
Goal. A customer points their own domain at the platform and their links resolve on it over HTTPS.
In scope. The full domain lifecycle and its states; the ownership TXT challenge; subdomain and apex routing; the verification job with its documented schedule; ACME issuance over HTTP-01 with DNS-01 fallback; dynamic certificate installation without restart; auto-renewal with the alert ladder; the live diagnostic UI showing expected versus observed records; per-plan domain limits; the domain runbooks.
Out of scope. Vanity domains for bio pages beyond the same mechanism (they share it).
Implements. Sections 13, 25.8.5, 25.8.6, 30.7.
Depends on. M3.
Deliverables. Domain onboarding UI with diagnostics; verification and renewal workers; certificate storage and hot loading; expiry alerting.
Exit criteria.
- A domain traverses
pending_dns→verifying→provisioning_tls→activeend to end against the local ACME server, and again on staging against a real authority. - Failure paths reach
dns_failedandtls_failedwith actionable messages, and can be retried by the customer. - The diagnostic shows expected records against currently observed records, including the "what we currently see" comparison.
- Both a subdomain (CNAME) and an apex (A/AAAA) configuration work.
- A new certificate is installed and served without restarting the resolver.
- Renewal triggers at 30 days remaining; the alert ladder fires at 14 and pages at 7; verified with a clock override.
- Removing a domain does not break any QR code — the canonical URL continues to resolve.
- Domain limits per plan are enforced with the correct error code.
Effort. 3 weeks.
28.10 M10 — Billing, plans and entitlements #
Goal. Money is collected, plans are enforced everywhere, and no billing state can stop a QR code resolving.
In scope. Making live subscription state the source the M2 entitlement seam reads, in place of static configuration — the seam's interface, its response shapes and its call sites do not change; plan and price configuration and the 14-day trial; checkout, upgrade, downgrade and cancellation; the hosted billing portal; proration; monthly and yearly intervals; webhook handling with idempotency and replay; dunning, grace and the Section 22.7 past-due schedule; the guided downgrade with keep-selection and archival; the 90-day resolve-then-landing behaviour for over-cap links; branding removal above Free; the billing runbook; entitlement metrics.
Out of scope. Invoicing beyond the processor's own documents; tax handling beyond what the processor provides. Any billing capability on the public API — there is none, by design, so that a leaked API key can never reach payment state.
Implements. Sections 22, 25.8.9.
Depends on. M2, M3, M6, M8. Nothing later.
Deliverables. Billing UI; entitlement engine; webhook handler; dunning and downgrade automation; the entitlement test suite.
Exit criteria.
- Every case in Section 26.5.1 passes, now against live subscription state rather than configuration.
- Entitlements are evaluated server-side on every mutating endpoint including the public API; a binary feature gate returns 403
plan_feature_unavailablebefore validation runs. - Upgrade lifts limits within the same request cycle; downgrade archives rather than deletes, newest-first, honouring the user's keep-selection. Archived resources do not count toward a cap. A link pinned to a QR code is never archived and never counts toward the link cap.
- Over-cap short links resolve for exactly 90 days then serve the branded landing page with 200; neither state returns 404.
- Rows 5–9 of the Section 26.5.4 matrix now pass, and the property-based test is widened to cover them: with the workspace past due, then canceled, then downgraded to Free, every one of 500 QR codes still resolves to its active destination at rung 1 (
active). This is the milestone at which the QR promise is most likely to break, so the matrix is re-run in full, not only on the new rows. - Write blocking follows the Section 22.7 schedule exactly: full write access on days 0–7 of past due,
billing_write_blockedfrom day 8. Assert both boundaries against an injected clock, and assert that resolution is unaffected at every point on the schedule. - Replaying every processor webhook twice produces identical state.
- With the processor stubbed as unavailable, no entitlement downgrade occurs and every public surface behaves normally.
- Branding is present on Free public surfaces and absent above Free, asserted on rendered HTML.
- The 14-day trial works end to end: a card is collected but not charged, entitlements are full from the moment the subscription reaches trialing, the day-11 reminder fires, and a second trial on the same billing account is refused.
Effort. 3 weeks.
28.11 M11 — A/B testing and experiment lifecycle #
Goal. A customer runs a valid experiment and is prevented from drawing an invalid conclusion.
In scope. Experiments on bio page variants and link destinations; the single assignment function, which mixes the weekly experiment-salt epoch with the daily-rotating visitor hash; the consented cookie path; cross-surface stickiness; variant configuration and weights; the minimum-sample guard; the two-proportion z-test; results presentation; promote-winner and the audited force-promote; experiment deletion mid-flight; the entitlement gate.
Out of scope. Multivariate testing and sequential testing methods — roadmap.
Implements. Section 16.
Depends on. M5, M8, M10. Nothing later.
Deliverables. Experiment UI; assignment engine; statistics; promotion flows; the stickiness test suite.
Exit criteria.
- Every case in Section 26.5.6 passes.
- Assignment distribution over 100,000 hashes is within ±1.5% of a 50/50 split.
- Assignment on the no-consent path is stable for 24 hours and is not claimed to be stable for longer. The visitor hash rotates daily, so the assignment function's input changes daily regardless of the weekly experiment-salt epoch; the milestone is signed off against a 24-hour stability assertion plus a deterministic re-bucketing assertion at the rotation boundary. With the consent cookie present, stability survives both a daily hash rotation and a weekly epoch rotation — that is the only path on which stickiness exceeds 24 hours, and the difference is stated in the results UI rather than hidden.
- No
Locationheader and no rendered anchor contains variant identity. - Promote is disabled until both the per-arm visitor threshold and the minimum-duration threshold pass; the endpoint returns 409
experiment_guard_not_metbefore that, naming the failing condition. The minimum duration exists because assignment re-buckets daily: a conclusion drawn inside a single 24-hour window is a conclusion about one day's visitors, not about the variants. - Force-promote requires the exact typed confirmation and writes an audit entry recording the bypass.
- Deleting a running experiment reverts traffic to control with no error and no dropped request.
- Bio page winner metric is click-through rate; short-link winner metric is the configured conversion pixel or unique click volume — both verified against a fixture with a known answer.
Effort. 2 weeks.
28.12 M12 — Integrations, leads and the public API #
Goal. Data leaves the product cleanly through supported paths, and third parties can build on it.
In scope. Lead storage, lead management and export behind the capture blocks M5 shipped; ESP destinations and the generic webhook destination; the single per-workspace outbound webhook with HMAC signing, the six-attempt ladder and dead-letter visibility; the analytics pixels with consent gating; server-side forwarding respecting the same consent state; the Slack app; the Zapier REST-hooks app; the complete public API with key management, scopes, per-key rate limits, idempotency, cursor pagination, the canonical envelope, and the generated OpenAPI document; extending the M2 capability endpoint to API-key credentials; contract tests.
Out of scope. Webhook subscription management UI, full CRM integrations, ad-platform conversion APIs — all roadmap (Section 30.11). Billing on the API: there are no billing endpoints on the public API and none are added here.
Implements. Sections 19, 20, 21.
Depends on. M5 (the email-capture block this milestone stores leads from ships there), M8, M10. Nothing later.
Deliverables. Lead capture and management; integrations; webhook delivery; public API and documentation; contract test suite.
Exit criteria.
- Every documented endpoint returns the canonical success or error envelope with
request_idpresent, and every collection endpoint implements cursor pagination with the documented defaults and maximum. - The OpenAPI document is generated from the validating schemas; CI fails on drift and on an unversioned breaking change.
- API keys are shown once, stored hashed with a display prefix, scoped, rate-limited per key, revocable, and track last use.
- A key exceeding its plan's rate limit receives 429 with the documented headers; the Free plan has no API access.
- Webhook payloads verify against the documented HMAC scheme; a wrong signature is rejected; the 300-second tolerance is enforced; replaying an old timestamp fails. Every outbound endpoint is
httpson port 443 — plain HTTP is refused at save time and again before each attempt, with no exception for any port, network or customer request. - A failing endpoint is retried on the documented six-attempt ladder and then dead-lettered with UI visibility and a manual retry action.
6a. The API surface carries no billing capability and no
audit:readscope;accountreads return member id, display name and role but never member email addresses; and lead export is not reachable through the API at all. Assert each by attempting it with a key holding every scope the catalogue defines. - Pixels do not fire before consent in a gated region, and server-side forwarding respects the same state — asserted with a simulated EEA visitor.
- A lead submitted on a public page appears in the dashboard and reaches the configured destination; SSRF protections apply to the destination.
Effort. 4 weeks.
28.13 M13 — Hardening, conformance and launch readiness #
Goal. The product is operable by someone who did not build it, and defensible to a customer's security and privacy reviewer.
In scope. Every runbook in Section 25.8 written and rehearsed; all alerts and dashboards in place with routing verified; the on-call rotation established; the first restore drill executed; the SLO definitions instrumented and reporting; the support console with the impersonation policy and its boundaries; the consent banner and its geo-gating; GDPR export and deletion with the QR carve-out; the DPIA summary, DPA and sub-processor list; the external penetration test and remediation; the full manual accessibility matrix; load, soak and spike suites green; the abuse-report endpoint and trust workflows; the status page.
Out of scope. New product features.
Implements. Sections 23, 24, 25 in full, 26.14, 27.11 (the autoscaling signals and floors for every service, verified end to end under the spike test), 27.14 (the cost model calibrated against real staging figures and wired to the Cost dashboard), and the quarterly rotation drill that 27.5 requires.
Depends on. All prior milestones. Nothing later — this is the last milestone.
Deliverables. Runbooks; alerting and dashboards; support console; privacy artefacts; penetration test report and remediation; qualified release candidate.
Exit criteria.
- Every runbook in Section 25.8 has been executed at least once in staging by an engineer who did not write it, and each step's verification was observed.
- Every alert in Section 25.6 fires correctly in a test and routes to the right destination with a working runbook link.
- Every SLO in Section 25.5 is instrumented and reporting attainment and budget burn.
- A restore drill meets its RTO, and a point-in-time recovery is verified against a known marker row.
- Support can perform every action in Section 25.12.2 and can perform none of the actions in Section 25.12.4; the boundary failures are demonstrated.
- Impersonation requires consent, is read-only by default, is time-boxed, shows a banner, sends both emails and writes audit entries — all demonstrated.
- GDPR export and deletion complete end to end; deletion strips personal data from the memorial page while the QR slug still resolves.
- Penetration test critical and high findings are remediated and retested.
- The manual accessibility matrix is complete with zero unresolved serious or critical findings.
- The Section 26.14 manual script passes in full, including the physical print-and-scan step and the destination change on the same printed code.
- Autoscaling behaves as configured for every service: the spike test triggers scale-out within its stabilisation window, the resolver's scheduled floor is in force, and the cross-service connection cap holds total database connections below 70% of the instance limit.
- A secret rotation drill completes end to end with zero downtime, and the cost dashboard reports actual spend against the model's per-component figures.
- The complete Section 26.5.4 matrix passes — all 22 rows, all five assertions, plus the property-based test at full width. No milestone before this one was accountable for the whole matrix; this one is.
Effort. 3 weeks.
28.14 Parallelisation and critical path #
The full dependency list, so the path can be checked rather than trusted. Every entry names only milestones that come before it, which is the property the plan is built to hold:
| Milestone | Depends on | Effort |
|---|---|---|
| M1 Foundations | — | 2 |
| M2 Auth, accounts, workspaces | M1 | 3 |
| M3 Resolver and short links | M2 | 3 |
| M4 Public renderer | M2 | 3 |
| M5 Editor and blocks | M4 | 4 |
| M6 Dynamic QR codes | M3 | 4 |
| M7 Analytics ingest | M3, M6 | 3 |
| M8 Dashboards, export, link controls | M7 | 3 |
| M9 Custom domains and TLS | M3 | 3 |
| M10 Billing and entitlements | M2, M3, M6, M8 | 3 |
| M11 A/B testing | M5, M8, M10 | 2 |
| M12 Integrations, leads, public API | M5, M8, M10 | 4 |
| M13 Hardening and launch readiness | all | 3 |
| Total | 40 |
Critical path: M1 → M2 → M3 → M6 → M7 → M8 → M10 → M12 → M13, totalling 28 engineer-weeks. M12 is on the path and M11 is not, simply because M12 is four weeks against M11's two and both hang off M10; the two are otherwise interchangeable in position. Every other milestone has slack and can be overlapped.
It is worth saying plainly what this arithmetic means, because it is the single most useful number in this section: the total work is 40 engineer-weeks and the critical path is 28 of them. Adding engineers buys you the 12 weeks of off-path work and nothing more. Beyond three engineers the wall clock is bounded by the chain, not by capacity, and the right use of a fourth or fifth person is to pull M13's operability work forward into every milestone — which the plan already prefers — rather than to expect an earlier launch.
Concurrency plan by team size:
| Engineers | Approach | Wall clock |
|---|---|---|
| 1 | Strict sequence M1→M13 | ~40 weeks |
| 2 | After M2: one engineer takes the delivery spine (M3 → M6 → M7 → M8); the other takes the public surface (M4 → M5), then M9, then joins the spine at M10. M12 and M11 are shared | ~31 weeks |
| 3 | After M2: engineer A takes M3 → M6 → M7; engineer B takes M4 → M5; engineer C takes M9 (which needs only M3's host-resolution contract), then supports M8. M10 follows M8, then M12 and M11 in parallel, then M13 together | ~28 weeks — the critical path, reached |
| 4+ | As above. The fourth engineer owns observability, runbooks, alerting and the support console continuously from M3 onward instead of as an M13 push, which is the only remaining lever: it shrinks M13's residual from three weeks to about one | ~26 weeks |
Safe concurrency pairs, because they touch disjoint code:
- M4/M5 (public renderer and editor) with M3/M6 (resolver and QR) — different deployables, joined only by the shared schema.
- M9 (domains and TLS) with anything after M3 — it depends on the host resolution contract, not on link features.
- M11 (A/B) with M12 (integrations and API) — different subsystems, both depending on M5, M8 and M10.
- Observability, runbooks and dashboards from M13 can be built incrementally alongside every milestone from M3 onward, and doing so is strongly preferred over a big-bang operability push at the end. It is also the only way a team of four or more beats 28 weeks.
Do not parallelise: M7 with M8 (dashboards must be built against the rollup shapes that ingest actually produces, or they will be rewritten); M10 with M6 (the QR exemption from every billing effect must be implemented and tested by people who can see both sides at once); M2 with anything (the permission middleware and the entitlement seam are dependencies of every later route, and forking before they are stable creates a merge conflict across the whole route table).
Hard sequencing constraint independent of team size: the QR permanence guarantee spans M6, M7, M10 and M13, and each of those milestones owns a named part of the Section 26.5.4 matrix — M6 rows 1–4 and 10–22, M7 the analytics limb, M10 rows 5–9, M13 the whole matrix at full width. The matrix is extended at each rather than written once at the end. A milestone that touches billing, deletion or retention without re-running the rows it owns has not met its exit criteria.
29. Executor Instructions #
This section is written to the engineer or AI agent implementing this specification.
29.1 How to read this document, and in what order #
- Read this section (29) first, in full. It tells you the invariants you cannot break and where every definition lives.
- Read Sections 1 through 5. Decisions, stack, architecture and conventions. Everything after them assumes them.
- Read Section 6 completely. The schema is the substrate. Do not begin coding until you can describe every table and its relationships from memory.
- Read Sections 28 (execution plan), 25.5 (SLOs) and 26.12 (coverage floors) so you know what "done" measures.
- Then read only the sections for the milestone you are starting, plus the cross-cutting sections they touch (23 security and privacy, 24 accessibility, 25 observability, 26 testing, 27 configuration). Do not read all thirty sections before writing code; you will forget the details before you need them.
- Use Section 30 as a lookup, not a read. Glossary, error codes, event catalogue, reserved slugs, DNS records, sizing tables, decision log.
- When two sections appear to disagree, the section that owns the concern wins. Section 29.4 says which one owns it.
29.2 Non-negotiable invariants #
These may never be violated, by any feature, in any release, for any reason. A change that breaks one of these is reverted regardless of what else it delivers.
- QR permanence. A QR slug is reserved forever, never recycled, never reassigned, never purged — not on downgrade, non-payment, workspace deletion or account deletion. There is no code path anywhere that deletes a slug reservation, and a reservation blocks the identical short-link slug on that host as well, because the two share one namespace.
- No QR path ever returns a 4xx. Stated separately from invariant 1 because it is a separate property and is separately testable: a request to a reserved slug returns 302 or 200, never 404, never 410, never any other 4xx and never a 5xx. The chain has exactly four rungs —
active,paused_fallback,workspace_unavailable,generic— and always terminates in a page a human can read. No billing state, plan state, deletion, suspension, expiry, workspace closure or infrastructure failure changes that. - Raw IP is never persisted, and neither is the raw user-agent string. Both exist in memory only — the address long enough to derive the salted visitor hash and resolve country and region, the user-agent long enough to parse a family label and a salted hash — and both are then discarded. No column, no log line, no export, no metric label, no cache entry, no job payload and no audit entry ever contains either. What may be stored is
country_code,region_code,visitor_hash,user_agent_familyandua_hash, and nothing more granular. - The redirect latency budget holds. Server-side p50 < 20 ms, p95 < 50 ms, p99 < 120 ms, measured under the sustained load profile in Section 26.7, not on an idle box. Nothing is added to that path — not a trace, not a synchronous log write, not an extra query, not a third-party call, not an authorization check — without proving the budget still holds under load. The redirect path does not call
authorize()at all; it has no actor and no permission concept. - The public page works without JavaScript. Every link is a server-rendered anchor. JavaScript is enhancement only. Zero bytes of render-blocking JavaScript, and the Section 11 budgets are enforced in CI. A submission arriving from the no-JavaScript path is never challenged; it is accepted and, where the abuse heuristics warrant, queued for review.
- Tenancy isolation is absolute. Every query touching workspace-scoped data is scoped by workspace id. Cross-workspace access returns 404, never 403, so existence is never disclosed — and the same applies to a resource inside the workspace that a scoped member has no grant on, and to an API key presented against a workspace it does not belong to. The workspace check runs before any capability evaluation, so no scope or role error can leak the workspace's existence.
- Redirects are 302 with
Cache-Control: private, no-store. 301 and 308 are never issued for a short link or a QR code destination, under any circumstance, with no opt-in. The single 301 in the product is the HTTP→HTTPS scheme upgrade, which is a transport upgrade to the identical URL rather than a destination redirect; it is permanent because the URL it points at cannot change. - PostgreSQL is the system of record. Redis is a cache and a buffer. Losing Redis entirely must degrade performance, never correctness. The one place this cuts the other way is the event stream, whose Redis database runs under
noevictionand whose workers refuse to start otherwise — because eviction there is silent data loss rather than a cache miss. - The audit log is append-only. No update path, no delete path, no suppression, for any actor including operators. QR destination-change entries additionally carry a null retention expiry and are excluded from every purge, so they survive indefinitely.
- Analytics never block delivery. Publication is fire-and-forget. A failing analytics path must never change a redirect's status, latency or destination.
- Entitlements are enforced server-side, on every mutating path, including the public API. A client-side check is a hint, never a control.
- Secrets never enter the repository, a log line, an error message, an export or a screenshot. The redaction list in Section 25.2.6 is exhaustive and enforced by middleware and by test.
- Accessibility conformance is a gate, not a goal. A serious or critical violation on a public surface blocks the release, and no accessibility allow-list entry may cover contrast, keyboard operability, focus visibility, accessible names, or a cognitive-function test.
29.3 Build order and why #
Build in the milestone order of Section 28. The order is dictated by three things:
- Substrate before consumers. The schema (M1) and the permission middleware (M2) are dependencies of every route in the product. Building features first and retrofitting tenancy scoping produces a security review you cannot pass.
- The riskiest promise earliest. The resolver (M3) and QR codes (M6) come before dashboards, billing and integrations because the QR permanence guarantee and the latency budget are the two commitments most expensive to discover you cannot meet. If either is going to force an architectural change, you want that in week 8, not week 30.
- Producers before consumers of data. Ingest (M7) precedes dashboards (M8) so that dashboards are built against the rollup shapes ingest actually produces. Billing (M10) comes after the resources it governs exist, so the entitlement engine is written against real call sites rather than imagined ones.
Two things are deliberately late and that is correct: A/B testing (M11) needs both the public renderer and the analytics pipeline to be stable, and the public API (M12) should be generated from domain schemas that have stopped changing. Two things must be built continuously rather than at the end: observability (Section 25) and tests (Section 26). Adding either retroactively costs more than building them alongside, and the milestone exit criteria assume they were.
29.4 Where each concern is canonically defined #
| Concern | Owning section |
|---|---|
| Customization decisions to make before starting | 1 |
| Product scope, vision, non-goals | 2 |
| Roles, the permission matrix, per-resource grants | 3 |
| Stack, dependency versions, deployables, architecture | 4 |
| Naming, identifiers, timestamps, money, soft delete, code style | 5 |
| Tables, columns, indexes, constraints, partitioning | 6 |
| Auth methods, password rules, sessions, 2FA, auth rate limits | 7 |
| Workspaces, membership mechanics, invitations, audit log | 8 |
| Editor experience, autosave, draft/publish | 9 |
| Block types and their configuration | 10 |
| Public rendering, performance budgets, no-JS guarantee, caching | 11 |
| Short links, slugs, link CRUD, bulk operations | 12 |
| Custom domain lifecycle, DNS, ACME, TLS renewal | 13 |
| QR entity, styling, encoding, scannability validation, permanence and fallback chain | 14 |
| UTM builder, scheduling, expiry, targeting rules | 15 |
| A/B assignment, salts, stickiness, statistics, promotion | 16 |
| Event capture, stream, batch consumption, partitioning, rollups, retention | 17 |
| Dashboards, reports, CSV export | 18 |
| Pixels, embeds, outbound webhook, Slack, Zapier | 19 |
| Email capture, lead storage, ESP destinations | 20 |
| API envelope, pagination, status codes, API keys, scopes, rate limits | 21 |
| Plans, prices, entitlement table, downgrade behaviour | 22 |
| CSP and headers, URL safety, SSRF, consent, GDPR, DPIA, controller/processor | 23 |
| WCAG target, success criteria, contrast enforcement, screen-reader matrix | 24 |
| Logging fields, metrics, tracing, SLOs, alerts, runbooks, on-call, backup/DR, support | 25 |
| Test layers, critical suites, coverage floors, CI stages, release qualification | 26 |
| Environments, infrastructure, every environment variable, migrations, deploy, rollback, local setup, cost | 27 |
| Milestones and exit criteria | 28 |
| Invariants, build order, definition of done, pitfalls | 29 |
| Glossary, error codes, events, reserved slugs, DNS records, sizing, decision log, roadmap | 30 |
29.5 Handling a genuine ambiguity #
This document is written to be executed without questions. If you nonetheless hit something genuinely undetermined, follow this procedure exactly.
- Re-check the owning section in Section 29.4. Most apparent gaps are answered somewhere you have not looked.
- Check whether an invariant in Section 29.2 already forces the answer. It usually does. If one option preserves QR resolution, tenancy isolation, or the latency budget and the other does not, the choice is made.
- Apply the tie-breakers, in this priority order:
- The safer behaviour for the end visitor (never break a printed code, never leak across tenants, never break a page without JavaScript).
- The behaviour that preserves data (archive, never delete; soft delete, never hard).
- The behaviour consistent with the nearest analogous decided case in this document.
- The simpler implementation.
- The behaviour that is easier to reverse later.
- Decide. Do not ask, do not stall, do not implement both behind a flag unless the flag is genuinely a rollout control per Section 27.4.15.
- Record the decision in the repository's own
DECISIONS.md, in this format:
## D-014: Ordering of blocks returned by the public page API
- Date: <date>
- Context: Section 10 defines block ordering for rendering but the public read API's
ordering was not stated explicitly.
- Decision: Return blocks in render order (position ascending, hidden excluded).
- Rationale: Matches what the renderer does; a consumer building an alternative
renderer needs the same order. Tie-breaker 3, consistency with the nearest case.
- Alternatives rejected: Creation order (surprising); explicit sort parameter
(adds surface for no known use case).
- Reversible: Yes, additive sort parameter could be added later.- Never leave a placeholder. No
TODO, noFIXME, nothrow new Error('not implemented'), no commented-out branch, no silently returningnullwhere a value is required. If you decided it, implement it; if you have not decided it, decide it now using step 3. - Write a test that pins the decision, so that a future change that contradicts it fails rather than drifts.
29.6 Definition of done for a feature #
A feature is done when every box is ticked. Not most.
- Behaviour matches the owning section, including every stated edge case, empty state and validation rule.
- Every input is validated server-side with a schema; the client's validation is a convenience only.
- Every failure path returns the canonical error envelope with a code that exists in the Section 30.2 registry.
- Every guarded action checks permission (Section 3) and entitlement (Section 22) server-side, before doing any work.
- Every query touching workspace-scoped data is scoped by workspace id, and a test proves cross-workspace access returns 404.
- Audit entries are written for every event in the Section 8 list that this feature can cause.
- Unit tests cover the logic; integration tests cover the persistence and cache behaviour; E2E covers the user journey if the feature has one.
- Coverage floors are met, including the elevated floors if the feature touches a critical path.
- Metrics and structured logs are emitted per Section 25, with no field on the redaction list.
- If the feature touches the redirect path, the load scenario still meets the latency budget.
- If the feature touches a public surface, Lighthouse and bundle budgets still pass, and axe-core reports no serious or critical violation.
- If the feature touches billing, deletion or retention, the QR fallback matrix (Section 26.5.4) has been re-run and extended.
- Any new environment variable is added to Section 27.4's equivalent in the repository's
.env.exampleand to the startup validation schema. - Any new error code is added to the registry; the drift check passes in both directions, and no name on the deliberately-absent list in Section 30.2.16 has been reintroduced.
- Every
Section N.Mcitation added by this change resolves to a heading that exists; the reference-integrity stage passes. - Any migration is expand/contract and backward-compatible with the previous release; the CI check proves it.
- User-facing copy is written, including empty states, errors and confirmations. No lorem ipsum, no raw error codes shown to users.
- Documentation updated:
README.mdif setup changed,DECISIONS.mdif a decision was made, the OpenAPI document if the API changed. - The pull request describes what changed, why, how it was verified, and how to roll it back.
29.7 Commit, branch and pull-request discipline #
Branch naming, commit format, pull-request structure and merge strategy are defined in Section 5.7 and are not restated here. Read that section and follow it exactly; where anything in this document appears to say otherwise, Section 5.7 wins. Duplicating those rules is what let them drift in the first place.
Three requirements are specific to executing this plan and sit on top of Section 5.7 rather than replacing any part of it:
- Reference the milestone. Every branch name and every commit body names the milestone from Section 28 it belongs to, so that the exit-criteria evidence in a pull request can be traced to the milestone it closes.
- Two reviewers on the load-bearing paths. Anything touching authentication, the permission model, the entitlement seam, the redirect path, the QR fallback chain or a migration requires a second reviewer. These are the six areas where a defect is either a security incident, a billing incident, or a printed-material incident that cannot be recalled.
- No gate is bypassed. Every pull request passes every stage in Section 26.13. A red build is never merged with a promise to fix it afterwards, and a gate is never disabled to land a change — if a gate is wrong, fixing the gate is the change.
29.8 When a dependency version has moved on #
The version lines in Section 4 are a known-good floor, not a lockfile. Follow this when the current stable release differs:
- Same major line, newer minor or patch: install the current stable release. Take it without ceremony; the lockfile records what resolved.
- New major line available: install the version at the documented major line first and get the milestone working. Then, as a separate change, evaluate the upgrade: read the migration guide, upgrade in isolation, run the full suite plus the load and QR conformance suites, and merge only if everything is green. Never bundle a major upgrade with feature work.
- The documented major line is no longer installable or is end-of-life: move to the next major line, treat it as a spike, and record the decision in
DECISIONS.mdwith what changed and what was verified. - A security advisory affects a pinned dependency: patch immediately, ahead of feature work, per Section 26.9's gate.
- A dependency is deprecated or abandoned: do not fork it. Choose the closest maintained equivalent, hide it behind the existing adapter seam if one exists, and record the decision.
- Never downgrade a dependency to make a test pass. Fix the test or fix the code.
- Never pin an exact patch version in
package.jsonfor a runtime dependency; pin ranges and let the lockfile hold the resolved versions. Build tooling that affects output determinism (the container base image) is the exception and is pinned by digest.
29.9 Verification before declaring a milestone complete #
Run all of this. "It works on my machine" is not a verification step.
make checklocally: lint, type check, unit, integration. Green.- The full CI pipeline on the branch. Every stage green, including accessibility, visual regression, Lighthouse and contract drift.
- Every exit criterion in the milestone's Section 28 entry, individually verified, with the evidence recorded in the pull request. An exit criterion is not satisfied by asserting it; it is satisfied by demonstrating it.
- Coverage report: overall floor met, and every elevated floor met for paths this milestone touched.
- If the milestone touched the redirect path: the k6 steady-state, cold-cache and hot-slug scenarios, all thresholds met.
- If the milestone touched a public surface: Lighthouse on the reference device profile, all Section 11 budgets green; axe-core clean of serious and critical.
- If the milestone touched QR, billing, deletion or retention: the complete Section 26.5.4 matrix, including the property-based test.
- If the milestone touched the schema: apply migrations to a copy of staging data; run the previous release's integration tests against the migrated schema; confirm the down script works for additive changes.
- Deploy to staging and run the smoke suite plus the relevant portion of the Section 26.14 manual script.
- Confirm the observability for what you built exists: the metrics are being emitted, the dashboard panel shows them, and any new alert fires in a test.
- Update
DECISIONS.mdwith anything decided under Section 29.5 during the milestone. - Only then mark the milestone complete.
29.10 Common pitfalls specific to this product #
| Pitfall | Why it happens | The correct approach |
|---|---|---|
| Deleting or recycling a QR slug during cleanup, deletion or a test-teardown helper | Every other resource has a soft-delete-then-purge lifecycle, so the purge worker and cleanup helpers are written generically | The reservation table is exempt from all cleanup. It has no deleted_at and no delete path. Assert this with a static check, and exclude it explicitly in every teardown helper |
| Issuing a 301 because "the destination rarely changes" | Permanent redirects feel more correct and benchmark better | Destinations are editable at any time and a browser-cached 301 is unrecoverable. Always 302 with private, no-store. There is no opt-in |
| Storing the IP "just for debugging" or "just in the log" | It is the most natural thing in the world to log a request's source | Derive the hash and the country, then discard. The redaction middleware strips it, but do not rely on the middleware — do not put it in the object in the first place |
| Adding a query, a trace or a synchronous log to the redirect path | Each addition looks individually cheap | Measure before and after with the load suite. If it is not required to produce the Location header, it happens after the response or not at all |
| Adding JavaScript that the public page needs in order to navigate | Modern component patterns default to client-side interactivity | Links are anchors. Test with JavaScript disabled in E2E, every release |
| Caching the bio page for a workspace with a running experiment | Page caching is an easy performance win | Assignment is per-visitor and server-side. Experiment pages bypass the CDN entirely (Section 27.12) |
| Appending the variant to the outbound URL so it can be attributed | It is the obvious way to join assignment to conversion | Join server-side on the visitor hash. Polluting the customer's destination URL breaks their own analytics and is visible to their visitors |
| Forgetting that the visitor salt rotates daily, and claiming a longer stickiness than the system can deliver | The experiment salt epoch is a week, so a week of stickiness looks like the obvious consequence | The assignment function mixes the weekly epoch with the visitor hash, and the visitor hash rotates every 24 hours — so the composite input changes daily and stickiness on the no-consent path is 24 hours, full stop. The weekly epoch bounds how long an experiment's bucketing basis is comparable, not how long one visitor stays in one arm. Unique-visitor metrics are per-day and multi-day uniques are an upper bound. The minimum-duration guard exists precisely because assignment re-buckets daily |
| Blocking the redirect on the analytics publish | Awaiting a promise is the default | Fire and forget. If publication fails, increment the counter and return the redirect anyway |
| Enforcing an entitlement only in the dashboard UI | The UI is where the limit is visible | Every mutating endpoint, including the public API, checks server-side before doing any work |
| Deleting resources on downgrade | It is the simplest way to enforce a cap | Archive, never delete. Over-cap links resolve for 90 days then serve a branded page. QR codes are exempt entirely |
| Returning 403 for a resource in another workspace, or for a resource a scoped member has no grant on | It feels more truthful, and a scoped member is "inside" the workspace so 403 seems harmless | Return 404 in both cases. A 403 confirms the resource exists, which is exactly the fact the grant was created to withhold. The same applies to an API key presented against a workspace it does not belong to: check the workspace before evaluating any scope, or the scope error itself becomes the oracle |
| Writing a launch check that fails the build on any 301 | The never-301 rule is the most repeated rule in the document, so a blanket grep feels like the faithful implementation | The rule governs destination redirects, whose targets are editable. The HTTP→HTTPS scheme upgrade is a different thing — same URL, different scheme — and it must be a 301 so it is cached and pairs with HSTS. Scope the check to the destination-resolution modules and exempt the scheme-upgrade handler explicitly, with its own positive test. A blanket grep forbids the correct implementation and fails the build on compliant code |
| Writing a milestone whose exit criteria need a later milestone | Exit criteria are written by reading the feature's section, which describes the finished state including the parts other milestones deliver | Check every criterion against the dependency list before committing to it. If a criterion needs something later, either move the seam earlier (as the entitlement evaluation is moved to M2) or name the subset this milestone owns (as the QR matrix rows are split). A criterion that cannot be met is not a criterion; it silently converts the plan into a single big-bang delivery |
| Letting a customer's slow webhook endpoint consume the worker fleet | Delivery is naturally synchronous per job | Enforce the per-attempt timeout, use the documented backoff ladder, and isolate delivery in its own queue group |
| Treating Redis as authoritative because it is faster | The cache holds the same data | Redis is a cache and a buffer. Every path must have a correct behaviour when it is empty or unavailable |
| Running migrations on container boot | It is convenient and works with one replica | N replicas booting concurrently race. Migrations are a separate deployment step with an advisory lock |
| Shipping expand and contract in the same release | The change feels incomplete otherwise | The previous release must still work against the new schema. Contract in a later release; CI proves it |
| Building the QR styling UI before the decode validation pipeline | Styling is the visible part | Validation is the constraint that determines which styling options can exist. Build the pipeline first, then expose only what passes it |
| Testing the QR fallback chain once, at the end | It is a large matrix | It spans milestones 6, 7, 10 and 13. Extend and re-run it at each. A billing change that breaks resolution is the most likely way to break the product's core promise |
| Letting the geo lookup return a city because the database offers one | The field is right there | Country and region only. Storing finer geography changes the privacy position the product is sold on and invalidates the DPIA |
| Using a workspace id as a metric label to "make debugging easier" | It genuinely would | Unbounded cardinality will take down the metrics backend. Per-workspace numbers come from the rollups. A CI check rejects it |
30. Appendices #
30.1 Glossary #
| Term | Definition |
|---|---|
| Active destination | The URL a link or QR code currently resolves to when no fallback applies — rung 1, fallback_stage = active |
| Archived resource | A resource above a plan cap after downgrade: retained, read-only, still resolving per Section 22, never deleted, and not counted toward any cap (Section 22.2.5). A link pinned to a QR code is never archived at all |
| Assignment | The variant a visitor is allocated in an experiment, derived deterministically from the experiment salt, the experiment id and the visitor hash. Because the visitor hash rotates daily, an assignment is stable for 24 hours on the cookie-free path |
| Audit log | The append-only record of consequential actions in a workspace (Section 8) |
| Bio page | A public, server-rendered page composed of blocks, addressed by a handle |
| Block | One configurable unit on a bio page, drawn from the catalog in Section 10 |
| Bot event | An event classified as non-human by user-agent or ASN, stored with the bot flag and excluded from default views |
| Branded landing page | A workspace-styled page served where a resolution cannot proceed to a destination |
| Bucket | The 0–9999 integer derived by the assignment function, mapped to a variant by weight |
| Cursor | An opaque, base64-encoded position token used for pagination (Section 21) |
| Custom domain | A customer-owned hostname verified and TLS-provisioned by the platform (Section 13) |
| Daily salt | The 24-hour rotating secret mixed into the visitor hash and into ua_hash. Its rotation period is what bounds cookie-free assignment stickiness at 24 hours |
| Dead letter | A job or delivery that exhausted its retries and is retained for inspection and manual retry |
| Deployable | One of the four independently deployed services: web, redirect resolver, public API, worker |
| Dimension | A rolled-up analytics attribute such as country, device type, referrer host or UTM source |
| Dynamic QR code | A QR code encoding a platform URL, so its destination is changeable after printing |
| Entitlement | A plan-derived capability or limit enforced server-side (Section 22) |
| Error budget | The permitted shortfall against an SLO over its window (Section 25.5) |
| Experiment salt | The 7-day rotating secret used by the assignment function. It bounds how long an experiment's bucketing basis stays comparable; it does not bound a visitor's stickiness, which the daily visitor-hash rotation caps at 24 hours |
| Fallback rung | The position reached in the QR resolution chain, numbered 1–4 and always written alongside its fallback_stage enum value: 1 active (302 to the active destination), 2 paused_fallback (302 to the configured fallback URL), 3 workspace_unavailable (200, workspace-branded page), 4 generic (200, neutral platform page). There is no rung 5, and no rung is ever a 4xx or 5xx |
fallback_stage |
The four-value enum — active, paused_fallback, workspace_unavailable, generic — carried on the response, the log line, the analytics event and the metric label. It is the single vocabulary for the concept; there is no rival serving_mode |
| Fair use | A monthly creation ceiling on an entitlement described as unlimited |
| Handle | The human-chosen public identifier of a bio page |
| Idempotency key | A client-supplied token making a repeated write safe (Section 21) |
| Interstitial | The warning page served in place of a redirect when a destination is flagged |
| Lead | A contact record captured by an email-capture block (Section 20) |
| Memorial page | The permanent page served for a QR code whose workspace no longer serves it. Before erasure it is the rung 3 workspace-branded page and may carry the workspace display name; after erasure it is the rung 4 neutral platform page and carries no workspace name, handle, logo or other identifying content — which is what keeps the "no personal data is retained" limb of the privacy position in Section 23.15 true |
| Minimum-sample guard | The rule blocking winner promotion until both thresholds pass: 100 visitors per variant, and the minimum elapsed duration. The duration limb exists because assignment re-buckets daily, so a conclusion drawn inside one 24-hour window describes one day's visitors rather than the variants |
| Negative cache | A short-lived cache entry recording that a host and slug combination does not resolve |
| Per-resource grant | A Business-only allow-list restricting a member to specific resources, intersecting with their role |
| Quiet zone | The mandatory blank margin, at least 4 modules wide, around a QR symbol |
| Redirect payload | The minimal cached object the resolver needs to answer a request |
| Rollup | A pre-aggregated analytics row at hourly or daily granularity |
| Rung | See fallback rung |
| Scannability validation | The three-condition automated decode check run on every QR render (Section 14) |
| Short link | A branded, shortened URL resolving to a destination (Section 12) |
| Single-flight | Collapsing concurrent cache misses for one key into a single origin read |
| SLI / SLO | Service level indicator (the measurement) and objective (the target) |
| Slug | The path component identifying a link or QR code on a host |
| Slug reservation | The permanent record that a slug has been used by a QR code and may never be reissued — to a QR code or to a short link, on that host, by any workspace, ever |
| Soft delete | Marking a row deleted with a 30-day restore window before hard purge. Slug reservations are exempt: they have no soft-delete column and no delete path |
| Targeting rule | A condition (geography, device, schedule, language) selecting an alternative destination (Section 15) |
ua_hash |
The daily-salted 16-byte digest of the raw user-agent string, rotating on the same salt as the visitor hash, used only for bot-signature clustering. The raw string itself is never persisted |
user_agent_family |
The short parsed label stored in place of the raw user-agent string, for example Chrome on Android |
| Visitor hash | The cookie-free, salted, daily-rotating identifier for a visitor |
| Workspace | The tenancy boundary owning all resources, members, billing and analytics |
| Write-through | Updating the cache synchronously with the database write, so the next read is correct |
30.2 Complete error code reference #
This registry is generated, not written. It is produced from a single constant in the shared core package, and the drift check in Section 26.10 fails the build in both directions: a code emitted anywhere in the source that is absent here fails, and an entry here that no code path emits and no test covers fails. Hand-editing this table without changing the constant is therefore not a documentation slip, it is a broken build.
Codes are snake_case, stable, and returned in the canonical error envelope defined in Section 21.3. Every entry has exactly one HTTP status. Where two sections historically used different names or different statuses for the same condition, the resolution rule is stated once and applied mechanically:
- The owning section's name wins. The section that owns the concern names the code; every other section adopts that name. Section 29.4 says which section owns what.
- The owning section's status wins, except where a status is fixed below by the cross-cutting rules: a body over the size cap is always 413, a wrong content type is always 415, a refused plan transition is always 409, a second factor demand is always 401, and every cross-tenant or ungranted lookup is always 404.
- Entitlement refusals collapse to exactly two codes.
plan_limit_reached(403) covers every numeric or period cap, distinguished bydetails[0].kind(countorperiod).plan_feature_unavailable(403) covers every binary feature gate. No section defines a third. - Names that were retired are listed in 30.2.16 and must never reappear. A third check asserts none of them occurs anywhere in the source.
The entitlement payload shape, since two codes carry most of the product's commercial behaviour:
{ "error": { "code": "plan_limit_reached", "message": "...",
"details": [ { "field": "qr_codes", "issue": "limit_reached",
"limit": 100, "current": 100, "plan": "pro", "kind": "count" } ],
"request_id": "req_..." } }plan_feature_unavailable uses the same array shape with "issue": "feature_unavailable", plan, required_plan, and no limit or current.
Two things are deliberately not in this registry. The public error pages in Section 11.10 — not found, expired, not-yet-available, password form, safety interstitial, and the rung 3 and rung 4 pages — are HTML surfaces for human visitors and carry no machine-readable code; their statuses are governed by Section 11.10 and by the QR chain in Section 14.8. And the batch capability endpoint in Section 21 introduces no code of its own: it emits unauthenticated, insufficient_scope, validation_failed, bulk_too_many_items and rate_limited, all of which appear below, and reports per-entry denials using the same codes the enforcing route would have returned.
30.2.1 Authorization core #
Emitted by the single authorize() function every deployable calls. These are the codes a caller sees before any feature logic runs.
| Code | HTTP | Owning subsection | Meaning | Remedy |
|---|---|---|---|---|
unauthenticated |
401 | 3.5 | No credential presented, or a malformed Authorization header |
Sign in, or supply a valid API key |
account_suspended |
403 | 3.5 | The acting user's account is suspended for abuse | Contact support |
action_not_available_to_api_key |
403 | 3.5 | The action is in the billing, member-management, audit or GDPR domain, which no API key may reach regardless of its scopes | Perform it from the dashboard |
workspace_not_found |
404 | 3.5 | The workspace does not exist, the actor is not a member, or an API key was presented against a workspace it does not belong to. All three are deliberately indistinguishable | Check the id and your membership |
workspace_deleted |
410 | 3.5 | The workspace is inside its deletion grace window; only an Owner's restore is permitted | Restore it, or use another workspace |
membership_inactive |
403 | 3.5 | The membership exists but is not active | Ask an Owner or Admin to reactivate it |
two_factor_required |
403 | 3.5 | The workspace enforces 2FA and this session has not satisfied it | Complete the second factor for this session |
reauthentication_required |
403 | 3.5 | A step-up action was attempted without a re-authentication inside the last 5 minutes | Re-enter credentials, then retry |
insufficient_role |
403 | 3.5 | The membership's role does not permit this action, including an Admin acting on an Owner or another Admin | Ask an Owner |
not_found |
404 | 3.5 | The resource does not exist, belongs to another workspace, or is outside a scoped member's grants. Never 403 — a 403 would confirm existence | Check the id and workspace |
resource_gone |
410 | 3.5 | A mutation was attempted on a soft-deleted resource | Restore it within its window |
resource_archived |
409 | 3.5 | A mutation was attempted on a resource archived by a downgrade | Restore it within the cap, or upgrade |
resource_locked |
409 | 3.5 | The resource is locked by a downgrade in progress | Complete or cancel the downgrade |
confirmation_required |
422 | 3.5 | A destructive action was attempted without its exact typed confirmation | Type the confirmation phrase shown |
30.2.2 Authentication and account #
| Code | HTTP | Owning subsection | Meaning | Remedy |
|---|---|---|---|---|
invalid_email |
400 | 7.13 | Email fails format validation. Also emitted when inviting a member | Correct the address |
password_too_weak |
400 | 7.13 | Below the minimum length or strength score, or found in a breach corpus; details[].issue distinguishes them |
Choose a longer, less predictable password |
password_reused |
400 | 7.13 | The new password equals the current one | Choose a different password |
invalid_credentials |
401 | 7.13 | Email or password incorrect. Identical for an unknown account, by design | Re-enter, or use password reset |
session_expired |
401 | 7.13 | Rolling or absolute session lifetime reached | Sign in again |
session_revoked |
401 | 7.13 | The session was revoked | Sign in again |
totp_required |
401 | 7.13 | First factor passed, second factor pending. The response carries a challenge token | Submit a current TOTP code |
totp_invalid |
401 | 7.13 | TOTP code wrong or replayed | Retry with a current code |
recovery_code_invalid |
401 | 7.13 | Recovery code unknown or already used | Use an unused code; contact support if exhausted |
email_verification_required |
403 | 7.13 | The action is gated behind a verified email; details.action names it |
Open the verification email, or request a resend |
oauth_email_unverified |
403 | 7.13 | The provider did not verify the email address | Verify at the provider, or use another method |
oauth_state_invalid |
403 | 7.13 | OAuth state missing, expired or replayed |
Restart the sign-in flow |
totp_enforced |
403 | 7.13 | The workspace requires 2FA and this member has none enrolled | Enrol a second factor |
account_pending_deletion |
403 | 7.13 | Sign-in during the account deletion grace period | Cancel the deletion to continue |
csrf_invalid |
403 | 7.14.3 | Double-submit token missing or mismatched on a state-changing dashboard request | Reload the page and retry |
email_change_pending |
409 | 7.13 | An email change is already in flight | Complete or cancel it first |
token_email_mismatch |
409 | 7.13 | The token was issued for a different address | Sign in with the address the token was issued for |
last_credential |
409 | 7.13 | Unlinking would leave no way to sign in | Add another credential first |
owner_transfer_required |
409 | 7.13 | The account is the sole Owner of a shared workspace; details.workspaces[] lists them |
Transfer ownership, then retry |
owner_totp_required |
409 | 7.13 | An Owner must enable 2FA before enforcing it workspace-wide | Enrol a second factor first |
token_expired |
410 | 7.13 | Any authentication token past its lifetime — verification, magic link, reset, invitation | Request a new one |
token_already_used |
410 | 7.13 | A single-use token was replayed | Request a new one |
account_locked |
429 | 7.13 | Account lockout is active; Retry-After carries the remaining time |
Wait for the lockout to expire |
30.2.3 Workspaces, members and invitations #
| Code | HTTP | Owning subsection | Meaning | Remedy |
|---|---|---|---|---|
slug_reserved |
400 | 8.2 | The chosen workspace slug is a reserved term | Choose another |
invitation_token_invalid |
400 | 8.5 | Invitation token malformed or badly signed | Request a new invitation |
seat_limit_reached |
403 | 8.5 | The plan's seat count is reached. Re-checked at accept time, not only at send time | Upgrade, or remove a member |
workspace_slug_taken |
409 | 8.2 | Slug already in use | Choose another |
subscription_active |
409 | 8.2 | Workspace deletion attempted while a paid subscription is active | Cancel the subscription first |
owner_cannot_leave |
409 | 8.4 | The sole Owner cannot leave the workspace | Transfer ownership, or delete the workspace |
already_member |
409 | 8.5 | The address is already an active member | Change their role instead |
invitation_pending |
409 | 8.5 | An invitation to that address is already outstanding | Resend it rather than creating another |
invitation_revoked |
409 | 8.5 | The invitation was revoked | Ask for a new one |
resend_limit_reached |
409 | 8.5 | Five resends already sent for this invitation | Revoke and issue a new invitation |
target_scoped |
409 | 8.8 | Ownership transfer target is a scoped member | Un-scope the member first |
target_email_unverified |
409 | 8.8 | Ownership transfer target has not verified their email | Ask them to verify |
target_totp_required |
409 | 8.8 | The workspace enforces 2FA and the transfer target has none | Ask them to enrol |
invitation_expired |
410 | 8.5 | Past its 7-day validity | Ask for a new invitation |
invitation_already_used |
410 | 8.5 | The invitation has already been accepted | Sign in normally |
workspace_unavailable |
410 | 8.5 | Invitation accepted into a deleted or suspended workspace | Contact the workspace Owner |
30.2.4 Bio pages, blocks and the editor #
| Code | HTTP | Owning subsection | Meaning | Remedy |
|---|---|---|---|---|
block_order_mismatch |
400 | 9.3.2 | The reorder id list is not exactly the container's current child set | Send the complete, current id list |
handle_reserved |
400 | 9.2.3 | The handle is a reserved, blocked or confusable term | Choose another handle |
page_not_found |
404 | 9.12 | No such page in this workspace, or it was deleted | Check the id, or restore it |
block_not_found |
404 | 21.12 | No such block on this page | Check the id |
page_handle_taken |
409 | 21.12 | The handle is in use on that host | Choose another |
page_revision_conflict |
409 | 9.5.4 | The page changed underneath this edit | Resolve the conflict per 9.5.4 |
page_validation_failed |
422 | 9.9.1 | One or more blocks failed their schema at publish time; details names the blocks |
Fix the named blocks |
block_config_invalid |
422 | 21.12 | A block's configuration does not match its kind's schema | Correct the configuration |
block_kind_immutable |
422 | 21.12 | A block's kind cannot be changed after creation |
Delete and recreate the block |
block_nesting_unsupported |
422 | 9.3.2 | Nesting depth is exactly 1; a group may not contain a group | Flatten the structure |
page_has_no_blocks |
422 | 21.12 | An empty page cannot be published | Add at least one block |
theme_contrast_failed |
422 | 9.6.4 | The theme fails the 4.5:1 contrast requirement | Apply the suggested fix, or use the typed-confirmation override |
media_not_ready |
422 | 9.9.1 | A referenced upload has not finished processing | Wait for processing, then publish |
invalid_stripe_payment_link |
422 | 10.8 | The buy block's payment link is not a valid processor link | Paste the canonical payment link |
30.2.5 Links and slugs #
| Code | HTTP | Owning subsection | Meaning | Remedy |
|---|---|---|---|---|
link_slug_invalid_chars |
400 | 12.11 | Slug contains characters outside [a-z0-9-] |
Correct the slug |
link_slug_length |
400 | 12.11 | Slug is empty or over 64 characters | Shorten it |
link_slug_invalid_format |
400 | 12.11 | Leading or trailing hyphen, --, or a numeric-only slug over 12 digits |
Correct the format |
link_slug_reserved |
400 | 12.11 | Slug is a reserved system word on a platform-owned host | Use another slug, or a custom domain |
link_slug_blocked |
400 | 12.11 | Slug matches the profanity or impersonation blocklist | Choose another |
link_slug_confusable |
400 | 12.11 | Slug is confusable with a reserved or existing slug after homoglyph folding | Use one of the offered alternatives |
link_destination_invalid |
400 | 12.11 | Unparseable URL, control characters, or too many parameters | Provide a valid absolute URL |
link_destination_too_long |
400 | 12.11 | URL or query string over the limit | Shorten the destination |
link_field_too_long |
400 | 12.11 | title or notes over the limit |
Shorten it |
import_missing_required_column |
400 | 12.11 | The CSV has no destination_url column |
Fix the header row |
import_malformed_csv |
400 | 12.11 | Unparseable CSV, or inconsistent column count | Re-export the file |
link_not_found |
404 | 12.11 | No such link in this workspace | Check the id |
link_slug_taken |
409 | 12.11 | Slug already in use on this host | Choose another |
link_slug_held |
409 | 12.11 | Slug belongs to a soft-deleted link inside its 30-day window | Restore that link, or wait |
link_slug_immutable_qr |
409 | 12.11 | A slug change was attempted on a QR-backed link. A printed symbol encodes the slug | Create a new QR code instead |
link_domain_immutable_qr |
409 | 12.11 | A domain change was attempted on a QR-backed link, for the same reason | Create a new QR code instead |
link_has_qr |
409 | 12.11 | Deletion was attempted on a link that backs a QR code | Archive it, or delete the QR code first |
link_in_active_experiment |
409 | 12.11 | A destination edit was attempted while an experiment owns the split | Stop the experiment first |
import_already_committed |
409 | 12.11 | Commit called twice on a completed import | Read the result instead |
import_report_expired |
410 | 12.11 | The validation report is older than 24 hours | Re-upload the file |
link_destination_scheme_not_allowed |
400 | 12.11 | Scheme outside the allow-list | Use https, http, mailto, tel or sms |
link_destination_private_address |
422 | 12.11 | Destination resolves to a private, loopback, link-local or otherwise reserved address | Use a publicly routable destination |
link_destination_unresolvable |
422 | 12.11 | DNS lookup failed at create time | Check the hostname, or create anyway |
link_destination_unsafe |
422 | 12.11 | Reputation-feed match, or the host is on the internal deny-list | Use a different destination; appeal via support |
link_destination_redirect_loop |
422 | 12.11 | Self-referential, or an over-deep redirect chain | Point at a final destination |
folder_depth_exceeded |
422 | 12.11 | More than 3 folder levels | Flatten the structure |
tag_limit_reached |
422 | 12.11 | 20 tags on a link, or 500 in a workspace | Remove tags first |
deep_link_fallback_required |
422 | 12.11 | A platform URL was set with no web fallback | Provide web_fallback_url |
slug_generation_exhausted |
503 | 12.11 | Ten consecutive slug collisions | Retry; an operator is paged automatically |
30.2.6 Custom domains and TLS #
| Code | HTTP | Owning subsection | Meaning | Remedy |
|---|---|---|---|---|
domain_invalid_hostname |
400 | 13.10.1 | Not a parseable hostname, or it carries a scheme, path, port or wildcard | Provide a bare hostname you control |
domain_is_public_suffix |
400 | 13.10.1 | The hostname is a public suffix, not a registrable domain | Use a registrable domain |
domain_reserved |
400 | 13.10.1 | The hostname is under a platform-owned domain | Use your own domain |
domain_not_found |
404 | 13.10.1 | No such domain in this workspace | Check the id |
domain_already_added |
409 | 13.10.1 | Already present in this workspace | Use the existing record |
domain_already_claimed |
409 | 13.10.1 | Active in another workspace | Contact support to release a stale claim |
domain_removal_requires_qr_acknowledgement |
409 | 13.10.1 | Removal attempted without acknowledging that QR codes were printed on this host | Acknowledge, and read what happens to those codes first |
handle_conflicts_with_slug |
409 | 13.10.1 | A bio-page handle collides with a slug on a shared-namespace host | Rename one of them |
domain_in_use |
409 | 21.12 | Links, pages or QR codes still reference the domain; counts are in details |
Reassign or remove them first |
verification_token_expired |
422 | 13.10.1 | The 7-day domain ownership token lapsed | Regenerate the token and re-add the record |
domain_not_active |
422 | 13.10.1 | The operation requires a domain in active |
Complete domain setup |
domain_retained_read_only |
422 | 13.10.1 | The domain is retained solely so printed codes keep resolving, and cannot be mutated | Nothing; this state protects printed material |
domain_verification_pending |
422 | 13.10.1 | A TLS or serving operation was requested before verification completed | Wait for verification |
dns_resolver_unavailable |
502 | 13.10.1 | Every configured resolver failed; the check is retried automatically | Retry later |
acme_unavailable |
503 | 13.10.1 | The certificate authority is unreachable; issuance is retried automatically | Retry later |
30.2.7 QR codes #
| Code | HTTP | Owning subsection | Meaning | Remedy |
|---|---|---|---|---|
qr_name_required |
400 | 14.11 | name missing or empty |
Provide a name |
qr_error_correction_too_low |
400 | 14.11 | Level L was requested; the floor is M |
Use M, Q or H |
qr_quiet_zone_too_small |
400 | 14.11 | Fewer than 4 modules requested | Use 4 or more |
qr_logo_invalid_format |
400 | 14.11 | Logo is not PNG, JPEG or SVG, or exceeds 2 MB | Re-upload |
qr_cta_text_too_long |
400 | 14.11 | Call-to-action text over 24 characters | Shorten it |
qr_code_not_found |
404 | 14.11 | No such QR code in this workspace | Check the id |
qr_slug_reserved |
409 | 14.11 | The slug is permanently reserved from prior use and can never be reissued — to a QR code or to a short link, regardless of whether the original resource, workspace or account still exists. This is the permanence guarantee enforced at the namespace level | Use the suggested alternative |
qr_unscannable |
422 | 14.11 | Decode validation failed after the permitted escalations; the offending styling choice is named in details |
Change the identified styling choice |
qr_contrast_too_low |
422 | 14.11 | Foreground/background ratio below 4.5:1 | Increase contrast, or use "fix for me" |
qr_logo_too_large |
422 | 14.11 | Logo exceeds 22% of symbol width or height | Reduce the logo |
qr_logo_overlaps_finder |
422 | 14.11 | The logo intersects a finder pattern | Reduce or re-centre the logo |
qr_logo_overlaps_timing |
422 | 14.11 | The logo intersects a timing pattern | Reduce the logo |
qr_logo_overlaps_format_info |
422 | 14.11 | The logo intersects format information | Reduce the logo |
qr_logo_overlaps_version_info |
422 | 14.11 | The logo intersects version information | Reduce the logo |
qr_logo_overlaps_alignment |
422 | 14.11 | The logo would cover two or more alignment patterns | Reduce the logo |
qr_frame_encroaches_quiet_zone |
422 | 14.11 | Frame geometry enters the mandatory quiet zone | Choose another frame |
qr_error_correction_locked |
422 | 14.11 | An attempt to lower error correction below the level a styling choice requires | Remove that styling choice first |
qr_size_below_minimum |
422 | 14.11 | The requested physical size is below the computed minimum for this configuration | Increase the size |
qr_payload_too_long |
422 | 14.11 | The encoded URL exceeds what fits at the required error-correction level | Use a shorter slug or a shorter domain |
qr_render_unavailable |
503 | 14.11 | The render service is degraded | Retry; the job is queued automatically |
30.2.8 UTM, scheduling, expiry and targeting #
| Code | HTTP | Owning subsection | Meaning | Remedy |
|---|---|---|---|---|
utm_value_too_long |
400 | 15.9 | A UTM value exceeds 200 characters | Shorten it |
utm_value_empty |
400 | 15.9 | A UTM value is empty after normalisation | Provide a value, or remove the parameter |
utm_too_long |
400 | 15.9 | The combined UTM query exceeds 512 characters | Shorten the values |
utm_reserved_prefix |
400 | 15.9 | A UTM value begins with the reserved lh_ prefix |
Use a different value |
utm_preset_name_taken |
400 | 15.9 | Preset name already exists in this workspace | Choose another name |
param_forwarding_mode_invalid |
400 | 15.9 | Mode is not allow_list, all or none |
Use a valid mode |
param_forwarding_list_too_long |
400 | 15.9 | More than 20 custom allow-list entries | Remove entries |
rule_condition_invalid |
400 | 15.9 | Unknown field, an operator illegal for that field, or an empty in list |
Correct the condition |
rule_value_list_too_long |
400 | 15.9 | More than 50 values in an in/not_in list |
Split into multiple rules |
rule_too_many_conditions |
400 | 15.9 | More than 8 conditions in one rule | Split the rule |
schedule_timezone_invalid |
400 | 15.9 | Not a recognised IANA zone name | Use a valid zone |
schedule_activation_in_past |
422 | 15.9 | activates_at is not in the future |
Choose a future time |
schedule_window_invalid |
422 | 15.9 | expires_at is not after activates_at, or the window is in the past |
Correct the window |
click_limit_out_of_range |
422 | 15.9 | Outside 1 – 10,000,000 | Use a value in range |
rule_default_destination_required |
422 | 15.9 | An attempt to clear the default destination while rules exist | Set a default destination |
rule_destination_invalid |
422 | 15.9 | A rule destination failed the checks in Section 12.3 | Correct the destination |
rule_experiment_conflict |
422 | 15.9 | An attempt to attach a second experiment to a slot that already has one | Stop the running experiment first |
30.2.9 Experiments #
| Code | HTTP | Owning subsection | Meaning | Remedy |
|---|---|---|---|---|
experiment_not_found |
404 | 16.14 | Unknown experiment id, one in another workspace, or a soft-deleted one | Check the id |
experiment_epoch_not_found |
404 | 16.14 | Results requested for an epoch number that does not exist | Check epoch |
experiment_invalid_state |
409 | 16.14 | A lifecycle transition not permitted by 16.9.3; details names from and to |
Use a permitted transition |
experiment_resource_busy |
409 | 16.14 | Another experiment is already running on this resource | Stop the running one first |
experiment_already_promoted |
409 | 16.14 | Promotion attempted on an experiment already promoted | Start a new experiment |
experiment_promotion_window_expired |
409 | 16.14 | Undo attempted more than 30 days after promotion | The promotion is final; make the change directly |
experiment_guard_not_met |
409 | 16.14 | Normal promotion attempted while the minimum-sample or minimum-duration guard blocks; failing_conditions[] names which |
Keep running, or force-promote deliberately |
experiment_srm_blocked |
409 | 16.14 | Normal promotion attempted while sample-ratio mismatch is critical | Investigate the mismatch before concluding |
experiment_target_conflict |
409 | 16.14 | Start attempted while a targeting rule would consume all traffic | Adjust the rule first |
experiment_page_unpublished |
409 | 16.14 | Start or resume attempted on an unpublished bio page | Publish the page first |
experiment_arm_count_invalid |
422 | 16.14 | Fewer than 2 arms, or more than the mechanism's maximum | Adjust the arm count |
experiment_weights_invalid |
422 | 16.14 | Non-integer weights, or they do not sum to the required total | Correct the weights |
experiment_weight_below_minimum |
422 | 16.14 | An arm is below its minimum weight | Raise that arm's weight |
experiment_variants_identical |
422 | 16.14 | Two arms render or resolve identically, or a variant patch is empty | Make the arms differ |
experiment_block_missing |
422 | 16.14 | A variant patch targets a block id not present on the page | Correct the patch |
experiment_asset_missing |
422 | 16.14 | A variant patch references an asset not in the workspace library | Upload the asset first |
experiment_block_order_invalid |
422 | 16.14 | A block_order patch is not a permutation of the published block ids |
Send a complete permutation |
experiment_destination_rejected |
422 | 16.14 | A variant destination failed the safety pipeline; reason names which check |
Correct the destination |
experiment_contrast_failed |
422 | 16.14 | A variant's theme override fails the 4.5:1 gate | Adjust the variant's colours |
experiment_duration_below_minimum |
422 | 16.14 | Minimum duration set below the floor. The floor exists because assignment re-buckets daily | Use the floor or higher |
experiment_conversion_requires_click_id |
422 | 16.14 | A conversion goal was configured without click-level attribution enabled | Enable click-level attribution |
experiment_force_promote_confirmation_invalid |
422 | 16.14 | The typed confirmation did not match the experiment name | Type the exact name |
experiment_schedule_invalid |
422 | 16.14 | start_at in the past or more than 90 days out, or end_at earlier than the minimum duration allows |
Correct the schedule |
experiment_variant_removed |
422 | 16.14 | Promotion of an arm removed from the current epoch | Promote a current arm |
30.2.10 Analytics, exports and reports #
| Code | HTTP | Owning subsection | Meaning | Remedy |
|---|---|---|---|---|
export_not_found |
404 | 18.10 | Unknown export job, or one belonging to another workspace | Check the id |
download_link_exhausted |
403 | 18.10 | The per-artefact fetch cap was reached | Request a fresh export |
download_link_expired |
403 | 18.10 | The signed download link is past its validity | Request a fresh export |
export_already_running |
409 | 18.10 | An identical export is already in progress | Wait for it to finish |
export_expired |
410 | 18.10 | The artefact has passed its retention | Request a new export |
export_type_invalid |
422 | 18.10 | Unknown export type |
Use a documented type |
export_range_invalid |
422 | 18.10 | Start after end, or a span exceeding 400 days | Correct the range |
export_too_large |
422 | 18.10 | The estimated result set exceeds the row cap | Narrow the range or the filters |
export_filter_invalid |
422 | 18.10 | A filter not in the catalogue, or an operator unsupported for that field | Correct the filter |
export_retention_exceeded |
422 | 21.12 | Raw events requested beyond the plan's raw retention. Aggregate reads are clamped rather than refused; only a raw-event export refuses | Narrow the range, or upgrade |
range_too_large |
422 | 21.12 | An analytics or audit range beyond 366 days | Narrow the range |
interval_too_fine |
422 | 21.12 | An hour interval requested over a span longer than 31 days |
Use a coarser interval |
lead_export_too_large |
422 | 20.10.2 | More than 1,000,000 lead rows match | Narrow the filters |
A retention shortfall on a read is never an error. The response returns the permitted window with meta.clamped_from set, because refusing a 90-day query outright would make a naive client fail on a Free workspace instead of receiving the 30 days it is entitled to.
30.2.11 Leads and forms #
| Code | HTTP | Owning subsection | Meaning | Remedy |
|---|---|---|---|---|
email_required |
400 | 20.10.2 | The email field was empty | Provide an address |
email_too_long |
400 | 20.10.2 | Exceeds the address, local-part or domain length limits | Use a shorter address |
email_unicode_local_unsupported |
400 | 20.10.2 | SMTPUTF8 local part | Use an ASCII local part |
name_invalid |
400 | 20.10.2 | Length or character violation | Correct the name |
custom_field_required |
400 | 20.10.2 | A required custom field was empty | Complete the field |
custom_field_invalid |
400 | 20.10.2 | Custom field length violation | Shorten the value |
consent_required |
400 | 20.10.2 | The consent control is configured and was not ticked | Tick the consent control |
form_token_invalid |
400 | 20.10.2 | Form token missing or badly signed | Reload the page and resubmit |
double_opt_in_token_invalid |
400 | 20.10.2 | Confirmation token malformed or badly signed | Request a new confirmation email |
unsubscribe_token_invalid |
400 | 20.10.2 | Preference-page token badly signed | Use the link from the most recent email |
challenge_required |
403 | 20.10.2 | The page is armed for escalated bot protection and a JavaScript-path submission arrived without a challenge token. Never returned to a no-JavaScript submission, which is accepted and queued for review instead | Complete the challenge |
challenge_failed |
403 | 20.10.2 | Challenge verification returned invalid | Retry the challenge |
lead_form_disabled |
403 | 20.10.2 | Capture is disabled for this page or workspace | Re-enable the form |
capture_block_not_found |
404 | 20.10.2 | block_id does not belong to the page, or is not a capture block |
Check the block id |
page_not_published |
404 | 20.10.2 | The page is unpublished and is not accepting responses | Publish the page |
lead_not_found |
404 | 20.10.2 | Wrong workspace, deleted, or locked | Check the id |
form_unavailable |
409 | 20.10.2 | The workspace is suspended, or the block is disabled | Contact support |
lead_already_unsubscribed |
409 | 20.10.2 | Unsubscribe on an already-unsubscribed lead. The public preference page is idempotent and returns success instead | No action |
lead_sync_target_not_configured |
409 | 20.10.2 | A retry was requested for a target that is not connected | Connect the target first |
lead_sync_in_progress |
409 | 20.10.2 | A bulk re-sync is already running for this workspace | Wait for it to finish |
double_opt_in_token_expired |
410 | 20.10.2 | Confirmation token older than 7 days | Use the resend action on the page |
email_domain_undeliverable |
422 | 20.10.2 | No MX and no A/AAAA for the domain | Check the address |
email_domain_not_allowed |
422 | 20.10.2 | A disposable-address domain, under a blocking policy. Also emitted on account registration | Use a permanent address |
email_role_address |
422 | 20.10.2 | A blocked role address such as info@ or postmaster@ |
Use a personal address |
form_expired |
422 | 20.10.2 | The form token is older than 6 hours | Reload the page and resubmit |
lead_bulk_rate_limited |
429 | 20.10.2 | More than one bulk operation per 60 seconds | Wait, then retry |
double_opt_in_resend_limited |
429 | 20.10.2 | More than one resend per 10 minutes, or more than three in total | Wait, or contact the page owner |
30.2.12 Integrations and outbound webhooks #
| Code | HTTP | Owning subsection | Meaning | Remedy |
|---|---|---|---|---|
slack_token_revoked |
401 | 19.12.2 | The Slack app was uninstalled | Reconnect the integration |
integration_permission_denied |
403 | 19.12.2 | The connected account lacks a permission at the provider | Grant it, or reconnect with a different account |
zapier_subscription_limit_reached |
403 | 19.12.2 | 25 active subscriptions per workspace | Remove a subscription |
integration_not_found |
404 | 19.12.2 | No such integration for this workspace | Check the id |
integration_already_connected |
409 | 19.12.2 | One connection per provider per workspace | Disconnect first |
integration_disabled |
409 | 19.12.2 | The integration is in error or disconnected |
Reconnect |
webhook_suspended |
409 | 19.12.2 | Auto-suspended after sustained delivery failure | Fix the endpoint and send a test to re-enable |
webhook_signature_secret_rotating |
409 | 19.12.2 | A rotation window is already open | Wait for it to close |
integration_credentials_invalid |
422 | 19.12.2 | The provider rejected the credentials at connect time | Reconnect |
integration_probe_failed |
422 | 19.12.2 | The validation probe did not succeed within 10 seconds | Check the provider, then retry |
integration_config_invalid |
422 | 19.12.2 | A stored configuration value no longer resolves at the provider | Re-select the configuration |
ga4_measurement_id_invalid |
422 | 19.12.2 | Does not match the provider's measurement-id format | Correct the identifier |
ga4_api_secret_invalid |
422 | 19.12.2 | The Measurement Protocol rejected the secret | Issue a new secret |
pixel_id_invalid |
422 | 19.12.2 | The pixel identifier failed format validation | Correct the identifier |
webhook_url_scheme_invalid |
422 | 19.12.2 | The endpoint is not https. There is no plain-HTTP exception, on any port, for any network |
Use an HTTPS endpoint |
webhook_url_port_invalid |
422 | 19.12.2 | A port other than 443 | Serve the endpoint on 443 |
webhook_url_private_address |
422 | 19.12.2 | The endpoint resolves to a non-routable address | Use a publicly reachable endpoint |
webhook_url_invalid |
422 | 19.12.2 | Malformed, contains userinfo or control characters, or is a bare IP address | Provide a valid hostname-based HTTPS URL |
webhook_test_failed |
422 | 19.12.2 | The activation test delivery did not return 2xx | Fix the endpoint, then retest |
slack_channel_not_found |
422 | 19.12.2 | The channel was archived or deleted | Re-install and pick another channel |
embed_provider_not_supported |
422 | 19.12.2 | The URL host is not an allow-listed embed provider (Section 30.5) | Use a supported provider |
embed_url_invalid |
422 | 19.12.2 | The host is allow-listed but the URL does not identify an embeddable resource | Paste the canonical share URL |
integration_rate_limited |
429 | 19.12.2 | The provider throttled us; retried with backoff | None; automatic |
integration_provider_unavailable |
502 | 19.12.2 | The provider returned 5xx or timed out; retried automatically | None; automatic |
Delivery-state codes. The following are recorded on a delivery or sync record and surfaced in the delivery log; they are never returned as an HTTP status because there is no synchronous caller to return them to. They are part of the registry because the code paths emit them and the drift check sees them.
| Code | HTTP | Owning subsection | Meaning |
|---|---|---|---|
ga4_rejected |
— | 19.12.2 | The debug endpoint returned validation messages; see the debug view |
ga4_event_too_old |
— | 19.12.2 | Event older than 72 hours; dropped before sending |
pixel_never_fired |
— | 19.12.2 | Traffic occurred but the pixel never fired — usually blocked, or a wrong id. Warning only |
webhook_redirect_not_followed |
— | 19.12.2 | The endpoint returned a redirect; redirects are never followed |
webhook_delivery_timeout |
— | 19.12.2 | The attempt exceeded the 10-second budget |
webhook_endpoint_gone |
— | 19.12.2 | The endpoint returned 410; the webhook is disabled |
webhook_throttled |
— | 19.12.2 | The hourly click-event delivery cap was reached; further click events were dropped, with the count shown in the delivery log |
30.2.13 Billing and entitlements #
| Code | HTTP | Owning subsection | Meaning | Remedy |
|---|---|---|---|---|
tax_id_invalid |
400 | 22.14.1 | The tax identifier failed format or registry validation | Correct it |
webhook_signature_invalid |
400 | 22.14.1 | An inbound payment-processor webhook failed signature verification. Never surfaced to a customer | None; investigated internally |
plan_limit_reached |
403 | 22.14.1 | Any numeric or period entitlement cap. details[0].kind is count or period, and carries limit, current and plan |
Upgrade, or free capacity |
plan_feature_unavailable |
403 | 22.14.1 | The plan does not include the capability. Carries plan and required_plan |
Upgrade |
billing_write_blocked |
403 | 22.14.1 | The workspace is past due from day 8 onward. Days 0–7 of past due carry full write access; details[0].invoice_url links the outstanding invoice. Resolution of links, pages and QR codes is unaffected at every point on this schedule |
Settle the outstanding invoice |
seat_suspended |
403 | 22.14.1 | A suspended member attempted to use the workspace | Contact the workspace Owner |
invoice_not_found |
404 | 22.14.1 | Unknown invoice id, or one belonging to another workspace | Check the id |
billing_blocked |
409 | 22.14.1 | An open or lost payment dispute exists on the billing account | Contact support |
subscription_not_found |
409 | 22.14.1 | A plan change or portal request with no subscription | Start a subscription |
subscription_already_active |
409 | 22.14.1 | Checkout requested while a subscription exists | Use the plan-change flow |
plan_change_not_permitted |
409 | 22.14.1 | The target plan equals the current one, or the plan key is not purchasable | Choose a supported plan change |
downgrade_selection_required |
409 | 22.14.1 | A downgrade or cancellation was confirmed without a valid preview token | Complete the guided keep-selection |
payment_requires_action |
409 | 22.14.1 | Strong customer authentication is needed to complete the charge | Open details[0].confirmation_url |
card_declined |
409 | 22.14.1 | The processor declined the payment; the customer-facing wording is mapped from the decline code and is deliberately vague for fraud-related declines | Update the payment method |
trial_already_used |
409 | 22.14.1 | A trial was requested by an account that has already used one. One 14-day trial per billing account, ever | Proceed to checkout without a trial |
billing_address_required |
409 | 22.14.1 | Tax calculation is impossible without an address | Add a billing address |
refund_window_expired |
409 | 22.14.1 | A refund was requested outside the policy window | Contact support |
payment_processor_unavailable |
503 | 22.14.1 | The processor is unreachable or erroring after retries. Delivery of links, pages and QR codes is unaffected | Retry with backoff |
30.2.14 API request shape, idempotency and platform #
| Code | HTTP | Owning subsection | Meaning | Remedy |
|---|---|---|---|---|
validation_failed |
400 | 21.12 | One or more body fields failed schema validation; details lists each |
Correct the named fields |
malformed_json |
400 | 21.12 | The body is not valid JSON | Send valid JSON |
unknown_field |
400 | 21.12 | An undocumented body field was sent; unknown keys are rejected, never stripped | Remove the field |
unknown_parameter |
400 | 21.12 | An undocumented query parameter, including offset and page |
Use cursor pagination |
invalid_filter |
400 | 21.12 | Unknown filter field | Use a documented filter |
invalid_filter_operator |
400 | 21.12 | Operator not supported for that field | Use a supported operator |
invalid_filter_value |
400 | 21.12 | The value could not be parsed for that field's type | Correct the value |
filter_too_many_values |
400 | 21.12 | More than 50 values in in/nin |
Split the query |
too_many_filters |
400 | 21.12 | More than 10 distinct filter fields | Simplify the query |
invalid_sort_field |
400 | 21.12 | The field is not sortable on this endpoint | Use a sortable field |
too_many_sort_keys |
400 | 21.12 | More than 2 sort keys | Reduce to 2 |
invalid_field_selection |
400 | 21.12 | An unknown name in fields |
Use documented field names |
invalid_expansion |
400 | 21.12 | Unknown or nested expand |
Use a documented expansion |
expansion_limit_exceeded |
400 | 21.12 | An expansion was requested with limit above 25 |
Lower the limit |
limit_out_of_range |
400 | 21.12 | limit outside 1–100 |
Use a value in range |
invalid_cursor |
400 | 21.12 | The cursor is malformed, from another workspace, or of an unsupported version | Restart pagination without a cursor |
cursor_expired |
400 | 21.12 | The cursor is older than 24 hours | Restart pagination |
cursor_filter_mismatch |
400 | 21.12 | The cursor was reused with a different filter or sort | Restart pagination |
api_key_in_query |
400 | 21.12 | A credential was supplied in the query string, where it would be logged by intermediaries | Send it in the Authorization header |
idempotency_key_invalid |
400 | 21.12 | Idempotency key format violation | Use a documented key format |
idempotency_key_required |
400 | 21.12 | Missing on an endpoint that requires one | Supply a key |
api_key_invalid |
401 | 21.12 | The key is unknown or malformed | Check the key |
api_key_revoked |
401 | 21.12 | The key was revoked | Issue a new key |
api_key_expired |
401 | 21.12 | The key passed its expiry | Issue a new key |
insufficient_scope |
403 | 21.12 | The key lacks the scope named in details.required_scope |
Issue a key with that scope |
workspace_suspended |
403 | 21.12 | The workspace is suspended or being deleted | Contact support |
https_required |
403 | 21.12 | A request arrived over plain HTTP where the upgrade is not applicable | Use HTTPS |
endpoint_not_found |
404 | 21.12 | No such path | Check the path |
method_not_allowed |
405 | 21.12 | The path exists; the method does not | Use a documented method |
idempotency_key_reused |
409 | 21.12 | The same key with a different body | Use a new key, or repeat the original body |
idempotency_key_in_flight |
409 | 21.12 | A request with this key is still executing | Retry after ~1 s |
idempotency_response_too_large |
409 | 21.12 | The stored response exceeded the replay cap | Verify state by reading the resource |
endpoint_sunset |
410 | 21.12 | The endpoint is past its announced sunset date | Migrate to the replacement |
api_version_sunset |
410 | 21.12 | The API version is past its announced sunset date | Move to the current version |
precondition_failed |
412 | 21.12 | If-Match did not match the current ETag |
Re-read and retry |
request_too_large |
413 | 21.12 | The request body exceeds the size cap | Reduce the payload, or split the batch |
uri_too_long |
414 | 21.12 | The request URI exceeds the limit | Move parameters into the body |
unsupported_media_type |
415 | 21.12 | Content-Type is not application/json |
Set the correct content type |
bulk_too_many_items |
422 | 21.12 | More items than the plan's per-request cap; details carries limit, received and plan |
Split the batch |
bulk_aborted |
422 | 21.12 | on_error: "abort" and one item failed; nothing was applied |
Fix the failing item, then resubmit |
destination_weights_invalid |
422 | 21.12 | Active destination weights do not sum to the required total | Correct the weights |
targeting_values_invalid |
422 | 21.12 | A value is not valid for that rule type | Correct the value |
rate_limited |
429 | 21.7 | Any rate-limit scope; details.scope names it and the response headers carry the retry timing. Distinct from plan_limit_reached: a rate limit means retry later, an entitlement cap means retrying is pointless |
Back off and retry |
concurrency_limit |
429 | 21.7 | Too many simultaneous requests for this key | Reduce concurrency |
internal_error |
500 | 21.12 | An unhandled fault; request_id identifies the occurrence |
Retry; quote the request id to support |
upstream_error |
502 | 21.12 | A dependency returned an unusable response | Retry with backoff |
service_unavailable |
503 | 21.12 | Maintenance or load shedding; Retry-After is present |
Retry after the stated interval |
upstream_timeout |
504 | 21.12 | A dependency exceeded its time budget | Retry with backoff |
30.2.15 Resource-specific not-found codes #
Every one of these is 404 and every one is returned for a resource that is missing, soft-deleted, or in another workspace — the three are indistinguishable by design, and none of them is ever a 403.
| Code | HTTP | Owning subsection | Applies to |
|---|---|---|---|
destination_not_found |
404 | 21.12 | A weighted destination on a link |
targeting_rule_not_found |
404 | 21.12 | A targeting rule |
webhook_delivery_not_found |
404 | 21.12 | A webhook delivery record |
The other resource-specific 404s are listed with their owning domains above: workspace_not_found, page_not_found, block_not_found, link_not_found, qr_code_not_found, domain_not_found, experiment_not_found, experiment_epoch_not_found, lead_not_found, capture_block_not_found, export_not_found, integration_not_found, invoice_not_found, and the generic not_found.
30.2.16 Deliberately absent names #
This subsection is not part of the registry. It is excluded from both directions of the drift check, and a separate check asserts that none of these identifiers appears anywhere in the source. They are recorded because each was a plausible name that a second implementer would reach for, and because a synonym that resolves to a different status is exactly the class of defect that survives review.
| Do not use | Use instead | Why |
|---|---|---|
qr_slug_unavailable, qr_slug_taken, link_slug_permanently_reserved |
qr_slug_reserved (409) |
One namespace, one permanence rule, one code |
resource_not_found |
not_found (404) |
One cross-tenant response |
resource_not_granted |
not_found (404) |
A 403 tells a scoped member the resource exists, which is the fact the grant withheld |
feature_not_available, plan_feature_not_available, experiment_not_available_on_plan, qr_style_not_available, api_not_available_on_plan, per_resource_grants_not_available, lead_export_not_available, feature_read_only_after_downgrade |
plan_feature_unavailable (403) |
Exactly two entitlement codes |
qr_limit_reached, domain_limit_reached, workspace_limit_reached, api_key_limit_reached, rule_limit_reached, link_creation_quota_exceeded, link_bulk_limit_exceeded |
plan_limit_reached (403), or bulk_too_many_items (422) for a per-request batch cap |
As above; kind distinguishes a count cap from a period cap |
plan_change_not_allowed |
plan_change_not_permitted (409) |
Fixed by the status rules above |
downgrade_requires_selection |
downgrade_selection_required (409) |
Owning section's name |
subscription_past_due, billing_read_only |
billing_write_blocked (403) |
One code, and only from day 8 of past due |
billing_portal_unavailable |
payment_processor_unavailable (503) |
One processor-outage code |
billing_forbidden, role_not_permitted, forbidden |
insufficient_role (403) |
One role-refusal code |
payment_method_required, coupon_invalid, currency_mismatch, proration_failed |
— | No code path emits them |
qr_not_found |
qr_code_not_found (404) |
Owning section's name |
qr_contrast_insufficient |
qr_contrast_too_low (422) |
Owning section's name |
qr_slug_immutable |
link_slug_immutable_qr (409) |
Owning section's name and status |
qr_render_failed |
qr_render_unavailable (503) |
A render fault is a dependency failure, not a caller error |
destination_url_invalid, destination_url_private_address, destination_url_blocked, destination_blocked, destination_scheme_not_allowed, link_destination_flagged, link_cycle_detected |
The link_destination_* family in 30.2.5 |
Section 12 owns destination validation |
slug_confusable |
link_slug_confusable (400) |
Section 12 owns slug validation. Note also that slug_reserved is a live code, but it means the workspace slug and nothing else: a reserved link slug is link_slug_reserved and a reserved page handle is handle_reserved. Three namespaces, three codes, and reaching for the shortest name is how they get conflated |
reorder_set_mismatch |
block_order_mismatch (400) |
Owning section's name |
payload_too_large |
request_too_large (413) |
One body-size code, one status |
request_body_too_large |
request_too_large (413) |
As above |
cursor_invalid |
invalid_cursor (400) |
Owning section's name |
resource_conflict |
page_revision_conflict (409) or precondition_failed (412) |
A generic conflict code tells a client nothing |
api_key_not_found |
not_found (404) |
Generic tenancy response |
api_key_scope_insufficient |
insufficient_scope (403) |
Owning section's name |
unsupported_api_version |
api_version_sunset (410) or endpoint_not_found (404) |
A version that never existed is a wrong path |
maintenance_mode |
service_unavailable (503) |
One code, Retry-After carries the detail |
analytics_retention_exceeded, analytics_range_invalid, analytics_range_too_large, analytics_resource_not_found |
Clamping with meta.clamped_from; export_range_invalid, range_too_large, not_found |
A retention shortfall on a read is not an error |
export_row_limit_exceeded |
export_too_large (422) |
Owning section's name |
link_expired, link_archived, link_schedule_invalid |
200 branded pages; resource_archived (409); schedule_window_invalid (422) |
Expiry is a public 200 page, not an API error |
member_not_found, member_already_exists, cannot_modify_owner, cannot_remove_self, owner_transfer_not_confirmed, invitation_not_found, invitation_already_accepted, invitation_email_mismatch |
not_found, already_member, insufficient_role, owner_cannot_leave, invitation_already_used, token_email_mismatch |
Section 8's names |
email_invalid, disposable_email_blocked, email_domain_unreachable, csrf_failed |
invalid_email, email_domain_not_allowed, email_domain_undeliverable, csrf_invalid |
One name per concern across every surface |
experiment_variant_weights_invalid, experiment_variant_limit, experiment_min_sample_not_met, experiment_force_promote_unconfirmed, experiment_already_running, variant_weights_invalid, force_confirmation_required, destination_locked_by_experiment |
The experiment_* family in 30.2.9, and link_in_active_experiment |
Section 16 owns experiment codes |
timezone_invalid, schedule_invalid |
schedule_timezone_invalid, schedule_window_invalid |
Section 15's names |
webhook_not_configured, webhook_replay_detected, integration_oauth_denied, integration_token_expired, integration_sync_failed |
not_found, the 300-second tolerance rejection at the receiver, integration_permission_denied, integration_credentials_invalid |
No code path emits them |
30.3 Event catalogue #
30.3.1 Webhook events #
One webhook URL per workspace (Section 19.7). Payloads carry the event as data inside the canonical envelope shape, are signed with HMAC-SHA256 over the raw request body, and carry X-LinkHub-Signature: t=<unix seconds>,v1=<lowercase hex> with a 300-second tolerance. During a secret rotation the header carries two v1 values for 24 hours and a verifier must accept if any matches.
Transport: HTTPS on port 443, with no exceptions. Plain HTTP is refused at save time with webhook_url_scheme_invalid and any other port with webhook_url_port_invalid. There is no port-80 allowance, no private-network allowance and no per-customer override. The endpoint must also be publicly routable and pass the SSRF guard in Section 19.7, re-evaluated immediately before every attempt rather than only at save time, because DNS can change in between.
The catalogue is exactly these twenty events. Gates are the per-workspace toggles in Section 19.7; an event whose gate is off is not queued at all rather than queued and discarded.
| Event type | Gate | Volume | Fires when |
|---|---|---|---|
webhook.test |
always | manual | The customer sends a test from the dashboard, or a health check runs |
link.clicked |
send_click_events |
high | A short link resolves successfully. Emitted post-ingest, never on the redirect path |
qr.scanned |
send_click_events |
high | A QR code resolves successfully. Carries the rung number and fallback_stage |
lead.captured |
send_lead_events |
medium | A lead is stored and, where double opt-in is on, confirmed |
lead.sync_failed |
send_lead_events |
low | An ESP sync exhausts its retries (Section 20.6) |
lead.unsubscribed |
send_lead_events |
low | A lead unsubscribes (Section 20.8) |
link.created |
send_management_events |
low | A short link is created |
link.updated |
send_management_events |
low | Any field changes other than the destination |
link.destination_changed |
send_management_events |
low | Separate from link.updated because it is an audited, security-relevant change |
link.deleted |
send_management_events |
low | A short link is soft-deleted |
qr.created |
send_management_events |
low | A QR code is created |
qr.destination_changed |
send_management_events |
low | A QR code's destination is repointed — the product's most consequential customer action |
page.published |
send_management_events |
low | A bio page is published |
page.unpublished |
send_management_events |
low | A bio page is unpublished |
experiment.significant |
send_management_events |
low | The minimum-sample guard passes (Section 16.7) |
experiment.promoted |
send_management_events |
low | A winner is promoted. Includes a forced boolean |
domain.verified |
send_management_events |
low | A custom domain reaches active (Section 13) |
domain.failed |
send_management_events |
low | A custom domain enters dns_failed or tls_failed |
usage.threshold_reached |
send_management_events |
low | An entitlement reaches 80% or 100% (Section 22) |
export.ready |
send_management_events |
low | An asynchronous export completes (Section 18.10) |
There is deliberately no page.viewed webhook. Bio page views are the highest-volume event in the product after redirects, and a per-view webhook would be a delivery pipeline sized for the whole of a customer's traffic in exchange for data they already receive in aggregate through analytics and in bulk through export. Page-view data is available in the dashboard, the analytics API and the CSV export; it is not pushed.
Delivery. Six attempts in total — one immediate, then 10 seconds, 1 minute, 10 minutes, 1 hour and 6 hours, each with ±20% jitter, spanning roughly 7.2 hours — after which the event is dead-lettered with UI visibility and a manual retry action. Any non-2xx response, timeout or connection failure counts as a failure; a 429 honours Retry-After in place of the ladder delay, and a non-429 4xx is retried once only, because a 4xx normally means a misconfiguration that hammering will not fix. Per-attempt timeout is 10 seconds. link.clicked and qr.scanned deliveries are capped at 10,000 per hour per workspace; beyond the cap further click events are dropped rather than queued, with the dropped count shown in the delivery log and exported as a metric.
30.3.2 Audit log events #
Recorded in PostgreSQL, append-only, retained per plan. Each entry carries actor (user id, API key id, support actor or system), actor type, workspace, resource type and id, action, before/after values for changed fields, IP country (never the IP), user-agent family (never the raw user-agent string) and timestamp. before and after contain only the fields that changed, with secrets replaced by "[redacted]" and long values truncated at 500 characters with an ellipsis marker.
One retention exception, and it is load-bearing. Entries for qr.destination_changed are written with retention_expires_at = NULL, and the purge job's condition is WHERE retention_expires_at IS NOT NULL AND retention_expires_at < now(). A prose promise that these entries are kept forever would not survive the purge trigger; the null value and the null-excluding predicate together are what actually keep them. A printed code can be repointed years after the workspace's plan retention has lapsed, and the record of who repointed it is the only way to answer the question afterwards.
| Event key | Trigger | Actor types | Captured fields | before/after |
|---|---|---|---|---|
workspace.created |
Workspace created | user | workspace name, slug | — / {name, slug, plan_key} |
workspace.settings_changed |
Any settings save | user, api_key | changed setting keys | changed keys only |
workspace.branding_changed |
Logo or colour change | user | brand fields | changed keys only |
workspace.totp_enforcement_changed |
require_totp toggled |
user | — | {require_totp} |
workspace.deletion_requested |
Deletion started | user | resource counts, QR count | — / {purge_after, counts} |
workspace.deletion_cancelled |
Restored within the grace window | user | — | — |
workspace.owner_transfer_initiated |
Transfer started | user | from and to user ids and labels | {owner_user_id} |
workspace.owner_transfer_accepted |
Transfer accepted | user | from and to user ids and labels | {owner_user_id} |
workspace.owner_transfer_cancelled |
Transfer cancelled | user | from and to user ids and labels | {owner_user_id} |
workspace.owner_transfer_expired |
Transfer lapsed | system | from and to user ids and labels | {owner_user_id} |
member.invited |
Invitation created | user | email, role, scoped flag, grant count | — / {email, role, is_scoped} |
member.invitation_resent |
Resend | user | email, resend count | — |
member.invitation_revoked |
Revoke | user | {status} |
|
member.joined |
Invitation accepted | user | invited email, accepting email, role | — / {role, is_scoped} |
member.role_changed |
Role updated | user | target member | {role} |
member.scope_changed |
is_scoped toggled |
user | target member | {is_scoped} |
member.grant_added |
Grant added | user | resource type, id, label | {resource_type, resource_id} |
member.grant_removed |
Grant removed | user | resource type, id, label | {resource_type, resource_id} |
member.grant_auto_created |
A scoped member created a resource | user | resource type, id | — / {resource_type, resource_id} |
member.removed |
Membership ended by another member | user | target, reassignment decisions | {deleted_at, keys_revoked, experiments_reassigned} |
member.left |
Membership ended by the member | user | target, reassignment decisions | {deleted_at, keys_revoked, experiments_reassigned} |
link.created |
Short link created | user, api_key | slug, domain, destination | — / {slug, destination_url} |
link.destination_changed |
Destination edited | user, api_key | slug, domain | {destination_url} both sides |
link.rules_changed |
Targeting rules edited | user, api_key | rule set | full rule array |
link.split_changed |
Weighted split edited | user, api_key | variant set | full variant array |
link.status_changed |
Paused, archived or expired | user, api_key, system | slug | {status} |
link.deleted |
Deleted | user, api_key | slug | {deleted_at} |
qr.created |
QR code created | user, api_key | slug, domain, destination, error correction | — / {slug, destination_url, error_correction} |
qr.destination_changed |
QR destination edited. Retained indefinitely | user, api_key | slug, title, version number | {destination_url} both sides |
qr.styling_changed |
Style, colours, logo or error correction edited | user, api_key | slug, version, validation outcome | {style, error_correction} |
qr.status_changed |
Paused, resumed, archived or memorialised | user, system | slug | {status, paused_fallback_url} |
qr.rendered |
Artefacts regenerated | user, api_key, system | version, formats, validation result | — / {validation_status, escalations} |
page.created |
Bio page created | user, api_key | handle, domain | — / {handle} |
page.published |
Publish state changed to published | user, api_key | handle, version number | {status, published_version_id} |
page.unpublished |
Publish state changed to unpublished | user, api_key | handle, version number | {status, published_version_id} |
page.reverted |
Version restored | user | handle, from and to versions | {published_version_id} |
page.deleted |
Deleted | user, api_key | handle | {deleted_at} |
theme.contrast_override |
A failing theme saved with typed confirmation | user | theme name, failing pairs, ratios | {contrast_passed} |
domain.added |
Custom domain added | user | hostname, kind, purpose | — / {hostname} |
domain.verified |
Verification passed | system | hostname, records observed | {status} |
domain.tls_issued |
Certificate issued or renewed | system | hostname, expiry | {status, not_after} |
domain.tls_failed |
Issuance or renewal failed | system | hostname, error | {status, not_after} |
domain.removed |
Domain removed | user | hostname, reassigned resource counts | {status} |
api_key.created |
Key created | user | key name, display prefix, scopes | — / {name, display_prefix, scopes} |
api_key.revoked |
Key revoked | user, system | name, prefix | {revoked_at} |
experiment.started |
Experiment started | user, api_key | experiment name, subject | {status} |
experiment.paused |
Experiment paused | user, api_key | experiment name, subject | {status} |
experiment.promoted |
Winner promoted under the guard | user | variant, visitors, p-value | {winner_variant_id, status} |
experiment.force_promoted |
Promoted before the guard passed | user | variant, visitors per arm, elapsed duration, typed confirmation text | {winner_variant_id, promotion_mode} |
integration.connected |
Integration connected | user | provider, non-secret configuration | changed keys, secrets redacted |
integration.disconnected |
Integration disconnected | user | provider | changed keys, secrets redacted |
webhook.url_changed |
Webhook URL edited | user | old and new host only | {webhook_url} |
plan.changed |
Plan up- or downgraded | user, system | from plan, to plan, seats, archived resource counts | {plan_key, seats} |
billing.payment_failed |
A charge failed | system | invoice id, amount | {billing_status} |
billing.past_due |
The subscription entered past due | system | invoice id, amount | {billing_status} |
data.export_requested |
Export requested | user, system | scope, filters | — |
data.export_completed |
Export completed | system | scope, row counts | — |
data.deletion_requested |
Erasure requested | user | subject, grace end, QR carve-out flag | {status, qr_carve_out_applied} |
data.deletion_executed |
Erasure executed | system | subject, QR carve-out flag | {status, qr_carve_out_applied} |
abuse.action_taken |
Abuse response | support, system | resource, action | {status} |
account.totp_reset_by_support |
Support reset a second factor | support | both staff actors, ticket reference | {totp_enabled_at} |
experiment.concluded |
Experiment stopped without applying a winner | user, api_key, system | experiment id, final status | {status, ended_at} |
analytics.share_link_created |
Read-only analytics share link created | user | share name, scope, expiry, token prefix | — / {name, scope_type, metric_scope, expires_at, token_prefix} |
analytics.share_link_revoked |
Analytics share link revoked | user, system | share id | {revoked_at} |
30.4 Reserved slugs and validation rules #
Validation rules (apply to link slugs, QR slugs, bio page handles and workspace slugs unless noted):
- Charset
[a-z0-9-]only. Input is lower-cased and Unicode-normalised (NFKC) before validation. - Length 1–64 characters for user-chosen values. Auto-generated slugs are 7 characters from a Crockford base32 alphabet excluding the look-alike characters
i,l,oandu. - Must not begin or end with a hyphen; must not contain consecutive hyphens.
- Must not be purely numeric (reserved for future numeric addressing).
- Must not appear on the reserved list below, the profanity list, or the brand-impersonation list.
- Must pass the confusable/homoglyph check: the slug's skeleton (after confusable folding) must not collide with an existing slug on the same host, nor with a reserved term.
- Uniqueness is per host for links and QR codes, global for bio page handles and workspace slugs.
- QR slugs are additionally checked against the permanent reservation table and are rejected with
qr_slug_reservedif previously used, regardless of whether the original resource still exists.
Reserved terms — rejected for user-chosen slugs and handles on platform-owned hosts:
about, account, accounts, admin, administrator, api, app, apps, assets, auth, billing,
blog, board, careers, cdn, changelog, checkout, community, compare, contact, cookies,
cname, dashboard, dev, developer, developers, docs, documentation, download, downloads,
edit, editor, embed, enterprise, error, event, events, explore, faq, favicon, features,
feed, files, fonts, forgot, forum, ftp, go, graphql, guides, help, home, host, hosting,
identity, image, images, imprint, index, integrations, invite, invoices, jobs, join,
legal, link, links, login, logout, mail, mailer, manage, marketing, media, metrics, mx,
new, news, notifications, oauth, onboarding, order, orders, partners, password, pay,
payment, payments, plans, policy, portal, press, pricing, privacy, profile, public, qr,
redirect, register, reset, resources, robots, root, rss, s3, sales, security, server, session,
settings, setup, signin, signup, site, sitemap, smtp, sso, staff, static, status, store,
subscribe, support, system, team, terms, test, tos, trust, unsubscribe, update, upgrade,
upload, uploads, user, users, verify, video, webhook, webhooks, webmaster, welcome, whois,
widget, wiki, www, _next, __tests__Additionally reserved: any string beginning _, any string matching ^(v|api)[0-9]+$, and every current platform hostname label. The list is stored in a file loaded at startup so it can be extended without a code change, and additions never invalidate existing slugs — a slug legally created before a term was reserved keeps working.
30.5 Supported social networks and embed providers #
Social icon links — recognised for automatic icon selection on the social-links block. Any other URL is accepted and rendered with a generic icon.
| Network | Host pattern | Profile URL example |
|---|---|---|
instagram.com |
https://instagram.com/<handle> |
|
| TikTok | tiktok.com |
https://tiktok.com/@<handle> |
| X | x.com, twitter.com |
https://x.com/<handle> |
| YouTube | youtube.com, youtu.be |
https://youtube.com/@<handle> |
facebook.com, fb.com |
https://facebook.com/<page> |
|
linkedin.com |
https://linkedin.com/in/<handle> |
|
| Threads | threads.net, threads.com |
https://threads.net/@<handle> |
pinterest.com, pin.it |
https://pinterest.com/<handle> |
|
| Snapchat | snapchat.com |
https://snapchat.com/add/<handle> |
| Twitch | twitch.tv |
https://twitch.tv/<handle> |
| Discord | discord.gg, discord.com |
https://discord.gg/<invite> |
reddit.com |
https://reddit.com/user/<handle> |
|
| GitHub | github.com |
https://github.com/<handle> |
| Behance | behance.net |
https://behance.net/<handle> |
| Dribbble | dribbble.com |
https://dribbble.com/<handle> |
| Substack | substack.com, custom |
https://<name>.substack.com |
| Spotify | open.spotify.com |
https://open.spotify.com/artist/<id> |
| Apple Music | music.apple.com |
https://music.apple.com/<region>/artist/<id> |
| SoundCloud | soundcloud.com |
https://soundcloud.com/<handle> |
| Bandcamp | bandcamp.com, custom |
https://<name>.bandcamp.com |
wa.me, api.whatsapp.com |
https://wa.me/<number> |
|
| Telegram | t.me |
https://t.me/<handle> |
mailto: |
mailto:<address> |
|
| Phone | tel: |
tel:<number> |
Embed providers — rendered as click-to-load facades with an accessible name, never render-blocking. Each has a CSP allow-list entry; a URL not matching its provider's pattern is rejected with embed_url_invalid.
| Provider | Accepted URL patterns | Facade content |
|---|---|---|
| YouTube | youtube.com/watch?v=<id>, youtu.be/<id>, youtube.com/shorts/<id> |
Poster thumbnail, title, duration, play affordance |
| Vimeo | vimeo.com/<id>, player.vimeo.com/video/<id> |
Poster thumbnail, title, play affordance |
| Spotify | open.spotify.com/(track|album|playlist|episode|show|artist)/<id> |
Cover art, title, artist, play affordance |
| Apple Music | music.apple.com/<region>/(album|playlist|song)/… |
Cover art, title, artist |
| SoundCloud | soundcloud.com/<user>/<track>, soundcloud.com/<user>/sets/<set> |
Waveform placeholder, title, artist |
instagram.com/(p|reel|tv)/<id> |
Static preview, caption excerpt, link out | |
| TikTok | tiktok.com/@<user>/video/<id> |
Static preview, caption excerpt, link out |
| X | x.com/<user>/status/<id>, twitter.com/<user>/status/<id> |
Static rendering of text and author, link out |
Facades load the provider's script or iframe only after an explicit interaction. In a consent-gated region, an embed that sets third-party cookies additionally requires marketing consent before loading; before consent, the facade shows a short explanation and a load control.
30.6 QR physical sizing and scan distance #
The governing rule: scan distance ≈ 10 × symbol width. Sizes below assume the printed symbol width excluding the quiet zone, at 300 DPI or better, printed with adequate contrast on a matte surface.
| Symbol width | Reliable scan distance | Typical use | Minimum print resolution |
|---|---|---|---|
| 1.5 cm | ~15 cm | Below minimum — not recommended. Business card edge cases only, short URLs, error correction H | 600 DPI |
| 2.0 cm | ~20 cm | Absolute minimum. Business cards, product labels, tickets | 300 DPI |
| 2.5 cm | ~25 cm | Packaging, book covers, name badges | 300 DPI |
| 3.0 cm | ~30 cm | Menus, flyers, table tents | 300 DPI |
| 5.0 cm | ~50 cm | Posters at reading distance, shelf talkers, receipts | 300 DPI |
| 10 cm | ~1 m | Window decals, retail signage, exhibition panels | 300 DPI |
| 20 cm | ~2 m | Wall posters, trade-show booths | 150 DPI |
| 50 cm | ~5 m | Storefront windows, banners | 150 DPI |
| 1 m | ~10 m | Billboards near a footpath, vehicle livery | 72 DPI |
| 3 m | ~30 m | Roadside billboards | 72 DPI |
Adjustments, applied multiplicatively to the required width:
| Condition | Multiplier |
|---|---|
| Low light or indoor evening | × 1.3 |
| Glossy, laminated or reflective surface | × 1.3 |
| Curved surface (bottles, cans, tubes) | × 1.5 |
| Moving viewer (vehicle, escalator) | × 2.0 |
| Fabric, textured or absorbent stock | × 1.4 |
| Logo overlay at the maximum 22% | × 1.15 |
| Long destination URL forcing a higher symbol version | × 1.2 |
Always enforced regardless of size: a quiet zone of at least 4 modules, contrast of at least 4.5:1, and error correction of at least M (automatically H with a logo, gradient or custom module shape). The editor shows the computed minimum physical size for the current configuration and warns when a chosen output size falls below it.
30.7 DNS record reference #
Values shown are the platform's published targets. The dashboard always displays the exact values to copy for a specific domain; these are the shapes.
Scenario A — subdomain for short links or a bio page (recommended).
| Type | Name | Value | TTL |
|---|---|---|---|
| CNAME | go (for go.acme.com) |
cname.linkhub.app |
300 |
| TXT | _linkhub-challenge.go |
<issued verification token> |
300 |
Scenario B — apex domain, provider supports ALIAS/ANAME.
| Type | Name | Value | TTL |
|---|---|---|---|
| ALIAS or ANAME | @ |
cname.linkhub.app |
300 |
| TXT | _linkhub-challenge |
<issued verification token> |
300 |
Scenario C — apex domain, provider supports only A/AAAA.
| Type | Name | Value | TTL |
|---|---|---|---|
| A | @ |
Published anycast IPv4 addresses (both) | 300 |
| AAAA | @ |
Published anycast IPv6 addresses (both) | 300 |
| TXT | _linkhub-challenge |
<issued verification token> |
300 |
Scenario D — apex plus www. Configure the apex per B or C, then:
| Type | Name | Value | TTL |
|---|---|---|---|
| CNAME | www |
cname.linkhub.app |
300 |
Scenario E — the domain is behind a proxying CDN. The hostname used with the platform must have proxying disabled ("DNS only"), otherwise the CNAME resolves to the proxy and both verification and ACME HTTP-01 fail.
Scenario F — CAA records present. If the domain publishes CAA records, the issuing authority must be permitted:
| Type | Name | Value |
|---|---|---|
| CAA | @ |
0 issue "letsencrypt.org" |
Scenario G — DNS-01 fallback (wildcard or an apex where HTTP-01 is intercepted).
| Type | Name | Value | TTL |
|---|---|---|---|
| CNAME | _acme-challenge |
The delegation target shown in the dashboard | 300 |
Notes. Keep TTL at 300 during setup, then raise it once active. The ownership TXT record must remain in place permanently; removing it after activation causes the hourly re-check to fail. Some providers append the zone name automatically — if a record ends up as _linkhub-challenge.acme.com.acme.com, enter the label only. Verification polls every 30 seconds for 15 minutes, then every 5 minutes for up to 72 hours.
30.8 Browser and device support matrix #
| Tier | Definition | Browsers | Commitment |
|---|---|---|---|
| Tier 1 — fully supported | Tested every pull request; any defect blocks release | Chrome and Edge (last 2 major versions), Safari (last 2 major versions, macOS and iOS), Firefox (last 2 major versions), Samsung Internet (last 2), Chrome on Android (last 2) | Full functionality, full visual fidelity, budgets enforced |
| Tier 2 — supported, tested less often | Tested nightly and pre-release | Chrome/Edge/Firefox versions 3–6 majors old, Safari 3–4 majors old, Opera (last 2), Firefox ESR | Full functionality; minor visual differences acceptable |
| Tier 3 — public surfaces only, degraded | Not tested automatically | Any browser supporting ES2020 and CSS custom properties, including in-app browsers in social apps | Public pages and redirects must work. The dashboard may display an unsupported-browser notice |
| Not supported | — | Internet Explorer, any browser without TLS 1.2 | Redirects still function (they are plain HTTP responses). Bio pages render unstyled but navigable |
| Device class | Reference | Requirement |
|---|---|---|
| Mid-range Android phone | Moto G Power class, 4G, 4× CPU throttle | The performance reference device. All Section 11 budgets are measured here |
| Modern iPhone | Last 3 generations | Full fidelity |
| Older iPhone | 5+ generations old | Full functionality; budgets measured but not gated |
| Tablet | iPad and Android tablets | Full fidelity; editor is usable with touch, and reordering has a non-drag alternative |
| Desktop | 1280 px and above | Full fidelity |
| In-app browsers | Instagram, TikTok, Facebook, X, LinkedIn | Explicitly supported for public surfaces — this is where most bio page traffic originates. No feature on a public page may depend on an API these browsers restrict |
| Screen readers | NVDA/Firefox, VoiceOver/Safari on macOS and iOS, TalkBack/Chrome on Android | Per the Section 24 matrix |
Minimum viewport: 320 CSS px with no horizontal scrolling. JavaScript disabled: public pages fully navigable; the dashboard requires JavaScript and says so.
30.9 Plan comparison, as a customer sees it #
| Free | Pro | Business | |
|---|---|---|---|
| Price | $0 | $12/month or $108/year | $39/month or $348/year |
| Free trial | Not needed — Free is permanent, not a trial | 14 days, once per account, card required but not charged | 14 days, once per account, card required but not charged |
| Workspaces | 1 | 1 | 10 |
| Team seats | 1 | 1 | 25 |
| Bio pages | 1 | 10 | 100 |
| Short links | 25 | Unlimited* | Unlimited* |
| Dynamic QR codes | 3 | 100 | Unlimited* |
| Your QR codes keep working — forever, on every plan | ✓ | ✓ | ✓ |
| Custom domains | — | 1 | 5 |
| LinkHub branding on your pages | Shown | Removed | Removed |
| Analytics history | 30 days | 365 days | Unlimited |
| UTM builder | — | ✓ | ✓ |
| Link scheduling & expiry | — | ✓ | ✓ |
| A/B testing | — | ✓ | ✓ |
| Team roles & per-resource access | — | — | ✓ |
| Audit log history | 30 days | 365 days | Unlimited |
| CSV export | — | ✓ | ✓ |
| Public API | — | Read + limited write, 120 req/min | Full access, 600 req/min |
| Support | Community | Email, 48-hour response | Priority, 8-hour response |
* Unlimited under fair use: up to 10,000 links created per month on Pro, and 50,000 links or 5,000 QR codes per month on Business. Existing resources are never affected.
About the trial. Every paid plan includes a 14-day free trial, once per account. A card is collected at the start and is not charged until the trial ends; you get the full plan from the first minute. Cancel any time during the trial and you move to Free at the end of it, with nothing deleted. Fourteen days is chosen deliberately: it is long enough to print a QR code, put it somewhere real, and see scan data come back — which is the thing you are actually evaluating.
What happens if you downgrade. Nothing is deleted. Resources above the new plan's limits become read-only, and you choose which to keep during the downgrade. Archived resources stop counting toward your limits. Short links above the limit keep resolving for 90 days, then show a branded page.
And the promise the rest of the table is built around: your dynamic QR codes always keep working. On every plan, forever. After a downgrade, after a missed payment, after you cancel, after you delete the workspace, after you close your account entirely — a printed code never stops resolving. It never returns an error page. If there is nothing left to point it at, it reaches a page that tells the person holding your printed material something useful, rather than a browser error. We do not recycle a QR code's address to anyone else, ever, for any reason. You cannot recall a printed menu, a shipped box or a shopfront window, so the software behaves as though you never can.
30.10 Decision log #
| # | Decision | Rationale | Alternative rejected |
|---|---|---|---|
| 1 | QR slugs are reserved permanently and never recycled | Printed material cannot be recalled; a reused slug would send a customer's scans to a stranger's destination | Reuse after a long quarantine — any quarantine is a date on which someone's printed code silently changes meaning |
| 2 | Destination redirects are always 302 with private, no-store; the HTTP→HTTPS scheme upgrade is the single 301 in the product |
Destinations are editable at any time, so a browser-cached 301 is unrecoverable and would make the core feature unreliable. The scheme upgrade is the opposite case: it points at the identical URL, which cannot change, so making it permanent is correct — it is cacheable, it pairs with HSTS, and it saves a round-trip on the busiest surface in the product. The two are different operations that happen to share a status-code family, and any check that cannot tell them apart forbids the correct behaviour | 301 for destinations with a short TTL (caches ignore TTL semantics for permanent redirects in practice); or 302 for the scheme upgrade too (throws away the cache and the HSTS pairing for no gain) |
| 3 | Cookie-free analytics by default, with a rotating salted hash | Removes the consent banner from the default experience, materially reduces personal-data surface, and improves measured conversion. Consent is still honoured where third-party pixels are enabled | Cookie-based visitor identity — more accurate, but requires consent everywhere and undermines the privacy position |
| 4 | Raw IP is never persisted | The strongest privacy guarantee available at negligible product cost; also shrinks breach scope | Store hashed IP — still personal data under most interpretations, and no product benefit |
| 5 | Geography limited to country and region | City-level adds little for the target customer and materially changes the privacy analysis | City and coordinates — common in competitors, rejected on privacy grounds |
| 6 | PostgreSQL as the sole system of record, with Redis as cache and buffer only | One place to reason about correctness; a Redis outage becomes a performance event, not a data event | A dedicated analytics store (columnar or time-series) — better at scale, but a second system of record before it is needed |
| 7 | Daily range partitioning for the event table | Retention becomes a partition drop rather than a mass delete, which keeps purging cheap and predictable | Monthly partitions (too coarse for Free's 30-day window); no partitioning (deletes would dominate database load) |
| 8 | Four deployables rather than one | The redirect path's latency and scaling profile is entirely different from everything else and must scale independently | A single application — simpler to operate, but couples the busiest surface to the heaviest one |
| 9 | Blue/green for the resolver, rolling elsewhere | Instant, complete rollback for the surface with the tightest SLO and the highest consequence | Rolling everywhere — cheaper, but mixes versions and starts every instance cold |
| 10 | Fire-and-forget analytics publication | Delivery must never be slowed or broken by measurement | Synchronous write with a short timeout — still adds latency and a failure mode to the hot path |
| 11 | No tracing on the redirect path by default | Instrumentation overhead is material against a 50 ms budget, and metrics already decompose that path completely | Low-rate sampling always on — the overhead is per-request regardless of sample rate |
| 12 | Deterministic bucketing from the weekly experiment salt and the daily visitor hash, plus an optional consented cookie — with cookie-free stickiness stated honestly as 24 hours, not a week | The cookie-free path gives sticky assignment with no consent banner, and the consented path gives exact stickiness for visitors who opt in. The 24-hour bound is not a compromise we chose, it is arithmetic: the visitor hash rotates daily, so the assignment function's input changes daily no matter how long the experiment epoch is. Publishing a longer number would have been a claim the code cannot honour, and every test written against it would have been unsatisfiable | Always-cookie (requires consent everywhere); per-request random (destroys stickiness and validity); or claiming 7-day stickiness because the salt epoch is a week (false, and it fails its own tests) |
| 13 | Variant identity never appended to outbound URLs | Query-parameter pollution breaks the customer's own analytics and is visible to their visitors | Append a variant parameter — simpler attribution, unacceptable side effects |
| 14 | Minimum-sample guard of 100 visitors per arm and a minimum elapsed duration | The duration limb is required because assignment re-buckets every 24 hours: a volume-only guard would let a high-traffic customer reach 100 visitors per arm inside a single day and promote a winner drawn from one day's audience under one day's bucketing | Volume-only guard — statistically unsound given daily re-bucketing |
| 35 | Every code the product can return lives in one generated registry, with one status each, and CI fails in both directions | A registry that is written by hand drifts within weeks, and a drifted registry is worse than none: clients branch on codes that no longer exist and miss codes that do. Generating it from the same constant the code emits from makes drift impossible rather than merely discouraged, and checking both directions catches the two distinct failures — an undocumented code, and a documented code nobody emits | Hand-maintained appendix (drifts); one-directional check (catches undocumented codes but accumulates dead entries) |
| 36 | The four-rung QR fallback chain uses one vocabulary — a rung number and a fallback_stage enum, always written together |
Three names for one concept is how a fallback chain acquires a phantom fifth rung and an off-by-one in a log threshold. Numbers are readable in prose and terrible in code; enums are the reverse. Carrying both, always, costs one column and removes the ambiguity entirely | Numbers alone (off-by-one in every threshold); enum alone (unreadable in prose and in the runbooks); a separate serving_mode state machine (a third vocabulary for the same fact) |
| 37 | The entitlement evaluation seam ships in M2, with billing becoming its source in M10 | The authorization algorithm evaluates entitlements at step 7, so every route from M2 onward calls it. Leaving the seam until M10 would have made M6, M7 and M8 each depend on a milestone that comes after them — which means none of the three could ever have been signed off, and the thirteen-milestone plan would quietly have been a single delivery | Build entitlements with billing in M10 (creates three forward dependencies); or move all of billing to M3 (drags checkout, dunning and tax in front of the product they govern) |
| 15 | Downgrade archives rather than deletes | Deleting a paying-then-lapsed customer's work is unrecoverable and indefensible | Delete above cap — simpler enforcement, catastrophic customer outcome |
| 16 | QR codes exempt from every downgrade and deletion effect | The permanence promise is the product's differentiator and cannot have a billing exception | Suspend QR resolution on non-payment — a common industry behaviour, and the exact failure the product exists to prevent |
| 17 | One webhook URL per workspace, no subscription management UI | Covers the real use case (forward events to one place) at a fraction of the surface area | Full subscription management — deferred to the roadmap |
| 18 | UUIDv7 generated in application code, used as the public identifier | Time-ordered for index locality, generated before insert so batch writes need no round trip, and one identifier rather than two | Database-generated UUIDs (round trip), or separate internal and public ids (two identifiers to keep consistent) |
| 19 | snake_case in public API JSON |
Matches the database and is conventional for public APIs the customer will read in a terminal | camelCase — matches the TypeScript client, but creates a mapping layer for every field |
| 20 | Cursor pagination only, no total counts on large collections | Stable under concurrent writes and cheap at any offset; total counts on event-scale tables are expensive and rarely used | Offset pagination — familiar, but skips and duplicates records under concurrent mutation |
| 21 | Entitlements evaluated server-side on every mutating path including the API | A client-side check is a hint; the API is a first-class surface and must enforce identically | UI-only enforcement — trivially bypassed |
| 22 | Automated three-condition decode validation on every QR render | The only way to promise scannability while offering styling; catches the failure before it is printed | Visual heuristics on contrast alone — insufficient, since module shape and logo interact with decodability |
| 23 | Error correction auto-upgraded to H whenever styling is applied | Styling consumes decode margin; upgrading automatically removes an expert decision from the customer | Let the user choose — most users would choose wrong and discover it after printing |
| 24 | 22% logo width ceiling | Level H tolerates roughly 30% codeword damage; 22% leaves margin for print bleed and imperfect scanning | 30% — technically decodable in ideal conditions, fails in real print |
| 25 | ACME HTTP-01 through the edge, DNS-01 as fallback | HTTP-01 requires nothing from the customer beyond the CNAME they already added | DNS-01 as default — requires delegation most customers cannot perform |
| 26 | Expand/contract migrations, backward-compatible for one release, enforced in CI | Makes every code rollback safe without a schema change, which is the rollback that actually happens during an incident | Coupled migrations — faster to write, remove the ability to roll back safely |
| 27 | Migrations as a separate deployment step, never on container boot | N replicas booting concurrently race; a boot-time migration is a self-inflicted outage | Migrate on boot with a lock — the lock helps, but boot ordering still couples rollout to schema change |
| 28 | Public bio page HTML cached at the CDN for 60 s with stale-while-revalidate, except during experiments | Absorbs viral spikes at the edge while bounding staleness to a minute; experiments need per-visitor assignment | Longer TTL (unacceptable publish latency) or no caching (origin absorbs every spike) |
| 29 | System font stack by default, custom fonts strictly limited | Fonts are the largest avoidable cost against the 40 KB HTML and LCP budgets | Custom fonts by default — better brand fidelity, worse on every budget |
| 30 | Theme editor blocks saving a theme that fails contrast, with an override requiring typed confirmation | Accessibility conformance cannot survive an easily-dismissed warning; the override preserves customer autonomy and creates an audit trail | Warning only (ignored in practice) or a hard block with no override (customers will simply pick another product) |
| 31 | Cross-workspace access returns 404 rather than 403 | A 403 confirms a resource exists, which is an enumeration oracle | 403 — more semantically honest, leaks existence |
| 32 | Support impersonation requires customer consent, is read-only by default and is time-boxed | Support access to a customer's account is a privilege, not a convenience; consent plus audit makes it defensible | Silent impersonation — operationally easier, indefensible to a security reviewer |
| 33 | Coverage floor of 80% overall but 95% on critical paths | A single global number either under-protects the paths that matter or wastes effort on the ones that do not | One uniform floor — either too low where it matters or too high where it does not |
| 34 | Metrics never carry workspace-scoped labels | Unbounded cardinality is the standard way to take down a metrics backend; per-workspace numbers belong in the rollups | Per-workspace metrics — convenient for debugging, unsustainable at scale |
30.11 Roadmap — explicitly deferred #
Everything here is deliberately out of scope for the specification above. Each is deferred for a stated reason, not forgotten.
| Item | Why deferred |
|---|---|
| Native iOS and Android apps | The product's value is in public surfaces and a desktop-first editor; a mobile app duplicates the dashboard without new capability until scale justifies it |
| Enterprise SSO and SAML | Requires an identity-provider integration surface and an enterprise sales motion that does not exist at launch; the Business plan targets teams and agencies, not enterprises |
| White-label reseller programme | Depends on multi-tenant branding, sub-billing and a partner agreement model — a distinct product, not a feature |
| Full CRM integrations (Salesforce, HubSpot) | Object-model mapping, field sync and conflict resolution are a project in themselves; the generic webhook and ESP destinations cover the launch use case |
| Ad-platform conversion APIs (server-side CAPI) | Each platform has its own event schema, identity matching and consent model; the client-side pixels plus server-side GA4 forwarding cover the launch need |
| Built-in cart, checkout or e-commerce | A payments product inside a links product; link out to the customer's existing store instead |
| Webhook subscription management UI | One webhook URL per workspace covers the real use case; per-event subscriptions, filtering and per-endpoint retry policy are a management surface that can wait for demand |
| Multivariate and sequential testing | The A/B implementation with a two-proportion z-test and an honest guard is the right first step; more sophisticated methods need more traffic than the target customer has |
| City-level and coordinate geography | Deliberately excluded on privacy grounds, not deferred for effort. It would be reconsidered only alongside a consent-gated analytics tier |
| Link-level password protection and gated content | Straightforward to add, but interacts with the no-JavaScript guarantee and caching; deferred until the public path is proven |
| Bulk CSV import and export of links | The API covers programmatic bulk work; a UI importer with error reporting and dry-run is a meaningful project |
| Team activity feed and comments in the editor | Collaboration surface; the audit log covers the accountability need at launch |
| Custom fonts uploaded by the customer | Interacts directly with the performance budgets; needs subsetting infrastructure to do responsibly |
| Additional payment methods and local currencies | Adds tax, presentment and reconciliation complexity; single-currency USD at launch |
| Second region and active-active delivery | The disaster-recovery plan covers region loss with a documented RTO; active-active is a scale decision, not a launch one |
| Columnar cold storage for aged raw events | Named as the next step in Section 25.11 when database storage growth demands it |
30.12 Document conventions #
| Notation | Meaning |
|---|---|
## N. Title |
A top-level section. ### N.1, #### N.1.1 are subsections |
| "Section 14.3" | A cross-reference. The referenced section is the canonical definition; this document never defines the same concern twice |
| MUST, NEVER, "non-negotiable", "invariant" | A hard requirement. Violating it is a defect regardless of any other benefit |
| "Default:" | A decided value that may be changed by configuration, not an open question |
code font |
An exact literal: identifier, field name, environment variable, endpoint, HTTP header, error code, or shell command |
SCREAMING_SNAKE_CASE |
An environment variable. All are catalogued in Section 27.4 |
snake_case |
A database column, a JSON field in the public API, or an error code |
camelCase / PascalCase |
TypeScript variables and functions / types and components |
kebab-case |
A URL path segment or a queue name |
| Tables | The primary form for field lists, matrices and catalogues. Prefer reading the table over the surrounding prose where they overlap in detail |
| Fenced code blocks | Payloads, schemas, commands and configuration, given verbatim |
| Numbered lists in runbooks | Executable steps, in order, each with a stated verification |
| "p50 / p95 / p99" | Latency percentiles, measured server-side excluding network transit unless stated |
| Money | Whole units in prose and customer-facing tables; integer minor units with an ISO 4217 currency code in storage and API payloads |
| Timestamps | UTC, RFC 3339, in timestamptz columns |
| Durations | Explicit in the unit named by the field or variable suffix |
| "the four deployables" | The web application, the redirect resolver, the public API and the worker fleet |
| "the reference device" | Mid-range Android class handset on 4G with a 4× CPU throttle, per Section 11 |
| Placeholders in examples | Angle brackets, e.g. <workspace_id>, <issued verification token> |
| Example hostnames | linkhub.app, app.linkhub.app, api.linkhub.app, go.linkhub.app, cname.linkhub.app, lnkhb.co for the platform; go.acme.com and acme.link for customer domains |
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.