All your subscription revenue under one roof. Self-hosted, read-only, open source.
Deploy · Providers · Known limitations · Contributing · Spec
hoomuch connects to the billing providers you already use and puts the numbers on one dashboard: total MRR and ARR, active subscriptions, trials and revenue, converted into one currency and broken down by connection.
- Twelve providers. Stripe, Paddle, Lemon Squeezy, Polar, RevenueCat, Chargebee, Dodo Payments, Gumroad, Adapty, Superwall, App Store Connect and Google Play.
- Read-only. hoomuch never writes to a provider, and asks for read-only keys wherever the provider offers them.
- Your keys stay on your account. It runs on your own Cloudflare account, and provider keys are encrypted at rest (AES-256-GCM).
- No customer personal data. It keeps amounts, currencies, timestamps, IDs and plan names, never your customers' emails, names or addresses.
- Why MRR moved. New subscriptions, upgrades, downgrades and cancellations over the period, with the churn rates that follow (see MRR movement).
- Numbers in your inbox. An optional Monday email with last week's revenue, MRR, subscriptions and trials (see Email digest).
- Yours to take away. Transactions export to CSV, with the provider's fee where it reports one.
- Try it without a key. After signing up, Try demo data fills the dashboard with a sample business.
- Show your MRR, verified. On instances that turn it on, publish a product's MRR on a public page with a badge for your site or README (see Public pages).
Click Deploy to Cloudflare above. Cloudflare copies this repo into your GitHub or GitLab account,
creates the D1 database and the sync queue, and asks for ENCRYPTION_KEY (generate one with
openssl rand -base64 32). Every deploy applies pending D1 migrations first, and every push to your
copy redeploys it.
npx wrangler login
npx wrangler d1 create hoomuch # copy the printed database_id into wrangler.jsonc
openssl rand -base64 32 | npx wrangler secret put ENCRYPTION_KEY
npm run deploy # applies D1 migrations, then deploysThe first deploy also creates the hoomuch-sync queue that every sync runs on.
To pull new releases without conflicts, keep your changes out of wrangler.jsonc: copy
wrangler.local.example.jsonc to wrangler.local.jsonc (gitignored), put your database_id, your
domain and any vars you change in it, and deploy with npm run deploy:local. It applies your file
over wrangler.jsonc and then does what npm run deploy does.
Queues are on the Free plan too, with 10,000 operations a day. Each sync is about three operations, so that's roughly 3,300 syncs a day: around 130 connections at the default hourly interval.
- Back up
ENCRYPTION_KEY. If it's lost, every stored provider key becomes unreadable and each connection has to be re-keyed. Never rotate it in place. - Close sign-ups. Create your account, then set
"ALLOW_SIGNUP": "false"inwrangler.jsoncand deploy again (with the button, commit that change to your copy of the repo). - Use Workers Paid for production. Password hashing (PBKDF2, 100k iterations) and syncs can exceed the Free plan's 10 ms CPU limit.
| Name | Where | Default | What it does |
|---|---|---|---|
ENCRYPTION_KEY |
Secret (wrangler secret put) |
none, required | 32 random bytes, base64. Encrypts provider keys at rest. |
ALLOW_SIGNUP |
vars in wrangler.jsonc |
"true" |
Set to "false" once your own account exists, so nobody else can sign up. |
SYNC_INTERVAL_MINUTES |
vars in wrangler.jsonc |
"60" |
How often each connection syncs, rounded up to a multiple of the 30-minute cron. |
TURNSTILE_SITE_KEY |
vars in wrangler.jsonc |
"" (off) |
Optional. A Turnstile widget's site key. With the secret below, log in and sign up require the Turnstile check. |
TURNSTILE_SECRET_KEY |
Secret (wrangler secret put) |
none | Optional. The same widget's secret key. |
RESEND_API_KEY |
Secret (wrangler secret put) |
none (off) | Optional. A Resend API key with sending access. When set, sign-up mails a confirmation link and new accounts can't use hoomuch until it's clicked (see below), and people can turn on the email digest. |
MAIL_FROM |
vars in wrangler.jsonc |
"" |
Sender of those emails, on a domain verified in Resend, e.g. hoomuch <[email protected]>. Needed once RESEND_API_KEY is set. |
RL_REQUESTS, RL_AUTH |
ratelimits in wrangler.jsonc |
300 and 10 a minute | Per-network request limits (see below). namespace_id must be unique in your Cloudflare account. |
RL_WEBHOOKS |
ratelimits in wrangler.jsonc |
30 a minute | Webhook calls per connection (see Real-time updates). Webhooks skip the per-network limits, because a provider sends every customer's calls from the same addresses. Its namespace_id must be unique too. |
PUBLIC_PAGES |
vars in wrangler.jsonc |
"" (off) |
Optional. "true" lets owners publish a product's verified revenue at /p/<slug>, with a badge, a profile at /u/<handle> and a listing on /explore (see Public pages). |
MEDIA |
r2_buckets in wrangler.local.jsonc |
none (off) | Optional, with PUBLIC_PAGES. An R2 bucket for founders' photos and products' logos (see Public pages). Turn R2 on in your Cloudflare account, then add "r2_buckets": [{ "binding": "MEDIA", "bucket_name": "hoomuch-media" }]; wrangler deploy creates the bucket if it's missing. Without it, pages show initials. |
LEGAL_NAME, LEGAL_EMAIL |
vars in wrangler.jsonc |
"" (off) |
Optional. With both set, /privacy and /terms serve hoomuch.com's privacy policy and terms of service, naming this operator and address, and every footer and the sign-up form link them. They're written for an individual operator in Türkiye under the KVKK: if that isn't you, leave these empty and publish your own. |
SYNC_QUEUE |
queues in wrangler.jsonc |
queue hoomuch-sync |
Required. Every sync runs in this queue's consumer, one connection at a time per invocation (up to 10 at once). wrangler deploy creates the queue if it's missing. |
The cron schedule itself is triggers.crons in wrangler.jsonc. Each tick puts the connections that
are due on the queue, up to 1,000 of them, enough for about 2,000 connections on the hourly interval.
Past that, each connection syncs less often, the most overdue first, and the tick's cron_sync log
line is a warning with "stop":"cap".
If you keep sign-ups open, hoomuch defends itself in layers. Each one is cheap, and each catches what the one before lets through:
- Request limits (
RL_REQUESTS,RL_AUTH): Cloudflare's Rate Limiting binding turns a flood away per network (IPv6 by /64) before the Worker touches D1. Every request counts against 300 a minute; log-in and sign-up submissions against 10 a minute. - Exact limits in D1, each counted before the password hash: log-in attempts per email and network, per account, and per network across all emails (30 every 15 minutes, which stops credential stuffing), plus 5 new accounts per network a day.
- No throwaway inboxes. Sign-ups from the
disposable-email-domains
list (CC0) are refused, subdomains included. The list is vendored, so there's no lookup at runtime;
npm run domains:updaterefreshes it. - A honeypot field on both forms that people never see, so bots that fill every input give themselves away. It needs no JavaScript.
- Email verification, if you set
RESEND_API_KEYandMAIL_FROM. Without it, anyone can sign up with an address they don't own. With it, a new account is held at a "check your inbox" page until the person opens the link mailed to them. The link works once and expires after 24 hours, and "Send the link again" is limited to 3 every 15 minutes and 10 a day per account. An account nobody confirms is removed once all its links have expired, so the address is free for its real owner. Accounts from before this feature (migration0004) count as confirmed; ones created later while mail was off are asked to confirm the next time they're used. Verify your sending domain in Resend and turn its click tracking off, so links reach people unchanged. - Turnstile, if you set both keys. Create a widget in the Cloudflare dashboard (Managed mode,
your hostname), put the site key in
varsandwrangler secret put TURNSTILE_SECRET_KEY. Log in and sign up then need JavaScript, and only those two pages allowchallenges.cloudflare.comin their CSP. For local testing, Cloudflare's test pair (1x00000000000000000000AA/1x0000000000000000000000000000000AA) passes on any host.
Cloudflare blocks network-level DDoS on its own. Its WAF and Bot Fight Mode only see traffic on your
own domain, though, not on *.workers.dev. For a public instance, add a custom domain and set
"workers_dev": false in wrangler.jsonc, so nobody can go around them.
| Provider | Key | Permissions |
|---|---|---|
| Stripe | Restricted key rk_live_… |
Read: Subscriptions, Prices, Balance; Coupons is optional and takes discounts off MRR; Products is optional and splits MRR by product |
| Paddle | API key pdl_live_apikey_… (expires; one year at most) |
Read: Subscriptions, Transactions, Adjustments, Metrics; Discounts optional |
| Lemon Squeezy | API key (can't be scoped, so it has full access) | — |
| Polar | Organization access token polar_oat_… |
metrics:read, subscriptions:read, orders:read, refunds:read |
| RevenueCat | v2 secret key sk_… + Project ID |
charts_metrics:overview:read; charts_metrics:charts:read is optional and enables daily revenue history |
| Chargebee | Read-only API key + site name | Read-only key (All, or Restricted with transactional data) |
| Dodo Payments | API key dodo_live_… |
Read: Subscriptions, Balances |
| Gumroad | Access token from your own Gumroad application (can't be scoped, so it has full access) | — |
| Adapty | Secret API key secret_live_… (can't be scoped) |
— |
| Superwall | Organization API key + Organization ID | charts:read; data:read is optional and enables daily revenue, refunds and trials |
| App Store Connect | Team API key (.p8 file) + Issuer ID, Key ID and vendor number |
Sales role (Finance or Admin also work, but can do more). Individual keys can't read sales reports |
| Google Play | Service account JSON key + the reports bucket's Cloud Storage URI | Play Console: View app information and download bulk reports; View financial data. The Google Play Android Developer API turned on in the service account's project |
Keys are checked live before they're saved. The first sync backfills 90 days of revenue. After that,
each connection syncs every SYNC_INTERVAL_MINUTES (an hour by default), and Sync now runs one
straight away.
Stripe, Paddle, Lemon Squeezy, Polar, Chargebee, Dodo Payments and Gumroad can tell hoomuch when something changes, so a new subscription or refund shows up about a minute later instead of at the next scheduled sync. On the connection's page, under Real-time updates, click Turn on, then add the URL it shows as a webhook in the provider's dashboard. The page lists where to paste it and which events to pick.
- A call is only a signal. hoomuch never reads what a webhook sends, which can include your customers' details, and doesn't check the provider's signature. It syncs that connection through its read-only key, as a scheduled sync would. A burst of events becomes one sync, and webhook syncs of one connection start at least 5 minutes apart.
- Keep the URL private. It carries a secret token, and anyone who has it can make hoomuch sync that connection (at that same rate at most). New URL replaces it, and the old one stops working.
- Not offered for App Store Connect and Google Play (their reports arrive a day later), Adapty (one webhook URL per environment, which apps already use), and RevenueCat and Superwall (their charts refresh on their own schedule).
The dashboard's MRR movement card says why MRR changed over the selected period: what new subscriptions and upgrades added, what downgrades and cancellations took away, the net, and two churn rates (subscriptions that churned, and MRR lost to churn and downgrades, each as a share of what there was at the start of the period).
- How it's worked out. Each sync reads the provider's list of paying subscriptions and compares it with the list from the sync before. hoomuch keeps subscription ids and amounts for this, nothing about the customer.
- Which providers. Stripe, Lemon Squeezy, Chargebee, Dodo Payments and Gumroad, whose APIs list subscriptions one by one. The others report totals only and aren't in the card; it says which.
- It starts when you connect. There's no movement before a connection's first sync, and the card says so when the period reaches back further. A change counts on the day a sync saw it.
- Churned means stopped counting towards MRR. A subscription that's paused, unpaid or cancelled churns; if it comes back, it's new again. Movement is tracked for up to 20,000 subscriptions per connection.
On an instance that sends mail (RESEND_API_KEY and MAIL_FROM), Settings → Email digest offers
an email every Monday with last week's numbers: revenue, MRR, active subscriptions and trials with
their changes, and any connection that couldn't sync. It goes out around 8:00 in your time zone, is
built from what's already synced (no provider is called), and holds your own totals and connection
names, never anything about your customers. Every email has a link that stops it without logging in.
Transactions → Export as CSV downloads the charges and refunds between two days, for all providers or the one you're filtering by: amount and currency as charged, the provider's fee and the net where the provider reports a fee (Stripe, Paddle, Polar and Gumroad), and both converted to your display currency at that day's rate. A file holds up to 10,000 rows; export a longer period in parts. Providers that report daily totals only (RevenueCat, Dodo Payments, Adapty, Superwall and App Store Connect) have no transactions to export. Text cells that a spreadsheet would read as a formula are written with a leading apostrophe.
hoomuch has a web app manifest, so a phone's Add to Home Screen (or a desktop browser's Install) opens the dashboard in its own window with its own icon. It still needs a connection: nothing is stored offline.
With PUBLIC_PAGES set to "true", a Share page appears next to Transactions. There an owner
groups one or more connections into a product (Stripe for the web app and RevenueCat for the iOS app,
say) and publishes it:
-
/p/<slug>shows how much the product makes, in US dollars: total revenue (all the history hoomuch holds, from its first day), MRR with active subscriptions, revenue over the last 30 days, and subscriptions, with the founder under the name. Below that, a chart of revenue or MRR over 7 days to all time, and the providers the figures come from, with Visit, for an app its App Store and Google Play listings, and buttons to post it on X, LinkedIn or Bluesky at the top. Anyone sees revenue over the last 30 days; every other chart needs a free hoomuch account, and choosing one asks the visitor to sign up and brings them back to it. The locked history isn't in the page: it comes from/api/p/<slug>/history, for signed-in accounts only. They're the dashboard's figures for those connections, worked out again after every sync. The owner can't type them in; the name, description, website and founder details are theirs, and the page says so. -
About the product, the owner's own words, from a form of its own under Share: when it was founded and where (shown under the name with the country's flag), and an About section below the chart that opens with a message from the founder (a signed quote, in their own voice) and answers a visitor's questions in order: the facts at a glance (who it sells to, team size, funding), what it is, how it makes money, how it finds customers and how it's built. Pricing is a list of plans, each its own item with its billing (free, weekly, monthly, yearly or one-time): a yearly plan says what it comes to a month and what it saves against the monthly plan of the same name. Under the plans, the one figure hoomuch checks there: what a subscription brings in a month on average. Tags come from fixed lists for its market, tech stack and marketing channels. Every field is optional, and the section only shows what's filled in. The brand marks on the tags come from Simple Icons (CC0), built into
public/brand-icons.svgbynpm run icons:update. -
Share image, on the page and on the owner's Share page, draws the product as a picture to post: a figure (MRR, revenue over 30 days or the total), the revenue chart (7 days, 30 days or 12 months) or a revenue calendar (3, 6 or 12 months), light or dark, in a colour of your choice, to download as a PNG or copy. It's drawn in the browser, with the page's address at the bottom. A visitor without an account can make the ones from figures the page already shows; the year's chart and the calendar need a free account, like the charts.
-
/p/<slug>.mdis the same page as Markdown, for AI assistants. -
/badge/<slug>.svgis a badge ("Verified MRR | $12,340") for a site or README. It turns grey when the figures haven't been confirmed by a sync for 72 hours. -
/u/<handle>is the founder's profile: their public products added up, and each one's revenue, MRR and total. Publishing a product needs a founder profile, and the profile is public while at least one of their products is. -
Photos and logos, with the
MEDIAbucket: a founder can upload a photo, and a product gets its website's icon when it's saved with a website (or, without one, its App Store icon), or a logo its owner uploads (on the new-product form too). An image can be dragged onto the photo or logo card. They're served from your own domain at/media/<key>(so the pages'img-src 'self'still holds), checked by their bytes (PNG, JPEG, WebP or GIF; ICO from a website; never SVG), at most 2 MB and 4096 pixels a side, and stored without their metadata (a phone photo's location and camera details are taken out). A browser with JavaScript crops and scales an upload to 400 pixels before sending it, as WebP (or as JPEG, where the browser can't write WebP and the image has no transparency). An account can make 20 changes in 15 minutes. They're deleted with the product or the account. -
/explorelists the published products whose founders list them (a box on the publish form, ticked by default), by MRR, growth or newest, with a search that runs as you type, a provider filter, a Founders tab, and an "Add your product" button for visitors. A product shows there once it has MRR, a sync in the last 72 hours and 30 days of revenue history. Once six products qualify, the landing page shows the six growing fastest./sitemap.xmllists them all, each with its last sync as<lastmod>(see Search engines and AI assistants).
To take a product off Explore (spam, a name that breaks your rules), set hidden_at; its page and
badge stay public, and its owner sees that it was taken off:
npx wrangler d1 execute DB --remote --command \
"UPDATE public_products SET hidden_at = unixepoch() * 1000 WHERE slug = 'the-slug'"To take a page down altogether (a scam, someone else's personal data), set removed_at: the page,
its Markdown, badge and history become the ordinary 404, it leaves Explore, the sitemap and its
founder's profile, and its owner sees that it was taken down and can't publish it again. Setting it
back to NULL restores it:
npx wrangler d1 execute DB --remote --command \
"UPDATE public_products SET removed_at = unixepoch() * 1000 WHERE slug = 'the-slug'"What keeps the pages clean on their own:
- The bar to publish. A page needs a live payment provider key whose figures have synced; demo data and test-mode keys never count.
- Search engines wait. A page asks not to be indexed (
noindex, andX-Robots-Tagon its Markdown) until its figures go back 30 days, the same bar as Explore, and while it's off Explore. A founder profile is indexed once one of its products is. Until then a page is public and shareable, but publishing one for its links or words gets nothing from search. - No links in the founder's words. The About section turns back links, email addresses and phone
numbers (the website and store fields are where links go,
rel="nofollow ugc"), and drops the characters that make text pass for something else: direction overrides, zero-width spaces, marks stacked into "zalgo" text. Nothing a founder types is turned into a link. - Report it. With
LEGAL_EMAILset, every public page and profile has a "Report it" link that writes to that address with the page in the subject.
Total revenue starts where hoomuch's history does: the first sync reads 90 days back, so it isn't the business's lifetime revenue. Demo data and test-mode keys never count. Nothing is public until the owner ticks a confirmation and publishes, and unpublishing takes the page and badge down; a browser that loaded one in the last few minutes may still show its copy. Public pages and badges are rendered without the visitor's session, so the same response can be cached for everyone.
Every instance writes its own /robots.txt, /llms.txt and /sitemap.xml, with its own address:
robots.txt keeps crawlers out of the app pages and points at the sitemap, llms.txt tells AI
assistants what hoomuch is and how it counts, and the sitemap lists the landing page, the pages
below and, with PUBLIC_PAGES, Explore and every product and founder on it. Submit the sitemap in
Google Search Console and Bing Webmaster Tools.
Alongside the landing page, every instance serves an integration page for each provider
(/integrations/<provider>, what hoomuch reads from it and how it counts it), a guide to using
RevenueCat with Stripe without counting a sale twice, /self-host, comparisons with Baremetrics,
ChartMogul and TrustMRR (/compare/…), and with PUBLIC_PAGES, /verified-revenue. They render
without the session, like the public pages, so they can be cached. Every page search engines index
has one canonical address and JSON-LD structured data, built only from what the page shows.
Each release lists what changed and anything you have to do by hand. To hear about new ones, choose Watch → Custom → Releases on this repository.
Pull the latest changes from this repository into your copy and deploy (with the button, pushing to
your copy redeploys it). Every deploy applies pending D1 migrations first. Some migrations rebuild a
table (0003 does, to accept new provider ids), so note a restore point before you update:
npx wrangler d1 time-travel info hoomuchIf anything looks wrong afterwards, put the database back:
npx wrangler d1 time-travel restore hoomuch --timestamp=<that time>Changes that affect data you've already synced:
- Sales tax. Revenue from merchant-of-record providers now leaves out sales tax. Lemon Squeezy history synced before this change and older than about 30 days keeps tax in; delete and re-add the connection to recount it.
- Currency units. Amounts follow ISO 4217 minor units, so HUF, IDR, COP and PKR count in hundredths. They used to show 100× too large; rows already stored now read correctly without a resync. Stripe sends ISK, UGX and MGA in its own units, which are now converted on sync. Rows synced before that read 100× off (ISK and UGX too large, MGA too small) until the connection is removed and added again.
- v1.1: time zones and exchange rates. Days now follow your time zone, set once from where you sign in (change it in Settings). Revenue from transaction-based providers is regrouped automatically. Past revenue now converts at its own day's rate; the first cron after the upgrade fetches about 200 days of rates.
- Stripe MRR by product. The dashboard's By product card can now split Stripe MRR by product. It needs Products: Read on the Stripe key: edit the restricted key in Stripe (⋯ → Edit key) and the split appears after the next sync. Without it, Stripe syncs as before.
Each provider's section in SPEC.md §6 lists what was checked against its docs and what wasn't. The ones you'd notice:
- Stripe: customer-level discounts aren't applied, and with Stripe Tax, tax-inclusive prices stay tax-inclusive in MRR. Payments carry no product name (reaching one needs read access to invoices and charges, which hold customer details), so Stripe is split by product on MRR only, not on revenue.
- Lemon Squeezy: refunds issued more than about 30 days after purchase are missed, and several partial refunds on one order show as one refund. Its docs don't say which units it uses for currencies without two decimals; hoomuch assumes ISO 4217.
- Paddle and Polar: MRR is the provider's own figure, in one currency (Paddle: your balance
currency; Polar: USD). Polar's version pin (
2026-10) has to be updated before April 2027. A test fails from mid-January 2027 until it is. - RevenueCat, Adapty, Superwall: daily history is in USD only, revenue is the stores' gross (tax and store fees included), and test mode can't be detected.
- Dodo Payments: revenue comes from the balance ledger, so it's daily totals with no transaction list.
- App Store Connect: numbers come from Apple's Sales and Trends reports, so they run a day behind (Apple publishes each Pacific Time day the next morning). MRR counts standard-price subscriptions; subscribers on a paid introductory or promotional price count as active but not in MRR, because the report doesn't say what they pay. Revenue is the customer price, tax included, before Apple's commission, in each buyer's currency.
- Google Play: Google has no revenue API, so hoomuch reads the reports Play Console exports. MRR is Google's active-subscription count priced at each base plan's current price in that country, a few days behind; subscribers in a free trial, on an offer or on an older price are counted at that price, and trials show as unknown. Revenue comes per order from the estimated sales report (tax included, before Google's fee). A newly invited service account can take up to a day to see the reports.
- Gumroad: MRR is an estimate. The API doesn't say what each subscriber pays, so hoomuch prices them at their latest charge, or the tier's list price when it can't find one. Refunds land on the sale's day, because Gumroad doesn't date them.
- Creem isn't supported yet: its API has no date filter on transactions and no dated refunds (SPEC §6.9).
- Days run midnight to midnight in your time zone. It's picked up from where you first sign in, and you can change it in Settings. RevenueCat, Dodo Payments, Adapty and Superwall report daily totals in UTC, and App Store Connect in Pacific Time; those totals keep the provider's days (the dashboard says so). MRR snapshots taken before v1.1 stay on UTC days.
- Exchange rates: revenue converts at the exchange rate of its own day and MRR at the latest, both from Frankfurter: the ECB's reference rate where it publishes one, other central banks' otherwise. Rates before an instance's first sync reach back about 200 days; a currency that first shows up later converts its earlier days at its oldest rate.
- One Cloudflare Worker: Hono routes, React 19 pages rendered on the server and hydrated in the browser, D1, Cron Triggers.
- UI: coss ui on Base UI, shadcn Charts on Recharts, Lucide icons, Geist Sans and Geist Mono, Tailwind CSS 4, Motion where it helps.
- Forms are plain POST forms (Post/Redirect/Get), so every action works before the page hydrates.
Scripts are same-origin only (CSP
script-src 'self'). The one exception is opt-in: with Turnstile on, the log-in and sign-up pages also load Cloudflare's challenge script. - Metric definitions and the full product and technical spec are in SPEC.md (in Turkish).
Requirements: Node 24.14 or later (.nvmrc has the version CI and Cloudflare's builds use).
npm install
cp .dev.vars.example .dev.vars # then set ENCRYPTION_KEY, e.g. openssl rand -base64 32
npm run db:migrate:local
npm run dev # http://localhost:8787CONTRIBUTING.md has the rest: running a sync by hand, the npm scripts, the project layout, the ground rules for pull requests and how to add a provider.
Please don't open a public issue for a vulnerability. Report it privately through GitHub's Security → Report a vulnerability form on this repository. SECURITY.md says what's in scope and what happens next.
Bug reports, fixes and new provider adapters are welcome. Start with CONTRIBUTING.md; new providers and new dependencies begin with an issue. Everyone taking part follows the code of conduct.
hoomuch is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0-only).
You can use, self-host and modify it freely. If you run a modified version as a service other
people use, the AGPL requires you to offer those users your modified source code. The footer of every
page links to the source; point it at your fork (SOURCE_URL in src/views/layout.tsx).