from django.db import models
from django.utils import timezone
class WebhookEvent(models.Model):
class Status(models.TextChoices):
PENDING = "pending", "Pending"
PROCESSED = "processed", "Processed"
FAILED = "failed", "Failed"
provider = models.CharField(max_length=32, default="stripe")
event_id = models.CharField(max_length=255, unique=True)
event_type = models.CharField(max_length=120)
payload = models.JSONField()
status = models.CharField(
max_length=16, choices=Status.choices, default=Status.PENDING
)
error = models.TextField(blank=True, default="")
received_at = models.DateTimeField(auto_now_add=True)
processed_at = models.DateTimeField(null=True, blank=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["provider", "event_id"], name="uniq_provider_event"
)
]
def mark_processed(self):
self.status = self.Status.PROCESSED
self.processed_at = timezone.now()
self.save(update_fields=["status", "processed_at"])
def mark_failed(self, message):
self.status = self.Status.FAILED
self.error = str(message)[:2000]
self.save(update_fields=["status", "error"])
def __str__(self):
return f"{self.provider}:{self.event_id}"
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("payments", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="WebhookEvent",
fields=[
("id", models.BigAutoField(primary_key=True, serialize=False)),
("provider", models.CharField(default="stripe", max_length=32)),
("event_id", models.CharField(max_length=255, unique=True)),
("event_type", models.CharField(max_length=120)),
("payload", models.JSONField()),
(
"status",
models.CharField(
choices=[
("pending", "Pending"),
("processed", "Processed"),
("failed", "Failed"),
],
db_index=True,
default="pending",
max_length=16,
),
),
("error", models.TextField(blank=True, default="")),
("received_at", models.DateTimeField(auto_now_add=True)),
("processed_at", models.DateTimeField(blank=True, null=True)),
],
),
migrations.AddConstraint(
model_name="webhookevent",
constraint=models.UniqueConstraint(
fields=["provider", "event_id"], name="uniq_provider_event"
),
),
]
import stripe
from django.conf import settings
from django.db import IntegrityError, transaction
from django.http import HttpResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from .models import WebhookEvent
from .services import fulfil_payment
@csrf_exempt
@require_POST
def stripe_webhook(request):
payload = request.body
sig = request.META.get("HTTP_STRIPE_SIGNATURE", "")
try:
event = stripe.Webhook.construct_event(
payload, sig, settings.STRIPE_WEBHOOK_SECRET
)
except (ValueError, stripe.error.SignatureVerificationError):
return HttpResponseBadRequest("invalid signature")
try:
record, created = WebhookEvent.objects.get_or_create(
provider="stripe",
event_id=event["id"],
defaults={
"event_type": event["type"],
"payload": event.to_dict(),
},
)
except IntegrityError:
# A concurrent request inserted the same event first: treat as duplicate.
return HttpResponse(status=200)
if not created:
return HttpResponse(status=200)
try:
with transaction.atomic():
if event["type"] == "payment_intent.succeeded":
fulfil_payment(event["data"]["object"])
record.mark_processed()
except Exception as exc:
record.mark_failed(exc)
return HttpResponse(status=500)
return HttpResponse(status=200)
Webhook providers like Stripe, GitHub, and Twilio guarantee at-least-once delivery, which means the same event can arrive twice — after a network timeout, a retry, or a load-balancer hiccup. Processing the same payment_intent.succeeded twice could double-credit an account, so the handler must be idempotent. This snippet shows the standard defence: store every event's provider-assigned ID under a database unique constraint and let the database, not application logic, be the source of truth for "have I seen this before?".
The WebhookEvent model tab defines a durable row per delivery. The event_id field carries unique=True, and the status field tracks the processing lifecycle with a TextChoices enum. Because the uniqueness lives in the schema, two concurrent workers racing on the same event cannot both win — one insert succeeds and the other hits an IntegrityError. The mark_processed and mark_failed helpers keep state transitions in one place.
The 0002_webhookevent migration tab makes that guarantee explicit. Beyond the field-level unique=True, it adds a UniqueConstraint named uniq_provider_event, scoped to (provider, event_id). Scoping by provider matters when several webhook sources share one table, since a Stripe event ID could theoretically collide with a different provider's ID namespace. A db_index on status keeps the "find unprocessed events" query cheap.
The stripe_webhook view tab ties it together. It first verifies the signature with stripe.Webhook.construct_event, rejecting forged or malformed payloads with a 400 before any database work. It then calls get_or_create, keyed on the provider and event ID, which is the crux of the pattern: if the row already exists, created is False and the handler returns 200 immediately without re-running side effects. The get_or_create call is wrapped in a try/except IntegrityError to close the last race window — two requests can both pass the existence check and then attempt the insert, but the unique constraint ensures only one commits.
Returning 200 for duplicates is deliberate: any non-2xx status tells Stripe to retry, so a well-behaved handler acknowledges duplicates as success. Actual business work runs inside a transaction only on the created path, and failures flip the row to FAILED so a replay job can retry later without losing the audit trail.
Related snips
timestamp = request.headers.fetch('X-Signature-Timestamp')
signature = request.headers.fetch('X-Signature')
payload = request.raw_post
data = "#{timestamp}.#{payload}"
expected = OpenSSL::HMAC.hexdigest('SHA256', ENV.fetch('WEBHOOK_SECRET'), data)
HMAC signed API requests for webhook and partner integrity
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
class Product(models.Model):
name = models.CharField(max_length=200)
slug = models.SlugField(blank=True)
price = models.DecimalField(max_digits=10, decimal_places=2)
cost = models.DecimalField(max_digits=10, decimal_places=2)
margin = models.DecimalField(max_digits=5, decimal_places=2, blank=True)
Django model signals vs overriding save
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
from django.urls import path
from . import views
app_name = 'blog'
urlpatterns = [
Django URL namespacing and reverse lookups
Share this code
Here's the card — post it anywhere.