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.
- Verify the signature using the raw request body before trusting anything.
- Save the raw payload with the provider's event ID in a table with a unique constraint.
- Return 200 immediately after saving; do not call other services inline.
- Process the stored event in a Celery task with
transaction.on_commit, idempotently. - 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.
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.
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
- Apply this to payments with Deploy Django Stripe Webhooks in Production.
- Make handlers safe to repeat with Make Celery Tasks Idempotent With Retries.
- Send events reliably in the other direction with Transactional Outbox With Django and Celery.
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.