Process Incoming Webhooks Reliably in Django

Build reliable webhook endpoints in Django: verify signatures, store the raw event first, return 2xx quickly, process asynchronously with Celery, deduplicate by event ID, handle out-of-order delivery, and replay failed events safely.

Problem statement

Payment providers, Git hosts, CRMs, and shipping services notify your Django app with webhooks. Providers retry on timeouts and errors, deliver events more than once, deliver them out of order, and sometimes send bursts of thousands. An endpoint that does all the processing inline times out, gets retried, processes the same event twice, and loses events when a deploy restarts workers mid-request.

Reliable webhooksVerify signaturereject forgeriesStore raw eventunique event IDReturn 200 fastprovider happyProcess asyncCelery, idempotentAccept quickly and durably; do the real work later, exactly once in effect.
  1. Verify the signature using the raw request body before trusting anything.
  2. Save the raw payload with the provider's event ID in a table with a unique constraint.
  3. Return 200 immediately after saving; do not call other services inline.
  4. Process the stored event in a Celery task with transaction.on_commit, idempotently.
  5. Track status per event (received, processed, failed) and provide a replay command.

Step-by-step solution

1. Verify signatures on the raw body

import hmac, hashlib
from django.http import HttpResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST

@csrf_exempt
@require_POST
def partner_webhook(request):
    signature = request.headers.get("X-Signature-256", "")
    expected = "sha256=" + hmac.new(settings.PARTNER_WEBHOOK_SECRET.encode(), request.body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(signature, expected):
        return HttpResponseBadRequest("bad signature")
    ...

Use request.body (raw bytes), not parsed JSON, because re-serializing changes bytes and breaks the signature. Use the provider's SDK helper where available (Stripe, GitHub). Check timestamps in signed headers to reject replays of old requests.

2. Store before processing

class WebhookEvent(models.Model):
    provider = models.CharField(max_length=30)
    event_id = models.CharField(max_length=100)
    event_type = models.CharField(max_length=100)
    payload = models.JSONField()
    status = models.CharField(max_length=20, default="received")
    received_at = models.DateTimeField(auto_now_add=True)
    processed_at = models.DateTimeField(null=True)
    error = models.TextField(blank=True)

    class Meta:
        constraints = [models.UniqueConstraint(fields=["provider", "event_id"], name="uniq_webhook_event")]
    data = json.loads(request.body)
    with transaction.atomic():
        event, created = WebhookEvent.objects.get_or_create(
            provider="partner", event_id=data["id"],
            defaults={"event_type": data["type"], "payload": data},
        )
        if created:
            transaction.on_commit(lambda: process_webhook.delay(event.pk))
    return HttpResponse(status=200)

Duplicate deliveries hit the unique constraint and return 200 without reprocessing.

request path and background path1Verify signatureraw body, timestamp2Insert event rowunique event ID3Return 200within milliseconds4Celery processes eventidempotent handler5Mark processed or failedreplayableThe request only verifies and stores; everything else is background workdjango-deployment.com
If Celery is down, events wait safely in the table.

3. Process idempotently

@shared_task(bind=True, autoretry_for=(TransientError,), retry_backoff=True, max_retries=8)
def process_webhook(self, event_pk):
    with transaction.atomic():
        event = WebhookEvent.objects.select_for_update().get(pk=event_pk)
        if event.status == "processed":
            return
        handle(event.event_type, event.payload)       # must be safe to run twice
        event.status = "processed"
        event.processed_at = timezone.now()
        event.save(update_fields=["status", "processed_at"])

Handlers should set state rather than apply deltas ("mark invoice paid", not "add payment amount"), so reruns do not double-count.

4. Handle ordering

Providers do not guarantee order: subscription.updated may arrive before subscription.created. Strategies:

  • Fetch the current object from the provider's API in the handler, instead of trusting the payload's snapshot.
  • Compare a version or timestamp in the payload with what you stored, and ignore older events.

5. Monitor and replay

Alert on events stuck in received for more than a few minutes and on failed counts. A management command replays them after a fix:

python manage.py replay_webhooks --provider partner --status failed --since 2026-10-01

Purge processed events after a retention period.

failure and protectionFailureProtectionduplicate deliveryunique event ID; second insert ignoredslow processing, timeoutstore then 200; work in Celeryout-of-order eventsfetch current stateor compare versionsbug in handlerstatus failed; fix, then replayStoring first makes every other failure recoverabledjango-deployment.com
Without the stored event, a handler bug means lost data.

Explanation

Webhook providers give at-least-once delivery: they retry until they see a 2xx response within their timeout. Your endpoint's job is to accept each event durably and quickly, so retries stop, and to make repeated or reordered events harmless. Storing the raw event with a unique ID provides durability and deduplication in one step; asynchronous processing keeps the response fast and isolates failures; idempotent handlers make retries safe.

Edge cases / notes

  • CSRF. Webhook views must be csrf_exempt; signature verification replaces CSRF protection.
  • Body size. Large payloads may exceed DATA_UPLOAD_MAX_MEMORY_SIZE; raise it for the webhook route if needed.
  • Security. Restrict webhook routes from your WAF's body inspection if it causes false positives, but never skip signature checks.

Next steps

FAQ

Should webhook views process events synchronously?

No. Verify, store, return 2xx, and process in the background.

How do I avoid processing a webhook twice?

Store events with a unique constraint on the provider's event ID and make handlers idempotent.

Why does signature verification fail?

You probably verified parsed JSON instead of the raw body, or used the wrong secret or timestamp tolerance.

What if events arrive out of order?

Fetch the current state from the provider in the handler, or compare versions and skip older events.

Keep going