Most payment integration bugs aren’t in the happy path. A customer pays, the provider sends a webhook and your backend marks the order as paid. It works in testing. Then in production the same event arrives twice, and a customer ends up with two policies, two invoices or two refunds.
This article shows how to make a webhook handler safe to retry, using FastAPI, SQLAlchemy and Stripe as the example. The same approach works with any provider that sends events with a unique ID.
Why webhooks arrive more than once
Providers deliver webhooks at least once. If your endpoint times out, returns an error or the response gets lost on the network, the provider can’t tell whether you processed the event, so it sends it again. Stripe, for example, keeps retrying failed deliveries for up to three days.
Retries are a good thing. They mean a short outage on your side doesn’t lose payments. But they also mean your handler has to cope with seeing the same event more than once.
Record every event you process
The fix is simple: store each event’s ID in a table with a unique constraint, in the same database transaction as the work the event triggers. If the insert fails because the ID already exists, the event has already been handled and you can acknowledge it without doing anything else.
import os
from typing import Annotated
import stripe
from fastapi import FastAPI, Header, HTTPException, Request
from sqlalchemy import String
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
WEBHOOK_SECRET = os.environ["STRIPE_WEBHOOK_SECRET"]
engine = create_async_engine(os.environ["DATABASE_URL"])
app = FastAPI()
class Base(DeclarativeBase):
pass
class ProcessedEvent(Base):
__tablename__ = "processed_events"
event_id: Mapped[str] = mapped_column(String(255), primary_key=True)
async def handle_event(session: AsyncSession, event: stripe.Event) -> None:
# Update orders, policies or invoices here, using the same session.
...
@app.post("/webhooks/stripe")
async def stripe_webhook(
request: Request,
stripe_signature: Annotated[str, Header()],
) -> dict[str, bool]:
payload = await request.body()
try:
event = stripe.Webhook.construct_event(
payload, stripe_signature, WEBHOOK_SECRET
)
except (ValueError, stripe.SignatureVerificationError):
raise HTTPException(status_code=400, detail="Invalid webhook")
async with AsyncSession(engine) as session:
try:
async with session.begin():
session.add(ProcessedEvent(event_id=event["id"]))
await session.flush()
await handle_event(session, event)
except IntegrityError:
pass # Already processed. Acknowledge so the provider stops retrying.
return {"received": True}Why the transaction matters
Recording the event ID and updating the order happen together. If the handler crashes halfway through, both roll back and the provider’s next retry processes the event cleanly. If you record the ID in a separate step, you can end up with an event marked as processed that never did its work, or with work done twice.
The unique constraint also covers the awkward case of two deliveries of the same event arriving at the same moment. The database lets one through and the other fails with an integrity error, which the handler treats as a duplicate.
Verify the signature first
Anyone can send a POST request to a public URL. Check the provider’s signature before you trust anything in the payload, and check it against the raw request body. Parsing the JSON and serialising it again changes the bytes and breaks the check.
Don’t rely on the order of events
Events can arrive out of order, such as a refund notification before the payment it refers to. Treat a webhook as a prompt to look at the current state: fetch the object from the provider’s API, or compare timestamps before you overwrite newer data with older.
Reply quickly
Providers expect a successful response within a few seconds. If an event means calling other services or generating documents, record the event, hand the slow work to a background job keyed by the same event ID, and reply straight away.
A short checklist
- Verify the signature against the raw request body.
- Store each event ID with a unique constraint.
- Record the ID and do the work in one transaction.
- Acknowledge duplicates with a 2xx response so retries stop.
- Re-read the current state instead of trusting event order.
- Keep the handler fast and move slow work to a queue.
We tested the example above against a local database with duplicate deliveries and an invalid signature. If you want a second pair of eyes on your own payment flow, our payment and API integrations work covers exactly this.