HomeServicesPortfolioAboutContactBlogCareers
Book a call
AI Engineering

Stripe Webhooks Without Double Charges

October 2026 · Leo Karpov

The three things Stripe tells you up front

Stripe's documentation is honest about webhooks: an event can arrive more than once, events are not guaranteed to arrive in the order they were created, and if your endpoint fails, Stripe keeps retrying for up to three days. Many billing bugs in subscription products come from code that quietly assumes the opposite: one delivery, in order, on time.

The symptoms are familiar. A customer pays and does not get access because invoice.paid was processed before customer.subscription.created. A plan downgrade is applied twice. A refund triggers a cancellation email to someone who is still subscribed. None of these are Stripe bugs. They are what happens when a webhook handler is written like a function call instead of like a message consumer, which is why Stripe webhook idempotency has to be designed in, not added later.

This article is the handler we would put in front of any Stripe integration: verify, record, acknowledge, then process in the background, idempotently, against the current state of the object.

Verify the signature on the raw body

Every webhook request carries a Stripe-Signature header. Verify it with the endpoint's signing secret before you trust anything in the payload, and verify it against the raw request body, not a parsed and re-serialised version of it. Frameworks that parse JSON before your code runs are the usual reason verification fails in production while it passed in a test.

A rejected signature gets a 400 and nothing else. Do not log the payload as if it were a real event, and do not try to process it "just in case".

Acknowledge fast, process in a queue

Stripe needs a 2xx response quickly. The handler's only jobs are to verify the signature, store the event, and enqueue work. Everything that touches your database, sends emails, or calls other services happens in a background worker.

In Django with Celery, the endpoint is short:

import json

import stripe
from django.conf import settings
from django.db import transaction
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt

from .models import StripeEvent
from .tasks import process_stripe_event


@csrf_exempt
def stripe_webhook(request):
    try:
        event = stripe.Webhook.construct_event(
            request.body,
            request.headers.get("Stripe-Signature", ""),
            settings.STRIPE_WEBHOOK_SECRET,
        )
    except (ValueError, stripe.SignatureVerificationError):
        return HttpResponse(status=400)

    with transaction.atomic():
        record, _ = StripeEvent.objects.get_or_create(
            id=event["id"],
            defaults={"type": event["type"], "payload": json.loads(request.body)},
        )
        if record.processed_at is None:
            # Enqueue only after the row is committed, so the worker
            # never looks for an event that is not in the database yet.
            transaction.on_commit(lambda: process_stripe_event.delay(record.id))
    return HttpResponse(status=200)

The handler enqueues whenever the event has not been processed yet, not only the first time it arrives. If enqueueing failed on the first delivery, Stripe's retry puts the event back on the queue instead of being acknowledged and forgotten. The worker sets processed_at when it finishes and skips events that already have it.

Two more things make this robust. A slow downstream service can no longer cause a timeout that Stripe treats as a failure and retries. And the stored event is a record you can replay when a worker has a bug, instead of waiting for Stripe to send it again. (Exception names differ between versions of stripe-python; check the one you use.)

Deduplicate on the event id

Every event has a unique id (it starts with evt_). Store it with a unique constraint, as the get_or_create above does, and have the worker lock the row and skip it if processed_at is already set. That pair is the cheapest protection against double processing: a repeated delivery never creates a second record, and a record is never processed twice.

Deduplicating on the event id is not the whole story, though. Two different events can describe the same change, for example a subscription update followed by the invoice it produced. The worker still has to be idempotent with respect to what it does, not only with respect to which event it received.

Do not trust event order: re-read the object

Because order is not guaranteed, the payload inside an event can be older than the state you already have. The reliable pattern is to treat the event as a notification that something changed and fetch the current object from the API before acting:

  1. Receive customer.subscription.updated.
  2. Retrieve the subscription by id from Stripe.
  3. Write its current status, plan, and period end into your own table.

If an older event arrives later, the same steps write the same current state again, so the result does not depend on arrival order. The extra API call is a small price for not having to reason about every possible sequence of events.

Use idempotency keys on your own calls

The same problem exists in the other direction. If your code creates a charge, a refund, or a subscription and the request times out, a naive retry can create a second one. Stripe accepts an Idempotency-Key header on POST requests: a retry with the same key returns the original result instead of repeating the operation.

Derive the key from your own data, for example the id of the order or the internal operation, not from a random value generated at retry time. Stripe documents that keys can be pruned after 24 hours, so they protect retries, not long-term duplicate detection. That part is your database's job.

Access follows billing state

The point of all this is that access in your product should follow the billing state you have recorded, not individual events. Keep one table that answers "what is this customer entitled to right now", update it only from the worker after re-reading the object, and have the application check that table.

That gives you one place to answer support questions ("why can't I log in?"), one place to correct by hand when something goes wrong, and one place to reconcile against Stripe.

Reconcile on a schedule

Even with all of the above, webhooks are a delivery mechanism, not a source of truth. A nightly job that lists recently changed subscriptions in Stripe and compares them with your table catches the rare event that never arrived, a worker bug that wrote the wrong state, or a manual change made in the Stripe dashboard. When drift is found, the job fixes it through the same code path the worker uses and logs what it changed.

Testing it before real money flows

The Stripe CLI can forward test-mode events to a local endpoint and trigger specific event types on demand. Use it to test the cases that break naive handlers: send the same event twice, deliver an update before the create, make the worker fail and replay the stored event. Those are exactly the failure modes a straightforward handler misses.

Where this stops being right

  • Very low volume, no subscriptions. A shop selling a handful of one-off items through Stripe Checkout can live with a simpler handler. The queue and the reconciliation job pay off when there is recurring billing and access depends on it.
  • Stripe is not the only system of record. If invoicing lives in another tool, such as a subscription management platform on top of Stripe, its webhooks become the primary signal and Stripe's events are secondary. Pick one source for entitlements, not two.
  • Strict real-time requirements. Re-reading the object adds a round trip. For a step that must react in milliseconds, process the payload first and let reconciliation correct the rare out-of-order case.

FAQ

Should the webhook handler update the database directly? Only to store the event. Everything else belongs in a background worker, so a slow dependency cannot turn into a retried delivery.

Is deduplicating on the event id enough? It stops the same delivery from being processed twice. The worker still needs to be idempotent about its effects, because different events can describe the same change.

How do I recover from a bug in the worker? Fix the bug, then replay the stored events for the affected period. Because the worker re-reads current state from Stripe, replaying is safe.

Payment integrations are one of the services we offer, alongside work with Chargebee and Orb: see payment and subscription integration. The queue-and-retry side of this pattern applies to any external service, which is covered under API and SDK integration.

See it in production
AI integration services → Portfolio → API and SDK integration →
← More on AI Engineering All articles