← All articles

Webhook Idempotency in Laravel: Prevent Duplicate Events

Build an idempotent Laravel webhook receiver with signature checks, unique event records, transactions, queues, and retry-safe side effects.

Bakry Abdalsalam builds websites, applications, integrations, and WordPress products. Bakry Dev Hub documents the technical decisions behind this work.

THE SHORT VERSION

Treat delivery as at least once: authenticate the raw request, claim the provider event ID with a database unique constraint, acknowledge quickly, and make every downstream side effect retry-safe.

Webhook providers retry when your endpoint is slow, unavailable, or returns an error. Networks can also lose the response after your application commits its work. The provider then delivers the same event again even though the first attempt succeeded.

Duplicate delivery is normal. A safe receiver produces the same business result whether a valid event arrives once or several times.

Define the idempotency boundary

Use the provider’s stable event or delivery ID when available. Do not use a random ID created by your receiver; every retry would receive a different value.

Your durable key is usually:

provider + provider_account + event_id

Include the account or tenant when event IDs are not globally unique. If the provider supplies no stable ID, derive a documented key from immutable business fields. Hashing the entire raw body can be a fallback for byte-identical retries, but it will not identify semantically identical payloads whose formatting or timestamps differ.

Idempotency is broader than duplicate detection. The whole business operation—database updates, emails, refunds, and external API calls—must tolerate retries.

Verify authenticity before storing the event

Read the raw request body and verify the provider’s signature before trusting its event ID or JSON fields. A malicious caller must not be able to reserve an idempotency key and block a real event.

The exact algorithm, signed message, timestamp tolerance, and header format are provider-specific. Follow the provider’s current documentation. The PHP and Laravel webhook signature guide explains the safe order.

After verification, validate the event type and required identifiers. Reject malformed requests without queuing them.

Create a durable inbox table

Use a database unique constraint as the final concurrency guard:

Schema::create('webhook_events', function (Blueprint $table) {
    $table->id();
    $table->string('provider', 50);
    $table->string('provider_account', 100)->default('');
    $table->string('event_id', 191);
    $table->string('event_type', 100);
    $table->string('status', 30)->default('received');
    $table->unsignedInteger('attempts')->default(0);
    $table->json('payload');
    $table->text('last_error')->nullable();
    $table->timestamp('processed_at')->nullable();
    $table->timestamps();

    $table->unique(
        ['provider', 'provider_account', 'event_id'],
        'webhook_events_delivery_unique'
    );
});

Store only what operations and audits require. Webhook payloads can contain personal or secret data, so apply encryption, access control, redaction, and retention rules appropriate to the data.

An application-level “check then insert” is not enough:

if (! WebhookEvent::where('event_id', $id)->exists()) {
    WebhookEvent::create([...]);
}

Two concurrent requests can both pass the check. The database unique constraint closes that race.

Claim the event atomically

After signature verification, attempt the insert and treat a duplicate-key result as an already accepted delivery:

use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;

try {
    $event = DB::transaction(function () use ($provider, $account, $id, $type, $payload) {
        return WebhookEvent::create([
            'provider' => $provider,
            'provider_account' => $account,
            'event_id' => $id,
            'event_type' => $type,
            'payload' => $payload,
        ]);
    });
} catch (QueryException $exception) {
    if (! isUniqueConstraintViolation($exception)) {
        throw $exception;
    }

    return response()->json(['status' => 'already_received'], 200);
}

ProcessWebhookEvent::dispatch($event->id)->afterCommit();

return response()->json(['status' => 'accepted'], 202);

Implement isUniqueConstraintViolation() for your supported database driver; do not classify every query exception as a duplicate.

Respond quickly after durable acceptance. Heavy processing inside the HTTP request encourages provider retries and consumes web capacity.

Make queued processing retry-safe

The queue job should load the inbox record, lock it inside a transaction, and return immediately when its status is already processed.

DB::transaction(function () use ($eventId) {
    $event = WebhookEvent::query()
        ->lockForUpdate()
        ->findOrFail($eventId);

    if ($event->status === 'processed') {
        return;
    }

    applyBusinessChange($event);

    $event->update([
        'status' => 'processed',
        'processed_at' => now(),
        'last_error' => null,
    ]);
});

Keep database changes in the transaction. External calls cannot be rolled back with your database, so give them their own provider-supported idempotency key—often the original webhook event ID plus the action name.

Email and notifications may need an outbox table with a unique constraint such as (event_id, action). A queue’s ShouldBeUnique can reduce duplicate work, but it should not replace the durable business record: locks expire, caches can be cleared, and operators may replay jobs.

Handle ordering and replay

Two different event IDs can describe the same object and arrive out of order. Deduplicating by event ID does not solve ordering.

Use provider sequence numbers or event creation times when they are authoritative. For stateful resources, fetching the provider’s current object can be safer than applying stale transitions blindly. Define which events may move an object forward and which should be recorded but ignored.

Provide an operational replay command that processes an existing inbox record without creating a second business action. Record attempt count, last error, and processing timestamps so support can distinguish “received,” “processing,” “failed,” and “processed.”

Test the failure cases

Automated tests should cover:

  1. The same signed event delivered twice sequentially.
  2. The same event delivered concurrently.
  3. A crash after the database change but before the HTTP response.
  4. A job retry after an external timeout.
  5. Two different events for the same object arriving out of order.
  6. An invalid signature using a real event ID.

Assert the business outcome, not only the number of inbox rows. There should be one charge, one entitlement change, or one notification—whatever “once” means for the domain.

Common mistakes and prevention

Avoid relying only on cache TTLs, checking duplicates before verifying signatures, acknowledging before durable storage, logging secret payloads, and assuming queue uniqueness guarantees business idempotency.

Start with the reliability boundary described in Reliable webhook integrations, operate workers with the Laravel queue troubleshooting guide, and make idempotency a documented invariant of each side effect.

Frequently asked questions

Should a duplicate webhook receive a success response?

Yes, after its signature is valid and your durable inbox confirms the event was already accepted. A success response tells the provider that another retry is unnecessary.

Is a Redis or cache key enough for webhook idempotency?

It can reduce concurrent work, but it is usually not the durable business record. Cache entries expire or can be evicted; use a database unique constraint for events whose side effects must remain auditable.

Official references

Have a question about this guide or an idea for a technical collaboration? Contact Bakry through the Dev Hub.

End of field note.