Stripe hardening rollout runbook¶
Operator procedure for the four-phase Stripe hardening rollout (issue #483): API version pinning, webhook event log + idempotency, two-endpoint (platform + Connect) webhook setup, and host-as-org binding.
Golden rules
- Demo first, always. demo.letsrevel.io mirrors prod (own env, own Stripe test-mode endpoint). Every phase — especially the Phase 3 cutover — runs there first and is verified with a test purchase before touching prod.
- Phases are ordered. Phase 3 (endpoint cutover) MUST NOT run before Phase 1 is deployed: the
StripeWebhookEventdedup is what makes the old/new endpoint overlap window harmless. - Webhook secrets are shown exactly once (at endpoint creation). Copy them immediately.
Version anchors (confirmed 2026-06-10)
| Surface | Before | After |
|---|---|---|
| Inbound webhook payloads (old endpoint pin) | 2025-07-30.basil | 2026-03-25.dahlia (Phase 3) |
| Outbound API responses (stripe SDK 14.3.0 built-in) | 2026-01-28.clover | 2026-03-25.dahlia (Phase 2) |
Log keys quick reference (read with the Loki CLI — see the loki-logs skill)
| structlog key | Meaning |
|---|---|
stripe_webhook_duplicate | Redelivered event id: full handler skipped, idempotent invoice/credit-note dispatches replayed. Expected noise during Phase 3 overlap. |
stripe_webhook_signature_failed | No configured secret matched → 403. Investigate if persistent. |
stripe_webhook_secret_missing | Only placeholder secrets configured → everything 403s. Config error. |
stripe_webhook_malformed_json | Valid HMAC but unparseable body. Should never happen from Stripe. |
stripe_webhook_unhandled_event | Subscribed-but-unmapped event type. Expected for the over-subscribed old endpoint until cutover. |
stripe_payment_success / stripe_refund_processed | Handler success paths. |
Admin event log: /admin/events/stripewebhookevent/ (read-only; filter by event_type, outcome, account).
Phase 1 — Hardening deploy (no behavior change)¶
Preconditions: PR 1 merged. No env changes needed — the code falls back to the existing single STRIPE_WEBHOOK_SECRET.
Steps (demo, then prod):
- Deploy backend as usual (
./deploy.sh updateon the target host); migrations run on deploy (newStripeWebhookEventtable + the pruning beat task). - Verify the migration applied:
- Verify fail-closed signature handling (this used to 400/500, must now 403):
- Generate real webhook traffic — make a test purchase (demo) or wait for organic traffic (prod) — then confirm rows appear in
/admin/events/stripewebhookevent/: - the purchase's
checkout.session.completedrow hasoutcome=handledand a non-emptyaccount(it arrives via the Connect endpoint); - a burst of
charge.*/payment_intent.*rows withoutcome=unhandledis expected — the old endpoint is over-subscribed (24 types vs 4 handled). This noise disappears at Phase 3 cutover. - Check logs are clean:
- Confirm the beat task is registered:
/admin/django_celery_beat/periodictask/→ "Prune Stripe webhook events", enabled, daily 04:45 UTC.
Rollback: redeploy the previous image. The new table is additive; leaving it in place is harmless.
Phase 2 — Pin API version to dahlia (outbound only)¶
Preconditions: Phase 1 deployed and verified. Compat audit (clover → dahlia for Checkout Session, Charge, Refund, PaymentIntent, Account) recorded in PR 2.
PR 2 also ships a charge.refunded compat fix: payloads rendered at any API version
= 2022-11-15 (so the basil-pinned endpoint today, and the dahlia endpoints from Phase 3) do not embed the charge's
refundslist; the handler now fetches the refunds outbound (stripe.Refund.list) when the payload omits them. The dashboard refund in step 2 below is what verifies this path end-to-end.
Steps (demo, then prod):
- Deploy PR 2. No env change needed (
STRIPE_API_VERSIONdefaults to2026-03-25.dahlia); set the env var only if you need a different pin. - Full payment loop on demo: test purchase → confirm in order:
- checkout session opens and completes (outbound
Session.createat dahlia works); checkout.session.completedrowoutcome=handled; ticket ACTIVE; invoice generated;- refund the charge from the Stripe dashboard →
charge.refundedrowoutcome=handled, ticket CANCELLED, credit note generated. - Exercise Connect onboarding sync (outbound
Account.retrieveat dahlia): in the org admin settings, trigger "verify Stripe account" for any connected org — flags should sync without error. - Watch for new errors:
Rollback: set STRIPE_API_VERSION=2026-01-28.clover in the env and restart (no redeploy needed), or redeploy the previous image. Inbound payloads are untouched by this phase either way.
Phase 3 — Endpoint cutover (the order-sensitive one)¶
Preconditions: Phases 1 + 2 deployed and verified on this host. PR 3 (the provisioning command) deployed. Run the WHOLE procedure on demo first.
Steps:
- Preview what will be created:
- Create the two new endpoints (old one still exists →
--force):Immediately copy both printeddocker compose exec web python manage.py provision_stripe_webhooks \ --url https://<host>/api/stripe/webhook --forcewhsec_*secrets — Stripe never shows them again. (If lost: delete the endpoint in the dashboard and re-run.)
For scripted self-hosted setup, provision_stripe_webhooks supports --format json (1.64.0+): it prints only a machine-readable object ({"platform": {"id", "secret"}, "connect": {"id", "secret"}}) instead of the human-readable block. It cannot be combined with --dry-run. 3. Update the env — new secrets first, old secret kept last (the overlap window):
handled row per logical event plus stripe_webhook_duplicate log lines as the second endpoint's copy is deduped. Both are healthy signals: 6. Check the Stripe dashboard (Workbench → Webhooks): both new endpoints show successful deliveries, zero failures. The new endpoints are pinned 2026-03-25.dahlia and subscribe only to the 4 handled event types. 7. Soak: demo — one verified purchase + refund is enough. Prod — leave the overlap for ~24h of organic traffic. 8. End the overlap, in this order: 1. Disable (don't delete) the OLD endpoint in the Stripe dashboard — keeps instant rollback available; 2. remove the old secret from STRIPE_WEBHOOK_SECRETS; 3. restart web. 9. Final verification: next purchase produces exactly one row per event, and stripe_webhook_duplicate lines stop appearing. After a quiet week, delete the disabled endpoint. Rollback (any point): re-enable the old endpoint in the dashboard and restore its secret to the CSV. The multi-secret verify tolerates extra secrets, so rollback is purely additive and instant.
Phase 4 — Bind the host org¶
Preconditions: Phases 1–3 complete on this host. Decide which organization is the host org (it must have no Stripe account connected yet).
Steps (demo first with a test-mode purchase, then prod with a real card):
- Django admin → Organizations → select exactly one org → action "Bind to platform Stripe account (superuser only)". Confirm the success message; the org's
stripe_account_idnow equalsSTRIPE_ACCOUNT. - Create a paid ticket tier on one of the org's events and buy it:
- checkout opens (no
stripe_accountparam, no application fee — verify the payment in the Stripe dashboard shows no fee); - the
checkout.session.completedrow appears with emptyaccount(platform endpoint delivery) andoutcome=handled; - ticket ACTIVE, invoice generated.
- Refund that charge from the Stripe dashboard →
charge.refunded(emptyaccount)handled, ticket CANCELLED, credit note generated. - Confirm a regular Connect org still works (any organic purchase, or one test purchase on demo).
Rollback: admin action "Unbind platform Stripe account" (refuses to touch real Connect bindings). Connect orgs are unaffected throughout.
Post-rollout state¶
STRIPE_WEBHOOK_SECRETSholds exactly two secrets (platform + Connect).STRIPE_WEBHOOK_SECRET(legacy, single) is unused and can be removed from env.- Webhook payloads and outbound calls are both pinned
2026-03-25.dahlia; future version bumps are a deliberate two-step: bumpSTRIPE_API_VERSION(outbound), then re-provision endpoints (inbound), with compat audit in between. - Event log self-prunes after
STRIPE_WEBHOOK_EVENT_RETENTION_DAYS(default 90).