This is the full developer documentation for Vumbnail # Vumbnail > Add an image extension to a video URL and get its thumbnail back. No API keys, no SDK, no server. These docs describe the product and double as its build spec Vumbnail is a real, running service. Every page is badged with its true status — **Live** (serving production traffic), **Flagged** (built but gated off), or **Planned** (designed here, not built yet). The machine-readable spec a build agent reads lives at [`/llms-full.txt`](/llms-full.txt). What it is A thumbnail image host for video. Take any Vimeo or YouTube URL, swap it for a `vumbnail.com/.jpg` URL, and get the poster frame back as a real JPEG — cacheable, hotlinkable, and globally fast. How it makes money Thumbnails are free up to a monthly request allowance. High-volume sites that cross the line are served a watermarked image until they subscribe; paying customers are allow-listed back to clean images automatically. ## Start here [Section titled “Start here”](#start-here) [Introduction ](/overview/introduction/)What Vumbnail is and the problem it solves. [Who it's for ](/overview/who-its-for/)Site owners, CMS users, and the operator. [How it works ](/overview/how-it-works/)One request, end to end, through the edge. [Thumbnail URL API ](/features/thumbnail-api/)The URL shapes and what each returns. [Freemium watermarking ](/features/freemium-watermark/)How free, watermarked, and clean are decided. [Roadmap ](/project/roadmap/)What's live, flagged, and next. # Data model > Where Vumbnail's data lives — R2 objects, KV keys, D1 tables, and the committed allow-list. Vumbnail has very little “database” in the traditional sense. Its real data is cached images in R2, a small live allow-list in KV, and a billing record in D1. The Postgres schema is mostly auth boilerplate. ## R2 — the thumbnail store (`vumbnail-thumbnails`) [Section titled “R2 — the thumbnail store (vumbnail-thumbnails)”](#r2--the-thumbnail-store-vumbnail-thumbnails) All objects live under the `thumbnails/` prefix: ```plaintext thumbnails//clean.jpg clean variant (paying / under-threshold) thumbnails//wm.jpg watermarked variant thumbnails/_shared/error.jpg one shared graphic for all failures thumbnails/_shared/locked.jpg one shared graphic for password-locked thumbnails/_negative/.json negative-cache record (see below) thumbnails/_resolver-success/.json memoized Vimeo resolution ``` Clean images on the public `cache.vumbnail.com` domain are blocked by a WAF rule; only `wm.jpg` and the shared objects are publicly fetchable. See [Caching & delivery](/features/caching-and-delivery/) for the record shapes. ## KV — `THUMBNAIL_CONFIG` [Section titled “KV — THUMBNAIL\_CONFIG”](#kv--thumbnail_config) The hot-path config and live allow-list, shared between the thumbnail Worker (read) and the accounts Worker (write): | Key | Purpose | | ---------------------------------- | ------------------------------------------------------------------------------------ | | `billing-entitlements:projection` | the live allow-list (domains/IPs/keys/UAs) read on the hot path | | `billing-entitlements:mode` | `static` / `shadow` / `runtime` (currently `runtime`; hardcoded out of the hot path) | | `youtube:playlist-resolver-url(s)` | optional external playlist-resolver overrides | A separate KV namespace, `PROCESSED_EVENTS_STORE`, de-duplicates Stripe webhook events (\~90-day TTL). ## D1 — `vumbnail-billing-entitlements` [Section titled “D1 — vumbnail-billing-entitlements”](#d1--vumbnail-billing-entitlements) The billing record of truth, written by the accounts Worker: ```sql -- latest entitlement per subscription billing_entitlements_current ( stripe_subscription_id TEXT PRIMARY KEY, stripe_customer_id TEXT, stripe_price_id TEXT, stripe_product_id TEXT, customer_email TEXT, domains_json TEXT NOT NULL, -- referer hosts → clean ips_json TEXT NOT NULL, -- client IPs → clean added_at TEXT NOT NULL, updated_at TEXT NOT NULL ); -- append-only audit of every processed event billing_entitlement_events ( stripe_event_id TEXT PRIMARY KEY, stripe_event_type TEXT NOT NULL, stripe_subscription_id TEXT NOT NULL, domains_json TEXT NOT NULL, ips_json TEXT NOT NULL, observed_at TEXT NOT NULL ); ``` D1 is regenerated into the KV `projection`, which is what the hot path actually reads. See [Paid access](/features/paid-access/). ## Committed allow-list — `apps/thumbnail/lib/customers.json` [Section titled “Committed allow-list — apps/thumbnail/lib/customers.json”](#committed-allow-list--appsthumbnaillibcustomersjson) A third copy of entitlements, committed to git and bundled at deploy time as the fallback when KV is unavailable. Each entry mirrors the [`CustomerAllowlistRecord`](/features/paid-access/). It is kept in sync automatically by the accounts Worker via the GitHub API (visible as `chore(thumbnail): sync Stripe allowlist for sub_…` commits). ## Postgres (Neon) — auth only [Section titled “Postgres (Neon) — auth only”](#postgres-neon--auth-only) The Drizzle schema is Better Auth + organization-plugin boilerplate: `user`, `session`, `identity`, `verification`, `organization`, `member`, `team`, `team_member`, `invitation`, `passkey`. It backs operator-dashboard sign-in. **No Vumbnail product entity lives here** — thumbnails are R2, entitlements are D1/KV. ## Shared policy constants — `@repo/core` [Section titled “Shared policy constants — @repo/core”](#shared-policy-constants--repocore) Single source of truth for numbers that several apps depend on: vumbnail-pricing.ts ```ts VUMBNAIL_FREE_TIER_MONTHLY_REQUESTS = 50_000 VUMBNAIL_OUTREACH_GRACE_PERCENT = 20 VUMBNAIL_OUTREACH_MONTHLY_THRESHOLD = 60_000 // billing-entitlements.ts BILLING_ENTITLEMENTS_PROJECTION_KEY = "billing-entitlements:projection" BILLING_ENTITLEMENTS_MODE_KEY = "billing-entitlements:mode" // projection "kind": "billing-entitlements/v1" ``` # Deployment topology > The Workers, Snippet, domains, and request path that make up the running system. Everything runs in the `vumbnail.com` Cloudflare zone. The product is a small set of Workers plus an edge Snippet; several supporting and legacy Workers sit around them. ## Workers & routes [Section titled “Workers & routes”](#workers--routes) | Worker | Route | Role | | -------------------------------------- | --------------------------------- | ---------------------------------------------------------------- | | `vumbnail-thumbnail` | `vumbnail.com/*` (apex catch-all) | the product: resolve + watermark + serve thumbnails | | *(Snippet)* watermark-router | runs ahead of the apex Worker | hot-set short-circuit & edge redirects (cost) | | `vumbnail-accounts` | `accounts.vumbnail.com/*` | Stripe webhook → entitlement sync (KV + D1 + GitHub) | | `vumbnail-astro-site` | `astro.vumbnail.com/*` | marketing site; thumbnail Worker passes non-thumbnail paths here | | `vumbnail-animated-thumbnail-renderer` | service binding | WASM animated rendering (flagged) | | `vumbnail-video-metadata` (consumer) | queue | metadata observation (flagged) | | `playlist-resolver(-worker)` | external resolver | resolves unknown YouTube playlists | | API / dashboard | starter-kit routes | operator metrics + auth (mostly internal) | Public R2 is exposed at **`cache.vumbnail.com`**; the edge Snippet redirects hot watermarked traffic there, and a WAF rule blocks `…/clean.jpg`. ### Test & staging hosts [Section titled “Test & staging hosts”](#test--staging-hosts) * `watermark-test.vumbnail.com` — thumbnail Worker with animated thumbnails and debug enabled. * `relay-test.vumbnail.com` — forces Vimeo relay-only mode, separate R2 prefix. * `accounts-staging.vumbnail.com`, `accounts-preview.vumbnail.com` — webhook staging/preview. ### Legacy (pre-Cloudflare) [Section titled “Legacy (pre-Cloudflare)”](#legacy-pre-cloudflare) `legacy-vumbnail-vercel-api`, `legacy-vumbnail-video`, and `legacy-vumbnail-worker-canary` are retained from the Vercel era; moving the last `/6*` canary routes fully onto Cloudflare is a roadmap item. ## Bindings [Section titled “Bindings”](#bindings) **Thumbnail Worker:** `THUMBNAIL_BUCKET` (R2), `THUMBNAIL_CONFIG` (KV), `ASSETS` (watermark.png + fallbacks), `IMAGES` (legacy transform), `SITE_PASSTHROUGH_SERVICE` (→ Astro site), `ANIMATED_THUMBNAIL_RENDERER` (service binding), `VIDEO_METADATA_QUEUE` (producer), plus `VERSION_METADATA`. **Accounts Worker:** `PROCESSED_EVENTS_STORE` (KV), `THUMBNAIL_CONFIG` (KV, shared), `BILLING_ENTITLEMENTS_DB` (D1); secrets `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `GH_TOKEN`. ## Request path (clean vs. watermarked) [Section titled “Request path (clean vs. watermarked)”](#request-path-clean-vs-watermarked) ```plaintext Browser → vumbnail.com/.jpg │ ▼ Cloudflare edge Snippet (watermark-router) ├─ paying domain/IP? → pass through ──┐ ├─ /.jpg malformed? → 308 error.jpg │ ├─ Borås loop? → 307 wm.jpg │ ├─ hot id + wm referrer? → 307 cache.vumbnail.com/.../wm.jpg └─ else → pass through ──┤ ▼ vumbnail-thumbnail Worker ├─ parse id/size/password ├─ watermark decision (clean | wm) │ └─ KV entitlement projection (paid bypass) ├─ edge cache → R2 variant → R2 clean→wm └─ miss: resolve source (Vimeo/YouTube/relay) → store R2 → cache → respond ``` ## Deploy & safety [Section titled “Deploy & safety”](#deploy--safety) * Per-app deploys via Wrangler (`bun --filter @repo/ deploy`); the Snippet has its own `deploy` / `verify-hotset` / `smoke` / `rollback:snapshot`. * The thumbnail Worker ships with a predeploy route-safety guard and ramps by percentage (`0 → 1 → 5 → 10 → 25 → 50 → 100`) with a health-check suite. * Multi-worker deploy safety is enforced by `scripts/cloudflare` checks so one app’s deploy can’t clobber another’s routes. * The hard cost guardrail applies to every deploy: ≥ $1/month of new Cloudflare spend requires a written estimate in `docs/` first. # Tech stack > The platform, runtime, and libraries Vumbnail is built on. Vumbnail is Cloudflare-native end to end. Compute, storage, config, billing state, and the edge cost-control layer are all Cloudflare primitives, with a Bun * TypeScript monorepo on top. ## Platform & runtime [Section titled “Platform & runtime”](#platform--runtime) | Layer | Choice | Role | | ------------------------- | -------------------------------------------------------- | --------------------------------------------- | | Runtime / package manager | **Bun** (≥1.3) | dev, build, test, scripts | | Language | **TypeScript 5.8** | everything | | Compute | **Cloudflare Workers** | thumbnail serving, accounts/webhooks, API | | Edge pre-filter | **Cloudflare Snippet** | hot-set watermark short-circuit (cost) | | Persistent store | **Cloudflare R2** (`vumbnail-thumbnails`) | clean/wm variants, negative cache, memos | | Hot config / allow-list | **Cloudflare KV** (`THUMBNAIL_CONFIG`) | entitlement projection, processed-event dedup | | Billing record of truth | **Cloudflare D1** (`vumbnail-billing-entitlements`) | subscription entitlements + audit | | Async | **Cloudflare Queues** | video-metadata observation (flagged) | | Image transform | **Photon (WASM)**, default; Cloudflare **Images** legacy | watermark overlay | | Deploy | **Wrangler** | per-app Workers deploys | ## Application frameworks [Section titled “Application frameworks”](#application-frameworks) * **Thumbnail Worker** (`apps/thumbnail`) — the product core; plain Worker fetch handler with the cache/source/watermark libraries. No web framework on the hot path. * **Accounts Worker** (`apps/accounts`) — **Hono** app handling the Stripe webhook and entitlement sync. * **API Worker** (`apps/api`) — **Hono + tRPC** with **Better Auth**; serves the operator dashboard’s metrics endpoints. Most generic starter-kit routers (user/organization) are unused stubs. * **Marketing site** (`apps/astro`, `apps/web`) — **Astro** static site at `astro.vumbnail.com`, reached via a service binding for non-thumbnail paths. * **Operator dashboard** (`apps/local-dash`) — **React 19 + TanStack Router + Jotai + Tailwind v4 + shadcn/ui**. * **Shopify app** (`apps/shopify`) — **React Router 7** embedded app + theme extension (planned). ## Integrations & libraries [Section titled “Integrations & libraries”](#integrations--libraries) * **Stripe** — subscription billing; Checkout custom fields carry the domains/IPs to allow-list. API version `2026-02-25.clover`. * **`@repo/videos`** — provider metadata resolution (Vimeo/YouTube oEmbed, player config, password handling, size mapping). * **External resolvers** — a playlist resolver service and a Vimeo relay (`noembed.com` by default) for fallback resolution. * **Drizzle ORM + Neon Postgres** — auth tables (Better Auth) for the dashboard; **not** where billing lives (that’s D1). * **GitHub API** — the accounts Worker commits `customers.json` as a third entitlement store. ## Tooling [Section titled “Tooling”](#tooling) * **Vitest** (+ Happy DOM) for tests, including Worker and Snippet contract tests. * **Biome** for lint/format, with a stricter **agentic overlay** (`biome.agentic.jsonc`) run on touched files via `bun run lint:touched`. * **Opt-in stricter TypeScript** presets per package (`packages/typescript-config/agentic-*.jsonc`). * Repo conventions: trunk-based development, scripts written in the `docs/tiger` style, and a hard **Cloudflare cost guardrail** (any change ≥ $1/month needs a written estimate in `docs/` first). ## Notable monorepo apps [Section titled “Notable monorepo apps”](#notable-monorepo-apps) Beyond the core, the repo contains `animated-thumbnail-renderer-worker` / `anim-worker` (animated rendering), `video-metadata-worker` (queue consumer), `playlist-resolver(-worker)`, `outreach`, `email`, and three `legacy-vumbnail-*` apps retained from the pre-Cloudflare (Vercel) era. See [Deployment topology](/architecture/deployment-topology/). # Design principles > The handful of rules that decide Vumbnail's trade-offs. These are the rules that settle arguments when two designs both look reasonable. They explain why the system is shaped the way it is. ## 1. The URL is the product [Section titled “1. The URL is the product”](#1-the-url-is-the-product) The entire integration surface is a URL shape. Anything that would force a user to sign up, fetch a key, install an SDK, or read documentation before getting their first thumbnail is a regression. New capabilities should be reachable by changing the path or a query parameter, not by adopting an API. ## 2. Fail open, never blank [Section titled “2. Fail open, never blank”](#2-fail-open-never-blank) A thumbnail that fails should degrade to a clean image or a small fallback graphic — never an HTTP 500 or a broken ``. The watermark transform fails open to the clean image; unknown and blocked videos fall back to a shared `error.jpg` / `locked.jpg`; provider failures are cached as negative results so they don’t repeat. A visitor should always see *something* reasonable. ## 3. Monetize without breaking free usage [Section titled “3. Monetize without breaking free usage”](#3-monetize-without-breaking-free-usage) Free traffic is never cut off with an error or a quota wall. The lever is the **watermark**: over-threshold free sites keep working, just with a visible mark that prompts a subscription. Conversion pressure comes from the image, not from downtime. Paying customers must *never* see a watermark, so the clean path is the safe default whenever the entitlement signal is ambiguous. ## 4. Cost discipline is a feature [Section titled “4. Cost discipline is a feature”](#4-cost-discipline-is-a-feature) Vumbnail runs on metered Cloudflare primitives, and the operator treats spend as a first-class constraint: * Any change likely to add **$1/month or more** of Cloudflare spend requires a written cost estimate saved in `docs/` *before* it ships. * The cheapest layer that can answer a request should answer it. That’s why a free edge Snippet short-circuits hot watermarked traffic before the metered Worker runs, and why hot config values are hardcoded out of the KV read path. * Hot-path KV reads, Worker invocations, and subrequests are measured and actively reduced. ## 5. Edge-first, resolve once [Section titled “5. Edge-first, resolve once”](#5-edge-first-resolve-once) Resolving a video’s poster from the provider is the expensive, fragile step, so it happens as rarely as possible. A resolved thumbnail is persisted to R2 and served from cache thereafter; successful and failed resolutions are both memoized. The provider is the source of last resort, not the hot path. ## 6. Trunk-based and always deployable [Section titled “6. Trunk-based and always deployable”](#6-trunk-based-and-always-deployable) Work lands on `main`. Incomplete features hide behind flags or branch-by-abstraction (KV-driven modes, rollout percentages, `*-test` subdomains) rather than long-lived branches, so a half-finished change never blocks deploying the rest. Rollouts ramp by percentage with health checks at each step, and risky edge changes ship with a rollback snapshot. ## 7. Observe before optimizing [Section titled “7. Observe before optimizing”](#7-observe-before-optimizing) Optimizations are driven by sampled production attribution, not hunches. The Worker emits cache-layer hit/miss counters on a small request sample; the operator ranks error buckets and miss paths before deciding what to fix. A plan without a measured baseline doesn’t ship. # Animated thumbnails > Short animated preview frames for a video, rendered on demand — built but gated off. Animated thumbnails return a short looping preview of a video instead of a single still frame. The rendering path exists in the codebase and runs on the test host, but it is **gated off in production** behind a flag and an allowed-hosts list. ## Behavior [Section titled “Behavior”](#behavior) When enabled, a thumbnail request can be served as a short animated image rendered by a dedicated renderer Worker (a WASM-based animation pipeline) instead of a static JPEG. The thumbnail Worker calls the renderer over a service binding, caches the result in R2 like any other variant, and returns it. Today: * In production, `ANIMATED_THUMBNAIL_ENABLED = 0`, so requests always get the static JPEG. * On the `watermark-test.vumbnail.com` host the feature is enabled for testing, with debug headers on. ## Inputs & outputs [Section titled “Inputs & outputs”](#inputs--outputs) **Inputs:** a thumbnail request whose host is on the animated allowed-hosts list, with the feature flag enabled; the renderer service binding. **Outputs:** an animated image variant stored in R2 (under a configurable namespace) and returned with the normal caching behavior; renderer error detail is exposed only to allow-listed hosts. Controlling env vars (thumbnail Worker): * `ANIMATED_THUMBNAIL_ENABLED` — `0` (off) in production. * `ANIMATED_THUMBNAIL_ALLOWED_HOSTS` — comma-separated hostnames permitted to request animated output. * `ANIMATED_THUMBNAIL_ERROR_DETAIL_HOSTS` — hosts allowed to see renderer error detail. * `ANIMATED_THUMBNAIL_R2_NAMESPACE` — R2 path override for animated outputs. ## States & edge cases [Section titled “States & edge cases”](#states--edge-cases) * **Flag off (production default)** — the animated path is never taken; static JPEG is served. * **Host not allow-listed** — even with the flag on, a non-listed host gets the static image. * **Renderer failure** — fails open to the static thumbnail; error detail is suppressed except for the error-detail hosts. * **Cost gating** — turning this on broadly is a Cloudflare-spend change and therefore requires a written cost estimate first (see [Design principles](/design/principles/)); container/WASM size has been a focus of research to keep it affordable. ## Data shape [Section titled “Data shape”](#data-shape) ```ts type AnimatedThumbnailConfig = { enabled: boolean // ANIMATED_THUMBNAIL_ENABLED allowedHosts: string[] // ANIMATED_THUMBNAIL_ALLOWED_HOSTS errorDetailHosts: string[] // ANIMATED_THUMBNAIL_ERROR_DETAIL_HOSTS r2Namespace: string // ANIMATED_THUMBNAIL_R2_NAMESPACE } // Rendered output is cached in R2 alongside clean/wm variants, // under the configured animated namespace. ``` ## Open questions [Section titled “Open questions”](#open-questions) * **Productization.** Whether animated previews become a paid add-on, which path shape exposes them (`.gif`/`.webp` vs. a query flag), and what the per-render cost ceiling is — all undecided. * **Demand gate.** Broad rollout is blocked on demonstrated demand and a cost estimate. See the global [Open questions](/project/open-questions/). # Cache purge API > A paid-only way to force-refresh a cached thumbnail when the source video changes. A cache purge API lets a paying customer force a specific thumbnail to be re-resolved when the underlying video’s poster changes. This feature is **planned** — it is designed here but not built. ## Behavior [Section titled “Behavior”](#behavior) A paying customer requests a purge for one of their video IDs. Vumbnail invalidates the cached clean and watermarked variants for that ID (edge cache and R2), so the next request re-resolves the current poster from the provider and re-populates the caches. The purge is scoped to IDs the customer is entitled to. ## Inputs & outputs [Section titled “Inputs & outputs”](#inputs--outputs) **Inputs (proposed):** an authenticated paid request (API key) naming one or more video IDs (or a playlist) to purge. **Outputs (proposed):** a confirmation that the named variants were invalidated, and a count of objects purged. ## States & edge cases [Section titled “States & edge cases”](#states--edge-cases) * **Not a paying customer** — purge is rejected; this is a paid-only capability. * **Unknown / never-cached ID** — treated as a no-op success (nothing to purge). * **Negative-cached ID** — the negative record is cleared so a recovered video can resolve again. * **Rate limiting / abuse** — purge must be bounded so it can’t be used to force expensive re-resolution at scale; the limit is undecided. * **Partial failure** — if edge invalidation succeeds but R2 deletion fails (or vice versa), the response must report partial success rather than claim a full purge. ## Cost-safe purging model [Section titled “Cost-safe purging model”](#cost-safe-purging-model) Every purge — customer-requested or operational (for example, a watermark flip) — runs under the same cost-safety model. On this platform there are exactly two ways a purge produces a surprise bill: purging faster than the edge snippet has propagated, or purging a URL that isn’t covered by the HOT list, so the miss lands on the worker instead of a cached redirect. The model below closes both. The full procedure lives in the [cost-safe cache purging runbook](/runbooks/cost-safe-cache-purging/). ### Sequencing: purge last [Section titled “Sequencing: purge last”](#sequencing-purge-last) A purge is always the **last** step of a rollout, never a lever to force one: 1. **Snippet live** — the edge snippet release is deployed. 2. **Propagated** — confirmed at multiple colos, not just one. 3. **IDs HOT** — every id being purged has 100% HOT coverage. 4. **Cohort removed** — the affected host is out of the worker referrer cohort in the same release, so the worker can’t re-pin the old variant on miss. 5. **Smoke green** — post-deploy smoke checks pass. 6. **Then trickle-purge.** Purging earlier in the sequence doesn’t speed anything up — it just re-warms the cache with the state you were trying to leave. ### Trickle rate and per-wave caps [Section titled “Trickle rate and per-wave caps”](#trickle-rate-and-per-wave-caps) Purges run as a slow trickle of **20–30 URLs per minute**, in waves capped by the host’s volume tier: **50 / 25 / 15 / 5** URLs per wave, smallest caps for the highest-volume tiers. **Planned** — the batch purge wrapper that enforces these caps is designed but not built; the current purge tool is single-URL and uncapped, so no wave runs until the wrapper exists. ### Fail-closed pre-wave cost gate [Section titled “Fail-closed pre-wave cost gate”](#fail-closed-pre-wave-cost-gate) Before each wave, a cost gate (**Planned**) estimates the wave’s exposure and refuses to run unless all three ceilings hold: * **\~$0.25** re-warm cost for the wave, * **\~$0.10/month** permanent (recurring) cost added, * **\~$1.00** total program cost. The gate is fail-closed: if cache-busting data for the wave is missing, it refuses rather than assumes zero. This matters because Cloudflare has **no hard spend cap** — billing alerts only, with roughly a 24-hour lag — so every real brake here is self-built. The cache-busting permanent-cost trap A purged URL that carries a cache-busting query string and is **not** on the HOT list bypasses the cached redirect and hits the worker on every request — a permanent, recurring cost, and the one unbounded vector in the whole model. This is why 100% HOT coverage for the affected ids is a hard precondition of any purge, and why the cost gate refuses when cache-busting data is missing. ### Kill action [Section titled “Kill action”](#kill-action) If a wave misbehaves, the kill action is: **stop the purge and back the host off in the worker cohort**. Disabling the snippet is not a brake — cached redirects are what keep requests off the worker, so snippet-disable **increases** cost. Run correctly, the program nets out as a monthly **savings**, because redirects served from cache skip the worker entirely (illustrative blended worker rate: \~$1 per 1M requests). ## Data shape [Section titled “Data shape”](#data-shape) ```ts // Proposed type PurgeRequest = { videoIds: string[] variants?: ("clean" | "wm")[] // default: both } type PurgeResult = { requested: number purged: number notFound: string[] failed: { videoId: string; reason: string }[] } ``` ## Open questions [Section titled “Open questions”](#open-questions) * **Contract & auth surface.** Is purge a path (`//purge`), a separate endpoint, or part of a future account API? API-key only, or dashboard too? * **Rate limits and cost.** What per-customer purge budget prevents abuse while staying useful? Operational purges already run under the [cost-safe model](#cost-safe-purging-model) above; the customer-facing budget is undecided. * **Priority.** Rated a medium-value “support and trust” multiplier rather than a direct revenue unlock; sequenced accordingly on the [roadmap](/project/roadmap/). See the global [Open questions](/project/open-questions/). # Caching & delivery > The three-layer cache, negative caching, and graceful fallbacks that make thumbnails fast and cheap. Resolving a poster frame from a provider is the expensive, fragile step, so Vumbnail does it as rarely as possible. Every thumbnail is resolved once and then served from progressively cheaper caches. ## Behavior [Section titled “Behavior”](#behavior) On each request the Worker checks, in order: 1. **Cloudflare edge cache** (`caches.default`). A warm hit returns immediately with no further work. Clean and watermarked variants are cached under separate keys (the watermarked key carries an internal `?_wm=1` marker that is hidden from clients). 2. **R2 persistent store** (`vumbnail-thumbnails` bucket). The resolved variant is looked up at `thumbnails//clean.jpg` or `thumbnails//wm.jpg`. If the watermarked variant is missing but the clean one exists, the watermark is applied from the stored clean image rather than re-resolving the source. 3. **Source resolution.** Only on a full miss does the Worker fetch the poster from the provider (see below), store the clean variant to R2, derive the watermark if needed, populate the edge cache, and return the image. A successfully resolved Vimeo source is **memoized** to R2 so repeat misses reuse the discovered image URL instead of re-walking the provider’s metadata. ### TTLs [Section titled “TTLs”](#ttls) * **Browser** cache: \~1 day for both variants. * **Edge/CDN** cache: up to \~30 days for the watermarked variant (it changes rarely and is the most cost-sensitive). * **Fallback graphics** (`error.jpg` / `locked.jpg`): served `no-cache, must-revalidate` so a temporary failure doesn’t pin a bad image. ## Inputs & outputs [Section titled “Inputs & outputs”](#inputs--outputs) **Inputs:** the parsed [`ThumbnailRequest`](/features/thumbnail-api/), the resolved `variant` (clean vs. watermarked, from the [freemium decision](/features/freemium-watermark/)), and a `debug` flag that disables cache reads/writes. **Outputs:** the image bytes plus the variant-appropriate cache headers, and — on a sampled fraction of requests — internal attribution counters describing which layer answered. ## States & edge cases [Section titled “States & edge cases”](#states--edge-cases) * **Negative cache.** A failed resolution is recorded in R2 under `thumbnails/_negative/.json` with a state and a backoff-based expiry, so a deleted, blocked, or rate-limited video isn’t re-fetched on every hit. States: `not-found`, `password-protected`, `blocked`, `rate-limited`, `unknown`. Backoff roughly doubles the retry window up to a cap. * **Shared fallbacks.** `error.jpg` and `locked.jpg` are single shared R2 objects reused across all failing IDs, so failures cost almost nothing to store. * **Watermark transform failure** — fails open: the clean image is served and the failure is logged as a structured signal rather than thrown. * **Debug mode** (`?debug`, `?_debug`, or a `watermark-test.` host) — bypasses edge cache reads and writes and returns `no-store`, so probes always exercise the live path. * **Cache key fragmentation** is a known risk: the clean/watermarked split and query handling must not accidentally create multiple keys for one image. Miss- path attribution (below) exists to catch this. ## Data shape [Section titled “Data shape”](#data-shape) R2 layout and the cached records: ```ts // R2 object keys under the `thumbnails/` prefix // /clean.jpg clean variant // /wm.jpg watermarked variant // _shared/error.jpg shared failure graphic // _shared/locked.jpg shared password graphic // _negative/.json negative cache record // _resolver-success/.json memoized Vimeo resolution type NegativeCacheRecord = { state: "not-found" | "password-protected" | "blocked" | "rate-limited" | "unknown" reason: string // e.g. "source-http-404", "vimeo-block-detected" backoffLevel: number // drives the retry window (≈ 2^min(level,3)) softExpiresAt: string // ISO; serve-stale-while-revalidate boundary hardExpiresAt: string // ISO; record is discarded after this } type ResolverMemo = { url: string // discovered upstream image URL resolvedAt: string softExpiresAt: string hardExpiresAt: string } ``` Cache-attribution counters sampled on \~1% of requests (configurable via `THUMBNAIL_INTERNAL_ATTRIBUTION_SAMPLE_RATE`) include `cdnMatchHit/Miss`, `r2VariantHit/Miss`, `r2CleanForWmHit/Miss`, `r2NegativeHit/Miss/Stale`, and `r2SharedFallbackHit/Put`. ## Open questions [Section titled “Open questions”](#open-questions) * **Miss-path ranking (P1).** Stage-1 attribution is deployed but a long-enough sample to rank edge-cache misses vs. R2 misses vs. negative-cache misses hasn’t been collected. The optimization (cache-key fragmentation fix, removing redundant `wm → clean` recursion, or reordering negative-cache reads) is gated on that data. * **Miss coalescing via Workers Cache.** Cloudflare’s new pre-Worker [Workers Cache](/research/workers-cache-for-thumbnails-2026-07-07/) provides request collapsing (one Worker run per key per colo, free on any plan), which is the single-flight this layer otherwise lacks. It keys on URL not referrer, so it is **not** safe to front the clean/watermark variant path until the variant is encoded in the URL (see [Edge watermark router](/features/edge-watermark-router/)). Direction: pilot on fixed-variant surfaces (shared fallback graphics, a test route) first; adopt on the image path only after the Snippet URL-encodes the variant, then it can replace the hand-rolled `caches.default` layer. The [savings estimate](/research/workers-cache-savings-estimate-2026-07-07/) finds this is **not a cost play** (\~$1–$12/mo, ceiling \~$13/mo, negative if naive), so sequence it for collapsing/latency — not savings. * **Projection/cache TTL tuning** for the entitlement allow-list interacts with freshness — see [Paid access](/features/paid-access/) and the global [Open questions](/project/open-questions/). # Edge watermark router > A Cloudflare Snippet that short-circuits hot watermarked traffic before the metered Worker runs. The watermark router is a tiny Cloudflare **Snippet** that runs ahead of the thumbnail Worker. It is the **deterministic watermark control point**: a watermark is never written into the bare URL’s cache — it is a `307` redirect to a distinct `wm.jpg` key, so the bare URL only ever holds clean bytes. Flipping a host onto watermarks is **additive**: add the host to `WM_DOMAINS` and pin its current ids into the hot set. Because the redirect also skips the metered Worker (and its KV reads), every flip is a cost saving, not a cost. Everything else passes through untouched. ## Behavior [Section titled “Behavior”](#behavior) The Snippet evaluates a shallow decision tree (it has a \~5 ms CPU budget, so no heavy logic): 1. **Paying customer?** If the client IP or referrer domain is in the paying set, pass through to the Worker (which serves a clean image). Paying customers are never short-circuited. 2. **Malformed path `/.jpg`?** `308` redirect to the shared `error.jpg`. 3. **Borås WordPress loop?** If the exact loop predicate matches (video ID, UA, self-referrer, ASN `14061`), `307` to that video’s `wm.jpg`. See [Redirects](/features/redirects/). 4. **Hot-set short-circuit?** If the request is `GET`/`HEAD` for a **hot** video ID from a **whitelisted watermark referrer** (and not a paying domain/IP), `307` to `https://cache.vumbnail.com/thumbnails//wm.jpg`. 5. **Otherwise** pass through to the Worker. ### Watermark via redirect [Section titled “Watermark via redirect”](#watermark-via-redirect) The router never rewrites bytes. Watermarking a request means answering it with a `307` to a **distinct** `wm.jpg` cache key — the bare thumbnail URL is never asked to hold watermarked bytes, so no shared edge cache can pin `wm` where a clean image belongs. This is what makes the mechanism deterministic: the decision is re-evaluated per request at the edge instead of being frozen into a referrer-blind cache entry, and a host flip (or unflip) takes effect as fast as the Snippet deploy propagates. ### The hot set is a performance pin, not a policy boundary [Section titled “The hot set is a performance pin, not a policy boundary”](#the-hot-set-is-a-performance-pin-not-a-policy-boundary) Cold ids from a flipped host still pass through to the Worker, which applies the **same** referrer-cohort decision on its miss path — the same watermark outcome, just metered and cache-dependent. The hot set only decides *where* the decision is served (a deterministic, unmetered redirect at the edge), not *whether* the host is watermarked. Worker-cohort overlap Every flipped host in `WM_DOMAINS` is also in the Worker’s referrer cohort, so the Worker watermarks that host’s **non-hot** ids on miss — and can re-pin `wm` at the bare URL. Three consequences: * **A purge does not stick** unless the host’s cohort removal ships in the **same release**; otherwise the next miss re-pins `wm`. * **Disabling the Snippet alone never restores clean delivery.** Rollback is dual-lane: Snippet off **plus** cohort backoff **plus** purge. See [reverting-deploys](/runbooks/reverting-deploys/). * **Hot-set decay erodes determinism, not coverage.** An id that ages out of the hot set falls back to the Worker’s cohort decision — still watermarked, just metered and cache-dependent. See [watermark-system](/runbooks/watermark-system/). ### Why a Snippet instead of Worker code [Section titled “Why a Snippet instead of Worker code”](#why-a-snippet-instead-of-worker-code) Snippets run before Workers in Cloudflare’s pipeline and are far cheaper per request. The hot set is a hand-maintained list of the most-requested watermark-eligible video IDs whose `wm.jpg` is pre-generated in R2. Serving those via a redirect skips a metered Worker invocation and its KV reads. At current volume the blended saving is on the order of **$1.07 per million** requests diverted; the Snippet pays for itself immediately. ### Protecting clean images [Section titled “Protecting clean images”](#protecting-clean-images) Because `cache.vumbnail.com` is a public R2 domain, a WAF rule blocks direct fetches of any `…/clean.jpg` path, so the public bucket can only ever serve the watermarked or shared objects. ## Inputs & outputs [Section titled “Inputs & outputs”](#inputs--outputs) **Inputs:** request method, path, `Referer`, `CF-Connecting-IP` / `True-Client-IP` / `X-Forwarded-For`, `User-Agent`, and Cloudflare ASN. **Outputs:** a `307`/`308` redirect to a public R2 object, or pass-through to the thumbnail Worker. The Snippet itself never renders or fetches an image. ## States & edge cases [Section titled “States & edge cases”](#states--edge-cases) * **Hot ID without a pre-rendered `wm.jpg`** — the `verify-hotset` step ensures every hot ID has its watermarked object in R2 before deploy; a missing object would redirect to a 404, so the hot set and R2 are kept in sync. * **Hot ID ages out of the set** — the request falls through to the Worker, which makes the same cohort decision on miss. Coverage holds; determinism (and the cost saving) is what decays. Rotating-catalog hosts decay fastest — see [hot-set-management](/runbooks/hot-set-management/). * **Dev/local referrer** — dev and local traffic is served **clean by policy**, detected by **referrer host** (`localhost`, `127.0.0.1`, `[::1]`, `*.local`, `*.test`, RFC-1918 ranges, tunnel hosts) — never by user-agent. The explicit early return (`isDevCleanReferrer`) is **Planned**; today these referrers simply never match `WM_DOMAINS` and pass through. * **Size / bundle limits** — the deployed Snippet must stay within a 32 KB bundle and 5 ms CPU; it uses plain `Set` lookups and avoids regex. Current usage is \~10.5 KB. * **Paying customer on a watermark referrer** — the paying check runs first, so they pass through to a clean image even if the referrer is whitelisted. * **Rollback** — deploys take a snapshot first; `rollback:snapshot` restores the previous Snippet and rule. Restoring **clean delivery** for a flipped host is the dual-lane procedure above, not a Snippet rollback alone — see [reverting-deploys](/runbooks/reverting-deploys/). * **Deploy / verify / smoke** — `bun --filter @repo/snippet deploy`, `verify-hotset`, and `smoke` are the operational commands; `lint` enforces the size budget. ## Data shape [Section titled “Data shape”](#data-shape) ```ts // Hand-maintained constants in watermark-router.snippet.js const HOT_VIDEO_IDS: Set // ~68 most-requested watermark-eligible ids const WM_DOMAINS: Set // referrers that trigger watermarking: // the always-on self-referred hosts // S01–S04, plus flipped rollout hosts // (H01, …) — a flip is additive const PAYING_DOMAINS: Set // e.g. C01's domain → always pass through const PAYING_IPS: Set // e.g. C01's entitled IP → always pass through const BORAS_WORDPRESS_VIDEO_IDS: Set // the two pinned loop ids const BORAS_ASN = 14061 type SnippetOutcome = | { action: "passthrough" } | { action: "redirect"; status: 307 | 308; location: string } ``` ## Open questions [Section titled “Open questions”](#open-questions) * **How far to expand the hot set.** Larger hot sets (e.g. a `top200-per-host` slice) save more Worker/KV spend but grow the bundle toward the 32 KB ceiling (\~22.5 KB for the largest candidate). The coverage-vs-size trade-off is open, and the hot-set refresh automation is not built — rotating-catalog hosts decay toward \~0% hot coverage in \~8 weeks without it. See [hot-set-management](/runbooks/hot-set-management/). * **`Cache-Control: no-store` on the `307`** — required before flipping any host above \~1M requests/month, so intermediaries cannot pin the redirect itself. * **`isDevCleanReferrer` early return** — the explicit dev/local clean short-circuit is Planned, not built. * **No-referrer policy at the edge** — no-referrer → clean is decided; the written sign-off document is still open. See [Freemium watermarking](/features/freemium-watermark/). * **Keeping `PAYING_DOMAINS`/`PAYING_IPS` in sync** with the Worker’s KV entitlement projection (the Snippet list is currently hand-maintained). Tracked on the global [Open questions](/project/open-questions/). # Freemium watermarking > How Vumbnail decides whether a request gets a clean or a watermarked thumbnail. The watermark is the business model. Free traffic is never cut off; instead, high-volume free sites are served a **watermarked** poster frame — a visible prompt to subscribe — while everyone else gets a clean image. This page is the decision logic; [Paid access](/features/paid-access/) is how a site earns its way back to clean. ## Behavior [Section titled “Behavior”](#behavior) For every thumbnail request the Worker resolves a single `variant`: `clean` or `wm`. The decision runs in this order: 1. **Paid bypass?** If the request matches the entitlement allow-list (domain, IP, API key, or user-agent), `variant = clean`. Paying customers stop here and never see a watermark. See [Paid access](/features/paid-access/). 2. **Non-domain watermark cohort?** If the request matches a reviewed non-domain signal (a branded `X-Requested-With` app identifier, a specific IP, or a branded user-agent that has been graduated into the cohort), `variant = wm` at 100%. 3. **No `Referer` header?** `variant = clean`, by design. No-referrer traffic is dominated by datacenter renderers and SDK defaults; real browsers are a minority, so marking it would punish exactly the direct users the developer-friendly policy protects. (Historically this defaulted to a blanket watermark; it was narrowed to targeted cohorts.) 4. **Dev/local referrer?** `variant = clean`. Development traffic is detected by the **referrer host** — `localhost`, `127.0.0.1`, `[::1]`, `*.local`, `*.test`, RFC-1918 addresses, and tunnel hosts — never by user-agent. The explicit early-return branch in the edge snippet (`isDevCleanReferrer`) is **Planned**; until it lands, dev hosts get the same clean result by being absent from the cohort. 5. **Referrer in the target cohort?** If the `Referer` host is in the reviewed high-volume referrer cohort, apply the rollout percentage. The percentage is driven by a stable hash of the video ID so a given video is consistently marked or not. The rollout is currently pinned to **100%** (hardcoded out of the KV hot path for cost), so a cohort referrer reliably gets `wm`. 6. **Otherwise** `variant = clean`. The threshold that decides which referrers belong in the cohort is a policy number, not a live per-request counter: **50,000 requests/month** is the free clean allowance, with a **20% grace buffer** (so outreach starts at **60,000/ month**). Hosts crossing it are reviewed and added to the watermark cohort and the [outreach](/features/outreach-toolkit/) list. ## Inputs & outputs [Section titled “Inputs & outputs”](#inputs--outputs) **Inputs:** `Referer` host, `CF-Connecting-IP`, `User-Agent`, `X-Requested-With`, the video ID (for the stable rollout hash), the entitlement allow-list, and the `FORCE_ROLLOUT` override env var. **Outputs:** a `variant` of `clean` or `wm`, consumed by [Caching & delivery](/features/caching-and-delivery/) to select or render the right image. In debug mode the decision is exposed via `X-Thumbnail-Rollout`, `X-Thumbnail-Rollout-Reason`, and the bypass/variant headers. ## States & edge cases [Section titled “States & edge cases”](#states--edge-cases) * **Ambiguous entitlement** — when the allow-list signal can’t be read, the safe default is `clean`; a paying customer must never be watermarked. * **Watermark render failure** — fails open to the clean image (see [Caching & delivery](/features/caching-and-delivery/)). * **Reverse leak** — a referrer-blind long-TTL edge cache can pin `wm` bytes on the bare URL, so no-referrer / direct traffic receives `wm` where the policy says `clean`. The policy is explicit: **ambiguous traffic stays clean.** Under the snippet delivery model the bare URL always stays clean (the watermark is a 307 redirect to a distinct `wm.jpg` key), and any pinned-`wm` bare URLs that violate the policy are purged as part of every host flip — coupled in the same release with worker-cohort removal so the miss path can’t re-pin `wm`. * **Funnel gap (known issue)** — a watermarked image is only a conversion lever if it points the viewer to `/pricing`. Wiring the watermark to the pricing funnel is the binding constraint on converting watermarked hosts. * **Very high-volume referrers** — reviewed individually as a separate expansion rather than flipped in bulk, because delivery surface and audience vary widely at that scale. ## Data shape [Section titled “Data shape”](#data-shape) ```ts type WatermarkDecision = { variant: "clean" | "wm" reason: | "paid-bypass" | "non-domain-cohort" | "no-referrer-default-clean" | "dev-clean-referrer" // Planned: explicit isDevCleanReferrer branch | "target-referrer-rollout" | "default-clean" rollout: number // 0.0–1.0; currently pinned 1.0 for the cohort cohortMatch: boolean } // Policy thresholds (shared from @repo/core/vumbnail-pricing.ts) const FREE_TIER_MONTHLY_REQUESTS = 50_000 const OUTREACH_GRACE_PERCENT = 20 const OUTREACH_MONTHLY_THRESHOLD = 60_000 // ceil(50_000 * 1.20) ``` ## Open questions [Section titled “Open questions”](#open-questions) * **No-referrer policy sign-off.** No-referrer → clean is decided (see [Decisions](#decisions)), but the written policy sign-off document is still required before the first purge wave runs. * **Cohort graduation rules.** When should a non-domain signal auto-graduate into the watermark cohort (volume, persistence, confidence), and which signals are always excluded (browsers, generic runtimes, crawlers, shared social/ search referrers)? These are tracked on the global [Open questions](/project/open-questions/) page. ## Decisions [Section titled “Decisions”](#decisions) * **2026-06-14 — No-referrer requests are served clean, explicitly.** Evidence: no-referrer traffic is dominated by datacenter renderers plus SDK defaults, with real browsers a minority. Watermarking it would hit the direct users the developer-friendly policy exists to protect. A written policy sign-off document is still required before the first purge (open). * **2026-06-14 — Dev/local traffic is served clean, detected by referrer host.** `localhost`, `127.0.0.1`, `[::1]`, `*.local`, `*.test`, RFC-1918 ranges, and tunnel hosts match by referrer host — never by user-agent. The explicit snippet early-return (`isDevCleanReferrer`) is **Planned**, not yet built. * **2026-06-14 — The edge snippet is the deterministic watermark control point.** A watermark is a 307 redirect to a distinct `wm.jpg` cache key; the bare URL stays clean. Flipping a host = adding it to `WM_DOMAINS` plus its current ids to `HOT`. * **2026-06-14 — Ambiguous traffic stays clean (reverse-leak policy).** A live re-probe found delivery had flipped wm-dominant at the probed colo, with no-referrer users receiving `wm` from the referrer-blind long-TTL cache — a policy violation. Pinned-`wm` bare URLs that violate the policy are purged as part of any host flip, in the same release as worker-cohort removal so the worker’s miss path cannot re-pin `wm`. # Operator dashboard > The internal dashboard the operator uses to watch cache health, storage, and watermark decisions. The operator dashboard is an internal, authenticated SPA that surfaces the health of the thumbnail service. It is **not** customer-facing — embedders never see it — but it is a real, shipped part of the system, so it’s documented here. ## Behavior [Section titled “Behavior”](#behavior) After signing in, the operator lands on a metrics view (the dashboard index; `/dashboard` redirects to it) that shows: * **R2 storage size** — total and per-group breakdown (thumbnails, error logs, suffix-cleanup), so storage growth and cleanup are visible. * **Telemetry time-series** — requests over time, cache hit/miss rates, and watermark-decision counts. * **Production health** — request rates, error rates, and latency for the thumbnail Worker. * Supporting views for analytics, reports, settings, and about, with search, sort, and refresh controls. The data is pulled from a small set of metrics endpoints on the API Worker; the dashboard itself holds no business logic beyond presentation. ## Inputs & outputs [Section titled “Inputs & outputs”](#inputs--outputs) **Inputs:** an authenticated operator session (Better Auth); metrics requests to `/api/local-dashboard/metrics/*`. **Outputs:** rendered charts and tables of R2 size, telemetry summaries/ time-series, and production-health metrics. Backing endpoints (on the API Worker): * `GET /api/local-dashboard/metrics/r2-bucket-size` * `GET /api/local-dashboard/metrics/telem-r2-timeseries` * `GET /api/local-dashboard/metrics/telem-summary` * `GET /api/local-dashboard/metrics/prod-health` ## States & edge cases [Section titled “States & edge cases”](#states--edge-cases) * **Unauthenticated** — `/login` is the entry; an already-authenticated user hitting `/login` is redirected to the sanitized `returnTo` path (or `/`). * **Metric source stale** — some panels depend on the [video-metadata pipeline](/project/roadmap/), which has been intermittently stale; affected panels can show gaps until that pipeline is repaired. * **Boilerplate routes** — the underlying starter kit ships `users`, `organization`, and similar tRPC routers as **stubs** (TODO-marked, not wired to product logic). The live dashboard value is the metrics views above, not those stubs. ## Data shape [Section titled “Data shape”](#data-shape) ```ts type R2BucketSize = { totalBytes: number groups: { name: "thumbnails" | "error-logs" | "suffix-cleanup"; bytes: number }[] sampledAt: string } type TelemetrySummary = { windowHours: number requests: number cacheHits: number cacheMisses: number watermarkDecisions: { clean: number; wm: number } } type ProdHealth = { requestRate: number // req/s errorRate: number // 0.0–1.0 p50LatencyMs: number p99LatencyMs: number } ``` ## Open questions [Section titled “Open questions”](#open-questions) * **Metric provenance & richer charts.** Adding provenance labels and pie/share charts is parked until the underlying operational signals (metadata pipeline, miss-path attribution) are repaired — polishing on top of stale data isn’t worthwhile yet. * **Customer-facing usage view.** Whether any of this graduates into a customer-visible usage dashboard is undecided; today billing is allow-list based with no end-user metering UI. See the global [Open questions](/project/open-questions/). # Outreach toolkit > An internal, evidence-driven workflow for converting over-threshold free domains to paid. The outreach toolkit is an internal operator workflow that turns first-party usage evidence into targeted, personalized conversion emails for domains that are clearly over the free allowance. It is deliberately **not** a bulk marketing tool — it only targets domains with proven, over-threshold Vumbnail usage. ## Behavior [Section titled “Behavior”](#behavior) The operator runs a short script-driven pipeline: 1. **Prepare local inputs** (gitignored): an `entry-tier.csv` of domains already over threshold plus contacts, and a `sites-meta.json` of per-domain overrides (industry, contact role, send priority, notes). 2. **Fetch usage evidence** (`bun outreach:usage`) — exports referrer, direct/ no-referrer, user-agent, and IP summaries from production analytics as NDJSON. 3. **Build sequences** (`bun outreach:build-sequences`) — generates a personalized three-part email sequence per domain into `docs/sites/.md`, merging evidence + templates + the policy thresholds. 4. **Configure sender** (gitignored `sender-config.json`) — from/reply-to and a compliant `List-Unsubscribe` header. 5. **Export & validate** — `bun outreach:export-mail-merge` produces a Thunderbird-compatible `mail-merge.csv`; `bun outreach:validate-mail-merge` gate-checks it before any send. The actual send is done by a human through Thunderbird Mail Merge — the toolkit prepares and validates, it does not send. ### The threshold [Section titled “The threshold”](#the-threshold) Targeting is gated on the shared policy numbers: free allowance **50,000/month**, **20%** grace, so outreach only fires at **60,000/month** and above. These come from `@repo/core/vumbnail-pricing.ts` so the toolkit, the watermark cohort, and the docs can’t drift. ## Inputs & outputs [Section titled “Inputs & outputs”](#inputs--outputs) **Inputs:** `entry-tier.csv`, `sites-meta.json`, production usage exports, templates in `docs/templates/`, `sender-config.json`, `suppression-list.json`. **Outputs:** per-domain sequence markdown, a validated `mail-merge.csv`, and a pass/fail validation report. No outreach artifacts or contact data are committed to git. ## States & edge cases [Section titled “States & edge cases”](#states--edge-cases) * **Under threshold** — a domain below 60,000/month is not eligible; the toolkit refuses to target it. * **Suppressed recipient** — validation fails if a recipient’s email or domain is on `suppression-list.json`. * **Duplicate / missing recipient** — validation fails on duplicate addresses or a row missing an email or domain. * **Missing compliance headers** — validation fails if sender headers or `List-Unsubscribe` aren’t populated. * **Pilot batches** — a `--max-send-priority` flag restricts a run to the highest- priority contacts for a small first send. ## Data shape [Section titled “Data shape”](#data-shape) ```ts type CsvEntryRow = Record // domain + contact columns from entry-tier.csv type SiteMeta = { domain: string contact_name?: string contact_email?: string contact_role?: string industry?: string send_priority?: number // lower = earlier notes?: string } type MailMergeRow = { to: string contact_name: string domain: string monthly: number // measured monthly requests monthly_requests_over_threshold: number free_tier_monthly_requests: 50000 outreach_monthly_threshold: 60000 from_email: string from_name: string list_unsubscribe: string part_1_subject: string; part_1_body: string part_2_subject: string; part_2_body: string part_3_subject: string; part_3_body: string } ``` ## Open questions [Section titled “Open questions”](#open-questions) * **Send-lane automation.** A pilot send lane is in progress but has been blocked on local sender configuration; how much of the send step to automate vs. keep human-in-the-loop is open. * **Contact selection.** The toolkit encodes role-preference guidance (web owner → marketing ops → … → generic alias), but the right primary/backup contact per archetype is still being tuned. See the global [Open questions](/project/open-questions/). # Paid access & entitlements > How a paying customer is allow-listed back to clean thumbnails, kept current from Stripe. A paying customer is one whose requests match the **entitlement allow-list**. When they match, the [watermark decision](/features/freemium-watermark/) short-circuits to `clean`. The allow-list is kept current automatically from Stripe subscription webhooks, so onboarding a customer does not require a code change or a deploy. ## Behavior [Section titled “Behavior”](#behavior) ### Earning clean images [Section titled “Earning clean images”](#earning-clean-images) A customer subscribes through Stripe Checkout and, in a checkout custom field, lists the **domains** and/or **IPs** their thumbnails are served from. On a successful checkout, Vumbnail records the entitlement and the customer’s traffic starts getting clean images within the allow-list’s propagation window. ### How a request is matched [Section titled “How a request is matched”](#how-a-request-is-matched) For each request the Worker checks the allow-list in this order and stops at the first match: 1. **API key** — from the `X-Vumbnail-Key` header or `?key=` query param (keys look like `vb_live_