python 132 lines · 3 tabs

Idempotent Stripe Webhook Handling in Django with a Unique Event-ID Constraint

Shared by codesnips Sep 2026
3 tabs
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}"
3 files · python Explain with highlit

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

ruby
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

hmac api-signing webhooks
by Kai Nakamura 2 tabs
typescript
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

typescript reliability retry
by codesnips 2 tabs
python
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

django python models
by Priya Sharma 2 tabs
go
package dbutil

import (
  "context"

  "github.com/jackc/pgconn"

Retry Postgres serialization failures with bounded attempts

go postgres transactions
by Leah Thompson 1 tab
ruby
require "csv"

class PeopleCsvStream
  include Enumerable

  HEADERS = %w[id full_name email signed_up_at plan].freeze

Resilient CSV Export as a Streamed Response

rails performance streaming
by codesnips 3 tabs
python
from django.urls import path
from . import views

app_name = 'blog'

urlpatterns = [

Django URL namespacing and reverse lookups

django python urls
by Priya Sharma 3 tabs

Share this code

Here's the card — post it anywhere.

Idempotent Stripe Webhook Handling in Django with a Unique Event-ID Constraint — share card
Link copied