Skip to content
GenerateSpecs home.mdDownload this spec as Markdown.htmlDownload this spec as a single self-contained HTML file
saasPUBLIC

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

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 #

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 #

  1. 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.
  2. Record the change in DECISIONS.md at 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.
  3. 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).
  4. 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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_dnsverifyingprovisioning_tlsactive 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/webapps/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:

  1. Exactly one Owner exists per workspace at all times. See Section 3.6.
  2. 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 C3 in Section 3.3.1).
  3. A user's role in workspace A has no bearing on their capabilities in workspace B.
  4. 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.
  5. 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
# 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_scopesnot 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_foundnever 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:

  1. 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.
  2. 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.
  3. The resource belongs to the workspace, not to the creator. created_by is recorded for audit purposes only and confers no ongoing authority.
  4. 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.
  5. 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.
  6. Auto-created grants are recorded in the audit log with source = auto_on_create to 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 Allow

3.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:

  1. Changing the Owner's role to Admin, Editor or Viewer.
  2. Removing the Owner's membership.
  3. The Owner using workspace.leave.
  4. 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.

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.

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.

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.md

4.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 #

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:raw

dom: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 sheet

The 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 keys

The 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 retention

Column 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.

  1. 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.
  2. The dependency graph must be small enough to audit. apps/edge imports three modules from packages/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.
  3. 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.
  4. Deployment risk differs. apps/edge deploys 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.
  5. 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.verifiedactive 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:

  1. 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.
  2. A QR key is never deleted and never negatively cached. Archive, delete, suspension and workspace deletion all rewrite the payload with the appropriate fallback_stage and rung. This is the cache-layer expression of the permanence rule in Section 14.8.
  3. 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:

  1. 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.
  2. 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.
  3. No job payload contains personal data beyond identifiers. Email addresses in email-send are resolved at execution time from the database, so a queued job is not a copy of a mailing list.
  4. Queues have a per-queue concurrency limit rather than a global one, so one slow external dependency cannot consume the worker pool.
  5. Dead-letter contents are retained 30 days, then purged.
  6. Every worker asserts at startup that the Redis database backing clicks:raw is configured noeviction, 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 sustained

5,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 events

The 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 TB

Daily 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/year

Hourly 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:

  1. any is banned by lint rule. Use unknown and narrow. The single permitted exception is inside a type-guard function body, which must carry a comment explaining the guard.
  2. Non-null assertion (!) is banned by lint rule. Narrow explicitly or throw a domain error.
  3. Type assertions (as) require a comment stating why the compiler cannot prove the claim. as unknown as T is banned outright.
  4. Exhaustiveness is enforced with an assertNever(value: never): never helper at the end of every discriminated-union switch.
  5. Enums are not used. Use as const objects with a derived union type, which erase cleanly and are structurally comparable across package boundaries.
  6. 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 clients

5.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_events

6.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 #

  1. System hostnames are rows in custom_domains with workspace_id IS NULL and is_system = true (linkhub.app, go.linkhub.app, lnkhb.co). Consequence: links.domain_id, qr_codes.domain_id and bio_pages.domain_id are all NOT NULL, and slug uniqueness is a plain two-column unique index rather than a COALESCE expression. This removes an entire class of null-handling bugs from the hottest lookup in the system.
  2. Membership is the only path from a user to data. No content table has a user_id owner column that grants access; created_by_user_id is provenance only. Section 8.10 defines the enforcement pattern that makes this structural.
  3. Versions are snapshots, not diffs. bio_page_versions, link_versions, qr_code_versions and block_versions each store a complete jsonb snapshot. Restoring a version never needs to replay history.
  4. The permanent-QR rule is expressed in the schema, not only in code. qr_slug_reservations has no deleted_at column, no ON DELETE CASCADE pointing at it, and a BEFORE DELETE trigger that raises an exception. See 6.10.
  5. Enumerations are text + CHECK, never PostgreSQL ENUM types. Adding a value to a CHECK constraint is a single non-blocking ALTER ... 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.
  6. The raw user-agent string is never persisted. No table in this schema has a column holding a raw User-Agent header. The worker parses the string in memory into user_agent_family (a short, non-identifying label such as Chrome on Android) and ua_hash (a daily-salted 16-byte digest used only to cluster bot signatures), and then discards it. Parser maintenance is served by ua_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.
  7. QR slugs and short-link slugs share one namespace per host. A public QR URL is https://{host}/{slug} — there is no /q/ prefix — so qr_slug_reservations protects 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 409 qr_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.

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 NULLthe 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.

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).

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.

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]$') az; 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.

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 NULL

Both 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:

  • pk on (occurred_at,id) — the dedupe probe on every insert.
  • ix_click_events_<part>_ws_resource_time on (workspace_id,resource_type,resource_id,occurred_at DESC) — per-link and per-QR detail views, per-resource exports.
  • ix_click_events_<part>_ws_time on (workspace_id,occurred_at DESC) — 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>_experiment on (experiment_id,variant_id,occurred_at DESC) 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_msg unique 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_sourceutm_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:

  1. 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.
  2. 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.
  3. 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.

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_hashua_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.

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 dayoccurred_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.

  1. Compute the set of required partitions: today through today + 14 days on all three daily-partitioned tablesclick_events, page_view_events and analytics_unique_visitor_days — plus the current and next month for analytics_rollup_hourly.
  2. For each missing partition: CREATE TABLE IF NOT EXISTS … PARTITION OF …, then create its indexes, then ANALYZE it.
  3. 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_days per parent table; an alert fires below 7, and pages below 3.
  4. The job is idempotent and safe to run concurrently: every statement uses IF NOT EXISTS and the whole run holds an advisory lock (pg_advisory_lock on 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:

  1. For each of the last three complete UTC days, recompute every grain directly from click_events and page_view_events with a single grouped aggregate per day.
  2. Recompute unique_visitors for the total rows by counting analytics_unique_visitor_days rows for the grain, and the hourly split by grouping the same rows on date_trunc('hour', first_seen_at).
  3. Write corrections by absolute assignmentSET events = $computed, unique_visitors = $computed — not by delta, then set reconciled_at = now().
  4. Delete rollup rows the recomputation did not produce. This cleans up double-counted grains left by a partially failed batch.
  5. Emit analytics_reconcile_drift_ratio per 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.
  6. Recompute links.click_count_cached, qr_codes.scan_count_cached, bio_pages.view_count_cached and blocks.click_count_cached from the reconciled daily rollups in the same run.
  7. 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_migrate role; 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 rnm, 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.com in active, acme.link in pending_dns with 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_events and page_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 pendingexpired 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.

  1. Keyset pagination only. WHERE (created_at, id) < ($cursor_ts, $cursor_id) ORDER BY created_at DESC, id DESC LIMIT $n+1. OFFSET never appears in application code — it degrades linearly and produces duplicates under concurrent inserts. The +1 row is what populates meta.has_more.
  2. No count(*) on unbounded collections. meta.total is present only where the set is inherently small (workspace members, domains, API keys, plans).
  3. Every analytics query is time-bounded before it reaches the database (6.4.4).
  4. workspace_id leads every composite index on a tenant table, so the tenancy predicate is always the leading key rather than a filter.
  5. Denormalised counters (click_count_cached and 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.
  6. pg_stat_statements is 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_membersresource_grants; workspace_invitations; audit_log_entries; workspace_resource_counters; subscriptionssubscription_items; invoices; entitlement_overrides; custom_domains (only those not retained in stage 3) → domain_verification_attempts, tls_certificates; bio_pagesbio_page_versions, blocksblock_versions; themes; fonts; uploaded_assets; linkslink_versions, link_destinations, link_rules; utm_presets; qr_codesqr_code_versionsqr_render_artifacts; experimentsexperiment_variants, experiment_assignments_rollup, experiment_results; leadslead_sync_attempts; lead_sync_targets; integrationsintegration_credentials; webhook_deliverieswebhook_dead_letters; api_keysapi_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 retainON 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:

  1. Transactional cleanup. The purge job that removes a bio page, link or QR code deletes the matching resource_grants rows and archives the matching experiments in the same transaction.
  2. Nightly integrity-check job. Deletes orphaned resource_grants, sets orphaned experiments to archived, and emits referential_orphans_total{table}. Any non-zero value on resource_grants is an alert, because an orphan grant is a latent authorisation bug.
  3. 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_entries and abuse_reports intentionally 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:

  1. The registration or password change proceeds — a third-party outage must not stop account creation.
  2. The event is counted (hibp_check_unavailable_total) and logged at warn. Sustained failure over 15 minutes raises an alert.
  3. The user record is marked with password_params.breach_check = 'deferred'.
  4. 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 }.

  1. 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).
  2. If users.locked_until > now() → 429 account_locked with Retry-After set 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 generic invalid_credentials until the IP limit trips.
  3. On password mismatch: increment the per-account and per-IP counters, write auth_attempts, return 401 invalid_credentials.
  4. 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).
  5. On success: reset failed_login_count and locked_until; if users.totp_enabled_at is set or the target workspace requires TOTP, create the session with mfa_satisfied_at null and return 200 with { "data": { "mfa_required": true, "challenge_token": "…" } }; the session grants no data access until the challenge is satisfied.
  6. Otherwise complete the session (7.8), update last_login_at and last_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.

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:

  1. (provider, sub) matches an existing identity → sign that user in. If Google's email differs from the stored provider_email, update it, but do not touch users.email.
  2. 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).
  3. No identity and no matching account → register: create users with email_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.
  4. 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_credential with 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_changed to 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:

  1. The user submits the support recovery request from the sign-in page.
  2. 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).
  3. Support raises a ticket that a second staff member must approve. No single employee can reset 2FA.
  4. 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.
  5. At execution, TOTP is disabled, all recovery codes are deleted, all sessions are revoked, and the audit log records account.totp_reset_by_support with 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 emaildeleted-<uuid>@deleted.invalid, nameDeleted 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 Email 3 15 min 429
POST /auth/magic-link IP 10 1 h 429
POST /auth/password-reset Email 3 1 h 429
POST /auth/password-reset IP 10 1 h 429
POST /auth/verify-email/resend Email 1 60 s 429
POST /auth/verify-email/resend Email 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_failedowner 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:

  1. Verify the signature, then load the invitation by id.
  2. Validate status='pending' and expires_at > now() → 410 invitation_expired / 409 invitation_revoked / 410 invitation_already_used.
  3. 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 stays pending so an Admin can free a seat, and both the invitee and the workspace Admins are emailed.
  4. 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 as dana@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.
  5. In one transaction: insert workspace_members (or restore a soft-deleted row for the same user), materialise pending_grants into resource_grants, set status='accepted', accepted_at, accepted_user_id; set users.email_verified_at if null and the addresses match.
  6. 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:

  1. 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."
  2. 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:

  1. No UPDATE path in code. The data-access layer exposes insert and select for this table only; there is no update or delete function to call.
  2. Database privileges. REVOKE UPDATE, DELETE, TRUNCATE ON audit_log_entries FROM app_rw, app_ro;. Only app_retention holds DELETE.
  3. Trigger. tg_audit_log_entries_immutable, a BEFORE UPDATE OR DELETE trigger, raises unconditionally on UPDATE; on DELETE it raises for any session whose role is not app_retention, and for app_retention it raises unless OLD.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 — because NULL < now() is NULL, not true — 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 the audit-retention job's own DELETE carries the identical predicate so the trigger is a backstop rather than the only guard.
  4. Schema shape. The table has no updated_at and no deleted_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 changedSpring Catalogue (go.acme.com/spring) Dana Reyes (dana@acme.com) · 12 May 2026, 14:32 (Europe/Berlin) · Germany · Chrome on macOS · Dashboard destination_url changed from https://acme.com/campaigns/spring-2026 to https://acme.com/campaigns/summer-2026 Version 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:

  1. 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.
  2. A single authorisation gate. Every request passes through one middleware that resolves (workspace, user) → role, is_scoped from ux_workspace_members_ws_user and produces a TenantContext { workspaceId, userId, role, isScoped, grantedResourceIds }. A handler that does not receive a TenantContext cannot query tenant data — the repository functions require it as their first parameter, so the type system rejects the mistake at compile time.
  3. Repository-level enforcement. Every tenant-scoped query is built by a helper that injects WHERE workspace_id = $ctx.workspaceId (plus the grant filter when isScoped). 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.
  4. 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.
  5. Cache keys carry the tenant. Redis keys are rd:{host}:{slug} (host is globally unique, so it is already tenant-bound), sess:{token_hash}, and rl:{scope}:{key}. No cache key is derived from a resource id alone, so a cache lookup cannot cross a boundary.
  6. Background jobs are tenant-scoped. Every job payload carries workspace_id, and every job builds its TenantContext the same way a request does. A job with no workspace (partition maintenance, reputation rechecks) touches no tenant-readable data.
  7. Analytics queries are tenant-first. workspace_id is 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.
  8. 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: reduce disables 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. acme on linkhub.app and acme on go.acme.com are different pages and both are permitted.
  • Soft-deleted pages hold their handle for the 30-day restore window. Attempting to claim it returns handle_taken with details[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 302 redirect — never a 301 or 308, 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 only available: boolean and a reason code, 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, lnkhb

The 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):

  1. Apply NFKC to the candidate.
  2. Map each code point through the Unicode confusables table to its prototype (0o, 1l, rnm, vvw, Cyrillic аa, and so on).
  3. Remove hyphens and collapse repeated characters (aaa).
  4. The result is the skeleton. Store it in an indexed handle_skeleton column.
  5. 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 403 plan_limit_reached with details.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 422 block_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 (null for 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: ContentAppearanceBehaviourVisibility & scheduling (Section 15) → AdvancedDanger 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 gets aria-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 = published and 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_published so 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:

  1. Autosave stops. The publish bar switches to a red "Editing conflict" state. No further writes are attempted.
  2. 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."
  3. 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
  1. Whichever is chosen, the local draft is first written to IndexedDB under conflict-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}/presence registers 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 the page: 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:

  1. For a solid background, use it directly.
  2. For a gradient, sample the gradient at 9 evenly spaced stops and take the worst-case (lowest-contrast) stop.
  3. 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.
  4. 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.

  1. The user opens Override contrast check.
  2. 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 CONTRAST exactly (case-sensitive, no auto-fill, paste disabled).
  3. A reason field (10–280 chars) is required.
  4. On confirm, theme.contrast_override is 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 }
  ]
}
  1. An audit-log entry theme.contrast_override_applied is written per Section 8, capturing before/after values, the reason and the actor.
  2. A persistent, non-dismissible warning banner appears in the editor for as long as the override is active.
  3. The workspace settings area lists all pages with an active override so an Admin can review them.
  4. 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.
  5. 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.ico

Public 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, and Content-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 postMessage to 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 postMessage channel 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:

  1. The iframe is recreated with a fresh, partitioned storage context, so localStorage, sessionStorage and any consent state from earlier previews are gone.
  2. The consent banner renders exactly as an EEA visitor would see it (region simulation is selectable: EEA / UK / US / other).
  3. The password gate renders, so the author can confirm their gate copy.
  4. The NSFW gate renders when enabled.
  5. Embed facades are in their un-clicked state.
  6. Countdown blocks recompute from the current time.
  7. 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.
  8. 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:

  1. Purpose — "What is this page for?" (Creator / Business / Event / Portfolio / Something else). Chooses a starting template and default block set.
  2. Identity — display name, avatar upload (skippable), one-line bio. Populates the header/profile block.
  3. 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.
  4. 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: 96px and 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 Esc then Tab exits) and the preview iframe (which is tabindex="-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-editor database, drafts store, key {page_id}) within 250 ms, independently of network state. The store holds the full draft document plus the last-known base_revision and a change log.
  • On editor open, if IndexedDB holds a draft newer than the server's updated_at for 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.
  • beforeunload warns 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-b carries 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-label only 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 tabindex anywhere.
  • prefers-reduced-motion: reduce disables 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.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/height are 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 label text. When description is 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_tab appends 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 h1 content. Exactly one h1 per page — the publish gate rejects a document with zero or multiple.
  • The verified badge is role="img" with aria-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: narrow uses max-width: 52ch; long words break with overflow-wrap: anywhere so 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-mark against 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_click for any <a> inside, attributed with block_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 has track_clicks disabled 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 alt text, or the caption when decorative is true and a caption exists. A linked image with decorative: true and 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 into alt.
  • 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_click when linked; block_impression on 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_id blocks 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 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 facebook page name, numeric id, or URL https://facebook.com/{h} ^[A-Za-z0-9.]{5,50}$ or ^\d{5,20}$
LinkedIn 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 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 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 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 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". For email/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 the title and 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: brand colours are contrast-checked; a brand colour failing 3:1 against the page background falls back to the theme's on_surface token 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_click per item with network as an extra dimension.
  • No-JS: fully functional.
  • Errors: an item whose value fails its pattern shows an inline error naming the expected format with an example; publish blocked. A custom item missing custom_icon blocks 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 as aria-label on 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.
  • autoplay is only permitted when muted is true; the editor explains that browsers block unmuted autoplay and that autoplaying video is disorienting. When prefers-reduced-motion: reduce is 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_play on first play (enhancement bundle, via the play event); block_impression on 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. Only media_play analytics 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_url that 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: upload is Pro and Business (self-hosted media bandwidth). source: url is 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 / startstart_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

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:

  1. The host must be exactly buy.stripe.com. No subdomain, no lookalike, no redirector.
  2. The scheme must be https.
  3. 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.
  4. client_reference_id is 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.
  5. 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.
  6. Validation failure returns 422 with code invalid_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: card is full-width below 640 px; at ≥ 640 px two product blocks placed consecutively inside a group block (10.14) with layout: grid render side by side. row keeps 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 is alt="" 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_click with extra dimensions price_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 url or name blocks publish; compare_at_price_cents ≤ price_cents is 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 Other to {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 responds 303 to ?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_url is set, both paths navigate there (303 natively, location.assign when 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_hash within 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 native required attribute 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, the role="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_submit server-side on accepted submissions only, carrying block_id and destination_id. The email address itself is never part of an analytics event. block_impression on 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_review instead (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: space mode is aria-hidden="true" and contains nothing — presenting an empty list item to a screen reader is pure noise. line mode uses a real <hr>, which carries the separator role natively. label mode with label_level: none renders a <p>; with h2/h3 it renders the heading and is validated for heading-order correctness at publish. Screen-reader announcement: "separator" for line, nothing for space, "heading level 2, Merch" for a labelled heading.
  • Analytics: none. This block emits no events at all.
  • No-JS: fully functional.
  • Errors: label mode 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 exposes aria-expanded, is keyboard-operable with Enter and Space, and is announced correctly by every screen reader in the Section 24 test matrix. No role="button", no aria-expanded and no tabindex are added — overriding native semantics here is the most common way this pattern is broken.
  • Exclusive accordions use the native name attribute on <details>, not JavaScript. When allow_multiple_open is 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-marker and list-style: none, and replaced by the explicit chevron so the affordance is never lost.
  • content-visibility: auto is applied to .lh-faq__a so 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 heading via aria-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_impression only. Individual expansions are not tracked; the value is low and the enhancement code required is not worth the bytes. Links inside answers emit block_click as 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_default items 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: nosniff

The 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_click for 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:

  1. ends_at is a single absolute instant stored as timestamptz in UTC. It is the same instant for every visitor on earth. There is no per-visitor target.
  2. timezone records 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").
  3. 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.
  4. 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 Date response header at page load. A visitor with a badly wrong device clock still sees the correct remaining time.
  5. display_timezone: visitor additionally renders the target as a local wall-clock string using Intl.DateTimeFormat after 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.
  6. 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: boxes wraps to two rows below 360 px; digits use font-variant-numeric: tabular-nums and a fixed ch width per unit so the layout never jitters as numbers change.
  • Accessibility: role="timer" with aria-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 separate aria-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_click on the CTA; block_impression on 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_at in the past is a field warning, not an error (an author may deliberately publish an already-ended countdown with on_expiry: message); a reveal target 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: reveal combined 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-2 collapses to one column below 480 px; grid-3 goes to two columns at 480–767 px and one column below 480 px. carousel is a CSS scroll-snap row with overflow-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 operating scrollBy. 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 via aria-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-3 and collapsible modes. carousel is scrollable but without the arrow buttons.
  • Errors: exceeding 50 children or attempting to nest a group blocks the operation with 422 block_nesting_unsupported; collapsible with 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                                     -> rendered

A 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 only country produces 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, or null to 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 dangerouslySetInnerHTML requires 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:

  1. 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.
  2. Any rename, removal, type change or semantic change requires a migration. Bump version and append exactly one function to migrations.
  3. 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's defaults() rather than throwing — a corrupt block must degrade, never take a page down.
  4. 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.
  5. Migrations are append-only. An existing migration function is never edited; correcting a bad migration means appending another one.
  6. Every migration ships with a fixture test containing at least one real captured settings payload from the previous version.
  7. The settings_version column 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.
  8. 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 #

  1. 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.
  2. 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/edge is a Hono service that does exactly one thing.
  3. 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.
  4. Blast radius. apps/edge deploys 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.
  5. 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 headers

Steps 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 has rk = _ and exactly one cache entry. A page whose rules reference only country has one entry per country that visits. A page referencing country and device has at most countries × 3 entries. 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 :root block 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 with media="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:

  • charset first, unconditionally, so the parser never restarts.
  • preconnect before 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+json block 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:

  1. The <img> carries explicit width/height matching the rendered CSS box, so the box exists before any bytes arrive.
  2. The LQIP data URI paints in the same box on the first frame, so the space is never visibly empty.
  3. The image is preloaded from the head (11.5.6 item 5).
  4. When no avatar is set, the monogram fallback occupies the identical box — switching between them cannot shift anything.
  5. 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, before DOMContentLoaded.
  • Placed at the end of <body> so it is discovered last.
  • Served with Cache-Control: public, max-age=31536000, immutable under 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 for embeds only 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 #

  1. It may not insert, remove, resize or reposition any element before a user interaction (11.6.3).
  2. It may not be required for any documented capability in 11.4.1.
  3. 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.
  4. 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 embeds chunk leaves working facade links.
  5. 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.
  6. It must not read or write cookies other than the two defined in Section 16 and Section 23.

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:

  1. Parameters explicitly configured on the link's UTM set (Section 15) win over incoming ones with the same key.
  2. Incoming parameters are forwarded unless their key appears in the reserved list (lh_*, which LinkHub uses internally).
  3. The composed URL is capped at 2048 characters; excess incoming parameters are dropped from the right and a counter increments.
  4. Fragments (#…) are never sent to the server by browsers and therefore cannot be forwarded; the link's own configured fragment is appended last.
  5. 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 #

  • HEAD is handled identically to GET but 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 = true and excluded from default views.
  • Requests carrying Purpose: prefetch, Sec-Purpose: prefetch, or X-Moz: prefetch redirect normally but are recorded with is_prefetch = true and 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 #

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_stage is 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-store rungs 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 200 with the form, never 401, 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_failedand 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.xml

Redirect-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}.xml with 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). changefreq and priority are 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-Encoding only. 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 by Vary, because Vary: User-Agent would destroy the hit rate.
  • Media: Vary: Accept where 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:

  1. 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.
  2. 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.
  3. 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 #

  1. Force HTTPS: 301 from http to https, 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 — a 302 here would force every first-time visitor on every device to repeat the plaintext hop indefinitely, for no benefit.

  2. Strip a leading www. on customer bio-page domains via 302, unless the customer's configured canonical host includes it. This one is 302 because the canonical host is a customer setting they may change.

  3. 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 a 200 on 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.

  4. Block requests whose Host header 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.

  5. 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 429 with Retry-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.

  6. Never cache a response carrying Set-Cookie, enforced as an edge rule rather than relying on origin discipline.

  7. 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 #

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.

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 links is 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.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:

  1. Archived resources do not count toward any cap. Section 22.2.5 is the canonical statement of what counts; this row conforms to it.
  2. 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.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:

  1. Generate 7 characters from the Crockford base32 alphabet with look-alikes removed (i, l, o, u excluded), drawn from a cryptographically secure RNG. This yields 32⁷ ≈ 3.4 × 10¹⁰ candidates per host.
  2. Lower-case the result. Generated slugs never contain a hyphen, which keeps them visually distinct from hand-written slugs.
  3. Reject and redraw if the candidate matches the blocklist in 12.2.5 (a generated string can accidentally spell a blocked token).
  4. Attempt the insert. The unique index on (domain_id, slug) is the arbiter — never a pre-flight SELECT, which races.
  5. 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_exhausted and 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:

  1. Trim leading/trailing whitespace.
  2. Apply Unicode NFKC normalisation.
  3. 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.
  4. Replace internal whitespace with -.
  5. Strip a leading / if the user pasted a path.
  6. Reject anything still outside [a-z0-9-]. LinkHub does not silently transliterate accented characters, because cafécafe produces 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.json

Blocked 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, so secure-checkout on your own domain is fine and paypal-verify is 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:

  1. 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.
  2. 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.
  3. 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:

  1. Trim surrounding whitespace; reject if any C0/C1 control character remains anywhere in the string.
  2. If no scheme is present and the string parses as a hostname with an optional path, prepend https://. A bare acme.com/x becomes https://acme.com/x. A string with a scheme-like prefix that is not in the allow-list is rejected rather than coerced.
  3. Lower-case the scheme and the host. Never touch the path, query or fragment case — path case is significant on most origins.
  4. Convert a Unicode host to punycode (IDNA 2008, UseSTD3ASCIIRules = true). Store the punycode form; display the Unicode form in the UI.
  5. Remove the default port (:80 for http, :443 for https).
  6. Collapse .. and . path segments.
  7. Normalise percent-encoding: upper-case hex digits, decode unreserved characters that were needlessly encoded, and leave everything else byte-identical.
  8. Preserve the fragment (#…) verbatim. Fragments are never sent to the server but are meaningful to single-page destinations.
  9. 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:

  1. 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.
  2. 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).
  3. 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:

  1. The PostgreSQL transaction commits the new row.
  2. Write-through: SET rd:{host}:{slug} to the newly rendered redirect payload with the standard TTL, clamped by any schedule boundary (15.3.5).
  3. If the link is QR-backed, repeat step 2 for the mirrored system-host key rd:{system_host}:{slug}.
  4. DEL rd:miss:{host}:{slug} — a negative cache entry may exist from a pre-creation probe.
  5. 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/spring After: https://acme.com/collections/summer

Codes 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 email
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):

  1. The file is stored in object storage under a 24-hour lifecycle rule.
  2. The link-import worker parses and validates every row: schema, slug rules, destination validation including DNS and Safe Browsing, plan cap arithmetic, and intra-file duplicate slug detection.
  3. Each row is classified ok, warning, error or duplicate_of_existing.
  4. 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.

Search covers slug, title, notes, destination_url and tag values.

  • Implementation: a PostgreSQL generated tsvector for title + notes, plus a trigram index on slug and on destination_url for 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_url host-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.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.

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.

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:

  1. Security. Framing a third-party site strips its ability to defend itself; the majority of serious destinations send X-Frame-Options or a frame-ancestors CSP and would break anyway, producing a blank page the customer cannot debug.
  2. 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.
  3. 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.
  4. 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: noindex is 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_reached with 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 409 link_slug_held with 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.

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.

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.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.

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_id is set at creation and may be changed only while has_qr = false; moving a QR-backed link between domains would break printed media and returns 409 link_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_id is 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, enter go. If it does not, enter go.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 CNAME cannot coexist with any other record on the same name. If the subdomain already has an A, TXT or MX record, 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:

  1. 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.
  2. 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.
  3. 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_dnsverifying TXT value matches
D2 verifyingprovisioning_tls Routing record matches expectation for the domain type
D3 provisioning_tlsactive Certificate issued, stored, and confirmed loaded by at least two edge nodes
D4 pending_dns/verifyingdns_failed Check schedule exhausted 72 hours from creation
D5 provisioning_tlstls_failed ACME order failed after retries 6 attempts over 6 hours
D6 activedns_failed Routing has not matched for 24 consecutive hourly checks 24 hours
D7 activetls_failed Renewal failed and fewer than 7 days of validity remain Paging threshold (13.7.4)
D8 dns_failed/tls_failedpending_dns/provisioning_tls User clicks Retry, or fixes DNS and the hourly probe succeeds Manual or automatic
D9 activesuspended Plan downgrade below allowance, abuse action, or domain removal with QR retention
D10 suspendedactive 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 NS lookup 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.com will affect: • 1,284 short links — they will keep working at go.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 to linkhub.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 CNAME for go.acme.com in 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 #

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 results

A 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_reservationsthe 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:

  1. 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.
  2. 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.

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:

  1. One for the printed host, exactly as the hostname will appear inside the symbol — for example go.acme.com with slug mk4t2wq.
  2. 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:

  1. 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_reserved with a regenerated suggestion. There is no window in which a code exists without its reservation.
  2. 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).
  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_reserved and 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.
  4. Reservations are never deleted by the application. The database role used by the application has DELETE revoked on the table and a BEFORE DELETE trigger raises unconditionally. Removal is a manual DBA operation with a documented, audited runbook (Section 25.8) and exists only for a legal takedown order.
  5. The retention worker's purge queries carry an explicit NOT EXISTS guard 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 #

  1. The user opens the QR code and clicks "Change destination", or edits the backing link.
  2. 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.
  3. The confirmation modal in 14.3.2 appears.
  4. 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.
  5. 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/spring After: https://acme.com/summer

Scans 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 = NULL on both entries for a QR destination change. Every other audit entry gets now() + the plan's audit retention window from 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 than now() in SQL and would silently drop out of a naive < comparison, so the IS NOT NULL limb 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:

  1. SET rd:{host}:{slug} → newly rendered payload, TTL 3600 s clamped by any schedule boundary.
  2. DEL rd:miss:{host}:{slug}.
  3. 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:

  1. Default is M.
  2. Applying any of: a logo overlay, a foreground or background gradient, a non-square module shape, a non-square eye frame shape, or a non-square eye 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."
  3. The level never falls below M under any configuration or API input. A request specifying L is rejected with 400 qr_error_correction_too_low.
  4. 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_locked naming the styling choice that locked it.
  5. 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 result

Pass 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 validation on 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_version is 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.
PDF 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 as go.acme.com points at LinkHub. It also always resolves at https://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_minimum with 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:

  1. 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.
  2. 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.
  3. 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:

  1. 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.
  2. The backing links and QR code records are purged on the normal schedule. The reservation is not.
  3. 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.
  4. 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/mk4t2wq will 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 slug mk4t2wq can 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:

  1. Trim leading and trailing whitespace.
  2. Collapse internal runs of whitespace to a single _.
  3. Apply Unicode NFKC normalisation.
  4. Apply the workspace's UTM case policy: lowercase (default) or preserve. Lower-casing uses the invariant locale.
  5. 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.
  6. 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):

  1. Compute a comparison key: lower-cased, with _, -, . and spaces removed.
  2. If the comparison key matches an existing value but the raw value differs → case/separator collision.
  3. 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 newsletter instead? [ 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-2026

The 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, q

param_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-2026

Fragments: 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_at on an expired link returns it to active (transition T7 in 12.1.4) and re-runs destination validation, because the destination may have been flagged since.
  • Clearing scheduled_at on 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)
EXEC

Section 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:mmHH: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 monsun, 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_in accept 1–50 values. Beyond 50, 400 rule_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 #

  1. 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.
  2. Disabled rules are skipped without consuming a position.
  3. The first rule all of whose conditions are true supplies the destination. Evaluation stops.
  4. If no rule matches, the default destination is used.
  5. 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 422 rule_default_destination_required. A request can never fall through to nothing.
  6. 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 302

Consequences, 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, not ja) • Rule 2 "UK & Ireland" — matched (country GB is 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 carries limit, current, plan and "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 = 10000

Properties 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.

This is the path taken for every visitor unless the consented path in 16.2.3 applies.

  1. The surface (SSR renderer or edge resolver) computes visitor_hash in memory per Section 17.3.
  2. It reads the current experiment_salt from process-local memory (refreshed from Redis every 60 seconds; see 16.2.4).
  3. It calls assignVariant.
  4. It renders or redirects accordingly.
  5. 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_hash inside the same salt window always produce the same arm.
  • Because visitor_hash itself is derived from a salt that rotates every 24 hours (Section 17.3), the same human on a later UTC day presents a different visitor_hash and 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.

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
}
  • exp12 is 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 stored epoch will not match) and resolved by discarding the entry.
  • epoch is 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=0 response.

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_ab cookie 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, page experiment_id, visitor_hash).
  • The link experiment's arm is a pure function of (experiment_salt, link experiment_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 — unmodified

Same 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.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

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.

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_diff

Per-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₂) / 2

The 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.
  • draftpaused, draftconcluded, draftpromoted.
  • archived → anything.
  • promotedrunning.
  • Two running experiments 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: critical

Displayed: "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 shown

16.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/experiments403 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}/start403 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.sendBeacon on pointerdown / keydown[Enter|Space] for those anchors, with a keepalive: true fetch fallback for browsers without sendBeacon. 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): GeoResult

The 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_family is 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_corpus gives 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 unknown share 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:

  1. The primary key, on (id, occurred_at). Mandatory for invariant I3: it is the probe the ON CONFLICT clause 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.
  2. 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.
  3. 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.
  4. 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:

  1. The query layer exposes two repository types, RollupQuery and RawQuery, and a single router that chooses between them from the parsed filter set. No feature code constructs either directly.
  2. Every raw-backed query carries a mandatory bounded time range and a workspace id in its constructor (18.14), so partition pruning always applies.
  3. meta.source on 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 path

Why 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_family is 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 TB

Two 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 Estimated row 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_source

Three inclusions and three exclusions, each deliberate:

  • visitor_hash is 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_id is 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_family is 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_hash is 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_id and ingest_batch_id are 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_hit

summary_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_complete

unique_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_total

dimension_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, status

bio_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, status

qr_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, status

experiment_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_at

leads — 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 queuedrunningready | failed; readyexpired 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

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
Email 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 #

  1. 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-GCM using the DEK, a fresh 96-bit nonce per write, and the string integration:{workspace_id}:{provider} as additional authenticated data (AAD). Binding the AAD to workspace and provider makes a ciphertext copied between rows undecryptable.
  2. 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).
  3. 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.
  4. 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.
  5. 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-rekey job. The runbook lives in Section 25.
  6. Crypto-shredding. Disconnecting an integration overwrites credentials_ciphertext with NULL and clears the nonce and fingerprint in the same statement. Deleting a workspace destroys its DEK, which renders any residual ciphertext in backups unrecoverable.
  7. 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 #

  1. User selects Disconnect on the integration detail panel.
  2. 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".
  3. 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.
  4. On confirm, in one transaction: status = 'disconnected', credentials crypto-shredded, pending queue jobs for that integration removed by job-id prefix, audit entry written.
  5. 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).
  6. 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 config too.

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_host is dropped first, then {p}region. The drop order is fixed so reports are consistent.
  • user_id is never sent. LinkHub has no identifier for the visitor that is stable beyond the daily salt window, and sending a pseudo-identifier as user_id would misrepresent it as a durable user.
  • non_personalized_ads: true is 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.
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):

  1. 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.
  2. 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.
  3. 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.

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.

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-dynamic is 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 honour strict-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-src and connect-src tables, not here, because the challenge is not an integration a workspace connects.

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:

  1. 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.
  2. The visitor is in a gated context:
    • consent_mode = 'geo' (default) and the resolved country is in the gated set; or
    • consent_mode = 'global' (workspace override) — always gated, every visitor, every country; or
    • consent_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_consent from 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_required is derived from the country and the workspace's consent_mode, and both grant flags are false.

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...9a

X-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.
  • t is 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 t differs 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 every v1 and 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 custom auth), 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 Zapier Error naming 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:manage scope, 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 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
Instagram 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:

  1. 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.
  2. 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:

  1. appends a lead_submissions row (so per-page attribution and UTM history is complete);
  2. increments submission_count and updates last_bio_page_id;
  3. merges non-empty name and custom_fields values (new non-empty values overwrite; empty values never blank an existing value);
  4. records the new consent proof if the consent text changed;
  5. re-subscribes the lead if it was unsubscribed only when the consent checkbox was ticked in this submission — an unsubscribed contact resubscribing must give consent again;
  6. bumps synced_revision if any syncable field changed, which re-enqueues the sync;
  7. 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
Email 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.

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 tableshttps://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 subscribed after 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:

  1. Data access layer. All lead queries go through repository functions that take a WorkspaceContext as their first argument and inject workspace_id = $1 into every statement. There is no generic findById(id) for leads; the only lookup is findById(ctx, id).
  2. 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.
  3. Test gate. A dedicated tenancy test suite asserts that every lead endpoint returns 404 — never 403, 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; unsubscribedunsubscribed 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.
Email 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_revision on 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:

  1. Provider-native upsert. Mailchimp PUT on the md5 subscriber hash is an upsert. ConvertKit subscribe is idempotent by email. Neither can create a duplicate.
  2. Job-id deduplication. The revision-scoped job id prevents duplicate jobs for the same state.
  3. State-row short circuit. Before making any call, the worker loads lead_sync_states for (lead_id, integration_id) FOR UPDATE. If status = 'synced' and synced_revision >= job.revision, it returns immediately without calling the provider.
  4. Row-level lock. The FOR UPDATE on 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_letters holding 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 with status = '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 subscribed lead 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
Email 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:

  1. Identity — email (copy button), name, status pill, captured timestamp, submission count.
  2. Custom fields — key/value list; empty state "No custom fields collected."
  3. 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.
  4. Attribution — first and last page, block, UTM values, referrer host, country/region, submission path (js/nojs).
  5. Submissions — the lead_submissions history, newest first, each expandable to show that submission's values and UTM.
  6. 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.
  7. ActionsRe-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_at

Format 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:

  1. The hosted preference page. Every LinkHub double-opt-in email, and every generic-webhook payload, carries unsubscribe_url of the form https://linkhub.app/u/{token}, where the token is base64url(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. POST on confirm; GET never mutates.
  2. 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.
  3. 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.
Email 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 #

  1. 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 a POST to a sub-path.
  2. 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.
  3. snake_case everywhere 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. camelCase exists only inside TypeScript code, never on the wire.
  4. 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.
  5. UTC, RFC 3339, everywhere. All timestamps are emitted as 2026-08-19T10:12:03.221Z, millisecond precision, always Z. Date-only values are YYYY-MM-DD. The API never emits a local time and never accepts a timezone offset other than Z on input — clients convert.
  6. Money in minor units. Integer *_cents plus an ISO 4217 currency. No floats for money, anywhere.
  7. 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.
  8. 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.
  9. No surprises on write. Every mutating endpoint documents its side effects, including cache invalidation, audit entries and webhook events.
  10. Errors are actionable. Every error carries a stable machine code, a human message safe to log, a details array pinpointing offending fields, and a request_id that 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.code as 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 — the code is 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_a3F9KxQ2rV8pLmZ7dTn4WcB6yE1sHu0J

Rules:

  • Bearer is case-insensitive per RFC 7235; the key is case-sensitive.
  • No other header carries the key. X-Api-Key and friends are not accepted.
  • A key supplied in a query parameter (?api_key=, ?access_token=) is rejected with 400 and code api_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 Authorization header returns 401 with WWW-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:

  • *:write does 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 403 with insufficient_scope, details.required_scope, and a WWW-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:

  • field is always the entitlement key from the catalogue in Section 22.1.3. There is no separate entitlement member; carrying the same value under two names is how the three rival shapes arose in the first place.
  • kind exists only on plan_limit_reached and is exactly count or period. No other value is defined.
  • There is no feature_not_available code, no plan_feature_not_available code, and no per-resource cap code (qr_limit_reached, link_limit_reached, seat_limit_reached and 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_to and upgrade_limit may 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, 503 and 504. 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_id is 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:

  1. 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.
  2. Cost. OFFSET n requires the database to produce and discard n rows. Against click_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.
  3. 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, 400 too_many_sort_keys.
  • Only allow-listed fields per endpoint; others return 400 invalid_sort_field.
  • id is 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_at unless the endpoint documents otherwise.
  • NULL ordering is NULLS LAST for ascending and NULLS FIRST for 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.

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/60 per 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:

  1. Retry only on 429, 408, 500, 502, 503, 504, and on network/timeout errors. Never retry any other 4xx.
  2. On 429 or 503, wait exactly Retry-After seconds. Do not apply your own backoff on top of it.
  3. Otherwise use exponential backoff with full jitter: delay = random(0, min(60, 1 * 2^attempt)) seconds.
  4. Maximum 5 attempts, then surface the error with its request_id.
  5. Attach the same Idempotency-Key to every attempt of a POST or PATCH, so a retry after a timeout cannot duplicate work.
  6. Keep concurrency at or below the documented per-key limit. Parallelism above the limit produces 429s that are strictly slower than the serialised alternative.
  7. Read RateLimit-Remaining and slow down proactively when it drops below 10% of RateLimit-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.

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 createPOST /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 updatePATCH /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.

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: false and can_promote: false, the request is refused with 422 experiment_guard_not_met carrying details.samples, details.required_samples, details.days_elapsed, details.required_days, details.confidence.
  • With force: true, force_confirmation must equal the experiment's exact name, otherwise 422 force_confirmation_required. This mirrors the dashboard's typed confirmation.
  • Promotion writes audit entry experiment.promoted including forced, emits the experiment.promoted webhook, applies the winning variant to the underlying resource, sets status = 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:

  1. 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.
  2. Advisory, never authoritative. allowed: true is 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 a 403 on a write it was told would succeed. This endpoint exists to avoid showing dead controls, not to move the enforcement boundary into the browser.
  3. No 404 for an individual check. The response is 200 even when every check is denied. Only a malformed request, a missing scope, or an unauthenticated caller produces a non-200.
  4. Cross-workspace ids are indistinguishable from missing ones. A resource_id belonging to another workspace returns allowed: false with deny_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.
  5. Entitlement denials carry their payload. Because deny_details is 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.
  6. Freshness. meta.entitlement_version is 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.
  7. 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:

  1. the API request validators,
  2. the API response serialisers,
  3. the TypeScript types shared with the dashboard,
  4. 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:

  1. Send Authorization: Bearer and never place the key in a URL.
  2. Ignore unknown response fields rather than failing deserialisation.
  3. Branch on error.code, never on error.message.
  4. Treat cursors as opaque and never construct one.
  5. Retry only the statuses in 21.7.5, honouring Retry-After, with full jitter.
  6. Send an Idempotency-Key on every POST and PATCH that it may retry.
  7. Log request_id on every failure.
  8. 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:

  1. 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.
  2. 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).
  3. 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:

  1. "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).
  2. 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.
  3. 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.
  4. Analytics retention governs raw event and rollup retention separately, per the retention table in Section 17.
  5. 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:write and experiments:write, with domains:write and webhooks:write Business-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.
  6. Custom domains = 0 on Free. Free workspaces use the default redirect host and the default bio-page host only.
  7. 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_enabled is the key. The plan-comparison row labelled "A/B testing" resolves to it; there is no separate ab_testing key, and that name must not appear as an entitlement key anywhere.
  • experiments_concurrent counts experiments in running status. Concluded, promoted and archived experiments do not occupy a slot. Exceeding it is plan_limit_reached with kind: "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_goals gates the conversion primary 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_recipients is 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_links counts links in the active state. Revoked and expired share links do not count.
  • api_keys appears 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:

  • -1 on a numeric entitlement means uncapped. Comparison code must special-case -1 before any </>= comparison; a helper isUncapped(value) exists and a lint rule forbids raw comparison against an entitlement value.
  • 0 on a numeric entitlement means the feature is available in the product but this plan gets none of it (e.g. custom_domains on Free).
  • false on 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_period equals short_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:

  1. 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.
  2. 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.
  3. 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/config ships an ESLint no-restricted-imports rule forbidding any import of @linkhub/db from apps/web, apps/api, or apps/worker outside each app's src/composition-root.ts. A route handler physically cannot reach the tables.

  • Type-level requirement. Every creating service function takes a WorkspaceContext as 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() throws EntitlementEngineMisuse if called outside a transaction, if workspace_id is absent, or if the entitlement key is not in ENTITLEMENT_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 calls reserve() 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:

  1. 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.

  2. 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_id and 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 its archived_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 a qr_bound custom domain not count (Section 22.5.10).

  3. Soft-deleted resources do not count during the 30-day restore window. Deleting frees the slot immediately. Restoring calls reserve() again and is refused with plan_limit_reached if 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 = 1 therefore 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_cap state 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 -1 treated 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 effective to 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-store

Dashboard 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:

  1. 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.
  2. A grant is idempotent. Receiving subscription.created and invoice.paid for the same subscription grants once.
  3. 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).
  4. Every grant calls invalidateEntitlements and enqueues redirect-cache-repaint.
  5. A grant never lowers. Lowering is exclusively the job of the downgrade path, which has its own guarded flow.
  6. 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:

  1. Verify Owner role, verify a subscription exists (else 409 subscription_not_found, client is sent to Checkout).
  2. Compute direction by comparing catalogue sort_order. business > pro > free.
  3. Upgrade path: stripe.subscriptions.update(id, { items: [{ id, price }], proration_behavior: 'always_invoice', payment_behavior: 'error_if_incomplete' }) with an idempotency key of sha256(subscription_id|price_id|"upgrade").
  4. On success, respond 200 with the updated projection and meta.effective_at = "immediate".
  5. On requires_action, respond 409 payment_requires_action with details[0].confirmation_url.
  6. On decline, respond 409 card_declined with details[0].decline_code mapped 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_codetoo_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: true is 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 matching preview_token (opaque, 30-minute TTL) with 409 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 impacts array 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 page

22.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 on id ascending (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.

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 archived state: 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_removedfalse The LinkHub badge reappears on all public surfaces at apply time, including custom-domain-hosted pages
api_accessnone 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:

  1. A QR slug reservation is permanent. It is written to qr_slug_reservations, a table that has no deleted_at column and no delete path in the application. Nothing in the codebase issues DELETE FROM qr_slug_reservations, and a migration test asserts the table has no ON DELETE CASCADE inbound reference that could remove a row.
  2. 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_reserved even 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).
  3. QR resolution never returns 404 and never returns 410. No rung of the fallback chain is an error status.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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_blocked is 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 a past_due subscription.
  • 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_funds retries on the standard schedule; expired_card and incorrect_cvc additionally surface a targeted in-app prompt because they cannot succeed without user action; do_not_honor and generic_decline include "contact your bank" wording.
  • 3DS re-authentication. requires_action invoices 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 return 409 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|false immediately, with details explaining 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/api on 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:

  1. Read the Stripe-Signature header, parse t and v1.
  2. Compute HMAC-SHA256(secret, "{t}.{raw_body}") and compare in constant time against every v1 value present.
  3. Reject if |now - t| > 300 seconds (replay window).
  4. 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-process with 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:

  1. 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.
  2. 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 as skipped with reason stale_event and does not write. This prevents an old past_due from clobbering a fresh active in the narrow window where a re-fetch and a write race.
  3. 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_support internal 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:

  1. 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.
  2. 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 to packages/db itself 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_id into both the WHERE clause of reads and the VALUES of writes. A write cannot set a different workspace_id than 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 SECURITY closes 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_id is unset, current_setting('app.workspace_id', true) returns NULL, the comparison is NULL, 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_id column has row-level security ENABLEd and FORCEd, with a tenant_isolation policy whose USING and WITH CHECK clauses are both workspace_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 from workspace_members on 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, never 403 forbidden, because a 403 confirms the resource exists. Within the caller's own workspace, an insufficient-role failure correctly returns 403 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.
  • null and undefined are distinguished on PATCH semantics: absent means "leave alone", explicit null means "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 csp

Directive-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
Instagram 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 arbitrary iframe src is 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, with allow-top-navigation deliberately excluded so a hostile embed cannot navigate the parent page. allow-forms is 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:

  1. 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.
  2. 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.
  3. '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.
  4. connect-src and img-src are 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.

  1. 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.
  2. 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-report on the same origin, accepted as both the legacy application/csp-report body 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-src that 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 csp

Differences 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 #

  1. Parse with the WHATWG URL parser. Unparseable → 422 destination_invalid.
  2. 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.
  3. Reject embedded credentials (https://user:pass@host/) with 422 destination_credentials_not_allowed. They are almost exclusively a phishing device.
  4. Normalise the host: lowercase, IDNA/punycode conversion, trailing-dot removal, NFC normalisation.
  5. 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.
  6. Cap the total URL length at 2,048 characters.
  7. 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.
  • CNAME chains 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.
  • NXDOMAIN at 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:

  1. Resolve the host once, obtaining the full address list.
  2. Validate every address.
  3. Connect to a validated address directly, using a custom lookup function 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.
  4. 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.
  5. 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.
  6. 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:

  1. 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_review instead, 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.
  2. 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.
  3. A challenge is never the only way through. Where a visitor cannot complete an armed challenge, the submission falls to pending_review on 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. .env is git-ignored; .env.example contains 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 .env generated from .env.example with 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 is plan_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.yaml is committed and authoritative. CI installs with --frozen-lockfile; an install that would modify the lockfile fails the build.
  • ignore-scripts=true is set for CI installs, so a malicious postinstall cannot 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.
  • overrides in 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 .nvmrc and 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 RedactedIp type wraps any address at the point it enters the process. It has no toString, no toJSON, 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 RawUserAgent type 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_typealerted, 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 #

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:

  1. A click, scan or page view arrives at the edge.
  2. The client address and the raw user-agent string are read into memory only.
  3. visitor_hash = base64(sha256(daily_salt || client_ip || user_agent || workspace_id)[0..15]) is computed (Section 17.3).
  4. ua_hash is computed from the raw user-agent string under the same daily_salt and the same rotation, and user_agent_family is parsed from it — a short label such as Chrome on Android.
  5. Country and region are resolved from the address.
  6. The address and the raw user-agent string are both discarded. Neither is written anywhere (Section 23.11.2).
  7. 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_bot can 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_salt rotates 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_hash cannot 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 #

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.

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.

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}/exports202 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_session and lh_csrf are strictly necessary for a service explicitly requested by the user — exempt.
  • lh_consent stores the consent choice — exempt.
  • lh_ab is 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 Email 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 actual Set-Cookie header
  • 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 carrying workspace_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_found before 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 dangerouslySetInnerHTML outside 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-src violations for 7 consecutive days
  • Nonce is per-response, CSPRNG-generated, and correctly substituted on CDN-cached responses; the placeholder-count guard tested
  • frame-src raised only for providers present on the page; verified per provider
  • BLOCKING With the bot challenge armed, the challenge origin is present in both frame-src and connect-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 раypal fixture 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 https on 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_hash uses the same salt and rotation as visitor_hash, and is used only for bot-signature clustering — no join, no API exposure, no export
  • BLOCKING daily_salt and experiment_salt rotation 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-scripts set 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.txt published 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 at linkhub.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: none is accompanied by role="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
<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 tabindex values. No element is removed from the tab order except genuinely inert decorative content, which is aria-hidden and 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 position order; 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" (not disabled, 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, plus best-practice reported as warnings.
  • Zero violations at serious or critical is a hard failure. moderate and minor are 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:

  1. Land on a bio page, discover the page structure by landmarks and headings, hear the block count, traverse every block, activate a link.
  2. Traverse a bio page containing every block type; confirm every interactive element has a meaningful name and every image has alternative text.
  3. Activate an embed facade, enter the player, and escape it with Tab.
  4. Complete an email-capture form, including triggering and recovering from a validation error.
  5. Encounter the consent banner, understand the choices, reject all, then reopen preferences from the footer and accept analytics.
  6. Sign up, verify email, create a workspace, create a bio page — end to end, no sighted assistance.
  7. Add four blocks, reorder them using each of the three non-drag paths, confirm the announced positions match the saved order.
  8. Change a theme colour, hit the contrast gate, use "fix for me", save.
  9. Read the analytics dashboard: switch a chart to its table, change the date range, apply a filter, export.
  10. Complete a plan upgrade through checkout, then walk the downgrade preview and selection flow.
  11. Hit an entitlement refusal and understand what it says and what to do.
  12. 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.
  13. 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:

  1. The conformance claim, verbatim as in Section 24.1.3, including the standard, the level, the scope, the exclusions and the review date.
  2. 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.
  3. 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.
  4. Known limitations, reproduced from Section 24.12, each with its impact, its workaround, and what LinkHub is doing.
  5. 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).
  6. Feedback — the channels below and the response commitment.
  7. Formal complaints — the escalation path and, for EU and UK visitors, a note on national enforcement bodies.
  8. The technical specifications relied upon — HTML, CSS, JavaScript, WAI-ARIA — and the statement that the content is designed to work without JavaScript for navigation.
  9. 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
Email 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_id is generated at the first ingress that sees the request and propagated on the X-Request-Id header to every downstream call. An inbound X-Request-Id from an untrusted client is not trusted: it is recorded as client_request_id and a fresh server-side id is generated.
  • The request_id is returned to the caller in the X-Request-Id response header and inside the error envelope's request_id field (Section 21). A support ticket quoting that value resolves to the exact log lines.
  • Jobs enqueued during a request inherit request_id in the job payload, so an asynchronous webhook delivery can be traced back to the click that caused it.
  • Analytics events carry request_id into the stream but not into click_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 (14), 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:

  1. Tracing on the redirect path is controlled by a runtime flag, defaulting to off.
  2. An operator may enable it at a 1-in-10,000 sample rate for a maximum of 30 minutes; the flag self-expires.
  3. 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.
  4. 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.
  5. While the flag is on, linkhub_redirect_duration_seconds is 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.

  1. 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).
  2. 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.
  3. 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.
  4. If memory exhaustion: confirm the eviction policy is allkeys-lru for the cache database and noeviction for the stream database. noeviction on 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 reports maxmemory-policy noeviction.
  5. 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.
  6. 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.
  7. Scale the read replica up one size while cache-bypass mode is active. Verify: replica CPU below 70%.
  8. 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.
  9. 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.
  10. Post-incident: check linkhub_ingest_publish_failures_total for 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.

  1. 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".
  2. If saturated rather than down, go to step 9.
  3. Check whether the managed provider has already initiated automatic failover. Verify: provider console shows failover in progress or complete.
  4. 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.
  5. 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.
  6. Confirm the connection pools have re-established. Verify: pool utilisation is normal on the Database dashboard.
  7. 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.
  8. Verify no data loss beyond the failover window: compare the last created_at in 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.
  9. 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.
  10. 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.

  1. Identify which queue. The Queues dashboard shows depth and oldest-waiting per queue. Verify: you can name the single worst queue.
  2. 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".
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. Never drop from analytics-ingest, webhook-deliver, qr-render, domain-verify or tls-renew. These have downstream contractual or physical consequences.
  9. 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.

  1. Confirm the redirect path is unaffected. Verify: redirect error rate and latency at baseline. If they are not, this is a different incident.
  2. 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.
  3. If consumers are absent or crash-looping, read the worker logs for the ingest queue. Verify: you have the error_code and stack of the first failure.
  4. 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.
  5. 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.
  6. If consumers are idle-but-connected with pending entries claimed by a dead consumer, run the claim-reassignment procedure (XAUTOCLAIM with a 5-minute idle threshold) so a live consumer takes ownership. Verify: pending entry count falls to near zero.
  7. Scale ingest worker replicas until consume rate exceeds publish rate by at least 20%. Verify: ingest lag falls monotonically.
  8. 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.
  9. 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.
  10. 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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_remaining recovers.
  6. 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.
  7. 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.
  8. 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.
  9. 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_failed and 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.
  10. 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.

  1. Open the domain's diagnostic view, which shows expected records against currently resolved records (Section 13). Verify: you can state the exact mismatch.
  2. Resolve the records independently from at least two public resolvers to rule out propagation lag versus a wrong record. Verify: both resolvers agree.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. Once records are correct, trigger a manual re-verification rather than waiting for the schedule. Verify: state advances to verifying, then provisioning_tls, then active.
  9. 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_failed and stops consuming verification capacity.
  10. 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.

  1. 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 Location header and the request_id.
  2. 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.
  3. 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.
  4. 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.
  5. If cause (b), purge the cache key for that host and slug and re-request. Verify: the diagnostic reports cache=miss then cache=hit with the correct payload. Then investigate why write-through invalidation failed — this is a defect, not a one-off.
  6. 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).
  7. If cause (f), this is a serious defect: confirm the slug reservation table and the host-matching logic. Escalate immediately and freeze deploys.
  8. 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.
  9. 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 = NULL so 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.
  10. 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.
  11. 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.
  12. 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.
  13. 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
  9. 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.
  10. 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.

  1. 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.
  2. 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.
  3. Freeze all entitlement downgrades driven by billing state. Dunning transitions to past_due and any downgrade automation are suspended for the duration. Verify: the downgrade job is paused; no workspace loses entitlements during the outage.
  4. 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.
  5. 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.
  6. 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.
  7. Resume dunning and downgrade automation only after reconciliation is clean. Verify: the reconciliation report shows zero mismatches.
  8. 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.
  9. 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.

  1. 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.
  2. 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.
  3. Scale the resolver fleet immediately. It is stateless; scale aggressively rather than incrementally. Verify: CPU per instance below 60% and latency back within budget.
  4. Scale the ingest consumers proportionally — the spike produces the same multiple of analytics events. Verify: ingest lag stays under 120 s.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
  9. 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.
  10. 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.

  1. Confirm correlation with a deploy marker on the Service Overview dashboard. Verify: the regression starts within 5 minutes of a deploy.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. Purge caches that may hold payloads shaped by the bad release. Verify: diagnostic endpoint reports fresh payloads.
  8. Freeze deploys for that deployable until the fault is understood. Verify: the deploy freeze flag is set and visible to the team.
  9. 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
  9. Remediate the root cause. Do not close the incident on containment alone. Verify: a test exists that would have caught it.
  10. 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.

  1. 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.
  2. 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.
  3. If the state is processing beyond 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.
  4. If the range is genuinely too large, the row cap applies: the job should have failed fast with 422 export_too_large rather than running long. If it did not, that is a defect — record it. Verify: the cap is enforced on retry.
  5. 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.
  6. 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.
  7. 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).
  8. 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.
  9. 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 #

  1. Primary acknowledges within the severity's target.
  2. No acknowledgement within the target → automatic escalation to secondary.
  3. No acknowledgement from secondary within a further 10 minutes → escalation to the engineering lead.
  4. Any Sev-1 escalates to the engineering lead at 30 minutes unmitigated, and to leadership at 60 minutes.
  5. Suspected breach escalates to the security lead and leadership immediately, in parallel with technical response.
  6. 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 #

  1. Declare the incident and record the target recovery point. Verify: the recovery point is written down before anything is restored.
  2. 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.
  3. Run schema validation against the expected migration state. Verify: the migration table's head matches the release being restored.
  4. Run the data integrity checks: row counts on core tables against the last known-good figures; referential integrity spot checks; confirm qr_slug_reservations count 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.
  5. Point a non-production copy of the application at the restored database and exercise the smoke suite. Verify: the smoke suite passes.
  6. 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.
  7. Flush Redis caches so no payload from the pre-restore state survives. Verify: cache hit ratio starts near zero and climbs.
  8. Re-run the analytics reconciliation for the period between the recovery point and now. Verify: rollup deltas settle.
  9. 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.tssrc/entitlements/evaluate.test.ts.
  • One top-level describe per exported symbol.
  • Test names are assertions in plain English, present tense: returns plan_limit_reached when link count equals the cap, not test 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:

  1. Defaults are always valid — a factory with no arguments produces an entity that would pass every validation rule.
  2. Identifiers are deterministic per test via a seeded sequence, so failures are reproducible.
  3. 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.
  4. Factories never write to a database. Persisting factories live in the integration test package and are named createWorkspace() etc. to make the distinction obvious.
  5. 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:

  1. A single container starts once per test process and is reused across all test files in that process.
  2. 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.
  3. 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.
  4. Tests run in parallel across files, serially within a file.
  5. 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:

  1. 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.
  2. No spec asserts on global counts.
  3. Cleanup is by workspace deletion at teardown, with the QR reservation carve-out asserted rather than cleaned.
  4. 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:

  1. At limit − 1 resources, creating one more succeeds and returns 201.
  2. At limit, creating one more fails with 403 and error.code = "plan_limit_reached". error.details is an array whose single entry carries field (the entitlement key), issue: "limit_reached", limit, current, plan and kind, 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.
  3. An unlimited entitlement permits creation past any tested count and never returns plan_limit_reached.
  4. A fair-use period ceiling returns 403 plan_limit_reached with kind: "period", distinguishing it from the stored-count refusal, which carries kind: "count". Assert both kind values are produced by the right conditions; a period ceiling is not a rate limit and must not return 429.
  5. A binary feature gate that is off for the plan rejects the feature endpoint with 403 plan_feature_unavailable, issue: "feature_unavailable" and no limit/current, before any validation runs — so an invalid payload on a forbidden feature returns 403, never 400.
  6. Upgrading a workspace immediately lifts the limit within the same request cycle; no cache staleness permits or denies incorrectly.
  7. 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.
  8. During the guided downgrade, the user's explicit keep-selection is honoured and everything else above the cap is archived, never removed.
  9. 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.
  10. 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.
  11. 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.
  12. 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.
  13. 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_from naming 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.
  14. 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.
  15. 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.
  16. 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.

  1. Owner — every action succeeds, including billing operations and workspace deletion.
  2. Admin — member management, invitations, custom domains, brand settings, all content operations and all analytics succeed. Every billing endpoint returns 403. Workspace deletion returns 403.
  3. 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.
  4. 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.
  5. 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.
  6. An Owner cannot remove themselves while sole Owner: 403.
  7. An Admin cannot modify or remove the Owner, and cannot modify another Admin: 403 insufficient_role.
  8. 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.
  9. 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).
  10. Requesting per-resource grants on Free or Pro returns 403 plan_feature_unavailable with required_plan: "business".
  11. Grants intersect with, never widen, the role: a scoped Viewer with a grant on a link still cannot edit it.
  12. API keys are subject to the same matrix through their scopes: a links:read key receives 403 insufficient_scope on 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 403 action_not_available_to_api_key regardless of scope, and no billing endpoint is exposed on the public API surface (Sections 21 and 22). Assert there is no scope named audit:read in the catalogue.
  13. 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.
  14. 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.

  1. Known slug on the default host resolves to the active destination with 302 and Cache-Control: private, no-store.
  2. Known slug on a verified custom domain resolves identically.
  3. The same slug on two different hosts resolves independently — host is part of the identity.
  4. 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.
  5. 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 the Location differs 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.
  6. Slug matching is case-insensitive on lookup and normalised to lower case; Spring-Sale and spring-sale resolve to the same record.
  7. Trailing-slash and trailing-punctuation tolerance: /spring-sale/ and /spring-sale. resolve; the resolution is logged as normalised.
  8. 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.
  9. 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.
  10. Targeting rules evaluate in priority order; the first match wins; a rule that matches nothing falls through to the default destination.
  11. Geo targeting uses country and region only; a request with no resolvable geography falls through to the default rather than failing.
  12. Device targeting classifies from the user-agent family; an unknown user agent falls through to the default.
  13. 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.
  14. 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.
  15. Variant identity is never appended to the outbound URL. Assert the Location header contains no experiment or variant parameter.
  16. A destination flagged malicious serves the interstitial with 200, not a redirect.
  17. 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.
  18. 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.
  19. Negative cache: two requests for an unknown slug produce exactly one database lookup within the negative TTL.
  20. Single-flight: 100 concurrent requests for one uncached slug produce exactly one database read.
  21. Resolution completes within the latency budget under the load conditions in Section 26.7 — asserted there, referenced here.
  22. 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.
  23. 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.
  24. An archived (over-cap) link resolves for 90 days then serves the branded landing page with 200 — never 404, never 410.
  25. 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 its Location only 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:

  1. qr_slug_reservations has no deleted_at column. Assert against the live schema, not the model file.
  2. There is no code path in the application that issues a DELETE against qr_slug_reservations. Assert with a static check over the repository layer.
  3. Creating a QR whose slug matches an existing reservation returns 409 qr_slug_reserved regardless of whether the original QR, workspace or account still exists.
  4. 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.
  5. Hard-purging a workspace preserves its QR reservations: assert the reservation count before and after purge is unchanged.
  6. There is no rung 5, and no code path produces a fallback_stage outside 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.
  7. 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 #

  1. An event published once is written exactly once.
  2. The same event published twice with the same event id is written once; the duplicate counter increments.
  3. 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.
  4. A consumer that crashes after writing but before acknowledging causes redelivery; assert no duplicate rows after redelivery.
  5. A consumer that crashes before writing causes redelivery; assert the event is written after recovery. No event is lost.
  6. Entries claimed by a dead consumer are reclaimed after the idle threshold and processed.
  7. Events arriving out of order across a partition boundary are routed to the correct daily partition by their event timestamp, not by arrival time.
  8. An event with a malformed payload is dead-lettered and does not block the batch; the remaining events in the batch are written.
  9. 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.
  10. 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.
  11. Deliberately corrupt one rollup row, run reconciliation, assert it is corrected and the reconciliation delta counter increments by exactly the number of corrected rows.
  12. Bot-classified events are written with the bot flag set and are excluded from default dashboard queries but present when the toggle is on.
  13. 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.
  14. 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) and ua_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.
  15. 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.
  16. The stream database refuses to run under an eviction policy. At startup every worker reads the stream database's maxmemory-policy and asserts it is noeviction; 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 be allkeys-lru and is asserted to be so at startup with a warning rather than a refusal, because evicting cache entries is correct behaviour.
  17. 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 #

  1. The same visitor hash and experiment produce the same variant on repeated evaluation within one visitor-hash window.
  2. 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.
  3. Two different experiments produce independent assignments for the same visitor — assert no correlation above chance.
  4. 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.
  5. 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.
  6. Consent granted mid-experiment pins the visitor's current assignment into the cookie rather than re-rolling it.
  7. Cross-surface stickiness: a page view and a subsequent click from the same visitor hash join to the same assignment record server-side.
  8. No outbound URL contains variant identity — assert on every Location header and every rendered anchor href in an experiment.
  9. Promote-winner is disabled until both guard conditions pass; the endpoint returns 409 experiment_guard_not_met before then, with failing_conditions[] naming which threshold blocked it.
  10. 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.
  11. After promotion, all traffic serves the winner and new assignments cease being recorded.
  12. 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 #

  1. 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.
  2. 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.
  3. List endpoints are scoped: creating 10 resources in B and 1 in A returns exactly 1 for A.
  4. Analytics queries are scoped: rollups and raw drill-downs for A never include B's events, asserted with identical resource slugs in both workspaces.
  5. 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.
  6. 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.
  7. Redis rate-limit counters are namespaced per scope; exhausting A's API limit does not affect B.
  8. Webhook deliveries carry only the owning workspace's events.
  9. 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.
  10. Background jobs carry the workspace id and are asserted to scope their queries; a job enqueued for A never reads B's rows.
  11. 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:

  1. 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.
  2. The gate fails the pull request on any violation of impact serious or critical. moderate and minor violations are reported as annotations and tracked, not blocking.
  3. 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.
  4. 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.
  5. 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.
  6. Reflow is asserted at 320 CSS px width with no horizontal scrolling on every public template.
  7. Target size is asserted on interactive controls in the public templates.
  8. prefers-reduced-motion is 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:

  1. Every corpus member decodes to exactly the expected URL under all three runtime conditions. 100%, no tolerance.
  2. 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.
  3. Contrast below 4.5:1 is rejected at render time with qr_contrast_too_low — assert with a deliberately low-contrast pair.
  4. A logo above 22% is rejected with qr_logo_too_large.
  5. 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.
  6. 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.
  7. Quiet zone is present and at least 4 modules in every output format — asserted by measuring the rendered output, not by reading configuration.
  8. The same input produces byte-identical SVG output across runs (deterministic rendering), so a regenerated code always matches the printed one.
  9. 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:

  1. Reject loopback (127.0.0.0/8, ::1) and all forms of localhost.
  2. 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.
  3. Reject after DNS resolution, not only on the literal string — a hostname resolving to a private address is rejected.
  4. Reject on redirect: a public URL that 302s to a private address is rejected at fetch time, and each hop is re-checked.
  5. Reject DNS-rebinding attempts by pinning the resolved address used for the connection to the address that was validated.
  6. Reject non-allow-listed schemes (file, gopher, ftp, data, javascript) with link_destination_scheme_not_allowed.
  7. 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.
  8. Reject any outbound webhook endpoint that is not https on port 443. Plain HTTP is refused with webhook_url_scheme_invalid and any other port with webhook_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:

  1. An absolute URL to an external origin is rejected or forced to the default landing route.
  2. A protocol-relative URL (//evil.com) is rejected.
  3. Backslash, encoded-slash and mixed-encoding variants are rejected.
  4. A path-only relative URL is accepted and normalised.
  5. 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.

  1. 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.
  2. Request contract: for every operation, valid examples pass validation and each documented error case produces the documented status and error code.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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 #

  1. 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.
  2. Every block type is captured in isolation with short, long and empty content, and in its error and loading states where they exist.
  3. The not-found, expired, interstitial, unavailable and memorial pages are captured. The memorial page is captured with and without personal data stripped.
  4. QR code renders are captured for a representative styling subset; pixel comparison here is a deterministic-rendering check complementing Section 26.8.
  5. Captures use a fixed seed, frozen clock, disabled animation, and preloaded fonts so that only real changes produce diffs.
  6. 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.
  7. Rendering is done in a single containerised browser build so that baselines are portable across machines.
  8. 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:

  1. The coverage job fails the pull request and posts the delta with the specific uncovered lines.
  2. 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.
  3. The elevated-floor paths have no ratchet tolerance: below the floor is a hard failure, full stop.
  4. 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.
  5. 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:

  1. Full pull-request pipeline green, including every gate in Section 26.13.
  2. Nightly suite green on the candidate: soak, load, spike, extended E2E, extended QR conformance.
  3. Coverage floors met, including all elevated paths.
  4. No open high or critical security findings.
  5. No open serious or critical accessibility findings.
  6. Migration plan reviewed and confirmed backward-compatible for one release (Section 27.6).
  7. Error-budget status reviewed: a headline SLO in breach blocks any release that is not itself a fix for that breach.
  8. 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:

  1. Sign up with a new email; confirm the verification email arrives and the link works; confirm publishing is blocked before verification and permitted after.
  2. Sign in with a magic link; sign in with Google; enrol TOTP; sign out; sign in with TOTP; use a recovery code.
  3. 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.
  4. 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.
  5. Submit the email-capture form; confirm the lead appears in the dashboard and reaches the configured destination.
  6. Create a short link with UTM parameters, a schedule and an expiry; confirm each behaves at its boundary using the clock override on staging.
  7. 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.
  8. 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.
  9. Add a custom domain against the staging DNS fixture; walk the full lifecycle to active with TLS; confirm a link resolves on it.
  10. Run an A/B experiment on a bio page; confirm sticky assignment across reloads; confirm promote is blocked before the guard passes.
  11. Open analytics; confirm today's events appear within the freshness target; apply filters; export a CSV and open it.
  12. 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.
  13. Create an API key; call three endpoints; exceed the rate limit and confirm a correct 429; revoke the key and confirm 401.
  14. Trigger a webhook; confirm the signature validates against the documented scheme; force a failure and confirm retry then dead-letter with UI visibility.
  15. Exercise support impersonation on a consenting test account; confirm the banner, the emails and the audit entries.
  16. 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 latest tags 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 tmpfs for 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 tracefatal; 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 email 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 email 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 email 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:

  1. Migrations run as a separate deployment step, before the new application version is rolled out. MIGRATION_RUN_ON_START is false everywhere: application containers never migrate on boot, because N replicas booting simultaneously must not race.
  2. The migration step acquires a PostgreSQL advisory lock, so concurrent runners serialise rather than conflict.
  3. Only after the migration step reports success does the application rollout begin.
  4. 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.

  1. 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.
  2. 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.
  3. Additive migrations (new column, new table, new index) are reverted safely at any time.
  4. Destructive migrations (drop, narrowing type change) are not reverted; they are rolled forward with a corrective migration. Reverting them loses data.
  5. 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.
  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:

  1. Deploy the new colour alongside the current one, receiving no production traffic.
  2. 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).
  3. Warm the new colour with a replay of 60 seconds of recent resolution keys, so it does not start cold.
  4. Shift 5% of traffic. Hold 3 minutes. Compare error rate and p95 latency against the current colour.
  5. Shift 50%. Hold 5 minutes. Compare again.
  6. Shift 100%. Hold 15 minutes with the old colour still running.
  7. Terminate the old colour.
  8. 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:

  1. It is deployed blue/green precisely so that no instance is ever both serving and shutting down under load.
  2. 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.
  3. Its shutdown drain is 30 seconds — long enough for in-flight requests (which take milliseconds), short enough to release capacity quickly during scale-in.
  4. Connection reuse is preserved across the shift by draining at the load-balancer layer rather than closing keep-alive connections abruptly.
  5. During the shift, both colours use the same cache, so a shifted request is a cache hit regardless of which colour serves it.
  6. 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×.
  7. 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 mode

What 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:

  1. Add shop.localtest.me as a custom domain in the dashboard.
  2. 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 through verifying.
  3. 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.
  4. The domain reaches active, and https://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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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:

  1. 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.
  2. 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.

  1. make up && make dev on a clean machine produces all four services running and seeded in under 30 minutes.
  2. Every table, column, index, constraint and partition in Section 6 exists; a schema dump matches the model definitions with zero drift.
  3. Migrations apply cleanly to an empty database and to a database seeded at the previous state; the CI migration check passes.
  4. qr_slug_reservations exists and has no deleted_at column.
  5. All four services return 200 on /healthz and correct dependency-aware behaviour on /readyz, and expose the running git SHA.
  6. Every stage in Section 26.13 executes; the full pipeline completes in under 20 minutes.
  7. Starting any service with a required variable missing aborts with a message naming that variable. Starting a worker against a stream database whose maxmemory-policy is not noeviction aborts with a message naming the observed policy.
  8. A commit merged to main deploys automatically to staging.
  9. 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.
  10. 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_reached and plan_feature_unavailable responses 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.

  1. All three sign-in methods work end to end; email verification is required before any publish action and the block is enforced server-side.
  2. Password rules are enforced: minimum length, strength score, and breach check rejection.
  3. Rate limits produce lockout at the specified thresholds per account and per IP.
  4. TOTP enrolment, verification, recovery-code use and per-workspace enforcement all work; recovery codes are single-use.
  5. The permission matrix suite passes for all four roles including the enumerated-route Viewer denial test.
  6. The tenancy isolation suite passes, including the 404-not-403 cross-workspace assertion.
  7. Invitations expire at 7 days, are single-use, can be resent and revoked, and work for both new and existing accounts.
  8. 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.
  9. No code path updates or deletes an audit row; asserted by static check.
  10. The entitlement seam refuses at the caps in the static plan catalogue and returns the canonical plan_limit_reached / plan_feature_unavailable shapes, including kind — asserted by the parts of Section 26.5.1 that do not require a live subscription (cases 1–5 and 15).
  11. 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.

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.

  1. Every case in Section 26.5.3 passes.

  2. The load scenario sustains 5,000 rps with p50 < 20 ms, p95 < 50 ms, p99 < 120 ms and error rate < 0.01%.

  3. Cache hit ratio exceeds 97% in the steady-state scenario; 100 concurrent misses on one key produce exactly one database read.

  4. 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 a Location derived from a stored destination — and fails the build if any of them can emit 301 or 308. Exactly one module is exempt, named explicitly in the check's configuration: the scheme-upgrade handler, which answers a plain-HTTP request with a 301 to 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 the Location must 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.

  5. Every SSRF and open-redirect case in Section 26.9 is rejected.

  6. Updating a destination changes the resolved answer on the very next request, with no sleep in the test.

  7. 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.

  1. On the reference device profile, FCP < 0.8 s, LCP < 1.2 s, CLS < 0.02, INP < 200 ms.
  2. Gzipped HTML ≤ 40 KB; critical inline CSS ≤ 14 KB; zero bytes of render-blocking JavaScript.
  3. With JavaScript disabled, every link on the page navigates correctly.
  4. A pull request that deliberately regresses any budget fails CI; demonstrated in the milestone review.
  5. axe-core reports zero serious or critical violations on every public template.
  6. Reflow at 320 px produces no horizontal scrolling; target sizes meet the minimum; focus is visible and never obscured.
  7. A theme failing 4.5:1 contrast cannot be saved; the "fix for me" suggestion passes; the typed-confirmation override writes an audit entry.
  8. 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.

  1. Every block type in Section 10 can be added, configured, reordered, duplicated, hidden and deleted, and renders correctly on the public page.
  2. Block reordering is fully achievable using only the keyboard, and this is asserted in E2E.
  3. 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.
  4. Autosave never loses more than 5 seconds of work; publishing is explicit and the draft/published distinction is visible.
  5. Every block has a defined empty state and a defined validation failure state, both exercised in visual regression.
  6. Uploads are format- and size-validated, converted to AVIF with WebP fallback, and served with explicit intrinsic dimensions.
  7. 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.

  1. 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_stage match, 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.
  2. The chain has exactly four rungs. fallback_stage is 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.
  3. The decode conformance corpus in Section 26.8 passes at 100% under the three runtime conditions, using two independent decoders.
  4. A logo above the maximum is rejected with 422 qr_logo_too_large; contrast below 4.5:1 with 422 qr_contrast_too_low; an unresolvable styling combination with 422 qr_unscannable naming the offending choice.
  5. Applying a logo, gradient or custom module shape at error correction M auto-upgrades to H, verified on the stored version record.
  6. Identical input produces byte-identical SVG output across runs.
  7. A physically printed 2 cm code scans from 20 cm on two different phone models — verified by hand and recorded.
  8. 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.
  9. Rolling back to a prior version restores the destination, invalidates the cache and writes an audit entry.
  10. 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.

  1. Every case in Section 26.5.5 passes.
  2. At 10,000 events/second for 5 minutes, zero publish failures and ingest lag under 60 s at the end.
  3. With the stream forced to fail, redirects still return the correct 302 and the drop counter increments by exactly the number of failures.
  4. 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_family and the daily-salted ua_hash.
  5. Geographic data is limited to country and region; no column stores city, coordinates or postal code.
  6. Rollups computed incrementally match a full recomputation exactly over a 10,000-event fixture.
  7. 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.
  8. 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.
  9. 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_stage the response carried, so a code silently degrading to rung 4 is visible in the data and not only in the logs.
  10. 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.

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.

  1. Dashboard queries over a 90-day range return p95 under 1.5 s with 50 concurrent users on the production-shaped data set.
  2. 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.
  3. 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.
  4. The bot toggle changes results; bot events are excluded by default.
  5. 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.
  6. Targeting rules evaluate in priority order, first match wins, and unmatched requests fall through to the default destination.
  7. UTM parameters merge correctly with an existing query string and preserve fragments.
  8. Twenty concurrent one-million-row exports complete within 15 minutes; a request above the row cap fails fast with 422 export_too_large rather 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.

  1. A domain traverses pending_dnsverifyingprovisioning_tlsactive end to end against the local ACME server, and again on staging against a real authority.
  2. Failure paths reach dns_failed and tls_failed with actionable messages, and can be retried by the customer.
  3. The diagnostic shows expected records against currently observed records, including the "what we currently see" comparison.
  4. Both a subdomain (CNAME) and an apex (A/AAAA) configuration work.
  5. A new certificate is installed and served without restarting the resolver.
  6. Renewal triggers at 30 days remaining; the alert ladder fires at 14 and pages at 7; verified with a clock override.
  7. Removing a domain does not break any QR code — the canonical URL continues to resolve.
  8. 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.

  1. Every case in Section 26.5.1 passes, now against live subscription state rather than configuration.
  2. Entitlements are evaluated server-side on every mutating endpoint including the public API; a binary feature gate returns 403 plan_feature_unavailable before validation runs.
  3. 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.
  4. Over-cap short links resolve for exactly 90 days then serve the branded landing page with 200; neither state returns 404.
  5. 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.
  6. Write blocking follows the Section 22.7 schedule exactly: full write access on days 0–7 of past due, billing_write_blocked from day 8. Assert both boundaries against an injected clock, and assert that resolution is unaffected at every point on the schedule.
  7. Replaying every processor webhook twice produces identical state.
  8. With the processor stubbed as unavailable, no entitlement downgrade occurs and every public surface behaves normally.
  9. Branding is present on Free public surfaces and absent above Free, asserted on rendered HTML.
  10. 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.

  1. Every case in Section 26.5.6 passes.
  2. Assignment distribution over 100,000 hashes is within ±1.5% of a 50/50 split.
  3. 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.
  4. No Location header and no rendered anchor contains variant identity.
  5. Promote is disabled until both the per-arm visitor threshold and the minimum-duration threshold pass; the endpoint returns 409 experiment_guard_not_met before 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.
  6. Force-promote requires the exact typed confirmation and writes an audit entry recording the bypass.
  7. Deleting a running experiment reverts traffic to control with no error and no dropped request.
  8. 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.

  1. Every documented endpoint returns the canonical success or error envelope with request_id present, and every collection endpoint implements cursor pagination with the documented defaults and maximum.
  2. The OpenAPI document is generated from the validating schemas; CI fails on drift and on an unversioned breaking change.
  3. API keys are shown once, stored hashed with a display prefix, scoped, rate-limited per key, revocable, and track last use.
  4. A key exceeding its plan's rate limit receives 429 with the documented headers; the Free plan has no API access.
  5. 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 https on port 443 — plain HTTP is refused at save time and again before each attempt, with no exception for any port, network or customer request.
  6. 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:read scope; account reads 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.
  7. Pixels do not fire before consent in a gated region, and server-side forwarding respects the same state — asserted with a simulated EEA visitor.
  8. 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.

  1. 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.
  2. Every alert in Section 25.6 fires correctly in a test and routes to the right destination with a working runbook link.
  3. Every SLO in Section 25.5 is instrumented and reporting attainment and budget burn.
  4. A restore drill meets its RTO, and a point-in-time recovery is verified against a known marker row.
  5. 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.
  6. Impersonation requires consent, is read-only by default, is time-boxed, shows a banner, sends both emails and writes audit entries — all demonstrated.
  7. GDPR export and deletion complete end to end; deletion strips personal data from the memorial page while the QR slug still resolves.
  8. Penetration test critical and high findings are remediated and retested.
  9. The manual accessibility matrix is complete with zero unresolved serious or critical findings.
  10. 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.
  11. 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.
  12. 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.
  13. 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 #

  1. Read this section (29) first, in full. It tells you the invariants you cannot break and where every definition lives.
  2. Read Sections 1 through 5. Decisions, stack, architecture and conventions. Everything after them assumes them.
  3. Read Section 6 completely. The schema is the substrate. Do not begin coding until you can describe every table and its relationships from memory.
  4. Read Sections 28 (execution plan), 25.5 (SLOs) and 26.12 (coverage floors) so you know what "done" measures.
  5. 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.
  6. Use Section 30 as a lookup, not a read. Glossary, error codes, event catalogue, reserved slugs, DNS records, sizing tables, decision log.
  7. 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.

  1. 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.
  2. 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.
  3. 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_family and ua_hash, and nothing more granular.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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 noeviction and whose workers refuse to start otherwise — because eviction there is silent data loss rather than a cache miss.
  9. 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.
  10. Analytics never block delivery. Publication is fire-and-forget. A failing analytics path must never change a redirect's status, latency or destination.
  11. Entitlements are enforced server-side, on every mutating path, including the public API. A client-side check is a hint, never a control.
  12. 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.
  13. 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.

  1. Re-check the owning section in Section 29.4. Most apparent gaps are answered somewhere you have not looked.
  2. 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.
  3. Apply the tie-breakers, in this priority order:
    1. The safer behaviour for the end visitor (never break a printed code, never leak across tenants, never break a page without JavaScript).
    2. The behaviour that preserves data (archive, never delete; soft delete, never hard).
    3. The behaviour consistent with the nearest analogous decided case in this document.
    4. The simpler implementation.
    5. The behaviour that is easier to reverse later.
  4. 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.
  5. 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.
  1. Never leave a placeholder. No TODO, no FIXME, no throw new Error('not implemented'), no commented-out branch, no silently returning null where a value is required. If you decided it, implement it; if you have not decided it, decide it now using step 3.
  2. 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.example and 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.M citation 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.md if setup changed, DECISIONS.md if 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:

  1. 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.
  2. 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.
  3. 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:

  1. Same major line, newer minor or patch: install the current stable release. Take it without ceremony; the lockfile records what resolved.
  2. 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.
  3. 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.md with what changed and what was verified.
  4. A security advisory affects a pinned dependency: patch immediately, ahead of feature work, per Section 26.9's gate.
  5. 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.
  6. Never downgrade a dependency to make a test pass. Fix the test or fix the code.
  7. Never pin an exact patch version in package.json for 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.

  1. make check locally: lint, type check, unit, integration. Green.
  2. The full CI pipeline on the branch. Every stage green, including accessibility, visual regression, Lighthouse and contract drift.
  3. 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.
  4. Coverage report: overall floor met, and every elevated floor met for paths this milestone touched.
  5. If the milestone touched the redirect path: the k6 steady-state, cold-cache and hot-slug scenarios, all thresholds met.
  6. 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.
  7. If the milestone touched QR, billing, deletion or retention: the complete Section 26.5.4 matrix, including the property-based test.
  8. 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.
  9. Deploy to staging and run the smoke suite plus the relevant portion of the Section 26.14 manual script.
  10. 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.
  11. Update DECISIONS.md with anything decided under Section 29.5 during the milestone.
  12. 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:

  1. 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.
  2. 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.
  3. Entitlement refusals collapse to exactly two codes. plan_limit_reached (403) covers every numeric or period cap, distinguished by details[0].kind (count or period). plan_feature_unavailable (403) covers every binary feature gate. No section defines a third.
  4. 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
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 email {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):

  1. Charset [a-z0-9-] only. Input is lower-cased and Unicode-normalised (NFKC) before validation.
  2. 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, o and u.
  3. Must not begin or end with a hyphen; must not contain consecutive hyphens.
  4. Must not be purely numeric (reserved for future numeric addressing).
  5. Must not appear on the reserved list below, the profanity list, or the brand-impersonation list.
  6. 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.
  7. Uniqueness is per host for links and QR codes, global for bio page handles and workspace slugs.
  8. QR slugs are additionally checked against the permanent reservation table and are rejected with qr_slug_reserved if 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 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 facebook.com, fb.com https://facebook.com/<page>
LinkedIn linkedin.com https://linkedin.com/in/<handle>
Threads threads.net, threads.com https://threads.net/@<handle>
Pinterest 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 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
WhatsApp wa.me, api.whatsapp.com https://wa.me/<number>
Telegram t.me https://t.me/<handle>
Email 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 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.