Stripe webhook endpoints¶
Steady-state reference for Revel's Stripe webhook setup. For the one-time migration that established it, see stripe-hardening-rollout.md.
Topology¶
Two Stripe webhook endpoints, both pointing at the same URL (https://<host>/api/stripe/webhook), distinguished by Stripe's connect flag. Stripe delivers an event to every endpoint subscribed to it; the two endpoints never overlap because platform-account events and connected-account events are disjoint by construction.
| Endpoint | connect | Subscribed events | Delivers |
|---|---|---|---|
| Platform ("Your account") | false | checkout.session.completed, charge.refunded, charge.dispute.*, payment_intent.canceled | Events on STRIPE_ACCOUNT itself — i.e. the host-bound organization's sales |
| Connect ("Connected accounts") | true | same + account.updated | Events on organizations' connected accounts (event.account is set) |
Both endpoints are pinned to STRIPE_API_VERSION — webhook payload shapes follow the endpoint's pinned version, while outbound call/response shapes follow the stripe.api_version set in code. Keep them the same version; a future bump is a deliberate two-step (outbound pin first, then re-provision endpoints) with a compat audit in between.
The subscribed event lists live in events/management/commands/provision_stripe_webhooks.py and must stay in sync with the dispatch map in events/service/stripe_webhooks.py::StripeEventHandler.handle — Stripe only delivers events you subscribe to, and unmapped deliveries land as unhandled rows in the event log.
Secrets¶
STRIPE_WEBHOOK_SECRETS(CSV, no spaces) holds onewhsec_*per endpoint;verify_webhooktries each in order, first HMAC match wins. Order them most-traffic-first (Connect endpoint first in practice).- Secrets are shown by Stripe exactly once, at endpoint creation (the provisioning command prints them). If one is lost: delete that endpoint in the dashboard and re-provision.
- The legacy single
STRIPE_WEBHOOK_SECRETis only a fallback when the CSV is unset; steady state does not use it.
Provisioning / rotation¶
# Preview
docker compose exec web python manage.py provision_stripe_webhooks \
--url https://<host>/api/stripe/webhook --dry-run
# Create the pair (--force if endpoints already target the URL, e.g. rotation)
docker compose exec web python manage.py provision_stripe_webhooks \
--url https://<host>/api/stripe/webhook --force
Rotation = provision a new pair with --force, add the new secrets to the CSV alongside the old ones, restart, verify deliveries, then disable the old endpoints in the dashboard and drop their secrets. The event-log dedup (StripeWebhookEvent.event_id unique) makes the overlap window harmless.
Machine-readable output (1.64.0+)
Pass --format json to print only a JSON object ({"platform": {"id", "secret"}, "connect": {"id", "secret"}}) instead of the human-readable secrets block — for scripted self-hosted setup (e.g. setup.sh). It cannot be combined with --dry-run.
Host-as-org binding¶
Exactly one organization may use the platform's own Stripe account: Django admin → Organizations → select one → "Bind to platform Stripe account (superuser only)". Its checkout/refund events arrive via the platform endpoint (event.account empty, stored as "" in the event log). No application fee is charged (Stripe forbids fees on own-account charges). "Unbind platform Stripe account" reverses it and refuses to touch real Connect bindings. The unique constraint on stripe_account_id prevents double-binding.
Observability¶
- Event log:
/admin/events/stripewebhookevent/— every verified delivery, with outcome (handled/unhandled),account, full payload; pruned afterSTRIPE_WEBHOOK_EVENT_RETENTION_DAYS(default 90 — keep ≥ 30, the manual resend window). - Logs:
scripts/loki_logs.py web -g stripe_webhook --since 1h— key events:stripe_webhook_duplicate(dedup hit; expected during rotation overlap),stripe_webhook_signature_failed(no secret matched → 403; investigate if persistent),stripe_webhook_unhandled_event(subscribed-but-unmapped type). - Stripe side: Workbench → Webhooks shows per-endpoint delivery success rates.
Adding event types to existing endpoints¶
provision_stripe_webhooks only creates endpoints; it never edits ones that already exist. When a release adds event types to PLATFORM_EVENTS (for example charge.dispute.created, charge.dispute.updated and charge.dispute.closed, which feed the organizer dispute records), the already-provisioned endpoints keep their old subscription list until you update them by hand. Do this for both the platform and the Connect endpoint:
- Dashboard: Workbench → Webhooks → select the endpoint → Update details (or "Edit destination") → add the new event types → save. The signing secret does not change.
- API:
stripe.WebhookEndpoint.modify("we_...", enabled_events=[...])with the full desired list (the call replaces the list, it does not append). UsePLATFORM_EVENTSfor the platform endpoint andCONNECT_EVENTSfor the Connect endpoint.
Until an endpoint is updated, its new events simply never arrive, so nothing is recorded and no error is raised. Verify with a Dashboard test event or the event log at /admin/events/stripewebhookevent/.