How Kucatoo Pay works
Kucatoo Pay is the central billing hub for the Kucatoo product family (Coder, Cloud, Stores). Each person has one Stripe customer across all products — one checkout, one customer portal, one bill. Card details are always entered on Stripe's hosted pages, never in this app.
For your customers
| Page / action | What happens |
|---|---|
/ — pricing page |
Cards per product with plan badges. Customer enters their email and clicks
Subscribe → the app POSTs /api/checkout → browser is
redirected to Stripe Checkout. After paying, Stripe sends them back to
the product site ({product}.kucatoo.ai/billing/return). |
| Managing a subscription | Done in the Stripe Customer Portal (cancel, switch plans, update
card, download invoices). Product sites broker portal sessions server-to-server via
POST /api/portal with their per-product API key (the endpoint is
key-gated, like the entitlement API). There is no account page in this app —
identity is email → Stripe Customer. |
/legal/terms|privacy|refund |
Shared legal pages, linked from every product site's footer. |
Behind the scenes
- Webhooks (
POST /webhooks/stripe): Stripe events grant, sync, or revoke entitlements in the local database. Processing is idempotent by event ID, so Stripe retries are safe. - Entitlement API (
POST /v1/entitlementswith a JSON body{"customer_email": "…", "product": "…"}): product sites call this server-to-server (with their own API key) to check whether a customer may use the product. Statusesactive,trialing,past_duegrant access;canceled/nonedo not. POST keeps customer emails out of URLs and access logs. - Health:
GET /healthzchecks app + database (Fly.io health target).
Running it locally
start.bat # Windows — uses .venv if present ./start.sh # Linux/macOS
Serves on http://127.0.0.1:8081 (override with the PORT
env var) and opens the browser. The SQLite database (kucatoo_pay.db) and
its tables are created automatically on first start. Tests:
pytest tests/ -q — Stripe is mocked, no keys needed.
Configuration (.env)
| Variable | Notes |
|---|---|
STRIPE_SECRET_KEY |
sk_test_… for dev, sk_live_… for prod.
Empty → API returns 503 "Stripe is not configured". |
STRIPE_WEBHOOK_SECRET |
whsec_… from the Stripe dashboard (or from
stripe listen locally). |
PRODUCT_PRICES |
JSON map product → plan → Stripe price_… id. Products without a
configured price show "Pricing is being finalized" on the landing page. |
ENTITLEMENT_API_KEYS |
Comma-separated product=key pairs; each product site uses its own
key (403 on mismatch). |
ALLOWED_ORIGINS |
CORS + allowed return_url origins (open-redirect defense). |
DATABASE_URL |
Default sqlite:///./kucatoo_pay.db; Postgres in production. |
ENVIRONMENT |
development (default) or production. In production the
app refuses to boot on SQLite and expects the schema to be pre-applied via
python -m app.migrate (the Fly release_command). |
STRIPE_API_VERSION |
Pinned Stripe API version used for every SDK call; bump deliberately. |
ALERT_WEBHOOK_URL |
Optional. When a provision hook fails, a redacted JSON alert (no emails, no payloads) is POSTed here, best-effort. Empty disables alerting. |
Testing payments
- Success card:
4242 4242 4242 4242, any future expiry, any CVC. - Decline card:
4000 0000 0000 0002. - Local webhooks:
stripe listen --forward-to localhost:8081/webhooks/stripeand copy the printedwhsec_…into.env. - Stripe Products and Prices must have
metadata.product_siteset, or provisioning/plan sync breaks.
Gotchas
- No admin UI and no user accounts — everything runs through Stripe and email.
- Rate limit: 60 requests/minute by default per API key and per client IP on the open
endpoints (
/api/checkout,/api/portal); the Stripe webhook endpoint allows 300/minute per IP and rejects bodies over 1 MB (413). - Production deploys to Fly.io (
fly.toml/Dockerfile), servingpay.kucatoo.aion port 8080; webhooks must point athttps://pay.kucatoo.ai/webhooks/stripe.