# AGENTS.md Guidance for coding agents working in this repository. Claude Code loads this file through `CLAUDE.md`; other agents read it directly. Keep this file to what an agent cannot learn by reading the code: commands, environment facts, conventions, decisions and the reasons behind them, and boundaries. Do not add lists of routes, tables, tools, files, or env vars. They drift, and the code is the source of truth. The layout table below is the one map this file keeps: it names top-level directories and stays at that altitude. ## What this is webhooks.cc is a production webhook inspection and testing service. Users capture incoming webhooks, inspect requests, configure mock responses, verify provider signatures, and forward requests to localhost with the CLI. A TypeScript SDK (`@webhooks-cc/sdk`) and an MCP server (`@webhooks-cc/mcp`) give programmatic and AI-agent access. Teams share endpoints under a pooled, per-seat subscription. Production: `https://webhooks.cc` (app) and `https://go.webhooks.cc` (webhook receiver). The repository is public. ## Layout | Path | What lives there | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `apps/web/` | Next.js 16 App Router, React 19, Tailwind v4, Base UI. Dashboard, marketing and SEO pages, API routes under `app/api/`, env validation in `lib/env.ts`. | | `content/docs/` | MDX source for `/docs/*`, rendered by the catch-all route in `apps/web/app/docs/`. | | `apps/receiver-rs/` | Rust (Axum, Tokio, sqlx) webhook receiver. Captures at `/w/{slug}`. Env vars are read in `src/config.rs`. | | `apps/mx-rs/` | Rust (Tokio) receive-only SMTP host for email capture on `mailhooks.cc`. Env vars are read in `src/config.rs`. | | `apps/cli-rs/` | Rust CLI `whk` (Clap). Subcommands live in `src/cli/`; exit statuses and error codes in `src/error.rs`. | | `apps/edge-rs/` | Rust (Tokio, hyper, yamux) tunnel edge: serves `whk share` previews on sharehooks.cc and relays `whk tunnel --live` captures. Env vars are read in `src/config.rs`. | | `crates/tunnel/` | `whk-tunnel`, the tunnel protocol (WebSocket, yamux, splice) shared by the edge and the CLI. | | `apps/parser-rs/` | Rust attachment parser `webhooks-parser` (CSV, TSV, XLSX, XLS, ODS, PDF text, text): a sandboxed child process of the web app's parse worker. | | `packages/sdk/` | `@webhooks-cc/sdk`, published to npm. Also the canonical provider catalog (`TEMPLATE_PROVIDERS`, `VERIFY_PROVIDERS`). | | `packages/mcp/` | `@webhooks-cc/mcp`, stdio MCP server. Tools in `src/tools.ts`; the test suite pins the tool and provider counts. | | `packages/sdk-python/` | `webhooks-cc` on PyPI, the Python SDK (uv, sync client on httpx, pytest fixtures). Passes the TypeScript SDK's conformance vectors. | | `packages/sdk-go/` | `webhooks.cc/go`, the Go SDK (standard library only; signature checks in `verify/`, test helpers in `whktest/`). Passes the same vectors. | | `packages/pytest-webhooks-cc/` | `pytest-webhooks-cc` on PyPI, the pytest plugin: depends on the Python SDK, whose fixtures it brings, and tells a run when tests are skipped for want of a key. | | `packages/zapier-app/` | The Zapier app (CommonJS, `zapier-platform-core`): New Email as a REST hook on destinations, actions, a search. Published with the Zapier CLI. | | `packages/make-app/` | The Make custom app as IML definitions (base, connection, webhook, modules, RPC). Uploaded through Make. | | `plugins/` | The Claude Code plugin with the agent skill (`SKILL.md`) for webhook testing; `.claude-plugin/marketplace.json` at the root lists it. | | `supabase/migrations/` | Numbered SQL files: schema, functions, RLS policies, pg_cron jobs. Applied by hand with psql. | | `infra/` | Cloudflare Worker notify proxy, GoTrue email-auth config notes. | | `docs/`, `branch-docs/` | Local planning docs. Both are gitignored. | ## Commands ```bash pnpm install make dev # mprocs: web + receiver side by side pnpm dev:web # web only make dev-receiver # Rust receiver, sources .env.local make dev-cli ARGS="..." # run the CLI from source pnpm typecheck && pnpm lint && pnpm build pnpm test # SDK + MCP + web unit tests cd apps/web && pnpm test:integration # needs the local Supabase stack; some suites need the receiver running (make dev-receiver, which also opens its mail listener), some a web server at WEB_URL (default :3000): start it with EMAIL_FORWARDING_WORKER=false, since the forwarding suites drive the worker themselves and stop the run when another one drains the queue cd apps/receiver-rs && cargo test && cargo clippy -- -D warnings cd apps/mx-rs && cargo test && cargo clippy --all-targets -- -D warnings cd apps/cli-rs && cargo test # tests that reach a server (integration, and smoke's list/usage/lifecycle) skip unless WHK_API_URL names one; without it they would use webhooks.cc and this machine's saved login cd apps/parser-rs && cargo test && cargo clippy --all-targets -- -D warnings # cargo build --release for the web app's parse worker cd apps/edge-rs && cargo test && cargo clippy --all-targets -- -D warnings # the tunnel edge; crates/tunnel the same make dev-edge # the edge for whk share and --live; previews at http://.sharehooks.localhost:4000, CLI with WHK_TUNNEL_URL=ws://localhost:4000 cd packages/sdk-python && uv run pytest && uv run ruff check src tests && uv run mypy cd packages/pytest-webhooks-cc && uv run pytest && uv run ruff check src tests && uv run mypy # the pytest plugin, with the SDK from PyPI; add --with-editable ../sdk-python after `uv run` to test with this repository's cd packages/sdk-go && go vet ./... && go test -race ./... pnpm test:full # everything, including integration and Playwright e2e ``` ### Running in production Production runs as containers on a single host: the web app and receiver images built from this repository (`apps/web/Dockerfile`, `apps/receiver-rs/Dockerfile`), Caddy, Redis, and a self-hosted Supabase project, all under Docker Compose. Images are built in CI and shipped to the host; the host never builds. The deploy workflow, compose files, and host configuration live in a separate private operations repository, not here. A deploy builds the images for a given ref of this repository, loads them on the host, and recreates the web and receiver containers (and, where configured, the SMTP listener on the MX host). `NEXT_PUBLIC_*` values are build arguments, so changing one means a new build, not a restart. The CI job "Build Docker Images" checks on every PR that the images still build. ### Database changes Migrations are plain SQL files in `supabase/migrations/`, numbered sequentially. There is no migration runner. Apply each file against dev first, then against prod before deploying the release that needs it (the production database is only reachable from its host): ```bash PGOPTIONS="-c lock_timeout=5s" psql "$SUPABASE_DB_URL" --set=ON_ERROR_STOP=1 -f supabase/migrations/.sql psql "$SUPABASE_DB_URL" -c "NOTIFY pgrst, 'reload schema';" ``` Two rules that are easy to miss: - After adding or changing an RPC, column, or policy, run the `NOTIFY` above. PostgREST caches the schema, and new RPCs return 404 until it reloads. - Keep the psql invocation as shown: plain autocommit mode, because some migrations use `CREATE INDEX CONCURRENTLY` and `NOT VALID` constraints that fail inside a transaction; `ON_ERROR_STOP` so a failed statement halts the run instead of leaving a half-applied file; a short `lock_timeout` so a DDL lock wait cannot stall production traffic. ## How it fits together Web (3000), receiver (3001, plus a private mail listener on 3002), the SMTP listener for `mailhooks.cc` on its own box (25), the tunnel edge for previews and live tunnels on its own box (4000 behind Caddy, its control port 4001 and relay port 4002 on the private network only), a self-hosted Supabase instance (Postgres, Auth, Realtime), a Cloudflare Worker that outbound deliveries go through, and optional Redis for the web app's distributed rate limiting. Capture path: a sender POSTs to `go.webhooks.cc/w/{slug}/...`. The receiver validates the slug, strips proxy headers, and calls the `capture_webhook()` stored procedure once. That single call looks up the endpoint, checks expiry, decrements quota atomically, inserts the request, bumps counters, and picks the billing pool. The receiver answers with the configured mock response or a plain 200, and the row records that answer (`response_status`, and `response_rule` when a rule chose it; migration 00090). The rules arrive from the same `capture_webhook()` call that inserts the row, so the receiver predicts the answer from the rules the slug's previous capture returned (`handlers/answers.rs`) and passes it to the insert, then corrects the row only when the fresh rules chose differently (a cold cache or edited rules): a capture stays one write. Dashboards update over Supabase Realtime; the CLI streams over SSE from `/api/stream/{slug}`. Decisions worth knowing, with the reasons: - **Nobody needs the dashboard.** Every user-facing capability works from the API, the CLI, the SDKs and the MCP server, because people increasingly work through coding agents, and a step that needs a dashboard stops the agent. Signup and payment are the exceptions. Where a person has to decide (a sensitive change, a payment), the agent asks and the person approves through a link or a code, never by doing the work in the dashboard. The dashboard is a view, never the only way. `capabilities.json` at the root lists every capability with the API routes behind it and, for the TypeScript, Python and Go SDKs, the CLI and the MCP server, the calls that reach it or why there are none; the SDK, MCP and CLI test suites fail when a route, method, tool or command is missing from it or a name in it no longer exists, so a new route comes with a decision about every client. - **`whk` serves agents as well as people.** `--json` prints the API's own JSON (`Fetched` in `api/client.rs` keeps it beside the parsed value, so fields the CLI does not know still reach the caller), failures carry a code and a stable exit status (`src/error.rs`, listed in `whk --help` and `/docs/cli`), nothing reads stdin without a terminal (`cli/confirm.rs`: `--yes` instead), `whk wait` exits on the next arrival, and `whk api` reaches any route a command does not cover. There is no full-screen TUI: a bare `whk` opened one and hung any agent that ran it, so it was retired (November 2026) for one-line and NDJSON output from `listen` and `tunnel`. - **The receiver talks to Postgres directly** (`DATABASE_URL`), not through the web app. One fewer hop on the hot path, and the stored procedure keeps quota and counters atomic. In production it connects to Postgres on the same Docker network rather than through Supavisor: on one host the pooler only costs CPU, and a load test sustained about twice the throughput without it. - **DB failures are classified.** Transient errors (pool timeout, connection loss, SQLSTATE classes 08/40/53/57/58) return 503 with `Retry-After: 5` so senders retry; at-least-once delivery beats silent loss. Permanent errors fail open with 200 and are logged and counted in the receiver's once-a-minute `counts` log line (`capture_failed.`). NUL bytes in bodies and paths are sanitised (raw bytes kept in `body_raw`) instead of failing the insert. - **RLS is deny-by-default for client roles.** Anonymous users cannot read endpoints, requests, or device codes, and may only read published blog posts. Guest dashboard reads and guest endpoint creation go through server routes using the service role. Client roles have no write access to `users`, `endpoints`, or `api_keys` (migrations 00037 and 00045; every write goes through a server route that validates, rate-limits, and audits it) and no EXECUTE on `public` functions unless a migration grants it (00037 revoked them and reset the default privileges). Team members can read shared endpoints through `can_view_team_endpoint()`, which also lets them join the endpoint's Realtime topic. - **Live updates are Realtime Broadcast signals, not `postgres_changes`.** Triggers send `realtime.send()` signals carrying only ids on private topics (`endpoint:` for request and deletion events, `user:` for plan, billing and quota-state changes), and clients re-read through the authenticated routes. `postgres_changes` evaluates every change against every subscriber's RLS inside Postgres, which capped live updates far below capture throughput; a broadcast topic is authorized once per join by the `realtime.messages` policy (`can_join_realtime_topic()`). Keep `requests`, `endpoints` and `users` out of the `supabase_realtime` publication, and do not signal per capture on the user topic. The self-hosted Realtime tenant has its own rate limits (`max_events_per_second` and friends in `_realtime.tenants`) that drop messages above them. - **Sensitive routes want a session, not an API key.** Account deletion and billing mutations reject API keys with 403. Where a person must decide but an agent should not be stuck, the route answers an API key or agent token with an approval instead (`requestApproval()` in `lib/approvals/request.ts`, migration 00097): `202` with a link and a code, a one-purpose page at `/approve` where the account's owner (a session, past two-factor sign-in) compares the code and approves or refuses, and `GET /api/approvals/{id}`, where only the key or agent registration that asked collects the result, once. An action that makes a secret (a new API key) runs when it is collected, not when approved, so the secret goes straight to the asker and is never stored; the others (a share link, removing a domain, a rename: migration 00098; a spreadsheet chosen from Drive, whose file the page hands over, and AI setup: 00099; deleting a team, which ends its subscription at once, and a team invite, which emails an address the asker names: 00101) run the moment the owner approves, keep their result on the row for every poll, and leave nothing to withdraw, which suits an action that cannot be undone; connecting a Google account finishes in the browser instead: approving sends it to Google's consent page with the approval in the OAuth state, and the callback completes the approval with the connection or, cancelled or failed, puts it back to pending; add an action in `lib/approvals/actions.ts`, and mark its capability's `approval` in `capabilities.json` (a test fails otherwise). The page carries the device login's defences (`/cli/verify` works the same way): the code is compared, never typed, and the page names who asked and warns whenever the request did not come from the browser's own network, because a link that approves on one click is what a phisher sends. - **Teams are billed per team.** A Polar seat subscription on the `teams` row buys the member cap and a pooled quota. It is on one of two plan products, both from 2 seats (Polar's first seat tier; teams subscribed with one seat before that keep it until they change it, so the app checks the minimum too): Teams (seats x 100,000 requests per 30 days, monthly only) or Team Business (migration 00083: Business's requests, parsed pages and AI assists per seat, `plan_entitlements.per_seat`, which `account_entitlements()` multiplies; monthly or annual, the annual quota renewing monthly like an annual personal plan's). `teams.business` is written from the subscription's product with the rest of its state, so a past-due payment keeps Team Business live while Polar retries the card. A product switch keeps the claimed seats and sends no seat events. Moving up (Teams to Team Business, or to annual) is a product switch at once with `invoice`. Teams to Team Business within the month grants the extra allowance pro rata for the window (`teams.prorated_window`, `prorated_seat_requests`, `prorated_seat_pages`, dropped by the request-limit trigger when the period moves); a move to annual starts a new year, paid in full less the unused month, with a fresh allowance; moving down is `next_period`, mirrored in `teams.pending_plan`. Polar keeps seats and product in one pending update; `pending_update: null` and an immediate product switch both clear it, so keeping the plan and moving up schedule a pending seat reduction again. `users.plan` stays free, pro or business and does not gate team access. `capture_webhook()` bills an endpoint shared with an active team against that team and stamps `requests.team_id`; team-billed requests keep the team plan's retention (31 days, 90 on Team Business) regardless of the owner's plan. Each team gets its own Polar customer of type team: Polar allows one customer per email per organisation, so the owner's personal customer is never reused. Seat increases are billed at once (Polar `invoice`); seat reductions take effect at the next renewal (`next_period`), mirrored in `teams.pending_seats`, which also caps members until then. Invoices and the payment method live in Polar's customer portal, opened by a customer session made on click (`createCustomerPortalForUser()`, `createTeamCustomerPortal()`; session only, since the link signs its holder in as the customer for an hour); a team customer's session needs its owner member's `member_id`. Keep "Enable subscription seat management" (`customer_portal_settings.subscription.update_seats`) and plan changes (`subscription.update_plan`) off in the Polar dashboard: the code does not enforce either, seat management covers seat counts and seat assignment (seats assigned there would take Polar seats our invites cannot see), and a plan changed there skips our own plan rules. Never log a raw Polar SDK error: its message embeds the response body, which can echo customer emails; use `loggablePolarError()` from `lib/polar.ts`. The SDK is imported from a versioned entry point (`@polar-sh/sdk/2026-10`) and hands back Polar's snake_case JSON unchanged, timestamps as ISO strings. Webhook payloads follow the API version set on each webhook endpoint in the Polar dashboard, not the SDK's, so move the endpoints along when the SDK version moves. - **Extra requests are add-on subscriptions and orders on the plan's Polar customer.** Pro and Business accounts and subscribed teams buy 25,000-request blocks three ways (migration 00065, `lib/supabase/capacity-billing.ts`): monthly blocks (a unit-priced subscription), one-time blocks (unit-priced orders, never expire) and overage (a subscription with only a metered price). A capture spends, in order, the allowance (`request_limit + block_requests`, still one conditional update), then one-time blocks, then overage up to the customer's per-period maximum (`take_extra_request()`); `requests_used` counts the allowance only, so blocks bought later are never eaten by what came after it. The add-on subscriptions' billing period is moved onto the plan's (`current_billing_period_end`), only ever earlier, so they renew and bill together and a renewal race cannot hand out a free month; the one exception is the up to three days a short month took (Polar takes an add-on's renewal day from the end it was given, so one aligned with a Feb 28 window would otherwise renew on the 28th for good while the plan keeps the 31st), moved later once and only while that period still runs. Adding monthly blocks mid-period is a one-time order at the full monthly price (the user's choice, and Polar's off-session charges need its support to enable) followed by a `next_period` unit change; removing them waits for the renewal. Overage counts land on the account row and the web app's reporter (`lib/billing/overage-reporter.ts`) sends them to the Polar meter as events whose external id is the counted range, so a resend is dropped as a duplicate. The organisation allows several subscriptions per customer for this, so the Polar webhook routes add-on products before the plan handlers, which would otherwise read their events as the plan's, and add-ons are reconciled with the plan's stored state after every plan event (they end with it). Account and team deletion revoke them first, because their rows cascade away. - **Refunds go through a queue a person can hold.** A Polar charge that bought nothing (a second plan or extra-domains subscription the webhook revokes, an add-on order paid for an account or team deleted meanwhile, extra domains with nothing left to add them to, a plan or team subscription whose checkout completed after its account or team was deleted) is recorded in `polar_refunds` (migration 00086, `recordRefund()` in `lib/billing/refunds.ts`), one row per order or subscription, and the web app's refund worker does the rest; a webhook never calls Polar's refund API itself. While `POLAR_AUTO_REFUNDS` is off (production starts that way) every row is held; on, a refund above `POLAR_AUTO_REFUND_MAX_CENTS` (before tax, default $300) or a subscription with orders older than two days is held too. The worker prepares a held row (which orders, how much, in `orders` and `planned_cents`) and reports it; `approve_polar_refund()` and `dismiss_polar_refund()` are the decisions, and an approved row refunds exactly the orders prepared, past the cap and the window. Each order is refunded in full (`refundable_amount`, Polar adds the tax), after looking for a refund Polar already made for the row (its id rides in the refund's metadata, since Polar has no idempotency key); Polar refuses to refund more than an order has left, so a retry cannot pay twice. Polar keeps its fee on a refund. A subscription for a deleted account or team (plan, team or add-on) is only ended and refunded when Polar's current state says it is live, because deletion revokes the account's own subscriptions first and their late events must not refund months that were used; every call site records the refund before it revokes, since a redelivery after the revoke reads the subscription as ended. A refund Polar still has pending is checked again until it settles, and one that failed there goes to a person. Approval needs a prepared list (`orders`), and a failed row without one goes back to be prepared; turning `POLAR_AUTO_REFUNDS` off holds rows already queued, and a queued row no worker holds can be dismissed. The worker writes a row only while it keeps the status it claimed, so a dismissal is never overwritten, and a failure keeps what that pass already refunded. Extra domains that end with a plan revoked mid-period stay a report, since they were used for part of the period. - **Parsed pages are sold like requests, and only with Business.** Business and Team Business buy 1,000-page blocks the same three ways (migration 00070; the `resource` of each add-on in `capacity-billing.ts`, meter event `page.overage`), spent after the plan's pages by `take_parsed_pages()`. They need the plan that allows them: a plan change away from Business or Team Business sets them to end with the period, and they cannot be started or kept while that change waits, because they would renew and charge after the plan is gone. One-time page blocks stay on the account. - **Custom inbound domains are routed in the database, and extra ones are add-ons.** A Business account (or a Team Business team) adds its own domain (migration 00072): a TXT record `_mailhooks.` with its token and MX records that all point at `MAIL_MX_HOST` verify it, the web app's checker (`lib/supabase/inbound-domains.ts`, public resolvers from `DOMAIN_DNS_SERVERS` so a customer's nameservers never see our host) checks again every day and lapses a domain after three failed checks, counting checks the owner asks for too, and the receiver resolves each recipient at a domain it does not own through `resolve_inbound_recipient()` (local part, then the catch-all), caching the list of live domains for `INBOUND_DOMAINS_REFRESH_SECS`. Domains past `inbound_domains_allowed()` (the plan's `custom_domains` plus `extra_domains`) stay listed but are refused, the most recently verified first, so buying fewer never deletes anything. Extra domains are a capacity add-on (migration 00073, `resource: "domains"`): monthly units and the full-price mid-period add like monthly blocks, no one-time form and no overage, so paid domains that cannot renew are reported for a refund instead of kept as credits. Only a verified domain is held: when another account proves the domain with its own TXT record and the holder's is gone, the holder's row lapses at once, since whoever controls the DNS now owns the mail. A domain where any account has its email is refused, and verified domains join the capture domain in the GoTrue hook, in a trigger on `auth.users` that refuses either as a changed email (the hook only sees signups), and in every agent path that creates or names an account, because their mail is readable here. - **Zapier and Make are REST hooks on destinations.** Both apps (`packages/zapier-app`, `packages/make-app`) connect with an API key and add a JSON destination (email only) to the chosen endpoint when a Zap or scenario turns on, and delete it when it turns off, so they get the same signed `email.received` JSON, parsed fields included, as any destination, through the same queue, filters and retries; samples come from `POST /api/requests/{id}/render` with `format: "json"`, the same payload a hook receives, attachment links included. A destination whose URL answers 410 Gone (what both platforms answer for the hook of a removed Zap or scenario, and so a destination left behind when an unsubscribe failed) is turned off rather than retried. The routes they call count their rate limits per account, not per address, because each platform sends every customer's calls from a few shared addresses. They need no routes of their own: change the destinations API, the request list or the render route, and run both packages' tests against the dev server. - **AI only writes patterns, and only when asked.** AI setup and repair (migration 00075, `lib/ai/`) sends emails a person picked (1 to 5 of the endpoint's own, subject, sender, recipients, text and headers, never attachments) to Anthropic (`claude-sonnet-5-5` through `@anthropic-ai/sdk`, structured output, server-side fallbacks; Sonnet because it matched Opus on a comparison of made-up sample sets at half the cost, while Haiku proposed patterns that found the wrong value yet passed the check) only with the person's consent each time: `consent: true` on a session's request, or, asked with an API key or agent token, an approval (`ai.assist`) whose page lists the emails by subject and sender and runs the assist when the owner approves (`lib/ai/endpoint-assist.ts` holds what both share); the proposals, with the values they found, then stay on the approval for its asker until approvals are cleaned up a day after they expire. The samples go in as JSON and the system prompt calls them untrusted data; whatever the model answers is compiled with RE2 and run on every sample (`emailFieldsProblems`, `emailFieldValues`), and only fields that find a value in each are returned, once more with feedback if none did. Nothing is saved by the assist itself: proposals go through the usual layout and field editors. Each ask takes one of `plan_entitlements.ai_assists` in a rolling 30 days, per billing account (`take_ai_assist()`, the team the endpoint is billed to, else its owner), and is given back when the model or our side fails; a use row holds who, when and how it went, never what was sent. The feature is offered only with `ANTHROPIC_API_KEY`; `AI_API_BASE_URL` points tests at a fake model. Anthropic is listed on `/subprocessors`. AI never runs over live mail. - **File drops leave from the MX host.** A destination of format `file_drop` (migration 00074, Business) writes CSV, XML or JSON files to the owner's SFTP, FTP/FTPS or S3-compatible server; the forwarding worker delivers them like Sheets rows (one claim per destination, a file per request or `claim_drop_batch()` for everything waiting once the oldest is due, the destination's delay being the batch window). Every connection, FTP data connections included, goes through `openDropSocket()` in `lib/drops/connect.ts`: in production through the SOCKS5 egress in `apps/mx-rs/src/egress.rs` (`FILE_DROP_PROXY_URL` on the web app, `MX_EGRESS_LISTEN` and `MX_EGRESS_SECRET` on the MX host, private network only), which resolves names itself and refuses private and reserved addresses (plus `MX_EGRESS_DENY`, the hosts' own public addresses), so a customer's server sees the MX host's address and never the web host's; without the proxy, drops are offered only outside production, and `MX_EGRESS_ALLOW_PRIVATE` works only in a debug build. The egress answers a CONNECT with the address it reached, and an FTP data connection goes to that address rather than the name. A drop is bounded: at most two run per process, every connection of one ends at a 4-minute deadline inside its 5-minute lease, every operation has its own timeout, and an FTP server's replies are capped, so a slow or hostile server fails its own drop and never holds the forwarding worker. The FTP client is our own so that a data connection always goes to the control host, never to the address a PASV reply names. Secrets (`drop_secret_encrypted`) are write only, read only together with the config they were saved for, and asked for again whenever the drop's server, login, pinned host key or certificate fingerprint changes, so a stolen API key cannot redirect the stored password; SFTP host keys are pinned on first use by `pin_drop_host_key()`, only while the drop still names the server that was reached. Files are written under a unique temporary name and renamed, and named after the first request, so a retry replaces its own file. The MCP server does not offer drops, so an agent never handles a server password. - **Plan changes follow what Polar allows.** Business is a plan product like Pro ($29 monthly, $290 annual); the webhook reads the plan and interval from the product id (`planForProduct()`). Polar refuses `reset` proration for this organisation, so Pro to Business monthly applies at once with `invoice` (Polar charges the difference for the rest of the period) and the webhook grants the new plan's extra requests and pages in the same proportion (`request_limit`, and `prorated_pages` for the window in `prorated_window`); the `users_end_plan_proration` trigger restores the plan's own allowance when the period moves, the cron fallback included, so a charge for part of a period never buys a whole one. Every other change is `next_period`: Polar holds it as `pending_update`, mirrored in `users.pending_plan`, and keeping the plan sends `pending_update: null`. - **Light or dark is chosen in the browser, before paint.** Account > Appearance (signed in) picks light, dark or system through next-themes (`components/providers/theme-provider.tsx`), whose inline script sets the `dark` class on `` before React loads, so a page never flashes the wrong mode. The pick is kept in localStorage under `theme`, not on the account, so it stays with the browser across sign-outs. `color-scheme` is left off on purpose: the page's own CSS draws scrollbars and controls. - **Plans are rows, not names.** `plan_entitlements` (migration 00067) holds every limit and feature that differs between plans (free, pro, business) and team kinds (team, and team_business): requests, quota period, retention, parsed pages, layouts, AI assists, domains, file drops, add-on eligibility, annual billing. SQL functions read it instead of comparing plan names, and `lib/entitlements.ts` mirrors it; an integration test compares the two, so change them together. `users.period_start`/`period_end` are the quota period everything reads; `billing_interval` and `billing_period_end` are the subscription's, so an annual plan's quota renews every month (`process_billing_period_resets()`) while its billing period runs for the year. The monthly windows are whole months counted from `billing_period_end` itself (`annual_quota_window()`, migration 00084, mirrored by `annualQuotaWindow()` in `lib/supabase/billing.ts`), never from the previous window's end, so a year from the 31st keeps the 31st through short months and its last window ends exactly when Polar renews; counting from the previous end drifted to the 28th and let the renewal reset usage again a few days after the job had. - **Retention is per account and per endpoint, deleted in small batches.** `cleanup_expired_requests()` (every 10 minutes, bounded batches through the existing indexes) deletes each account's personal rows past its plan's retention, team-billed rows past the team's, and an endpoint's rows past its own shorter `retention_days`; `cleanup_delivered_requests()` (every minute) does "deliver, then delete" (`delete_after_delivery`) once no delivery or parse is pending and the last one finished a few minutes ago (alerts read them). A downgrade keeps the old retention for 7 days (`retention_floor_days` until `retention_floor_until`). Reads hide rows past retention that the cleanup has not reached yet. - **Captured email lives in object storage when it is configured.** The receiver stores each email's exact message (`m/{request_id}/raw.eml`) and decoded attachments (`m/{request_id}/a/{i}`) in S3-compatible storage (`OBJECT_STORAGE_*`; in production Hetzner Object Storage in Falkenstein, next to the servers, with path-style URLs and region `fsn1`; presigned links point at Hetzner's own host) before calling `capture_webhook()` with the request id it chose and the prefix; a failed upload answers transient, so the MX host says "try later" and the sender retries. Such rows keep `body` as before but no `body_raw`, and `email.raw_stored` is set. A row trigger queues the prefix of every deleted request that has one (whatever deleted it), and the web app's deleter (`lib/storage/deleter.ts`) removes the objects; the bucket's lifecycle rule (expire `m/` after 100 days) is the backstop. Downloads go through the web app (`/api/requests/{id}/raw`) or presigned links, never a public bucket. - **Layouts parse an email before it is delivered.** An endpoint's `layouts` (migration 00069) pick, by the rule language, how an email is read: fields, line items, attachment parsing. `capture_webhook()` queues a `request_parses` job instead of the deliveries for an email on an endpoint with layouts; the web app's parse worker (`lib/parsing/worker.ts`) leases jobs, runs the layout with RE2 and `apps/parser-rs` for attachments, and `complete_request_parse()` queues the deliveries (after five failed tries it completes without a result, so mail is never held back for good). The parser is a separate binary that sandboxes itself (rlimits, seccomp, no network or files) because attachments are hostile input; the web image builds it and ships it at `/app/bin/webhooks-parser` (`PARSER_BIN` overrides). Each file's full parse is stored beside the email (`m/{id}/p/{i}.json`), so a replay or the preview reads it again and never charges twice; the preview never runs the parser. Pages are charged all or nothing by `take_parsed_pages()`: the allowance, then one-time credits, then overage. `parsed.lineItemColumns` exists because `jsonb` does not keep an object's key order. The plan's layout cap (`layouts_cap()`) only limits which layouts are matched; layouts past it are kept. - **Free periods are lazy.** `period_end` is unset until the first capture triggers `start_free_period()`. - **Search is bounded by time, not indexed.** Search is substring `ILIKE` over path, headers, body and an email's decoded fields, without a text index: migration 00044 dropped the trigram indexes because they made every capture write about 8x the WAL and cost about a third of capture throughput, and a 2026-11 lab run found every GIN variant on `requests` (full, prefix, per partition) still cost captures 25 to 35x the WAL. Instead `search_requests_page()` (migration 00100) walks the range newest first in slices cut on `requests_endpoint_time` (or `requests_user_time` without a slug) by rows and stored bytes, stops at the page size or its time budget, and answers how far it read with a cursor to continue (the list route keeps its array body and sends `X-Search-Complete`, `X-Search-Searched-Until` and `X-Search-Cursor`). Callers that page send `paged=true` and get 2 s (`SEARCH_BUDGET_MS`); the rest get 10 s (`LEGACY_SEARCH_BUDGET_MS`), because clients released before bounded search read one answer and stop. Inside a slice the plan is pinned to the time-ordered walk (bitmap scans and parallel workers off on the function), because the planner guesses about 50 matches for any `'%x%'` and otherwise sorts the whole range first; a pure-ASCII term matches under `COLLATE "C"`, since `lower()` under the database locale was most of the cost a row (the dotted capital I and the Kelvin sign, which the libc locale lowers to i and k, then match only themselves: a deliberate trade, since matching them cost every i or k term 40% or more). PostgREST does not cancel a statement when its client goes away, so the routes pass a cancel token and record it (`cancel_request_search()`) when the request aborts, and the function stops after its current slice. A count with a term or method is a lower bound when the budget runs out (`exact: false`); a count without either is an exact index-only count per endpoint, which relies on every row of an endpoint carrying its owner's `user_id`. `lib/search-gate.ts` caps running searches (two per account, three per process) so searches never take every core from captures. Do not add a GIN index on `body` or `headers` without measuring capture throughput; if search ever needs an index, put it on a separate table a worker fills in batches, never on `requests`. - **Capture-domain addresses never become account emails.** Mail to `mailhooks.cc` can be read through webhooks.cc itself, so it proves nothing about who holds an address. A GoTrue before-user-created hook (migration 00052, enabled in the Supabase override, see `infra/supabase/gotrue-email-auth.md`) refuses them for every signup path, and agent registration refuses them in the app (`isCaptureDomainAddress`), because admin `createUser` skips the hook. - **Email is an ordinary request.** Email capture lands mail for `{slug}@mailhooks.cc` as a request with `kind = 'email'`, through the same `capture_webhook()` call, quota, billing period and counters as HTTP. The SMTP host (`apps/mx-rs`) runs on its own box, so a public MX record never exposes the production IP; it refuses unknown, guest, over-quota and oversize mail before accepting it and calls the receiver's private, HMAC-signed mail listener, which must only ever be reachable on the private network, never through Caddy. It asks a local rspamd (`MX_RSPAMD_URL`, on the same box) for a spam verdict; like the SPF, DKIM and DMARC results it is recorded for the user and never a reason to refuse, because test mail often looks like spam, and no third-party blocklists are queried (Spamhaus's free tier is non-commercial). It passes the raw message on byte for byte and keeps the hash of every message whose outcome the sender may not have learned (a 4xx, a lost reply, a crash mid-delivery), so the receiver can tell a sender's retry apart from a deliberate re-send. Endpoints without an owner get no email, because guest captures are readable by anyone who knows the slug. A sender's retry is matched in the database by the message hash, and only deliveries the MX host marks as retries are checked, so a message sent again on purpose is captured again. `check_email_recipient()` mirrors the billing selection in `capture_webhook()`; change them together. The capture domain never sends mail. The dashboard renders email HTML only inside a sandboxed `srcdoc` iframe (no `allow-scripts`) carrying its own CSP (`lib/email-preview.ts`); a `srcdoc` document also inherits the page's CSP, which is why every page allows `img-src https:` (not only the dashboard, since a document keeps the policy of the page it was first loaded as) (`lib/csp-img-src.ts`) so "Load images" works. Never render email HTML outside that frame. - **Forwarding is a queue the web app drains.** An endpoint sends to up to 10 rows of `endpoint_destinations` (migration 00063), each with its own kinds, filter, format, templates, throttle, delay and secret. `capture_webhook()` queues a delivery row (table `email_deliveries`, which holds HTTP requests too since migration 00058; the old names stay because a rename would need compatibility views across a deploy) in the capture's own transaction for each destination that takes the kind, so a captured request is never lost before it is queued; the delay is the first `next_attempt_at`. Filters are evaluated by the worker, not in SQL, because they share the rule evaluator (`packages/sdk/src/rules.ts`) with the dashboard's preview; a turned-down delivery is settled as `skipped`, and `bypass_filter` marks a hand redelivery to one destination. The throttle is taken when queueing for a destination without a filter (no row at all for the rest) and by the worker once the filter passed for one with a filter, so turned-down requests never use up the slot. Destination URLs on the receiver's own host are refused when saved and when sent: a delivery would be captured and forwarded again, by up to 10 destinations each time. The endpoint's old `forward_*` columns and `notification_url` still read and write through `legacy_role` (`forward`, `notify`) destinations, so the API and older SDKs keep working; notifications are chat destinations (throttle 1 s, no retries) and the receiver no longer sends any. The worker runs in the web app, not the receiver (`lib/forwarding/worker.ts`, started from `instrumentation.ts`), because the email JSON is built by one function (`buildEmailJson()` in `packages/sdk/src/email/json.ts`) shared with the dashboard's JSON tab; a Rust copy would drift, and the code extraction relies on regex lookbehind Rust's `regex` lacks. Formats: an HTTP request goes out as received (`lib/forwarding/relay.ts`: the same method, the stored headers minus hop-by-hop ones, the exact bytes, `requests.query_raw`, plus `webhooks-cc-*` metadata headers signed with the forwarding secret; never `webhook-*`, which Standard Webhooks senders use themselves), an email as signed JSON, either kind as the owner's JSON template (`{{ placeholders }}` filled from the request, signed like the JSON format) or as a chat message when the URL is a Slack or Discord webhook (`lib/forwarding/chat.ts`, pinned by `lib/forwarding/chat-vectors.json`). Claims are leased and re-checked under `FOR UPDATE SKIP LOCKED`, so several processes can run it and a crash only delays a retry. Deliveries go through the notify-proxy Worker when `NOTIFY_PROXY_URL` is set (`X-Proxy-Mode: forward` for JSON, `relay` for requests as received), so deploy the Worker before a web release that depends on a change to it; without the proxy the web app applies the receiver's SSRF rules and pins DNS. - **One rule language and one placeholder syntax.** Response rules (which answer a sender gets) and forwarding filters share the conditions in `packages/sdk/src/rules.ts`; mock responses and forwarded payloads share the `{{ path | filter }}` placeholders in `packages/sdk/src/placeholders.ts`. The receiver evaluates rules and fills in mock responses in Rust (`handlers/rules.rs`, `placeholders.rs`, `values.rs`), the web app with the SDK; both run `packages/sdk/conformance/rules.json` and `placeholders.json`, so change them together and regenerate the vectors. Regular expressions are RE2 on both sides (the `regex` crate, `re2js` in `lib/rules.ts`), never JavaScript's `RegExp`: they run against whatever anyone sends, and RE2 cannot backtrack. Placeholders only fill in values, with no loops or conditions (rules choose), so rendering always finishes, and an unknown first key is sent as written so old bodies never change. - **One app serves two sites.** webhooks.cc and mailhooks.cc (the mail-first front door) are the same Next app, accounts, endpoints and billing (`lib/site.ts`). The site comes from the Host: `proxy.ts` resolves it against the two configured origins only (`NEXT_PUBLIC_APP_URL`, `NEXT_PUBLIC_MAILHOOKS_URL`, a build argument; unset means one site), passes it on in `x-whk-site`, lets mailhooks.cc answer only its own paths (the rest 308 to webhooks.cc) and keeps `app/mailhooks` off webhooks.cc; `next.config.ts` serves `app/mailhooks` at the mail host's root with host rewrites, so those pages stay static, and only the app's own layouts read the site from the request. The docs are the one set of pages both sites serve: `app/mailhooks/docs` renders the same MDX under mailhooks.cc's header and footer, so mail-first visitors are not sent to webhooks.cc to read them, while each page's canonical URL stays on webhooks.cc, the mail sitemap leaves them out, and only webhooks.cc's copy goes into the search index; the mail host's docs rewrites come after the markdown twins, so an agent gets the same twin on either host. Client components read the path through `useSitePathname()` (`lib/site-pathname.ts`), because a page prerendered under `app/mailhooks` sees `/mailhooks/...` on the server and the visitor's path in the browser. Sessions stay per host (Supabase cookies have no domain), so the header's site switcher carries a session over (`lib/site-switch.ts`): the signed-in page asks `POST /api/site-switch` with its bearer token for a one-minute grant naming the account; `/auth/switch/start`, on the site switched to, sets a nonce cookie (`__Host-` over https); `/auth/switch/issue`, on the site switched from, makes a ticket only for the account the grant names and the session cookie there holds, sealing a GoTrue magic-link token (admin `generateLink`, nothing sent; for an existing user GoTrue stores it as the recovery token, so a switch cancels a pending password-reset link and holds off another for GoTrue's minute) with the nonce's hash for two minutes; `/auth/switch/finish` checks the cookie against the ticket and verifies the token, and uses up a ticket it refuses. The grant means no other page can have a ticket made for someone by sending their browser to issue; the nonce means a ticket signs no one in from another browser. An endpoint's `site` (migration 00078: `webhooks`, `mailhooks` or `both`) only decides which dashboard lists it, never what it captures; the dashboard sends the site it creates on, and existing endpoints and those made through the API without one are `both`. Everything that hands a browser back returns to the site it started on: `/auth/confirm` sends a mailhooks.cc link opened on webhooks.cc (GoTrue's site URL) to mailhooks.cc before verifying, `resolveRedirectBase` only ever answers one of the two origins, checkouts and invites return to the request's site (`returnOrigin`), and emails sent by background jobs link to `users.home_site`, the site the person last signed in on. The OAuth server, agent identities and the MCP server keep webhooks.cc as their issuer on both. Locally, run the two sites as `webhooks.localhost` and `mailhooks.localhost`: Next turns a redirect to its own `localhost` origin into a relative one, which loops between the sites. - **Staff, support tickets and the operations center** (migration 00087). Staff are the auth user ids in `staff`, inserted by hand with psql, never in a migration and never matched by email; a trigger on `users` keeps a staff account on Business without a subscription, so a revoked subscription or the period job never moves it. `/ops` (webhooks.cc only) and every `/api/ops` route answer 404 to anyone else, accept a session only, and need two-factor sign-in (`aal2`, `lib/staff.ts`); any account with an authenticator app has its aal1 sessions refused everywhere (`lib/mfa.ts`), and `RequireAuth` (or `MfaGate` on pages that sign in on their own) asks for the code. GoTrue has no recovery codes, so the app keeps hashed ones in `mfa_recovery_codes` (`lib/mfa-recovery.ts`): a code cannot raise a session to aal2, so redeeming one removes the account's authenticators instead, as does the staff reset in `/ops`, and the owner is emailed either way. A site switch signs in with a fresh magic-link session, which GoTrue rates aal1 and cannot raise, so when the session it started from had passed the second factor the switch lists the new session in `mfa_trusted_sessions` (migration 00088), and every aal2 check accepts it (`lib/mfa-session.ts`, `session_mfa_ok()`); the row goes with its session. Tickets (`/support` on both sites) belong to the person who opened them, never their team; clients have no access to the tables, and the server never returns notes to users. Staff see an account's metadata freely but its captured contents only through a read-only view session opened from a ticket whose owner gave access (until 7 days after it closes, withdrawable), and each look is written to `support_access_log`, which the user reads under Account, Support access; there is deliberately no way to sign in as a user or write as one. Emails to users carry staff replies in full but never the user's own words; alerts to `SUPPORT_ALERT_EMAIL` carry no ticket contents. The overview reads `ops_daily_metrics`, which the web app fills hourly (`lib/ops/metrics.ts`), leaving out staff and `users.internal` accounts, never counting `requests` on page load. - **Guest endpoint creation is bot-gated.** `POST /api/go/endpoint` returns 403 for crawler or missing user agents, and the landing page only auto-creates an endpoint after a human input signal. Browsers with `navigator.webdriver` get a manual create button, so Playwright and agents must click it and tests must send a browser user agent. - **Site totals survive deletes.** `site_stats.total_webhooks` is `sum(endpoints.request_count) + deleted_webhooks`; an AFTER DELETE trigger on `endpoints` (migration 00038) moves the counts of deleted rows into `deleted_webhooks`. Do not add another accumulate step to any delete path. - **State changes leave an audit trail; webhook traffic leaves only counts.** Routes that change account, team, billing, API key, or endpoint state call `auditUserAction()` from `lib/audit.ts`, and the Polar webhook calls `auditPolarEvent()`; both write `audit_events` (kept a year) and print one `[audit]` line, and never throw. Add the call when you add such a route. Password changes never pass through a route (the browser talks to GoTrue directly), so a trigger on `auth.users` records them instead (migration 00061), with no `[audit]` line. Individual webhooks are never logged: `capture_webhook()` upserts per-endpoint daily counts into `endpoint_daily_stats`, which outlives request retention and endpoint deletion. - **Errors and logs go to StatOSS** (our own monitoring service), when `STATOSS_DSN` is set; the web app, the receiver, the MX host and the tunnel edge are one StatOSS app each. Report what is our fault and needs someone: the web app through `reportError()` in `lib/observability.ts`, the Rust services with `tracing::error!`. A customer's URL failing, a 4xx or a rate limit is not an error. One failure tends to repeat for every request, so each call site sends at most one event a minute and the rest go as log lines (`console.error`, `warn` and `info` on the web server; `info!` and up in Rust), themselves capped per minute. The clients send from a thread or queue of their own and drop when StatOSS is slow or down; a capture never waits on them. Never put request or email contents in an error or a log line; the web app also replaces email addresses with `[email]` in what it sends, and keeps the `[audit]` lines (which `audit_events` holds) out of StatOSS. Browser errors go to a fourth app (`lib/observability-browser.ts`, DSN inlined at build time): a browser DSN is public, so that app's key only accepts webhooks.cc origins at a low rate and the app may use only part of the team's monthly errors, which keeps a flood from starving the server apps. Its source maps are uploaded during the Docker build and deleted, never served. To investigate, read StatOSS yourself rather than asking for screenshots: the REST API at `https://statoss.com/api/v1` (`/issues?app=&status=all`, `/issues/` with its latest event, `/logs`) or `npx statoss logs search|tail --app `, with a read key as a bearer token or `STATOSS_API_KEY`. The apps are `webhooks-cc-web`, `webhooks-cc-receiver`, `webhooks-cc-mail`, `webhooks-cc-edge` and `webhooks-cc-browser`; an event from `onRequestError` carries the route pattern and method in its tags, and the digest when Next gave one. - **Analytics: consent in the browser, accounts on the server.** posthog-js runs in cookieless mode until the visitor accepts in the cookie banner (`cookieless_mode: "on_reject"`, opted out by default, Do Not Track and GPC count as a decline), so nothing is stored in the browser without consent and a signed-in user is identified only after it; each site keeps its own choice, and the site switcher carries it over sealed in its grant and ticket (an accept only answers an unanswered banner, a decline always applies); a signed-in browser also saves it on the account (`users.analytics_consent`, migration 00082, `lib/analytics-account-consent.ts`) and is answered from it by the same rules, the banner waiting for that answer, an accept made before signing in is saved only when made in the same tab, since a remembered one may be someone else's on a shared computer, a decline that only Do Not Track or GPC made is never saved, and a choice is kept in the browser from the click until the account has it, then sent again on a later load unless the account was told something since (`analytics_consent_at` moves on every save, compared on the server's clock); the PostHog project's cookieless tracking setting must stay on or those events are dropped. `before_send` (`lib/analytics-redact.ts`) strips share tokens, fragments and every query parameter not on its list from every URL, because slugs, Polar session tokens and auth codes ride in them. Account analytics come from the server (`lib/analytics-server.ts`, on in production builds): every audit event is mirrored, so the API, CLI, SDKs, MCP and agents are counted without call sites of their own; applied Polar webhooks become `order_paid` (`revenue` in cents, `subscription_id`) and subscription events; and `lib/analytics-reporter.ts` sends hourly signups and a daily usage snapshot per account from `endpoint_daily_stats` (migration 00080). Never send an event per capture, and never put contents, URLs, email addresses or secrets in properties; accounts are named by id only. Dashboard components that show captured content or endpoint names carry `ph-no-capture`, so add it to new ones. /privacy promises the data stays with us and is never sold or shared; keep it true. - **Outbound deliveries hide the origin IP** by going through the Cloudflare Worker when `NOTIFY_PROXY_URL` is set; otherwise the web app delivers directly with SSRF-safe DNS pinning. - **Email/password auth is GoTrue configuration, not app code.** SMTP, minimum password length 8, and the template URLs live in the Supabase instance's `docker-compose.override.yml`; see `infra/supabase/gotrue-email-auth.md`. Email links carry a `token_hash` to `/auth/confirm`, which verifies server-side. If GoTrue could not fetch the templates at startup it keeps serving its defaults until the auth container restarts. - **Our own mail goes straight to Amazon SES over SMTP.** The web app (`sendEmail()` in `lib/email/mailer.ts`, the only way it sends) and GoTrue both send from `noreply@webhooks.cc` through SES's SMTP endpoint with one IAM user, not through the API: GoTrue can only speak SMTP, and at our volume the API's extras are all reachable over SMTP, so one credential and one path serve both. Every message lands in the `webhooks-cc-app` configuration set, the app's by header and GoTrue's through the address identity's default (`infra/supabase/gotrue-email-auth.md`), and each app message carries its `kind` as a message tag. There is no queue and no fallback transport: a failed send throws, and the caller retries (the pollers), shows the error, or reports it; the transport never retries, because the welcome email must not go out twice. The SES side and its event destinations are not in this repository. - **Agent registration follows auth.md v0.6.** An agent registers, gets a signed identity assertion, and exchanges it for short-lived access tokens that are ordinary `whcc_` rows in `api_keys`, so the bearer path and every route accept them unchanged; each exchange reads the registration again, so revoking it ends everything at once. Pre-claim tokens have no user: every consumer of an API key must tolerate a null user, and the routes that need one refuse them, which confines them to the sandbox. Sandbox endpoints belong to the registration, never to a user, so guest reads and the guest claim must keep skipping them; they have their own pool, budget and billing key in `capture_webhook()`. A claim (the human signs in and types the agent's code; the email the agent named decides who may) adopts them into the human's account in one transaction, deletes the pre-claim tokens, and lets the agent collect account tokens once with its claim token; claimed agent tokens are capped as connected agents, not counted as API keys, and never reach session-only routes. The proof of work on anonymous registration only raises the cost of scripted abuse; the rate limits, the live cap and the pool are the real bounds. `AGENT_IDJAG_PROVIDERS` stays empty in production until a real ID-JAG issuer exists. An ID-JAG identity `(iss, sub)` never binds silently to an existing account: without a delegation on file, an account with its verified email gets `interaction_required` and the same claim ceremony (`link_agent_idjag_identity()`, migration 00057), only an account created for the email in that request links at once, `auth_time` must be recent on every presentation, and a provider's Security Event Token at `/api/agent/event/notify` revokes the delegation with its tokens. The protocol itself is documented in `/auth.md` (`lib/agent/metadata.ts`). - **The pytest fixtures have one home, the Python SDK.** `webhooks_cc` and `webhooks_cc_endpoint` are defined in `packages/sdk-python` (`webhooks_cc/pytest_plugin.py`) and registered by the SDK's own `pytest11` entry point, named `webhooks_cc`, because projects that install only `webhooks-cc` rely on them. `pytest-webhooks-cc` (`packages/pytest-webhooks-cc`) exists so the plugin is found where pytest plugins are looked for (the plugin list on docs.pytest.org is generated from PyPI projects named `pytest-*`, and reads the summary, the status classifier and the `pytest` requirement): it depends on the SDK, defines no fixtures, and has an entry point of its own under another name (`pytest_webhooks_cc`). Never point a second entry point at the SDK's module or list it in `pytest_plugins`: pluggy refuses one module under two names, and every run would fail. The plugin registers the SDK's module itself only when pytest did not (`PYTEST_DISABLE_PLUGIN_AUTOLOAD`), under the SDK's name, so `-p no:webhooks_cc` still turns the fixtures off. Add fixtures to the SDK, and to the plugin only what is pytest's alone (report lines, options). - **Agents keep their registrations in one shared file.** The MCP server, the TypeScript, Python and Go SDKs and the CLI read and write `~/.config/whk/agent.json` (XDG, macOS and Windows rules, `WHK_AGENT_FILE` override; the CLI's `token.json` lives beside it), so a connection a human approved once survives restarts and works from every tool. The format and the rules (live slots, the cached access token, atomic 0600 writes, what `invalid_grant` removes) are in `packages/sdk/conformance/agent-credentials.md`, and every implementation runs `agent-credentials.json`; change them together. The claim token is never written, as `/auth.md` asks, so only the process that registered a sandbox can move its endpoints into an account; a later one connects the human through a service_auth registration. An explicit API key always wins over the file. - **The remote MCP server is the stdio server's tools behind OAuth.** `/mcp` (`app/mcp/route.ts`, `lib/mcp/hosted.ts`) runs `registerTools()` from `@webhooks-cc/mcp/tools` on the v2 MCP SDK (`@modelcontextprotocol/server`), statelessly, for the 2026-07-28 revision and the 2025 fallback; the stdio server stays on the v1 SDK, so add tools in `packages/mcp/src/tools.ts` once for both. Tools act through the SDK over loopback (`MCP_INTERNAL_API_URL`, default `http://127.0.0.1:$PORT`) with the caller's own token and address, so the API routes authenticate, rate-limit and audit as for any client; tools that post to a URL the caller names (`send_to`, `replay_request`, `targetUrl`) are left out because they would run from the box's network, the sends that remain (`send_webhook` to a capture URL) leave through the notify proxy so a capture never shows the box's IP, and waits are capped (50 s, polls 1 s apart at least, 8 tool calls running at once per account, counted around the handler because a 2025-era client's answer streams back before the tool finishes; no JSON-RPC batches). The OAuth server (`lib/oauth/`, migration 00066) is authorization code with PKCE for public clients only: Client ID Metadata Documents (fetched through the notify proxy like forwarding, so the box's IP stays hidden; the proxy returns 1 KB, the limit for a document) and dynamic client registration. A grant is an agent registration of kind `oauth`, claimed at once, so it shares Connected agents, the cap and disconnecting with claimed agents; its access tokens are `whcc_` rows and its refresh token rotates (a rotated token presented again within a minute gets a new pair and supersedes the one it lost; later, or a superseded token at any time, revokes the grant). Client ids and redirect URIs on our own hosts are refused (anyone can serve a document from a capture URL). The consent page posts with the session's bearer token, never a cookie, navigates to the redirect itself (`form-action 'self'` would block a redirect after a form post), and only after the human clicks: a refused request is shown, never redirected, since a dynamically registered redirect URI proves nothing. Scopes are advisory, as for every agent token. - **Tunnels: `whk share` previews and `whk tunnel --live` go through our own edge** (`apps/edge-rs`, migration 00091, `lib/tunnels/`). The CLI opens one WebSocket to the edge (`wss://tunnel.webhooks.cc`, kept off the preview domain so a blocklisted preview domain never cuts paying users off) carrying yamux streams (`crates/tunnel`), and each visitor or relayed capture is a stream the edge opens down it. The edge holds no account logic: it asks the web app over `/api/edge/*`, authenticated with `EDGE_SECRET`, whether a share may open, which password version applies, and which shares have ended, and hands over usage in numbered batches applied once (`tunnel_apply_usage_batch()`); the web app calls the edge's control port back for stops, password changes and request logs. The control and relay ports are for the private network only, never routed by Caddy. Previews live on sharehooks.cc, a domain of their own, so a flagged preview never lands on a webhooks.cc host; free previews get random names ending in `-free`, which is a label for rules and recognition, not isolation (a paid abuser can get the main domain listed too), and reserved names can never end in `-free`. What protects the domain is speed of takedown: the visitor notice on Free and for 7 days after `users.paid_since` (a stolen card buys a working link at once), the Report button the edge adds to every page, phishing flags from a password or card field next to a brand word, kill switches and name blocklists. The brand and lure words load from `EDGE_WORDS_FILE`, kept private on the edge box (the repository ships only `words.example.json`), so abusers cannot read what is checked; a release build refuses to start without it. Previews serve web pages only (an allowlist of media types, 50 MB a response) and never Werkzeug's debugger console. Limits are `plan_entitlements` rows (`share_slots`, `share_session_minutes`, `share_data_gb`, `reserved_names`, `share_passwords`), per person, the best of the user's own plan and their subscribed teams'; data is what passes through a share, both ways, counted per UTC calendar month (Free's request period is daily and lazy, so data does not follow it). A live capture is captured first, with `capture_webhook()` still one call, and only then relayed through the edge's relay port to the CLI, so the request is kept whatever the local app does: no session is 502, no answer head in 25 seconds 504, and the app's answer is stored in `requests.live_response` (64 KB of body). Live traffic counts against the request quota, not shared data. The `/share` pages and their framework guides (`apps/web/lib/share-framework-pages.ts`) describe what a dev server sees through a preview: `Host` as `localhost:`, the public name in `X-Forwarded-Host`, a WebSocket's `Origin` as the local origin, and redirects to `http://localhost:` sent back as the link. Each guide's advice follows from that (most dev servers need no allowed-hosts entry here, unlike with other tunnels), so change the guides with the edge whenever that request shape changes, and take framework facts only from the framework's own documentation for its current version. - **Share links are frozen copies.** `/s/{token}` shows a snapshot taken when the link was made (`request_shares`, migration 00062), with credential headers always removed and signature and token headers removed by default, so a link keeps working after retention deletes the request and never shows more than it did. Shares cascade from their endpoint and account but deliberately have no foreign key to `requests`: it would add a cascade trigger to every delete on that table. Only the endpoint's owner can share, because a team member's links would outlive their membership out of the owner's sight; the account's Share links page lists every live link, since a link outlives the request whose dialog made it. Listing and revoking take any key of the account; creating one with an API key or agent token is an approval (`request_shares.create`), because a link publishes data to anyone who holds it. Analytics and error reports never see a token: PostHog's `before_send` and the StatOSS redaction both rewrite `/s/{token}`, and elements showing a token carry `ph-no-capture` so session recordings skip them. The page is `noindex` and its unfurl metadata never includes the request. - **The free tools run in the browser and have no server.** `/tools` (webhooks.cc only; `lib/tools/catalog.ts` is the one list behind the index, sitemap, images and policy) holds a Discord webhook tester, a Slack incoming webhook tester and a webhook signature checker. A webhook URL and a signing secret are credentials, so there is no API route behind any of them and there must never be one: the testers call Discord and Slack from the browser, the checker makes no request, no control has a `name` (a native submit would put it in the address), nothing is stored, and controls and results carry `ph-no-capture`. Each sender page gets its one provider origin in `connect-src` on its own path only (`lib/tools/csp.ts`, read by `proxy.ts`): an origin anyone can make a webhook on is also somewhere an injected script could send data, so no other page allows it. A document keeps the policy it was loaded with, so links to those pages are plain anchors (`ToolLink`) and a page reached by a client-side navigation reloads itself once (`lib/tools/own-document.ts`). The StatOSS and PostHog redaction rewrite Discord and Slack webhook URLs wherever they appear (`redactWebhookUrls`), so a breadcrumb or an error message cannot carry one. Slack's webhook host refuses the CORS preflight a JSON request needs, so that tester sends the JSON as the form field `payload`, which needs none and which Slack accepts though its current documentation no longer describes it, and says so on the page with the documented request as a curl command; if the browser cannot read the answer it says that instead of guessing. The signature checker's provider schemes are the SDK's `verifySignature` and `buildTemplateSendOptions`, never a copy. A provider page with a tool for the other direction (`seo.tool`: Discord, Slack) links to it and leaves the "tester" searches to the tool page: its title, heading and keywords do not use the word. The tools are pages of their own; a widget on the provider pages was turned down. - **The agent skill tells agents when to use the MCP tools.** `plugins/webhooks-cc/skills/webhook-testing/SKILL.md` is the one copy: Claude Code installs it with the MCP server as a plugin, and other agents through `npx skills add kroqdotdev/webhooks-cc`, which finds it through the marketplace file. An MCP test fails when the skill names a tool the server no longer has, so rename tools in both places. The MCP release workflow also publishes `packages/mcp/server.json` to the official MCP Registry under `io.github.kroqdotdev/` (GitHub OIDC, no secret); a test keeps its version equal to the package's. - **Adding a webhook provider** touches the SDK catalog, the web editorial data in `apps/web/lib/webhook-provider-pages.ts`, the pinned counts in the MCP tests, the SDK conformance vectors, the Python SDK's `templates.py` and `verify.py` (its tests compare the provider lists with `templates.ts`), the Go SDK's `internal/templates` and `verify` packages (the same check), and a migration that extends the `check_signing_config` CHECK constraint on `endpoints`. The constraint is the one people forget; the signature-verification integration test is what catches it. - **The TypeScript SDK is the reference for every SDK.** `packages/sdk/src/__tests__/conformance.test.ts` writes JSON vectors (signing, verification, forwarding signatures, matchers, durations) to `packages/sdk/conformance/`, and SDKs in other languages run them in their own CI, so their behaviour cannot drift; the Python and Go SDKs (`packages/sdk-python`, `packages/sdk-go`) read them straight from that directory. Go takes `time.Duration` rather than duration strings, so it skips `durations.json`. The test fails when a file is stale: after a deliberate change to signing or verification, regenerate with `pnpm --filter @webhooks-cc/sdk exec vitest run conformance -u` and port the change. Email codes and links are computed only in TypeScript and served by the request routes on `extracts=true` (opt-in, because up to 20 links can break the MCP server's output budget), so ports never reimplement the extraction; the same goes for the reply text and an endpoint's named email fields (`emailFields`), whose owner-written patterns run only on the server with RE2 (`lib/email-fields.ts`; the dashboard asks the server rather than ship RE2 to browsers). The inbound provider shapes (Postmark, SendGrid, Mailgun, CloudMailin) are built by the SDK (`packages/sdk/src/email/inbound.ts`) and served to the CLI's tunnel and the other SDKs by `POST /api/requests/{id}/render`. ## Environment Env vars are validated with zod in `apps/web/lib/env.ts` and loaded in `apps/receiver-rs/src/config.rs` and `apps/mx-rs/src/config.rs`; read those for the current list and defaults. `.env.example` documents the required set. Secrets live only in `.env.local`, which is gitignored. Three that trip people up: - `DATABASE_URL` (receiver) is the session pooler in development and a direct same-host connection in production. Keep `PG_POOL_MAX` small (about 20) whenever it bypasses a pooler: every capture updates the same user and endpoint rows, and hundreds of direct connections only queue on those row locks. For the same reason the receiver caps concurrent captures per billing account (`CAPTURE_MAX_INFLIGHT_PER_ACCOUNT`, default 4, keyed by the `billing_key` that `capture_webhook()` returns): without it one flooded account fills the pool and every other account waits. `SUPABASE_DB_URL` is the direct connection used for migrations. - `SIGNING_SECRET_KEY` (AES-256-GCM, base64, 32 bytes) is needed by both the receiver and the web app once signature verification is configured. Generate with `openssl rand -base64 32`. - Polar, SMTP, StatOSS, and Redis are optional in development. ## Conventions - **Branch and PR for every change.** `main` requires linear history, signed commits, and a PR; only squash or rebase merges are allowed. Commits are GPG-signed locally. - **Reviews.** CodeRabbit and CodeQL comment on every PR (Codex too, when its subscription is active; it is off for now); address their findings before merging. CodeRabbit reviews only when asked (`@coderabbitai review`), about once an hour. CI runs lint, typecheck, the web build, the SDK, MCP, and web unit suites, and the Rust build, test, and clippy jobs. Web integration tests and Playwright do not run in CI, so run them locally when you touch the web app. - **Outside contributions need the CLA.** The code is open for inspection, not as a community project: a pull request from anyone else is merged only once the CLA check (`.github/workflows/cla.yml`, terms in `CLA.md`) passes, and is otherwise closed or reimplemented. Signatures live on the `cla-signatures` branch; never delete it. The README and site describe the code as open source and do not offer self-hosting; keep it that way. - **Fake secrets stay in test paths.** Push protection is on. Test fixtures that look like real credentials (Stripe-style `whsec_` secrets, signed vectors) belong under the paths in `.github/secret_scanning.yml`, which scanning skips; add a path there rather than bypassing a block, and never put a fixture like that in production code. - **A release label and a changelog file on every PR; the web version is given at deploy.** Every pull request carries one label: `release: patch` (fixes and small features), `release: minor` (significant features), `release: major` (reserved for 1.0), or `release: none` (nothing shipped to the web app, receiver or MX host: SDK-only, CI, tooling). Unless it is `none`, it adds its own changelog file to `apps/web/changelog/web/` (a title and items, never a version; see the README there). The required `Release label` check enforces both. Never write a web version in a PR or a commit title: a shared version line made every pair of open PRs conflict. The ops repository's Deploy workflow runs `apps/web/scripts/release.mjs plan`, which takes the newest `web-v*` tag the deployed commit contains, applies the highest bump among the PRs merged since, and writes `apps/web/lib/changelog-release.json` for the build (in the repository it holds no version, so local and CI builds report `dev`; never commit a generated copy); a production deploy then tags the commit `web-vX.Y.Z` with the release GitHub App. A dry run is `X.Y.Z-dryrun` and is not tagged, deploying a tag again keeps its version, and an untagged commit behind the newest tag is refused (roll back by deploying a tag). Tag rulesets let only admins and the release app (`webhooks-cc-release`) create `web-v*` tags, and only admins move or delete them. Web entries up to 0.65.1 were numbered by hand and stay in `changelog.ts`. CLI, SDK, and MCP releases bump `CLI_VERSION`, `SDK_VERSION`, or `MCP_VERSION` and add an entry on their own track when the tag is cut (`v*`, `sdk-v*`, `mcp-v*`). A unit test keeps the SDK and MCP constants in sync with the package versions. The Python SDK's version lives in `packages/sdk-python/pyproject.toml` and `src/webhooks_cc/__init__.py`; a `sdk-python-v*` tag publishes it to PyPI through trusted publishing (no token), and the workflow refuses a tag that does not match the version. The pytest plugin's version lives in `packages/pytest-webhooks-cc/pyproject.toml` and `src/pytest_webhooks_cc/__init__.py`, and a `pytest-plugin-v*` tag publishes it the same way; it depends on the SDK from PyPI, so publish the SDK first when the plugin needs something new from it. The Go SDK's version is `Version` in `packages/sdk-go/doc.go`; Go modules are published by their tags, so a release is the tag `packages/sdk-go/v` (the module sits in a subdirectory), pushed after the web release whose `/go` route serves the `go-import` tag; the release workflow checks the tag against `Version` and has the module proxy fetch it. - **Tests live next to the code they cover.** Unit tests as `*.test.ts` beside the source, web integration suites in `apps/web/tests/integration/`, Rust tests in each crate. Scratch verification scripts stay out of the repo. - **Design system.** One look: paper and ink (a cool paper canvas, white surfaces edged by hairlines rather than drop shadows, one pine green for buttons, links and selection, IBM Plex Sans with IBM Plex Mono), in light and dark mode. Every page gets its header from `components/nav/site-header.tsx`, anchored at the top, with page context passed as children. Colours are CSS variables in `apps/web/app/globals.css` (values in the base layer, mapped to Tailwind in `@theme`); write plain Tailwind against them (`text-primary`, `bg-selected`, `text-destructive-ink`). A plain `border` already takes the hairline colour; add `border-border` only to override a primitive's border colour through `cn()`. Shadows are `shadow-surface` at most on a resting surface and `shadow-float` or `shadow-float-lg` on floating layers, all tinted to the ink. The shared looks are components in `components/ui`: `Card` (`interactive` darkens its hairline on hover, as a card that is a link does), `Button` (control variants for forms and dialogs, `raised` for their lifted actions, and the padded surface variants `primary`, `surface`, `hairline` and `soft` used across the dashboard and site; `surface` is not the control `outline`), `CodeBox` and `InlineCode`, `FieldControl` (the field look on an input, or any control through `render`), and `field` on `Input`, `Textarea` and `SelectTrigger` (which keeps the control look and only adds the field's transition); `CodeInput` is the one-time code field (input-otp) and toasts go through Sonner's `toast.custom` with the app's own look (its Toaster is mounted only in `app/dashboard/layout.tsx`, so mount it where a new toast is needed). `Card`, `CodeBox`, `FieldControl` and `Button` take a `render` element for another tag. `CodeInput` draws its digits over input-otp's invisible input: a click puts the caret after the last digit, or selects the last digit once the code is complete, and a single digit cannot be clicked into. Their `ui-*` rules stay in `globals.css`'s components layer so a call site's utilities always win over them, and `lib/retired-classes.test.ts` fails on a `ui-*` look class outside `components/ui` (and on the classic style's old utilities). The primitives in `components/ui` wrap Base UI (`@base-ui/react`): dialogs, alert dialogs, menus (`look="list"` for the dashboard's bordered list menus), the context menu, selects and the slider are its parts with our classes; tabs, collapsibles, the accordion, radio groups, the switch, the popover, the meter and a dialog drawn its own way (`DialogPortal`, `DialogBackdrop`, `DialogPopup`) are its unstyled parts (a tab bar draws its tabs with `data-active:` classes, a chevron turns with `group-data-panel-open:`), so build a new tab bar, show-and-hide panel, pick-one choice (a segmented control too) or usage bar on them, not by hand, and import Base UI only inside `components/ui`; a `RadioGroup` reports changes through `onValueChange`, never a radio's `onClick`, and leaves a hidden input beside each radio, so space its radios with `gap-*`; `lib/retired-classes.test.ts` fails on a hand-written widget role (`role="radio"`, `"menu"`, `"dialog"`, `aria-modal` and the like) outside `components/ui`, the cookie banner (a non-modal notice with no behaviour of its own) being the one file allowed; the Copy and Saved replies list menus are not modal (`modal={false}`), so a click outside one still does what it clicks, as the menus they replaced did, while the context menu is modal like the browser's own (Base UI allows nothing else); and `Button` takes base-ui's `render` prop to draw as a link (`