Handle Stripe Webhooks in Production Django

Handle Stripe webhooks reliably in a production Django app: verify signatures with the raw body, exempt only the webhook view from CSRF, respond fast and process in Celery, make handlers idempotent with event IDs, and monitor failed deliveries.

Problem statement

Stripe tells your app about payments, subscriptions, refunds, and disputes through webhooks: HTTPS POST requests to an endpoint you register. Handling them in development with the Stripe CLI is easy. In production, the same handler meets real problems: signature verification failing behind proxies, CSRF rejecting the request, Stripe retrying slow responses so events arrive twice, events arriving out of order, and failures nobody notices until a customer complains they paid but got nothing.

Reliable Stripe webhooksStripe POSTsigned eventVerify signatureraw body, secretStore + 200 fastevent ID recordedCelery processesidempotent handlerVerify, record, acknowledge quickly, then process asynchronously and idempotently.
  1. Create the endpoint in the Stripe dashboard with only the event types you need; store its signing secret as STRIPE_WEBHOOK_SECRET.
  2. In the view, verify with stripe.Webhook.construct_event(request.body, sig_header, secret) using the raw body.
  3. Decorate only that view with @csrf_exempt; signature verification replaces CSRF protection.
  4. Save the event ID (unique) and return 200 within a few seconds; process in a Celery task.
  5. Make processing idempotent: skip already-processed event IDs and re-fetch the object from Stripe when order matters.
  6. Monitor failed deliveries in the Stripe dashboard and alert on your own processing errors.

Step-by-step solution

1. Register the endpoint

In the Stripe dashboard (Developers → Webhooks), add https://example.com/stripe/webhook/ and select events such as checkout.session.completed, invoice.paid, invoice.payment_failed, customer.subscription.updated, and charge.refunded. Each endpoint has its own signing secret (whsec_...); test mode and live mode have different secrets.

2. Verify the signature

# payments/views.py
import stripe
from django.conf import settings
from django.db import transaction
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST

from .models import StripeEvent
from .tasks import process_stripe_event

@csrf_exempt
@require_POST
def stripe_webhook(request):
    try:
        event = stripe.Webhook.construct_event(
            request.body, request.headers.get("Stripe-Signature", ""), settings.STRIPE_WEBHOOK_SECRET
        )
    except (ValueError, stripe.error.SignatureVerificationError):
        return HttpResponse(status=400)

    obj, created = StripeEvent.objects.get_or_create(
        stripe_id=event["id"], defaults={"type": event["type"], "payload": event.to_dict()}
    )
    if created:
        transaction.on_commit(lambda: process_stripe_event.delay(obj.pk))
    return HttpResponse(status=200)

Use request.body (the raw bytes). Any middleware or proxy that re-encodes the body, such as a JSON-parsing layer, breaks the signature. The signature also includes a timestamp; construct_event rejects events older than five minutes by default, which requires an accurate server clock (NTP).

from Stripe POST to fulfilled order1Verify signature on raw body400 if invalid2Insert event ID (unique)duplicates are no-ops3Return 200 quicklywell under Stripe timeout4Celery task processes eventretries on failure5Fulfil idempotentlycheck state before actingAcknowledge fast, process later, and never act twice on one eventdjango-deployment.com
The unique event ID is what makes Stripe retries harmless.

3. Keep CSRF on everywhere else

@csrf_exempt applies only to this view. Do not disable CsrfViewMiddleware globally. If the endpoint is in an API app with DRF, use a plain Django view or a DRF view with no authentication classes and the signature check as the only gate.

4. Process asynchronously and idempotently

# payments/tasks.py
@shared_task(bind=True, max_retries=8, retry_backoff=True)
def process_stripe_event(self, event_pk):
    ev = StripeEvent.objects.select_for_update(skip_locked=True).filter(pk=event_pk, processed_at__isnull=True)
    with transaction.atomic():
        ev = ev.first()
        if ev is None:
            return
        if ev.type == "checkout.session.completed":
            session = stripe.checkout.Session.retrieve(ev.payload["data"]["object"]["id"])
            fulfil_order(session)          # checks order state; safe to repeat
        ev.processed_at = timezone.now()
        ev.save(update_fields=["processed_at"])

Events can arrive out of order (an updated before a created) and more than once. Re-fetching the current object from Stripe and making fulfil_order check the order's state first avoids acting on stale data or twice.

5. Production infrastructure details

  • The endpoint must be reachable over HTTPS with a valid certificate; Stripe does not deliver to self-signed certificates.
  • Exclude the webhook path from rate limits, bot challenges, and maintenance mode redirects, or Stripe's retries pile up.
  • If you restrict by IP, keep Stripe's published webhook IP list current.
  • Stripe retries failed deliveries with backoff for up to three days in live mode, so short outages recover automatically if your handler is idempotent.
failure to fixWhat happensCause and fixsignature verification failsbody re-encoded or wrongsecret; use raw body403 from DjangoCSRF; csrf_exempt on this view onlyduplicate fulfilmentretries; unique event ID, state checkstimestamp outside toleranceserver clock drift; enable NTPStripe shows timeoutsslow handler; return 200, use CeleryStripe's dashboard shows each delivery attempt and your responsedjango-deployment.com
Use the dashboard's resend button after fixing a handler bug.

Verification check: with the Stripe CLI (stripe trigger checkout.session.completed against a staging endpoint), the event is stored once, processed once, and the dashboard shows a 200 response. Resending the same event from the dashboard does not create a second fulfilment.

Explanation

Stripe signs each webhook with an HMAC over the timestamp and the exact payload bytes, using the endpoint's secret, so verification proves the request came from Stripe and was not modified. Webhook delivery is at-least-once: Stripe retries until it gets a 2xx, and network issues can produce duplicates even after success. Ordering is not guaranteed either. Recording the event ID with a unique constraint, acknowledging immediately, and processing with state-aware, idempotent logic is the standard design for any at-least-once event source.

Edge cases / notes

  • Multiple endpoints (staging and production) each have their own secret; never share one.
  • Connect platforms receive events for connected accounts on a separate endpoint type with the account field.
  • Event payload size. Store the payload as JSON for debugging but rely on re-fetched objects for decisions.
  • dj-stripe is a library that syncs Stripe objects to Django models and handles much of this; evaluate it for complex billing.

Next steps

FAQ

Why does Stripe signature verification fail in production?

Usually the body was modified before your view read it, or the wrong signing secret (test versus live, or another endpoint) is used.

Is csrf_exempt safe for Stripe webhooks?

Yes, on that view only. The Stripe signature check replaces CSRF protection.

How do I avoid processing a Stripe event twice?

Store event IDs with a unique constraint and make fulfilment check current state before acting.

Should the webhook view do the work itself?

No. Return 200 quickly and process in a background task.

Keep going