Handling payment provider webhooks: HMAC check, dedup and Celery

Mock CTO Interview November 13, 2026 python

You: "The view does only three things: it verifies the HMAC signature, stores the event with a unique event_id and responds 200 immediately.

python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# ======================================================================
# Q: CTO: "How do you process the payment provider's webhooks?"
#
# You: "The view does only three things: it verifies the HMAC signature, stores
# the event with a unique event_id and responds 200 immediately. The processing
# happens in a Celery task that is idempotent and changes the payment status
# through a state machine. If the provider sends the same event twice, the
# second one is ignored. A beat job resends any events that were left
# unprocessed."
# ======================================================================

@csrf_exempt
def psp_webhook(request):
    if not verify_signature(request, settings.PSP_WEBHOOK_SECRET):
        return HttpResponse(status=400)
    event = json.loads(request.body)
    _, created = WebhookEvent.objects.get_or_create(
        event_id=event['id'], defaults={'payload': event})
    if created:
        process_payment_event.delay(event['id'])
    return HttpResponse(status=200)

@shared_task(acks_late=True)
def process_payment_event(event_id):
    with transaction.atomic():
        ev = WebhookEvent.objects.select_for_update().get(event_id=event_id)
        if ev.processed_at:
            return                                    # already processed
        payment = Payment.objects.select_for_update().get(
            psp_reference=ev.payload['data']['reference'])
        payment.apply_event(ev.payload['type'])       # allowed transitions only
        ev.processed_at = timezone.now()
        ev.save(update_fields=['processed_at'])