Billing & Stripe Webhooks¶
The billing integration lives in three files plus a one-time setup script:
CTF_Saas_CTRL_Pane/ctrlapp/blueprints/billing/routes.py— the two HTTP routesCTF_Saas_CTRL_Pane/ctrlapp/billing/stripe_client.py— stdlib-only Stripe REST clientCTF_Saas_CTRL_Pane/ctrlapp/billing/stripe_signature.py— stdlib-only signature verificationCTF_Saas_CTRL_Pane/scripts/stripe_bootstrap.py— creates your products, prices and webhook endpoint
The official stripe Python SDK is not installed. Both outbound API calls and inbound webhook signatures use only the Python standard library (urllib + hmac), which keeps the dependency surface minimal and the whole path fully auditable — there is no third-party code between the wire and the HMAC.
Start here: fill in .env, then run the bootstrap script
Copy CTF_Saas_CTRL_Pane/.env.example to .env and paste in your STRIPE_SECRET_KEY/STRIPE_PUBLISHABLE_KEY from the Stripe dashboard. Then run the bootstrap script — it creates the products and prices and prints the STRIPE_PRICE_* and STRIPE_WEBHOOK_SECRET values for you to paste back in. All variables ship empty in source.
POST /billing/checkout¶
Initiates a checkout for a given plan slug.
Accepts: application/x-www-form-urlencoded or application/json with a plan key (and optionally name, email).
Behavior:
- Resolves the plan slug via
get_plan(slug). Returns400if the slug is unknown. - Returns
400if the plan'scta_kindis"contact"(i.e. theenterpriseplan cannot be checked out). - Calls
service.create_tenant(name, tier, admin_email)— the pending tenant is stored immediately in state"provisioning". - Branches on configuration:
{
"status": "checkout_not_configured",
"message": "Stripe is not configured; created a pending tenant.",
"tenant_id": "<uuid4hex>",
"plan": "standard"
}
The tenant is created and stays in "provisioning" state. No Stripe call is made. You can poll GET /tenants/<tenant_id>/status to confirm it landed.
A real Checkout Session is created and its hosted-payment URL is returned. The session carries client_reference_id = tenant_id and metadata.plan/tenant_id/name, and sets subscription_data.metadata.tenant_id so the subscription it creates inherits the tenant id — that is how later customer.subscription.* events resolve back to this tenant. An Idempotency-Key is sent so a transient retry of the call cannot create two sessions. The completing webhook provisions this pending tenant rather than creating a duplicate.
{
"status": "checkout_created",
"tenant_id": "<uuid4hex>",
"plan": "standard",
"checkout_url": "https://checkout.stripe.com/c/pay/cs_test_…",
"session_id": "cs_test_…"
}
Redirect the buyer to checkout_url. The session is mode=subscription with a single line item — the plan's price id read from the env var named by plan.stripe_price_env (e.g. STRIPE_PRICE_STANDARD). success_url/cancel_url default to PUBLIC_BASE_URL and are overridable via STRIPE_SUCCESS_URL/STRIPE_CANCEL_URL.
{
"error": "price_not_configured",
"plan": "solo",
"message": "set STRIPE_PRICE_SOLO (run scripts/stripe_bootstrap.py)"
}
Returned with HTTP 503 when STRIPE_SECRET_KEY is set but the plan's STRIPE_PRICE_* var is empty. The price is validated before any tenant is created, so this path leaves nothing behind (no tenant_id in the body). Run the bootstrap script to populate it. On a Stripe API failure the route returns HTTP 502 with {"error": "stripe_checkout_failed", ...} and rolls back the pending tenant it had just created — a checkout that never yields a payment URL never orphans a tenant.
POST /billing/webhook¶
Receives Stripe events. The route verifies the signature before doing anything else.
Signature verification¶
The Stripe-Signature header looks like:
ctrlapp/billing/stripe_signature.py parses and verifies it using only the Python standard library:
- Parse the header: extract the
t=timestamp (integer) and allv1=hex digests. Any malformed part returnsFalseimmediately. - Replay check:
abs(now - t) > tolerance(default 300 seconds). Stale requests are rejected.nowis injectable for testing. - Build signed payload:
signed_payload = f"{t}.".encode() + raw_body_bytes - Compute expected:
HMAC-SHA256(secret.encode(), signed_payload).hexdigest() - Compare: iterate the
v1list and check each withhmac.compare_digest(constant-time). ReturnsTrueif any matches.
Multiple v1 values in a single header support Stripe's key-rotation window.
A 400 is returned if the secret is empty, the header is missing, the timestamp is stale, or no v1 digest matches.
Supported events¶
The webhook handles the full subscription lifecycle, not just the initial
checkout. Events are routed through a dispatch table in
ctrlapp/blueprints/billing/events.py (its SUPPORTED_EVENTS list is also what
the bootstrap script subscribes the endpoint to, so the two cannot drift):
| Event type | Action |
|---|---|
checkout.session.completed |
Reuse/create the pending tenant, link its Stripe subscription id, provision → active |
customer.subscription.created / .updated |
Sync tenant tier to the subscription's current price; map subscription status → tenant state |
customer.subscription.deleted |
Tenant → canceled (terminal) |
invoice.paid / invoice.payment_succeeded |
Renewal succeeded → tenant active |
invoice.payment_failed |
Renewal failed → tenant past_due |
| anything else | {"status": "ignored", "type": "..."} — 200, no action |
Tenant lifecycle states: provisioning → active, with failed (a
provisioning error), past_due (a renewal payment failed), and canceled (the
subscription ended). canceled is terminal — because Stripe delivers events
at-least-once and may reorder them, a late invoice.* or subscription.updated
for a canceled subscription is acknowledged ("status": "ignored_terminal") but
does not resurrect the tenant. The /tenants/<id>/status endpoint now also
returns subscription_id.
Subscription status → tenant state mapping: active/trialing →
active; past_due/unpaid → past_due; canceled/incomplete_expired →
canceled; incomplete leaves the tenant unchanged (first payment not yet
collected).
Correlating later events back to a tenant. Subscription events resolve the
tenant via metadata.tenant_id — set on the Checkout Session and, via
subscription_data[metadata], on the resulting Subscription. Invoice events
carry only a Stripe subscription id, so they resolve through the control
plane's in-memory subscription_id → tenant_id index (populated at checkout
time). An event whose tenant cannot be found returns {"status": "unmatched"}
with HTTP 200 so Stripe stops retrying — see the in-memory-store caveat below
for the operational implication after a restart.
checkout.session.completed flow¶
The handler extracts from event.data.object:
| Field | Source | Fallback |
|---|---|---|
| Plan slug | metadata.plan |
"standard" |
| Tenant name | metadata.name |
session.id → "ctfhive-event" |
| Admin email | session.customer_email |
metadata.email → "" |
It then resolves the tenant and provisions:
# Reuse the pending tenant created at checkout time when the session carries a
# known id; otherwise create one (covers webhook-only / replayed events).
existing_id = session.client_reference_id or metadata.tenant_id
if existing_id and service.get(existing_id):
tenant_id = existing_id
else:
tenant_id = service.create_tenant(name, tier=plan.slug, admin_email=...)
tenant = service.provision(tenant_id)
In-memory tenant store
ProvisionService keeps tenants and the subscription_id → tenant_id index in a process-local dict. If checkout and the webhook are handled by different worker processes, the webhook will not find the pending tenant and will create a fresh one. More importantly, after a restart the subscription index is empty, so an invoice.* event (which resolves only via that index) returns {"status": "unmatched"} and the tenant silently never flips to past_due/active on that renewal. A persistent (Postgres) store is the documented follow-up; the method surface does not change. For now, run the control plane single-process so the index survives for the life of the process.
A successful response looks like:
{
"status": "ok",
"type": "checkout.session.completed",
"tenant_id": "<uuid4hex>",
"tenant_state": "active"
}
tenant_state may be "failed" if provisioning raised an exception (captured in tenant.error).
One-time setup: stripe_bootstrap.py¶
Once your secret key is in the environment, this script creates everything Stripe-side and prints the remaining env values. It is idempotent — re-running reuses existing prices (matched by lookup_key) and an existing webhook endpoint (matched by URL).
cd CTF_Saas_CTRL_Pane
export STRIPE_SECRET_KEY=sk_test_… # from your .env / dashboard
# Preview without touching Stripe:
uv run python scripts/stripe_bootstrap.py --base-url https://your-domain --dry-run
# Create products, prices and the webhook endpoint for real:
uv run python scripts/stripe_bootstrap.py --base-url https://your-domain
What it does, driven by the canonical ctrlapp.pricing.PLANS list (so prices can never drift from the pricing page):
- For solo / standard / pro — creates a Product (
CTFHive <Plan>) and a recurring monthly Price ($25/$65/$125,usd) with a stablelookup_keyofctfhive_<slug>_monthly. Enterprise is contact-only and skipped. - Creates a webhook endpoint at
<base-url>/billing/webhooksubscribed to every event the control plane handles —checkout.session.completed,customer.subscription.created/.updated/.deleted, andinvoice.paid/invoice.payment_succeeded/invoice.payment_failed— pulled fromevents.SUPPORTED_EVENTSso the registered set and the handled set cannot drift.
It prints an env block to stdout (progress goes to stderr, so > .env.stripe captures only the block):
# --- CTFHive Stripe configuration (generated by stripe_bootstrap.py) ---
STRIPE_PRICE_SOLO=price_…
STRIPE_PRICE_STANDARD=price_…
STRIPE_PRICE_PRO=price_…
STRIPE_WEBHOOK_SECRET=whsec_…
Paste those into your .env.
| Flag | Effect |
|---|---|
--base-url URL |
Public site URL; the webhook posts to <URL>/billing/webhook. Defaults to PUBLIC_BASE_URL. |
--dry-run |
Print intended actions; make no API calls (works without a key). |
--no-webhook |
Skip webhook creation (e.g. you manage it in the dashboard). |
--force-webhook |
Always create a new endpoint to obtain a fresh signing secret. |
The webhook signing secret is only shown once
Stripe returns whsec_… only when an endpoint is created. If the script reuses an existing endpoint it cannot print the secret — reveal/roll it in the dashboard, or pass --force-webhook for a new one.
Environment variables¶
All values ship empty in config.py and are documented in CTF_Saas_CTRL_Pane/.env.example. The app starts without them: checkout returns checkout_not_configured and webhooks are rejected with 400 (empty secret → signature always fails). The STRIPE_PRICE_* and STRIPE_WEBHOOK_SECRET values come from the bootstrap script above.
| Variable | Purpose | Example value |
|---|---|---|
STRIPE_SECRET_KEY |
Authenticates outbound Stripe API calls | sk_live_… |
STRIPE_PUBLISHABLE_KEY |
Passed to the frontend for Stripe.js | pk_live_… |
STRIPE_WEBHOOK_SECRET |
Validates incoming webhook signatures | whsec_… |
STRIPE_API_VERSION |
Pins the Stripe-Version header on every call |
2024-06-20 |
STRIPE_PRICE_SOLO |
Stripe Price ID for the Solo plan | price_1… |
STRIPE_PRICE_STANDARD |
Stripe Price ID for the Standard plan | price_1… |
STRIPE_PRICE_PRO |
Stripe Price ID for the Pro plan | price_1… |
STRIPE_SUCCESS_URL |
Post-checkout success redirect (optional) | https://…/billing/success?session_id={CHECKOUT_SESSION_ID} |
STRIPE_CANCEL_URL |
Post-checkout cancel redirect (optional) | https://…/pricing |
PUBLIC_BASE_URL |
Site origin; default base for success/cancel + canonical links | https://ctfhive.us |
Testing the webhook locally¶
Use the Stripe CLI to forward events and forge the signature header automatically:
The test suite in tests/test_webhook.py, tests/test_billing_events.py and tests/test_stripe_signature.py constructs valid headers using TestingConfig.STRIPE_WEBHOOK_SECRET = "whsec_test" and the same HMAC logic, so you can verify the signature scheme and the entire subscription lifecycle (provision → renew → fail → recover → plan-change → cancel) without a live Stripe account.
End-to-end smoke test: scripts/live_smoke.py¶
The pytest suite is fully network-free. scripts/live_smoke.py is a separate,
manual check that makes exactly one real Stripe call — creating a TEST-mode
Checkout Session — to prove the checkout route is wired to the live REST API,
then drives every webhook handler locally with forged-but-valid signatures:
cd CTF_Saas_CTRL_Pane
set -a; source .env; set +a # loads STRIPE_SECRET_KEY (sk_test_…) + whsec
uv run python scripts/live_smoke.py
It refuses to run against a non-test key (sk_test_/rk_test_) unless
ALLOW_LIVE=1 is set, and the only Stripe-side artifact is an unpaid Checkout
Session that expires on its own — so it is safe to re-run.