<?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('processed_webhook_events', function (Blueprint $table) {
$table->id();
$table->string('provider', 40)->default('stripe');
$table->string('event_id')->unique();
$table->string('type', 80)->nullable();
$table->timestamp('processed_at')->useCurrent();
$table->timestamps();
$table->index(['provider', 'processed_at']);
});
}
public function down(): void
{
Schema::dropIfExists('processed_webhook_events');
}
};
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ProcessedWebhookEvent extends Model
{
protected $fillable = ['provider', 'event_id', 'type', 'processed_at'];
protected $casts = [
'processed_at' => 'datetime',
];
public static function markProcessed(string $eventId, string $type, string $provider = 'stripe'): self
{
return static::firstOrCreate(
['event_id' => $eventId],
[
'provider' => $provider,
'type' => $type,
'processed_at' => now(),
]
);
}
}
<?php
namespace App\Http\Controllers;
use App\Models\ProcessedWebhookEvent;
use App\Services\PaymentEventHandler;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Stripe\Exception\SignatureVerificationException;
use Stripe\Webhook;
class StripeWebhookController extends Controller
{
public function __construct(private PaymentEventHandler $handler)
{
}
public function __invoke(Request $request): Response
{
try {
$event = Webhook::constructEvent(
$request->getContent(),
$request->header('Stripe-Signature', ''),
config('services.stripe.webhook_secret')
);
} catch (SignatureVerificationException $e) {
return response('Invalid signature', 400);
}
try {
DB::transaction(function () use ($event) {
$record = ProcessedWebhookEvent::markProcessed($event->id, $event->type);
if (! $record->wasRecentlyCreated) {
Log::info('Skipping duplicate webhook', ['event_id' => $event->id]);
return; // already handled by a prior delivery
}
$this->handler->handle($event);
});
} catch (\Throwable $e) {
Log::error('Webhook processing failed', [
'event_id' => $event->id,
'error' => $e->getMessage(),
]);
// Non-2xx tells Stripe to retry later; the unique index keeps it safe.
return response('Processing failed', 500);
}
return response('OK', 200);
}
}
Payment providers like Stripe deliver webhooks with at-least-once semantics: the same event can arrive twice because of network retries, timeouts, or the provider re-sending after a slow response. Without protection, a duplicate charge.succeeded could credit an account twice or send two receipts. This snippet implements the standard defense — an idempotency ledger keyed by the provider's own event id — so each event is applied exactly once even under concurrent redelivery.
The create_processed_webhook_events_table migration defines the ledger. Its most important line is $table->string('event_id')->unique(): the unique index is what actually enforces deduplication at the database level, turning a race into a caught constraint violation rather than a double-apply. Columns like provider, type, and processed_at exist for observability and to make the table safe to prune later.
ProcessedWebhookEvent is a thin Eloquent model over that table. The markProcessed helper wraps firstOrCreate on event_id, and its return value (wasRecentlyCreated) tells the caller whether this process actually claimed the event or merely lost the race to another worker. This is the crux of the pattern: the claim and the check are a single atomic operation, not a read-then-write that two requests could both pass.
StripeWebhookController ties it together. It first verifies the signature with Stripe's Webhook::constructEvent, because an idempotency table is meaningless if attackers can forge event ids. It then runs the claim and business logic inside a DB::transaction, so if the handler throws, the ledger row rolls back and the event can be safely retried. When markProcessed reports the event was already recorded, the controller short-circuits and returns 200, signalling Stripe to stop retrying.
A subtle trade-off worth noting: the handler runs synchronously here for clarity, but if handle is slow it should enqueue a job after the ledger claim commits. Returning non-2xx on genuine failures is intentional — it lets Stripe's retry schedule act as a durable backstop, while the unique index guarantees those retries never double-process. This combination of signature verification, a unique event id, and a transactional claim is the reachable-for pattern whenever any at-least-once source drives money-moving side effects.
Related snips
timestamp = request.headers.fetch('X-Signature-Timestamp')
signature = request.headers.fetch('X-Signature')
payload = request.raw_post
data = "#{timestamp}.#{payload}"
expected = OpenSSL::HMAC.hexdigest('SHA256', ENV.fetch('WEBHOOK_SECRET'), data)
HMAC signed API requests for webhook and partner integrity
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
<?php
namespace App\Providers;
use App\Contracts\PaymentGateway;
use App\Services\StripePaymentGateway;
Laravel service container and dependency injection
class AddSettingsToAccounts < ActiveRecord::Migration[7.1]
disable_ddl_transaction!
def change
add_column :accounts, :settings, :jsonb, null: false, default: {}
Postgres JSONB Partial Index for Feature Flags
module EmailNormalization
extend ActiveSupport::Concern
included do
attr_accessor :soft_warnings
Soft Validation: Normalize + Validate Email
Share this code
Here's the card — post it anywhere.