# Changelog All notable changes to Webyz are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow semantic versioning. Breaking changes to the tracker script, the HTTP API or the self-hosting configuration are called out in their own subsection so operators can scan for them before upgrading. ## Unreleased ### Changed - Plan allowances raised: Free 10,000 to 20,000 events a month, Starter 100,000 to 250,000, Growth 500,000 to 1,000,000, Business 2,000,000 to 5,000,000 (`catalog/plans.config.ts`, seeded into `plans` on every deploy). Overage prices per 1,000 events are unchanged. An OPEN usage period takes the new allowance at the next hourly usage sync, so a site paused under the old Free limit resumes within the hour and overage already counted this period is recomputed against the new allowance; CLOSED periods are not touched. - Imported data under filters, Plausible's rule: `imported_stats` now keeps each imported row's attributes together per report family (a source with its UTM fields, a country with its region and city), and a filter keeps imported days whenever it and the report belong to one family ("source is Google" on campaigns, "country is India" on regions or the totals). Across two families imported days are left out and `meta.imported` says `excluded_filters`; before, any filter dropped them. Imported page and event rows no longer change bounce rate or visit duration (they add visits but no bounces or seconds), and Plausible entry-page bounces are no longer folded into pages. Migrations 013 and 014 were merged into a new 013 before release. - Browser and OS versions are labelled major.minor everywhere (`Chrome 128.0`, not `Chrome 128.0.0.0`; `macOS 10.15`, not `macOS 10.15.7`), so tracked rows and rows imported from GA4 (full versions) and Plausible (major.minor) fall on one row. Tracked sessions are cut when read (`VERSION_SQL` in `db/clickhouse/filters.ts`, `shortVersion` in `utils/version.ts`, tested to agree), so history changes with it and nothing is rewritten. A `browser_version` or `os_version` filter with a longer version, as in an older link or saved segment, is cut the same way and still matches. CSV exports of the version breakdowns change accordingly. ### Fixed - Marketing site: every page declared the homepage as its canonical URL (inherited from the root layout), so Google indexed only the homepage and treated pricing, docs and the legal pages as duplicates of it. Each page now declares its own canonical; the blog and changelog already did. - Channels: a referrer was matched against the social network and search engine lists by its first label only, so Facebook's and Instagram's link redirects (`lm.facebook.com`, `l.instagram.com`), `m.facebook.com`, `out.reddit.com` and `images.google.com` were filed as Referral. Hosts are now matched by every parent domain and by the registrable domain's name (`google.co.in` is Google), never by a label of someone else's subdomain (`x.example.com` stays Referral). Applies to visits from now on and to imports; sessions already stored keep their channel. Found by importing a real Plausible export. - CSV import: Plausible lists its own `engagement` (and older `pageleave`) events among custom events in its export; they are no longer imported as custom events. - The note under the overview named Google Analytics for any imported data; it now says imported history whatever the source. - Weekly and monthly graphs: bucket labels started on the window's first day while ClickHouse buckets start on Sunday and on the 1st, so a weekly graph of any period not starting on a Sunday (and a monthly one starting mid-month) showed zero everywhere. Labels now name the buckets ClickHouse fills; the first week can begin before the period does. ### Added - Export data (site settings, Imports & exports): the site's whole tracked history as one zip of CSVs in the import format (`POST /websites/:siteId/exports`), Plausible's file names and columns, so it imports back into Webyz or into Plausible as it is. As in Plausible, **Export to CSV** prepares the zip in the background (a dependency-free stored-zip writer, `utils/zip-stream.ts`), emails the requester when it is ready, and keeps it for seven days in `EXPORTS_DIR` (the new `exports` volume in `infra/docker-compose.yml`) for download or deletion; the `site-imports` job deletes expired files. New Postgres table `site_exports` (migration `site_exports`). Any plan; cut to retention. Regions go out as ISO 3166-2 codes and cities as GeoNames ids, looked up in the GeoLite2 database; a region it cannot code goes out as its name, which the importer accepts. Verified by exporting a site and importing the zip into an empty one: every report matched. - The Imports & exports settings page follows Plausible's layout: an Import data card with an empty state and Import from Google Analytics / Import from CSV opening their flows in dialogs, and an Export data card. - Up to five imports per site, Google Analytics and CSV mixed, as long as their dates do not overlap (409 `IMPORT_LIMIT`, `IMPORT_OVERLAP`), as in Plausible. The Import data screen lists every import with its own delete, and the CSV option has drag and drop, the list of expected files with the ones found ticked, and a link to the format guide. - Scroll depth and time on page in the Pages report and on a page's detail, sortable, from the tracker's engagement reports (Plausible's definitions: the average of each visit's deepest scroll on the page; engaged seconds per visit that reported engagement). Webyz collected these since engagement tracking shipped but showed them nowhere. - The Pages report and page detail include imported history: views, visits, visitors, entry and exit counts, trend, and imported scroll depth and time on page from Plausible exports. - Importing guide (`docs/importing.md`, published at `/docs/importing`): both imports and the export, what comes across, what does not and why, how imported days merge, and the self-hosting setup. - CSV import (site settings, Import data, Import from CSV) in Plausible's import format, for a Plausible export or any other tool's data (format in `docs/importing.md`): pick or drag in the CSV files or the zip they came in; the dashboard unpacks a zip in the browser and uploads each CSV in chunks of 240,000 characters, which the API maps into `imported_stats` as they arrive (`POST /websites/:siteId/imports/csv`, `PUT .../imports/:importId/chunks`, `POST .../complete`). Every report Plausible exports is used: visit totals, sources and UTM (channels from Webyz's rules), pages with scroll depth and time on page, entry and exit pages, custom events including outbound links and file downloads, countries, regions and cities (named from Webyz's own GeoLite2 database, so they match tracked rows), devices, browsers and operating systems. Custom properties are stored for a later report. The page engagement columns live in `imported_stats` (migration 013); Postgres migration `site_import_chunks` records received chunks so a retried upload is not counted twice. Self-hosters behind the example nginx config need its new imports location, which allows 2 MB request bodies; the bundled Caddy gets a matching route in `infra/caddy/Caddyfile`, because its 64 KB API body cap refused every chunk. - Google Analytics 4 import (site settings, Import data): connect a Google account with read-only Analytics access, pick a property and an optional range, and a background job (`site-imports`, every minute, resumable after a restart) copies the property's daily aggregates into the new ClickHouse `imported_stats` table (migration 013), tracked in the new Postgres `site_imports` table. Imported: visit totals, channels and sources (classified with Webyz's own channel rules), UTM fields, pages, entry pages, page titles, hostnames, countries, regions, cities, devices, browsers and operating systems with versions, screen sizes, languages, custom events, outbound links, file downloads and site search terms. The range ends the day before the site's first tracked visit, so days are never counted twice. The dashboard, conversions, custom events, outbound reports and CSV exports add imported days to their numbers unless a filter applies (`meta.imported` says which; `with_imported=false` turns it off). Left out on purpose and listed on the import screen: new vs returning visitors and cohorts (Webyz does not recognise a visitor across days), age, gender and interests (Google's ad profiling) and Google Ads dimensions; GA4 has no exit pages. The Google grant is encrypted, held only while the job runs, and revoked when it ends. Needs `GA4_IMPORT_REDIRECT_URI` registered on the Google OAuth client (defaults from `GOOGLE_REDIRECT_URI`) and the Google Analytics Admin and Data APIs enabled. Deleting a site or an account deletes its imported data. - Page titles, hostnames and site search terms: three new tabs on the Contents card (`titles`, `hostnames`, `search-terms` breakdowns, CSV datasets of the same names) with `title`, `hostname` and `search_term` filters. Search terms are read from page URLs (`q`, `s`, `search`, `query`, `keyword`, `keywords`, `term`), so no tracker change is needed. - AI Assistants channel: visits from ChatGPT, Perplexity, Claude, Gemini, Copilot and other assistants (`ingest/helpers/ai-assistants.ts`), matched on the referrer host or on the `utm_source` an assistant appends (ChatGPT's `utm_source=chatgpt.com`), are their own channel instead of Referral or Other Campaign. A paid or declared medium still wins. Sessions recorded before this are regrouped when read, so the channel covers history, and `f.channel=AI Assistants` filters to it. The live visitor list shows the stored channel, so a session already running before the upgrade still reads Referral there. - A custom event now counts as engagement with the visit, as in Plausible and Umami: it ends the bounce and extends the visit duration. Bounce rate everywhere is "one pageview and no custom event" (sessions carry `events`, so rows from before are unchanged). - Session writes are serialised per session (a short Redis lock), so a route change's engagement report and next pageview can no longer overwrite each other's update to the session row. - Engagement tracking: the script reports how long a page was visible and how far it was scrolled when the tab is hidden, loses focus or is left. Visit duration now runs to the last engagement report, so single-page visits are no longer 0 s (`sessions.engaged_seconds`, `sessions.scroll_depth`, new `engagements` table; ClickHouse migrations 010 and 011). Engagement reports are not billed. - Filtered traffic on the dashboard: the ingest filters count every drop per site and reason (`dropped_events`, migration 012; `GET /:siteId/filtered-traffic`) and the overview shows the total for the period with the split on hover. - Bot filtering that catches scripted browsers: the user-agent check now uses the `ua-parser-js` bot database (crawlers, link previewers, CLIs, HTTP libraries) plus headless Chrome, replacing a regex list that also dropped real DuckDuckGo browser users, and a new data-centre address filter (`BOT_DATACENTER_FILTER`, on by default) drops events from known cloud, hosting and VPN ranges fetched daily from `DATACENTER_IP_LISTS` into the geo volume, with Apple's published iCloud Private Relay ranges carved out (`DATACENTER_IP_ALLOWLISTS`) so iPhone users behind the relay are not mistaken for servers. Two further filters: referrer spam against the Matomo community list (`REFERRER_SPAM_LISTS`), and a behavioural filter (`BOT_CLUSTER_FILTER`) that flags groups of single-pageview visits sharing one screen, browser and language with no engagement from any of them, and drops them for 24 hours; a site is judged only once 30 of its sessions in the hour carry engagement. The API image carries a build-time copy of every list (`lists-seed/`) so a fresh install filters from its first request. Visitors on commercial VPNs are dropped too; turn the filter off if that matters more than the bots. - Browser logos for the in-app and Android OEM browsers that were showing a placeholder: Facebook, Instagram, X (Twitter), TikTok, WeChat, the Google app (GSA), Vivo, HeyTap (OPPO), MIUI, Huawei, UC, Yandex, Vivaldi, DuckDuckGo, Ecosia, WebKit and the Android browser, and the Chrome OS platform. - Filter operators: every dashboard filter takes `is`, `is not`, `contains` or `does not contain` (`f.page=!~/admin` on the API), plus new `event` and `goal` filters and `screen`/`language` dimensions. Sessions now store screen size and browser language (ClickHouse migration 009); rows from before show as empty. - Custom event properties: clicking a custom event on the dashboard filters to it and opens a Properties tab (`GET /:siteId/custom-events/properties`) listing keys and, per key, values by visitors. - Tracker: `data-hash-routing`, `data-outbound-links`, `data-file-downloads` (`data-file-types`) and `data-track-404` (with ``) send the well-known `Outbound Link: Click`, `File Download` and `404` events with no code. - Outbound links report: a dashboard card of where visitors leave to, grouped by destination domain (drilling into that domain's URLs) and by the page clicked from, with visitors, clicks and click-through rate (`GET /:siteId/outbound-links?by=domain|url|page`), on shared dashboards too, and an Outbound page (`/sites/:domain/outbound`) with totals against the previous period, a trend (`outbound-links/summary`, `outbound-links/timeseries`), the full lists and, under a destination filter, the sources, countries, devices and browsers of the sessions that clicked it. The three endpoints also read the tracker's `File Download` event with `kind=download` (plus a `type` grouping by file extension), which feeds the download CSVs and the `download` filter; there is no downloads page on purpose. New `outbound_domain`, `outbound_url` and `download` filters, usable in segments; CSV datasets `outbound-domains`, `outbound-links`, `outbound-pages`, `download-files`, `download-types`, `download-pages`. The install snippet gained tick boxes that write the `data-outbound-links` (ticked by default), `data-file-downloads` and `data-track-404` attributes. - Tracker: outbound clicks carry the link's label as `text`, destinations are stored without the fragment (and without the query with `data-outbound-strip-query`), `data-outbound-ignore` lists hosts and `*.` subdomain wildcards to treat as internal, a repeat click on the same link within 500 ms is counted once, downloads carry `text` too, and a link click never holds navigation longer than 300 ms (the beacon is sent with `keepalive`), for `data-analytics-event` links too. - Saved segments: name a set of filters per site and apply it from the dashboard header, on shared dashboards too. - Password-protected share links (`PUT /websites/:siteId/share/password`, `POST /shared/:slug/unlock`, `X-Share-Token`) and an embed mode for the public dashboard (`?embed=true&theme=system&background=...`) with an iframe snippet in Visibility settings. - Team members per site: invite by email as admin or viewer, accept at `/invitations/:token` with the invited address; seats come from the owner's plan (`team_members`), pending invitations hold one; sites shared with you appear in your list with the role. Admins manage everything but deletion; viewers read everything including realtime and exports. Plan features on a site (funnels, Search Console) are judged by the site owner's plan. - Email reports (weekly on Monday morning, monthly on the 1st, in the site's timezone) and traffic spike alerts, per site under Settings, Notifications. - Cookieless visitor identity: a daily-rotating server-side hash replaces the one-year `_webyz` cookie. The tracker no longer writes anything to the browser except the opt-out flag. - CSV export of every breakdown and the time series (`GET /api/v1/:siteId/export`). - Read-only personal API keys with bearer authentication, managed at `/settings/api-keys`. - Account self-service: JSON export of everything the account holds, change password, and account deletion that cancels billing and purges analytics. - Health endpoints `/health` and `/health/ready`, security headers on every API response. - One-command self-hosting: `infra/setup.sh` plus `docker compose up -d` with bundled Caddy and automatic TLS, prebuilt images on GitHub Container Registry, and a documented path for an existing reverse proxy. - Public documentation under `docs/`, rendered on the hosted site at `/docs`. - A blog on the marketing site at `/blog`, written as Markdown files in `apps/web/content/blog` with no admin panel or database: frontmatter is validated at build time so a malformed post fails the build, each post gets canonical, Open Graph, Twitter and JSON-LD metadata plus a social card generated from its title, and the feed is at `/blog/rss.xml`. - A changelog page on the marketing site at `/changelog`, with an RSS feed at `/changelog/rss.xml`. It is the reader's view of releases (dated entries in product language, each line tagged new, improved or fixed, written in `apps/web/src/lib/changelog.ts`); this file stays the operator's exhaustive record, and a release updates both. - Email verification for password signups (`EMAIL_VERIFICATION`, on by default whenever a mail provider is configured): the account sits on Free and cannot sign in until the emailed link is opened, and the 30-day Growth trial starts at that moment, so a throwaway address gets nothing. Google signups are verified on creation. Existing accounts are treated as verified. - `REGISTRATION` setting: the self-host stack allows only the first account and closes signups after it; `open` restores public registration. - ClickHouse small-server profile, log rotation on every container, an unprivileged dashboard image, issue and pull request templates, Dependabot, and this changelog. ### Changed - Dashboard consistency: every period-over-period change (Overview tiles and insight, site list, Pages, Page detail, Outbound, Search) uses one green and red pair, the `trend-up` / `trend-down` tokens, which switch with the theme; the brighter `success` / `danger` colours are kept for status and errors. The site sub-pages (Pages, Conversions, Journeys, Realtime, Search, Outbound) share one header, and their summary cards (Pages, Page detail, Outbound, Search, goal detail) share one card with sentence-case labels. The Outbound page title is now the same size as the other sub-pages. - Tracker ingest validates every payload (16 KB body limit, bounded fields, at most 30 custom properties of 500 characters) and the quota guard fails open only when Postgres is unreachable, never on other errors. - Client addresses are resolved under an explicit `TRUST_PROXY` setting; the API refuses to start in production without it. - Tracker ingest accepts cross-origin requests from any site; the dashboard API keeps its allowlist. - Plan grids no longer list team seats until a team model exists. - A Redis outage degrades instead of stalling: the client rejects commands at once while disconnected and after 2 seconds when Redis does not answer, session checks fall through to Postgres, and the rate limiter lets requests through while its store is unreachable. - Marketing site page weight: the handwriting font is vendored and subset to the characters the labels use (16 KB instead of the 75 KB file Google served), the stylesheet is inlined instead of render-blocking, the hero tiles load eagerly with honest `sizes`, and the below-the-fold dashboard screenshot is no longer preloaded. - The marketing site image (`apps/web/Dockerfile`) refuses to build without `NEXT_PUBLIC_SITE_URL`, `NEXT_PUBLIC_APP_URL` and `NEXT_PUBLIC_API_BASE_URL` as absolute origins; an image built without them published localhost URLs in its sitemap and canonical tags. ### Changed (billing provider) - **Payments move from Stripe to Paddle Billing.** Paddle is the merchant of record: it sells to the customer, takes the payment, charges the tax and issues the invoice, so no card detail reaches the server. Stripe is removed entirely, along with its SDK, its sandbox harness and its schedule handling. Nothing had ever been sold through Stripe, so no customer or subscription was migrated. - Checkout is Paddle's overlay, opened by the dashboard: `POST /billing/checkout` now answers with what the browser needs to open it (including the public client token) instead of a hosted URL, so one prebuilt dashboard image works for any installation. - Overage is no longer streamed to a meter. A closed period is charged once, from the usage ledger, as a one-off subscription charge of `ceil(events / 1,000)` units at the plan's rate, keyed so a retry cannot bill twice. Paddle has no usage metering, so the arithmetic is ours. - A plan is now one recurring price. Annual plans are a single yearly price rather than a mixed-interval subscription, and the monthly usage window inside a prepaid year is derived and rolled locally. - Deferred downgrades no longer use a provider schedule (Paddle has none for an item change): the provider is told to bill the new price from the next renewal, entitlements stay on the paid-for plan until its period ends, and the new hourly `apply-plan-changes` job moves the plan across at that boundary. - `GET /billing/invoices/:invoiceId/pdf` returns a fresh, short-lived PDF link per click, because the provider's links expire. - Overage worth less than Paddle's 70c minimum transaction is waived rather than billed: the period is settled, a `WAIVED` usage record says why, and reconciliation treats it as explained. Webhook signatures are verified with a five-minute window instead of the SDK's five seconds, with a message that says whether the secret or the timestamp was at fault. - `scripts/paddle/`: `setup.ts` provisions the catalogue and the notification destination from the plan catalog, `probe.ts` reports the account and whether the configured secret matches, `deliveries.ts` lists and replays webhook deliveries, `sync-webhook-secret.ts` writes the destination secret into `.env` without printing it, and `sandbox/` holds no-card scenarios for a subscription and an overage charge. - New settings `PADDLE_API_KEY`, `PADDLE_CLIENT_TOKEN`, `PADDLE_WEBHOOK_SECRET` and `PADDLE_ENVIRONMENT`; `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` and `STRIPE_METER_EVENT_NAME` are gone. The webhook endpoint is `POST /api/v1/paddle/webhook`. `apps/api/docs/billing-runbook.md` is rewritten for Paddle, including the payment-recovery setting that must never cancel a subscription over a failed usage charge. ### Changed (beta pricing) - `GET /plans` reports per plan whether it can be bought (provider configured and a price id present) plus the trial on offer. The pricing page and the billing page read that instead of offering a checkout that would fail: during the beta they say paid plans are not on sale, that new accounts get the 30-day Growth trial then the free plan, and that prices are provisional. Both become normal purchase flows the moment the payment provider and price ids exist. ### Fixed - Usage counted every event in a billing period. A period is anchored to the moment the subscription started, so it almost never begins on the hour, and the hour containing its start was discarded rather than counted as a partial hour: everything from the period start to the end of that hour was missing from the period total, up to an hour of traffic each period. Counts were therefore low, never high, so no one was overcharged. Open periods correct themselves on the next hourly sync; closed periods keep the totals they were billed on. **Operators upgrading:** that first sync raises every open period's total by up to an hour of traffic, so an account already sitting within an hour's traffic of its allowance can cross it and be paused or start accruing overage on the upgrade itself, with no change in its traffic. Check who is close before upgrading. - A trial that used up its allowance can now buy a plan. It was a dead end: a restriction outranks everything when access state is derived, so an exhausted trial reported `RESTRICTED` rather than `TRIAL`, and the billing page read that as "this account pays". The plan it was trialling showed as the current plan and could not be clicked, every other plan opened the plan-change dialog and failed with "no paid subscription to change", and the page offered payment-method and cancel buttons for a subscription that does not exist at the provider. The page now asks whether there is a subscription at the provider to change (`subscription.isProviderBacked` on `GET /billing/usage`) instead of inferring it from the access state, so a trial goes to checkout, including for the plan it is trialling. - An over-quota or usage-warning email that the mail provider rejected is retried on the next hourly run again. The escalation pointer was moved before the send, so a bounced message was recorded as delivered and never retried. - Cancelling a subscription now closes its usage period, with a final aggregation that includes the partial hour the cancellation lands in, so a closed period's total always matches its own hourly buckets. Previously the period was left open on the cancelled row and frozen at the last hourly total, because the usage sync only visits live subscriptions. The hourly sync also sweeps up any period left open on a subscription that is no longer live, so an interrupted cancellation repairs itself. Note this does not by itself invoice a departing pay-as-you-go customer's final overage: charging still skips cancelled subscriptions, and collecting it needs a decision about how to raise the charge at the provider. - Tests no longer send real email. A database run used to push dozens of messages through whatever mail key was configured, exhaust the provider's rate limit and then fail unrelated tests; the console transport is now forced under the test runner. - A hard-limit plan reaching 100% of its allowance now records that it warned at 100%. The pointer stopped at 90%, so the period claimed the customer had not been told they were over. - The billing page no longer tells a trial that tracking "resumes when the period resets". A trial is a single period covering the whole trial and it ends on the smaller free allowance, so it says to choose a plan instead. - A free plan's period rollover now lifts the quota pause immediately instead of waiting up to an hour for the enforcement job. - Dashboard: API failures render as failures with a retry instead of "no traffic yet" or "site not found"; a render error shows a message with a reload instead of a blank page; an expired session goes straight to the login page; the paused banner names the real reason (event limit, spending cap, payment, trial end, inactive site). - Timezone pickers list every IANA zone the browser knows. - Rate limits are counted in Redis, so they hold across API instances. - Request logs record the route pattern, status and duration, never the visitor's page URL, address or the OAuth code, and credentials are redacted. - Production without a mail provider no longer prints password-reset and confirmation links into the log. - Search Console refresh tokens are encrypted at rest (`ENCRYPTION_KEY`). - Events from a page on another domain than the site's are dropped (`INGEST_HOSTNAME_CHECK`). - Marketing site: Open Graph and Twitter tags, canonical URLs, `robots.txt` and `sitemap.xml`; stock placeholder assets removed. Dashboard bundle split into cacheable vendor chunks. - Billing page: the plan a trial runs on can be subscribed to during the trial (it was shown as "Current plan" and disabled), and the usage summary no longer describes a trial or free account as pay-as-you-go; overage is billable only when a provider subscription exists, the rule enforcement already used. - The geo database now downloads after `MAXMIND_LICENSE_KEY` is added: a weekly `update-geo` run that skipped for lack of a key used to hold its lock for six days, so the key had no effect until then. The updater also keeps the existing database when a download fails instead of deleting it first. - Framework-level client errors (body too large, invalid JSON, unsupported media type) are reported with their own 4xx status and code instead of a generic 500. - Event time is the server's receive time. The client-supplied `ts` used to be written verbatim, which let a wrong clock or a hostile request place events in a closed billing period, past the retention cut or years into the future; it is still accepted and now ignored, and the tracker no longer sends it. The unused ClickHouse `websites` table is dropped (migration 008). ### Breaking - The `usage_buckets` uniqueness migration and the API version are coupled, so **do not roll the API image back past this release against a migrated database**. The unique key moves from `(subscription_id, bucket_start)` to `(billing_period_usage_id, bucket_start)`; an older image still writes `ON CONFLICT (subscription_id, bucket_start)`, that index no longer exists, and every usage sync would fail. There is no down migration, and the old index cannot be recreated once two adjacent periods each hold a bucket for the clock hour they share. Roll forward, or restore the database alongside the image. - Stripe settings are removed. An installation that set `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` or `STRIPE_METER_EVENT_NAME` must replace them with the `PADDLE_*` settings, and point a Paddle notification destination at `/api/v1/paddle/webhook`; `/api/v1/stripe/webhook` no longer exists. Billing columns are renamed to provider-neutral names (`provider_customer_id`, `provider_subscription_id`, `provider_price_monthly_id`, `provider_price_yearly_id`) and the Stripe-only ones are dropped, by migration. `POST /billing/checkout` no longer returns `{ url }`. - `TRUST_PROXY` is required in production. - Old tracker payload fields `vid`, `ssid`, `new_session` and `new_visitor` are accepted but ignored; identity is server-derived.