Engineering
Idempotent Webhooks in Laravel: The Practical Guide to Never Double-Charge Again
Retries are normal. Duplicates are normal. Your code needs to be normal too — safely.
Webhooks fail in messy ways: network hiccups, timeouts, queue delays, provider retries, or your own deploy at the worst possible moment. The result is almost always the same: the provider re-sends the event.
If your handler isn’t idempotent, duplicates turn into real damage: double emails, duplicate invoices, double user credits, and (the scariest) accidental double charges or entitlement changes.
The goal of idempotency is simple: the same webhook event processed twice should produce the same end state as processing it once.
What idempotency actually means for webhooks
Idempotency is not “ignore duplicates blindly.” It’s “apply changes safely.” A good webhook handler assumes:
- The provider can retry the same event multiple times.
- Events can arrive out of order (especially when queues are involved).
- Your response might not reach the provider even if you processed the event.
- You might redeploy mid-flight and re-process jobs.
So the handler must be able to say: “I’ve already handled this exact event, and I can prove it.”
The most reliable pattern: store an event fingerprint
Most webhook payloads include a unique identifier (often called event_id,
id, or request_id).
Store it in your database with a unique constraint.
Recommended columns (minimal but powerful)
- provider (e.g., stripe, paddle, shopify)
- event_id (unique per provider)
- event_type (for debugging + filtering)
- payload_hash (optional, helps detect weird replays)
- processed_at and status (processed/failed)
With that in place, your handler does:
- Validate signature and parse payload.
- Attempt to insert
provider + event_id. - If insert fails due to uniqueness: return success and stop.
- Otherwise process normally, then mark as processed.
This is boring — and boring is exactly what you want for billing-critical code.
Idempotency for side effects (email, entitlements, “credits”)
The tricky part isn’t updating a subscription row. It’s the side effects: emails, notifications, provisioning, granting seats, generating invoices, sending “welcome” messages, etc.
A safe rule: make side effects depend on state, not on “event received.” For example:
- Instead of “send email when payment_succeeded arrives,” do “send email if invoice is paid AND we haven’t sent the receipt yet.”
- Instead of “grant pro plan when subscription_updated arrives,” do “ensure plan in DB equals provider plan, then reconcile entitlements.”
- Instead of “add credits,” do “set credits to target value based on plan/tier, then compute delta safely.”
This shifts your code from “event-driven chaos” to “state reconciliation,” which is much harder to break.
Queueing is good — but it must stay idempotent
In production, you’ll usually accept the webhook fast (so the provider stops retrying), then queue the heavy work. That’s a great approach, but it introduces another duplication layer: a job can be retried by your queue worker.
The solution is the same: the queued job should also check the stored event record, and it should not run side effects twice. If you already wrote the unique “event processing” guard, reuse it in the job.
A simple checklist you can apply today
- Unique event guard: store provider + event_id with a unique index.
- Fast ACK: respond quickly, move work to queue when needed.
- State-based side effects: send emails/provision based on “has it been done?”
- Handle out-of-order: compare timestamps/versions before overwriting newer state.
- Audit trail: keep enough logs to explain “why did this user lose access?”
Why monitoring still matters
Even perfect idempotency won’t tell you when your endpoint is timing out, returning 500s, or silently failing in the queue. Providers retry, but retries are not visibility.
A lightweight monitor that tracks failures, groups them into incidents, and alerts you early can save hours of “why is revenue down?” debugging — especially when the failure only hits one provider, one region, or one specific payload shape.
Make the handler safe. Then make failures obvious.