Skip to content

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 routes
  • CTF_Saas_CTRL_Pane/ctrlapp/billing/stripe_client.py — stdlib-only Stripe REST client
  • CTF_Saas_CTRL_Pane/ctrlapp/billing/stripe_signature.py — stdlib-only signature verification
  • CTF_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:

  1. Resolves the plan slug via get_plan(slug). Returns 400 if the slug is unknown.
  2. Returns 400 if the plan's cta_kind is "contact" (i.e. the enterprise plan cannot be checked out).
  3. Calls service.create_tenant(name, tier, admin_email) — the pending tenant is stored immediately in state "provisioning".
  4. 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:

t=1614556800,v1=abc123...,v1=def456...

ctrlapp/billing/stripe_signature.py parses and verifies it using only the Python standard library:

  1. Parse the header: extract the t= timestamp (integer) and all v1= hex digests. Any malformed part returns False immediately.
  2. Replay check: abs(now - t) > tolerance (default 300 seconds). Stale requests are rejected. now is injectable for testing.
  3. Build signed payload: signed_payload = f"{t}.".encode() + raw_body_bytes
  4. Compute expected: HMAC-SHA256(secret.encode(), signed_payload).hexdigest()
  5. Compare: iterate the v1 list and check each with hmac.compare_digest (constant-time). Returns True if 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, provisionactive
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: provisioningactive, 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/trialingactive; past_due/unpaidpast_due; canceled/incomplete_expiredcanceled; 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 stable lookup_key of ctfhive_<slug>_monthly. Enterprise is contact-only and skipped.
  • Creates a webhook endpoint at <base-url>/billing/webhook subscribed to every event the control plane handles — checkout.session.completed, customer.subscription.created/.updated/.deleted, and invoice.paid/invoice.payment_succeeded/invoice.payment_failed — pulled from events.SUPPORTED_EVENTS so 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:

stripe listen --forward-to localhost:5000/billing/webhook
stripe trigger checkout.session.completed

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.