APIs & integration
Idempotent Webhook Processing in Laravel: Event IDs
Process webhooks exactly once in effect in Laravel: store the provider event ID under a unique constraint, queue the work, and make retries safe.
To make webhook processing idempotent in Laravel, store the provider's unique event ID in a table with a database unique constraint, and insert it before doing any work. A duplicate-key result means you already received that event. Return a 2xx response quickly, process the event in a queued job, and make that job safe to run more than once.
Quick answer
- Use the provider's event ID (not a hash you invent) as the idempotency key, scoped by provider name.
- Enforce it with a unique index. Application-level "check then insert" code has a race window, a constraint does not.
- Insert the event row first, then dispatch a job. Respond with 2xx without running business logic inline.
- Make the job retry-safe: claim the row atomically, and make each side effect idempotent too.
- Treat queue-level uniqueness as an optimisation, never as the correctness guarantee.
Why webhooks arrive more than once
Webhook senders deliver at least once. If your endpoint times out or returns a non-2xx status, the sender retries. Stripe's webhook documentation states that endpoints occasionally receive the same event more than once, and recommends logging processed event IDs and skipping events you already logged. It also says event order is not guaranteed, and that your endpoint should return a 2xx status quickly, before any complex logic that could time out.
That gives three constraints for the design:
- The same event can be delivered twice, including concurrently on two PHP workers.
- Events can arrive out of order, so a handler must not assume that "created" came before "updated".
- The HTTP response must be fast, so the slow work belongs in a queue.
The examples below use Stripe-style semantics because they are well documented, but the pattern is provider-neutral. Check how your provider names its event ID and whether it can send two different events for the same underlying change. Stripe notes that in some cases two separate event objects are generated, and suggests identifying those with the object ID in data.object plus the event type.
The durable event table
Assumptions for all code here: PHP 8.3, Laravel 12, MySQL 8 or PostgreSQL 16. This is illustrative code written for this article, not an extract from a production system.
// database/migrations/2026_11_05_000000_create_webhook_events_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('webhook_events', function (Blueprint $table) {
$table->id();
$table->string('provider', 32);
$table->string('event_id', 128);
$table->string('type', 128);
$table->longText('payload');
$table->string('status', 16)->default('received');
$table->unsignedSmallInteger('attempts')->default(0);
$table->text('last_error')->nullable();
$table->timestamp('received_at')->useCurrent();
$table->timestamp('processed_at')->nullable();
$table->unique(['provider', 'event_id']);
$table->index(['status', 'received_at']);
});
}
};
The line that matters is $table->unique(['provider', 'event_id']). The Laravel migrations docs cover unique and composite indexes. Scoping by provider means two integrations that happen to use the same ID format never collide.
Keeping the raw payload also gives you something to replay and inspect later. That is the same evidence-first habit described in Reverse engineering starts with a notebook, not a guess.
Receive: verify, insert, acknowledge
The controller should do three things only: verify the signature, record the event, dispatch a job.
// app/Http/Controllers/PaymentWebhookController.php
use App\Jobs\ProcessWebhookEvent;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
public function __invoke(Request $request)
{
// 1. Verify the signature against the RAW body first (provider SDK or HMAC).
$raw = $request->getContent();
$event = json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
// 2. Insert under the unique constraint.
$inserted = DB::table('webhook_events')->insertOrIgnore([
'provider' => 'stripe',
'event_id' => $event['id'],
'type' => $event['type'],
'payload' => $raw,
'status' => 'received',
]);
// 3. Only a first delivery schedules work.
if ($inserted === 1) {
$row = DB::table('webhook_events')
->where('provider', 'stripe')
->where('event_id', $event['id'])
->first();
ProcessWebhookEvent::dispatch($row->id)->afterCommit();
}
return response()->noContent();
}
Signature verification must use the raw request body. Stripe's docs warn that any manipulation of the raw body makes verification fail, so do not verify against re-encoded JSON.
insertOrIgnore and its caveat
The query builder docs say insertOrIgnore ignores errors while inserting, that duplicate record errors are ignored, and that "other types of errors may also be ignored depending on the database engine" (they give MySQL strict mode bypass as an example). That is acceptable for a single-row insert with known columns, but it can hide a truncated value. If you prefer stricter behaviour, catch the unique violation exception instead and let other errors surface:
use Illuminate\Database\UniqueConstraintViolationException;
try {
$id = DB::table('webhook_events')->insertGetId([/* same columns */]);
} catch (UniqueConstraintViolationException) {
return response()->noContent(); // duplicate delivery
}
Verify the return value of insertOrIgnore on your own database driver and Laravel version before relying on === 1, and cover it with a test that posts the same event twice.
Why a constraint and not "check then insert"
A where(...)->exists() followed by an insert has a gap between the two statements. Two concurrent deliveries can both see "not found" and both insert. Eloquent's firstOrCreate has the same shape unless a unique index backs it. The unique index makes the database the single arbiter, and the loser of the race gets a duplicate-key outcome instead of a second row.
Process: a retry-safe queued job
Returning 2xx only means "stored", not "handled". The job does the work and can fail and retry without the sender being involved. Laravel's queue docs describe $tries, retryUntil, $timeout and $maxExceptions for controlling this.
// app/Jobs/ProcessWebhookEvent.php
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
class ProcessWebhookEvent implements ShouldQueue
{
use Queueable;
public $tries = 5;
public $timeout = 60;
public function __construct(public int $webhookEventId) {}
public function backoff(): array
{
return [10, 60, 300, 900];
}
public function handle(): void
{
// Atomic claim: only one worker can move the row into 'processing'.
$claimed = DB::table('webhook_events')
->where('id', $this->webhookEventId)
->whereIn('status', ['received', 'failed'])
->update([
'status' => 'processing',
'attempts' => DB::raw('attempts + 1'),
]);
if ($claimed === 0) {
return; // already processed or being processed
}
$row = DB::table('webhook_events')->find($this->webhookEventId);
try {
$this->apply(json_decode($row->payload, true));
DB::table('webhook_events')->where('id', $row->id)->update([
'status' => 'processed',
'processed_at' => now(),
]);
} catch (\Throwable $e) {
DB::table('webhook_events')->where('id', $row->id)->update([
'status' => 'failed',
'last_error' => mb_substr($e->getMessage(), 0, 1000),
]);
throw $e; // let the queue retry with backoff
}
}
private function apply(array $event): void
{
// Your handlers go here. See the next section.
}
}
The UPDATE ... WHERE status IN (...) is a compare-and-set. If two workers pick up the same row, only one sees an affected-row count of 1. One gap remains: a worker that dies mid-job leaves the row in processing. Add a scheduled command that resets rows stuck in processing for longer than your $timeout plus a margin, or the event will never be retried.
Where queue uniqueness fits
Laravel's ShouldBeUnique takes a lock on uniqueId() and skips dispatch if the lock is held. The docs note the lock is released when the job finishes or fails all attempts, that uniqueFor can expire it earlier, and that it depends on a lock-capable cache driver shared across servers. WithoutOverlapping has similar cache-lock semantics, and released jobs still count as attempts.
Those are useful for reducing duplicate work. They are cache locks with expiry, so they cannot replace the database constraint.
| Mechanism | Survives cache flush | Survives restart | Safe as the only guard |
|---|---|---|---|
| Unique index on (provider, event_id) | Yes | Yes | Yes |
| Atomic status claim in the database | Yes | Yes | Yes, with a stuck-row sweep |
ShouldBeUnique | No | Depends on cache driver | No |
WithoutOverlapping | No | Depends on cache driver | No |
Make the side effects idempotent too
Recording the event once does not make apply() safe. If the job fails after charging a card or sending an email and then retries, the side effect repeats. Options, roughly in order of preference:
- Write local state with upserts keyed on a natural ID. The Eloquent docs describe
upsertwithuniqueByandupdatecolumns, which needs a unique index on theuniqueBycolumns. - Wrap the local state change and the status update to
processedin one database transaction. - For calls to other APIs, pass an idempotency key if that API supports one, derived from the event ID.
- For ordering problems, compare a version or timestamp from the payload and ignore stale updates, rather than assuming arrival order.
A short rollout checklist
Add the table and unique index
Run the migration. Confirm the index exists with your database's schema tooling.
Post the same event twice in a test
Assert there is one row and one dispatched job. Run it with concurrent requests as well if your test setup allows.
Force the job to fail once
Throw inside
apply()on the first attempt and confirm the retry completes and side effects occur once.Add a stuck-row sweep and a retention policy
Reset old
processingrows, and prune processed rows on a schedule that matches how long the provider can replay events.
Where this is not enough
If you process very high volumes, a relational table per event may become a hot spot, and a broker with native deduplication or a dedicated ingestion service may fit better. If the provider offers no stable event ID, you have to derive a key from the payload, and you should document which fields define identity. If a webhook only says "something changed", fetch the current state from the provider's API in the job instead of trusting the payload.
Where this fits in my work
Integrations like this sit under backend architecture and product stability, which are the areas I own as Technical Lead at Legacies, the company I co-founded with Horia Stan. The pattern here is general Laravel guidance rather than a description of any specific Legacies system. For verified production results from earlier work, see Selected work, and to discuss an integration project, use Discuss a project.
Frequently Asked Questions
What is the best idempotency key for a webhook?
Use the unique event ID the provider assigns, combined with a provider name so different integrations cannot collide. Do not hash the payload unless the provider gives you no ID, because retries may carry different timestamps or signatures. Stripe, for example, generates a fresh signature and timestamp on each delivery attempt.
Should I process the webhook inside the HTTP request?
Usually not. Stripe's documentation advises returning a 2xx status quickly, before complex logic that could time out. Store the event, return 204 or 200, and let a queued job do the work.
Is ShouldBeUnique enough to prevent duplicate processing?
No. It uses a cache lock that expires through uniqueFor and depends on a lock-capable cache driver shared across servers. It reduces duplicate dispatches, but a database unique constraint is what guarantees you store an event only once.
What happens if my job fails after a side effect succeeds?
The queue retries it, so the side effect can run again unless it is idempotent. Use upserts, transactions that include the status update, or idempotency keys for outbound API calls. Mark the event processed only after all of that commits.