Stripe Billing Runbook¶
This runbook covers how to operate Stripe-backed billing in dev-health-ops across local development, CI, and production operations.
Deployment Paths¶
| Path | Stripe required | Core mechanism |
|---|---|---|
| SaaS | Yes | Stripe checkout, portal, and webhook flow |
| Self-hosted | No | Offline license flow (DEV_HEALTH_LICENSE) |
If you are self-hosting only, skip Stripe setup and use DEV_HEALTH_LICENSE as documented in the self-hosted guides.
Billing API Contract (Current)¶
The runbook assumes the current billing endpoints in dev_health_ops.api.billing.router:
| Endpoint | Method | Purpose |
|---|---|---|
/api/v1/billing/webhooks/stripe |
POST |
Receives Stripe webhook events |
/api/v1/billing/checkout |
POST |
Creates Stripe Checkout sessions |
/api/v1/billing/portal |
POST |
Creates Stripe Billing Portal sessions |
/api/v1/billing/entitlements/{org_id} |
GET |
Returns org entitlements |
/api/v1/billing/audit |
GET |
Lists billing audit and reconciliation entries (superadmin) |
/api/v1/billing/audit/{audit_id} |
GET |
Gets one billing audit entry (superadmin) |
/api/v1/billing/audit/{audit_id}/resolve |
POST |
Marks mismatch resolution (superadmin) |
/api/v1/billing/reconcile |
POST |
Triggers reconciliation run (superadmin) |
Required Environment Variables (SaaS)¶
Set these before running API billing flows:
export STRIPE_SECRET_KEY="sk_test_..."
export STRIPE_WEBHOOK_SECRET="whsec_..."
export STRIPE_PRICE_ID_TEAM="price_..."
export STRIPE_PRICE_ID_ENTERPRISE="price_..."
export LICENSE_PRIVATE_KEY="<base64-ed25519-private-key>"
Optional but recommended for checkout URL validation:
export APP_BASE_URL="http://localhost:3000"
export ALLOWED_CHECKOUT_DOMAINS="http://localhost:3000,https://staging.example.com"
Local Workflow (SaaS)¶
1) Start API with billing env¶
# Example local API startup
export CLICKHOUSE_URI="clickhouse://localhost:8123/default"
export POSTGRES_URI="postgresql+asyncpg://postgres:postgres@localhost:5432/devhealth"
dev-hops api --db "$CLICKHOUSE_URI" --host 0.0.0.0 --port 8000 --reload
2) Install and authenticate Stripe CLI¶
# macOS (Homebrew)
brew install stripe/stripe-cli/stripe
# Authenticate Stripe CLI for your Stripe account
stripe login
3) Forward Stripe events to local webhook endpoint¶
stripe listen \
--forward-to http://127.0.0.1:8000/api/v1/billing/webhooks/stripe \
--events checkout.session.completed,customer.subscription.created,customer.subscription.updated,customer.subscription.deleted,customer.subscription.trial_will_end,invoice.created,invoice.updated,invoice.finalized,invoice.paid,invoice.payment_failed,invoice.voided,charge.refunded,charge.refund.updated
Copy the emitted signing secret (whsec_...) and set it as STRIPE_WEBHOOK_SECRET in your shell where the API runs.
4) Trigger local event flows¶
# Simulate checkout completion
stripe trigger checkout.session.completed
# Simulate recurring invoice events
stripe trigger invoice.paid
stripe trigger invoice.payment_failed
# Simulate subscription updates
stripe trigger customer.subscription.updated
5) Validate API-level outcomes¶
# Health check
curl http://127.0.0.1:8000/health
# Trigger reconciliation (requires superadmin auth in real environments)
curl -X POST "http://127.0.0.1:8000/api/v1/billing/reconcile"
Webhook Replay, Retry, and Idempotency¶
Stripe retry model¶
- Stripe retries failed deliveries automatically.
- Manual replays can come from Stripe Dashboard or Stripe CLI.
- Your API must tolerate at-least-once delivery.
Current idempotency behavior in dev-health-ops¶
- Invoice webhook handling checks duplicate Stripe event IDs before processing invoice writes.
- Duplicate invoice events are skipped and logged.
- For subscription/refund mismatches or replay uncertainty, use reconciliation and audit endpoints.
Safe replay playbook¶
- Confirm the webhook endpoint returns non-2xx or missed state change.
- Replay specific events:
# List recent events
stripe events list --limit 20
# Replay one event to the local endpoint
stripe events resend evt_123 --webhook-endpoint we_123
- Run reconciliation:
curl -X POST "http://127.0.0.1:8000/api/v1/billing/reconcile"
- Inspect audit trail for unresolved mismatches:
curl "http://127.0.0.1:8000/api/v1/billing/audit?org_id=<org-uuid>"
Ops Workflow (SaaS Production)¶
Incident triage checklist¶
- Verify env vars in runtime (
STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET, price IDs,LICENSE_PRIVATE_KEY). - Verify Stripe webhook endpoint URL exactly matches:
https://<your-domain>/api/v1/billing/webhooks/stripe- Verify Stripe endpoint event subscriptions include the billing lifecycle events listed in this runbook.
- Confirm webhook delivery status in Stripe Dashboard (response code + body).
- Trigger reconciliation and review mismatches via audit endpoints.
Reconciliation commands¶
Use either API endpoint or CLI:
# API
curl -X POST "https://<your-domain>/api/v1/billing/reconcile"
# CLI (from ops runtime with DB/env configured)
python -m dev_health_ops.cli billing reconcile
# Scoped reconcile by org
python -m dev_health_ops.cli billing reconcile --org-id <org-uuid>
# Reconcile invoices since timestamp
python -m dev_health_ops.cli billing reconcile --org-id <org-uuid> --since 2026-02-24T00:00:00
Resolving mismatches¶
- Pull mismatch entries from
/api/v1/billing/audit. - Investigate local vs Stripe state.
- Mark resolved when remediation completes:
curl -X POST "https://<your-domain>/api/v1/billing/audit/<audit-id>/resolve" \
-H "Content-Type: application/json" \
-d '{"resolution":"manual correction applied after Stripe replay"}'
Free Trial Configuration¶
The Team tier supports a self-serve free trial via Stripe Checkout. When a user clicks "Start free trial" on the pricing page, they go through signup → onboarding → Stripe Checkout with subscription_data.trial_period_days set.
Required Setup¶
- Set
TRIAL_DAYS(defaults to 14 if unset):
export TRIAL_DAYS=14
- Set Stripe env vars (required for any billing flow):
export STRIPE_SECRET_KEY="sk_test_..."
export STRIPE_WEBHOOK_SECRET="whsec_..."
export STRIPE_PRICE_ID_TEAM="price_..."
export STRIPE_PRICE_ID_ENTERPRISE="price_..."
- Set
APP_BASE_URLto the frontend URL (used in trial emails):
export APP_BASE_URL="http://localhost:3000"
- Configure email delivery (for trial notifications):
export EMAIL_PROVIDER="smtp" # smtp for local Mailpit, resend for prod
export SMTP_HOST="localhost" # or mailpit in compose
export SMTP_PORT="1025"
export EMAIL_FROM_ADDRESS="noreply@yourdomain.com"
- Subscribe to the
customer.subscription.trial_will_endwebhook event in Stripe (or via CLI):
stripe listen \
--forward-to http://127.0.0.1:8000/api/v1/billing/webhooks/stripe \
--events checkout.session.completed,customer.subscription.created,customer.subscription.updated,customer.subscription.deleted,customer.subscription.trial_will_end,invoice.created,invoice.updated,invoice.finalized,invoice.paid,invoice.payment_failed,invoice.voided,charge.refunded,charge.refund.updated
Trial Flow (End-to-End)¶
Pricing page ("Start free trial")
→ /auth/signup?plan=team&trial=true
→ Registration + email verification
→ /auth/signin?plan=team&trial=true
→ /auth/onboard?plan=team&trial=true
→ /auth/trial-checkout (creates Stripe Checkout with trial_period_days)
→ Stripe Checkout (trial subscription created)
→ Webhook: customer.subscription.created (status=trialing)
→ Org tier updated to Team
→ trial_started email sent
→ [3 days before expiry] Webhook: customer.subscription.trial_will_end
→ trial_expiring email sent
→ [Trial ends] Stripe converts to paid or cancels
→ Webhook: customer.subscription.updated (status=active or canceled)
Trial Behavior by Tier¶
| Tier | Trial? | Duration | Behavior |
|---|---|---|---|
| Community | No | — | Free forever, no checkout |
| Team | Yes | TRIAL_DAYS (default 14) |
Self-serve via pricing page |
| Enterprise | No | — | Sales-led, no self-serve trial |
Trial Abuse Prevention¶
Orgs that previously had a trial are automatically detected. Subsequent checkout sessions are created without trial_period_days — the org is charged immediately instead of receiving another trial. This is logged in the billing audit.
Trial Emails¶
| Trigger | Template | When |
|---|---|---|
| Subscription created with trial | trial_started |
Immediately |
customer.subscription.trial_will_end |
trial_expiring |
3 days before trial end |
| Trial subscription canceled/expired | trial_expired |
On expiry |
Compose Setup (Local Dev)¶
The compose.yml worker already subscribes to the webhooks queue where send_billing_notification runs. Ensure the API and worker services have the Stripe env vars uncommented:
# In compose.yml, uncomment these in both api and worker services:
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
STRIPE_PRICE_ID_TEAM: ${STRIPE_PRICE_ID_TEAM:-}
STRIPE_PRICE_ID_ENTERPRISE: ${STRIPE_PRICE_ID_ENTERPRISE:-}
APP_BASE_URL: ${APP_BASE_URL:-http://localhost:3000}
TRIAL_DAYS: ${TRIAL_DAYS:-14}
Then set the values in dev-health-ops/.env (copy from .env.example and fill in your Stripe test keys).
Email Notifications¶
When billing webhook events are processed, the system automatically sends email notifications to the organization owner. This happens after all database operations complete successfully.
Emails Sent¶
| Event | Details | |
|---|---|---|
invoice.paid |
Invoice receipt | Amount, currency, link to hosted invoice |
invoice.payment_failed |
Payment failed alert | Amount, currency, retry attempt count |
customer.subscription.updated |
Subscription changed | Old tier → new tier (only sent when tier actually changes) |
customer.subscription.deleted |
Subscription cancelled | Current tier name |
Email Delivery Guarantees¶
- Emails are dispatched asynchronously via Celery on the
webhooksqueue — the webhook handler returns immediately after enqueuing. - Failed email deliveries are retried up to 3 times with exponential backoff (30s, 60s, 120s).
- Database state is never affected by email failures — DB commits happen before email dispatch.
- If the Celery broker (Redis) is unavailable, email dispatch is silently skipped — the webhook still succeeds.
- If no organization owner is found (missing
org_idin metadata or no owner-role member), the email is silently skipped with a warning log.
Email Provider Configuration¶
Billing emails use the same email service as account emails (invites, verification, password reset). Configure via:
export EMAIL_PROVIDER="resend" # or "console" for dev/test
export EMAIL_API_KEY="re_..." # Resend API key
export EMAIL_FROM_ADDRESS="noreply@yourdomain.com"
See Email Setup for full provider configuration, troubleshooting, and template details.
Verifying Email Delivery Locally¶
- Start the API with
EMAIL_PROVIDER=console(default) to log emails to stdout. - Forward Stripe events as described in the Local Workflow section.
- Trigger an event:
stripe trigger invoice.paid - Check API logs for the rendered email content.
To test with real email delivery, set EMAIL_PROVIDER=resend with a valid API key and from address.
CI Workflow¶
Secret handling¶
- Store Stripe values in CI secret manager, never in repo files:
STRIPE_SECRET_KEYSTRIPE_WEBHOOK_SECRETSTRIPE_PRICE_ID_TEAMSTRIPE_PRICE_ID_ENTERPRISELICENSE_PRIVATE_KEY- Inject them as environment variables at job runtime.
Example (generic CI shell step):
export STRIPE_SECRET_KEY="$CI_STRIPE_SECRET_KEY"
export STRIPE_WEBHOOK_SECRET="$CI_STRIPE_WEBHOOK_SECRET"
export STRIPE_PRICE_ID_TEAM="$CI_STRIPE_PRICE_ID_TEAM"
export STRIPE_PRICE_ID_ENTERPRISE="$CI_STRIPE_PRICE_ID_ENTERPRISE"
export LICENSE_PRIVATE_KEY="$CI_LICENSE_PRIVATE_KEY"
pytest -q tests/test_billing.py tests/test_subscriptions.py tests/test_invoices.py tests/test_refunds.py
CI guardrails¶
- Use Stripe test-mode keys only (
sk_test_...). - Avoid printing secret values in logs.
- Keep webhook-signature tests deterministic by stubbing payload/signature where possible.
Stripe Test Card Matrix¶
Use Stripe test mode and these cards during checkout/billing validation:
| Scenario | Card number | Notes |
|---|---|---|
| Successful payment | 4242 4242 4242 4242 |
Baseline success path |
| Generic decline | 4000 0000 0000 0002 |
Payment declined |
| Insufficient funds | 4000 0000 0000 9995 |
Insufficient funds path |
| 3DS required | 4000 0025 0000 3155 |
Authentication flow required |
| Expired card | 4000 0000 0000 0069 |
Expiration failure path |
| Incorrect CVC | 4000 0000 0000 0127 |
CVC validation failure |
For all test cards, use any future expiry date, any 3-digit CVC, and any postal code.
Self-Hosted Note¶
Self-hosted deployments do not require Stripe endpoint configuration. Use offline license keys and set:
export DEV_HEALTH_LICENSE="<signed-license-token>"
Reference: self-hosted-quickstart.md.