Minvoice (minimal invoice) is single-business invoicing that runs entirely on Cloudflare — Workers, D1, Access, and Email Sending. Create invoices, email them with a PDF attached, get paid by card (Stripe Checkout) or PayPal, and keep clean books. No servers, no framework runtime, no third-party requests on any page. Designed for one business (you), not as a SaaS.
One click gets you: the repo cloned to your GitHub, a D1 database provisioned and migrated, a
password-protected admin (set the ADMIN_PASSWORD secret when prompted), and the setup wizard on
first visit — a working invoicing app on workers.dev. Connect payments, email, and Cloudflare
Access at your own pace afterward (see Setup below).
Everything beyond the Worker itself is optional:
- Custom domain — optional. Everything works on the free workers.dev domain; the only feature that needs a custom domain is Cloudflare Access for admin auth, and the built-in password login covers that until you add one.
- Payments — optional, and Stripe and PayPal are each optional too: connect either, both, or neither. With none connected, clients still get the invoice page, print view, and PDF — and you record checks or bank transfers manually, which keeps the books just the same.
- Email — optional. Send through Cloudflare Email Sending or Resend, or turn email off in Settings and share pay links directly.
![]() |
![]() |
![]() |
![]() |
- Invoices — line items with multi-line descriptions, subjects, per-client payment terms and
default rates, custom or auto numbering (including dated prefixes like
{YYYY}{MM}{DD}with a per-day counter), duplicate-invoice, and full-status delete with guardrails - Get paid — unguessable pay links (20-char, 100-bit tokens) with hosted Stripe Checkout and PayPal buttons; webhooks are the source of truth, fully idempotent, and record each payment atomically (event, payment, and status change commit or roll back together); receipts and "you got paid" notifications are enqueued in that same transaction and delivered with retries (at-least-once — a rare duplicate beats a silent never)
- Documents — a print-optimized invoice view (one-click print,
PAID/VOIDstamps) and a generated PDF (pdf-lib) attached to invoice emails - History — every invoice keeps an activity timeline: edits, sends, payments (with undo), and pay-link views with city-level geolocation (bot- and scanner-filtered, admin views excluded)
- Email — Cloudflare Email Sending or Resend, selectable in Settings
- Languages — everything clients see (emails, pay page, PDF) in English, Spanish, German, or
French, with per-client overrides and regional date/number formatting (e.g.
de-AT); the admin stays English - Payment reminders — opt-in daily cron emails overdue clients on an editable schedule (default 1, 7, and 14 days past due, up to 10 reminders, burst-protected); every reminder is logged to the invoice history and delivered through a durable outbox that retries failures
- Admin — dashboard with status tabs and client filter, payments list, monthly reports (filterable by client), CSV export, first-launch setup wizard, configuration warnings for missing secrets, encrypted-at-rest storage for API keys entered in-app, and logo upload (PNG/JPEG stored in your database — no external image host needed; a logo URL still works)
- No fees — fits Cloudflare's free tier (Resend's free tier covers email there), so there's no monthly cost to run. No subscription and no added payment fees on any plan: you pay only Stripe's or PayPal's own rate (2.9% + 30¢ on card), straight to your own account. Nothing sits in between taking a cut
- Your data stays yours — books live in a D1 database in your Cloudflare account; no analytics, no tracking, no third-party requests on any page. Payment providers see only the charge itself, and clients' invoice pages are unindexed, unguessable URLs
- Ledger design — a warm, print-inspired design system (Fraunces + Instrument Sans, self-hosted); Lighthouse 100 performance and accessibility on the payment page, WCAG AA contrast
src/
index.tsx Hono app: routes, Access middleware on /admin/*, /health, styled 404
middleware/access.ts Cloudflare Access JWT verification (defense in depth, fails closed)
routes/ admin.tsx (dashboard/CRUD/reports/CSV), pay.tsx (public), webhooks.ts
services/ stripe.ts, paypal.ts, pdf.ts, email.ts
db/queries.ts typed D1 access, webhook idempotency, timeline assembly
views/ server-rendered JSX (hono/jsx) — no client framework
lib/ money (integer cents, bps tax), dates (timezone), tokens, config
migrations/ D1 schema migrations
public/ styles.css, self-hosted fonts, favicon
Invariants worth knowing before you change anything:
- Money is integer cents; tax rates are basis points; totals are computed once and stored.
- "Paid" comes from webhooks, deduplicated by
UNIQUE(provider, event_id)+UNIQUE(provider, provider_ref)+ status-guarded updates — replays are no-ops, and payment emails fire only on the actual transition. - Payments are soft-deleted (undo keeps history); storage is UTC with a business-timezone setting driving display and date logic.
/adminrequires a valid Cloudflare Access JWT verified in-Worker: if Access is misconfigured or disabled at the edge, admin fails closed with 403.
You'll need: a Cloudflare account, a domain on Cloudflare, a Stripe account, and optionally a PayPal Business account.
Plans: the Workers Free plan is enough — D1's free tier (5 GB, 5M reads/day) vastly exceeds single-business invoicing volume — if you use Resend as the email provider (free tier: 3,000 emails/month). The built-in Cloudflare Email Sending path requires the Workers Paid plan to send to arbitrary recipients (your clients).
Prefer the CLI over the deploy button? npm create minvoice scaffolds a fresh copy — repo
downloaded, dependencies installed, fresh git history — and prints these same steps.
The checked-in wrangler.jsonc is a zero-config starter (workers.dev, request-derived URLs).
wrangler.jsonc.example shows the full production shape — custom domain, Access, Cloudflare
email binding, staging — to graft in as you upgrade.
npm install
npx wrangler d1 create minvoice # paste the printed id into wrangler.jsonc (database_id)
npx wrangler types
npm run db:migrate:remote # deploy scripts also run this automaticallycp .dev.vars.example .dev.vars # set ADMIN_PASSWORD (any value locally); test-mode Stripe key, sandbox PayPal creds
npx wrangler d1 migrations apply minvoice --local
npm run dev # http://localhost:8787 — first visit runs the setup wizardLocal Stripe webhooks: stripe listen --forward-to localhost:8787/webhooks/stripe and put the
printed whsec_… in .dev.vars. PayPal needs no local webhook — capture-on-return keeps the
books correct. Note .dev.vars changes require a dev-server restart.
Admin auth picks the strongest configured mode automatically:
- Quick start — password:
npx wrangler secret put ADMIN_PASSWORDand you can sign in at/adminimmediately (signed 7-day session cookie, timing-safe checks, throttled failures). The dashboard will nudge you toward Access. - Recommended — Cloudflare Access: phishing-resistant SSO in front of
/admin, verified in-Worker on every request. Setting it up disables the password login automatically — Access always wins. Walkthrough below. - Neither configured:
/adminfails closed with setup instructions.
Prerequisite: a custom domain. Access cannot protect *.workers.dev hostnames, so first put
your domain on Cloudflare and attach it to the Worker — add a routes block to wrangler.jsonc
(see wrangler.jsonc.example) and deploy once so the hostname is live.
- Open Zero Trust. First visit ever? You'll be asked to
create an organization and pick a team name — choose carefully, because
<team-name>.cloudflareaccess.combecomes yourACCESS_TEAM_DOMAIN. - Go to Access → Applications → Add an application → Self-hosted.
- Name it (e.g. "Minvoice admin"). Under the public hostname, set domain to your Worker's
hostname (e.g.
invoice.example.com) and path toadmin. - Add an Allow policy with Include → Emails → your email address.
- Login method: new orgs default to the Cloudflare identity provider (sign in with your Cloudflare account — solid, MFA-backed). Heads-up: its consent screen briefly shows "Unknown app wants to access your account"; that's cosmetic and Cloudflare-side. Prefer emailed codes instead? Add One-Time PIN under Integrations → Identity providers and select it in the application's login methods.
- Save, then open the application's overview and copy the Application Audience (AUD) Tag.
- Put both values in
wrangler.jsonc—ACCESS_TEAM_DOMAIN(from step 1) andACCESS_AUD(from step 6) — andnpm run deploy. - Verify: in a private window,
https://yourhost/adminshould redirect to your*.cloudflareaccess.comlogin; after signing in you land in the admin. The dashboard's "password-based auth" warning disappears — Access has retired the password.
Notes:
- Fail-closed by design: if the AUD or team domain is wrong — or Access is later disabled at
the edge —
/adminreturns 403 rather than falling open. If you get a 403 after the Access login succeeds, one of the two values inwrangler.jsoncdoesn't match the application. - Staging shares the app: add the staging hostname as an additional domain on the same application (same AUD for both) instead of creating a second app.
- Your own pay-page visits stop being logged to invoice History once Access is active — the view tracking recognizes the Access session cookie.
Two providers, selectable in Settings:
- Resend (default for the starter config): verify your domain at Resend and set the
RESEND_API_KEYsecret. Free tier covers invoicing volume, works on the Workers Free plan. - Cloudflare Email Sending: onboard your domain (dashboard → Email Service) and add the
send_emailbinding fromwrangler.jsonc.example(requires Workers Paid to send to clients).
Either way, set the From address in Settings after first launch — sends fail loudly until it's set, and the dashboard warns about any provider misconfiguration.
Only ADMIN_PASSWORD is needed up front (the one-click flow prompts for exactly that). Payment
and Resend keys can be added either in-app (Settings → Payments & keys — zero CLI) or as
Wrangler secrets, which are excluded from database exports and always take precedence — the
hardened choice. Each payment method also has an on/off toggle in Settings. Pay buttons stay
hidden and the dashboard warns until a method is configured.
Keys entered in-app are envelope-encrypted (AES-256-GCM) before they're stored in D1, using a
SETTINGS_MASTER_KEY secret that npm run deploy generates automatically on first deploy. A
database export alone can't reveal them — reading a key requires the Worker secret too. Existing
deployments migrate on their own: any plaintext keys are re-encrypted the next time the Settings
page loads after the master key exists (Settings shows an alert until then). Don't rotate or
delete SETTINGS_MASTER_KEY — stored keys become undecryptable and must be re-entered.
npx wrangler secret put ADMIN_PASSWORD # unless you configured Access in step 3
npx wrangler secret put STRIPE_SECRET_KEY # restricted key: Checkout Sessions write
npx wrangler secret put STRIPE_WEBHOOK_SECRET # after registering the webhook (below)
npx wrangler secret put PAYPAL_CLIENT_ID
npx wrangler secret put PAYPAL_CLIENT_SECRET
npx wrangler secret put PAYPAL_WEBHOOK_ID
npx wrangler secret put RESEND_API_KEY # if using Resend for email
npm run deployRegister webhooks pointing at your domain:
- Stripe →
https://yourhost/webhooks/stripe, eventscheckout.session.completedandcheckout.session.async_payment_succeeded - PayPal →
https://yourhost/webhooks/paypal, eventPAYMENT.CAPTURE.COMPLETED
Payment buttons appear on pay pages only for providers whose credentials are configured — an invoice-only deployment (no payment secrets yet) still produces shareable, printable invoices.
Visit /admin, complete the setup wizard, set the email From address in Settings, and send
yourself a test invoice. The dashboard warns about any missing configuration.
The env.test block in wrangler.jsonc.example defines a full staging duplicate — its own D1,
hostname, and sandbox provider credentials — so fake money can never reach your real books.
npm run deploy:test / npm run db:migrate:test.
GET /health returns 200 only when the Worker and D1 both answer — point any external monitor
at it. If you firewall datacenter ASNs, exempt this path.
Set "Customer language & region" in Settings (built-in: en, es, de, fr), or override it
per client for businesses invoicing across languages. The value is a BCP-47 tag: the language
picks the wording, the full tag drives date and currency formatting — so de-AT gets German
text with Austrian formatting (18.07.2026, 1.234,56 €). Everything the CLIENT sees is
localized: invoice/reminder/receipt emails, the public pay page, the print view, and the PDF.
The admin UI stays English.
Adding a language is one file: copy src/lib/strings/en.ts, translate the values, and register
it in src/lib/strings/index.ts — the compiler enforces completeness. The same file is the
supported way to adjust wording you disagree with (say, MwSt. instead of USt.).
PDFs embed Noto Sans/Serif (SIL OFL, see public/fonts/pdf/OFL.txt) only when the document
actually needs them — the built-in locales (including umlauts, accents, and €) render with the
PDF standard fonts at ~3.5ms CPU, comfortably inside the Workers Free plan's 10ms limit. Text
in extended Latin (Polish, Czech, Turkish, Vietnamese), Greek, or Cyrillic triggers Noto
embedding with per-document glyph subsetting (~130ms CPU), which needs the Workers Paid plan's
CPU allowance. Right-to-left scripts (Arabic, Hebrew) and Indic scripts are out
of scope: correct rendering needs a text-shaping engine the PDF layer doesn't have. CJK would
need you to ship your own font files in a fork.
Spanish and French strings were drafted by the maintainers — native-speaker review PRs are welcome and are deliberately easy first contributions.
npm run deploy— applies pending D1 migrations (by binding name), then deploysnpm test— unit tests over the money/date/timeline/config logicnpm run db:wipe:prod— clear transactional data, keep settings (CLI-only by design)npm run db:reset:prod— factory reset; re-arms the setup wizard- D1 Time Travel provides 30-day point-in-time restore; take periodic
wrangler d1 exportsnapshots for older history
MIT. Bundled fonts (Fraunces, Instrument Sans) are under the SIL Open Font License — see public/fonts/LICENSE.




