Skip to content
~/ashwin-shresthaLoading

000%

All posts

Stripe webhooks in Laravel: verify, dedupe, process safely

The redirect after checkout is a hint, not proof. A practical pattern for handling Stripe webhooks without double-fulfilling orders.

When a customer pays, Stripe redirects them back to your site. It is tempting to mark the order as paid right there. Do not. The customer can close the tab before the redirect, lose connection, or visit the success URL by hand. The only trustworthy signal that money moved is a webhook from Stripe to your server.

What a webhook handler must do

A good handler does four things, in this order:

  1. Verify that the request really came from Stripe.
  2. Acknowledge fast with a 2xx response.
  3. Deduplicate, because Stripe can send the same event more than once.
  4. Process the event without assuming it arrives in order.

1. Verify the signature

Stripe signs every webhook with a secret that is specific to the endpoint. Verify against the raw request body, not a parsed copy, because even whitespace changes the signature.

use Stripe\Webhook;
use Stripe\Exception\SignatureVerificationException;

public function __invoke(Request $request)
{
    try {
        $event = Webhook::constructEvent(
            $request->getContent(),                 // raw body
            $request->header('Stripe-Signature'),
            config('services.stripe.webhook_secret')
        );
    } catch (\UnexpectedValueException | SignatureVerificationException) {
        return response('Invalid', 400);
    }

    ProcessStripeEvent::dispatch($event->id, $event->type, $event->data->object->toArray());

    return response('', 204);
}

Exclude this route from CSRF protection (webhooks have no session), and keep the secret in .env, never in the repository.

2. Answer quickly, work later

Stripe expects a fast response and retries on failure or timeout. If your handler sends emails, generates invoices and updates three tables before replying, a slow database turns into duplicate deliveries. Dispatch a queued job and return immediately, as above.

3. Deduplicate with the event id

Every Stripe event has a unique id. Store the ones you have handled and refuse to handle them twice. A unique index makes the database enforce it for you.

Schema::create('stripe_events', function (Blueprint $table) {
    $table->string('id')->primary();      // evt_...
    $table->string('type');
    $table->timestamp('processed_at')->nullable();
    $table->timestamps();
});
public function handle(): void
{
    $inserted = DB::table('stripe_events')->insertOrIgnore([
        'id' => $this->eventId, 'type' => $this->type, 'created_at' => now(), 'updated_at' => now(),
    ]);

    if (! $inserted) {
        return; // already handled
    }

    // ...fulfil the order
}

For the order itself, make the state change idempotent as well: update the order only if it is still pending, so a replay cannot fulfil it twice.

4. Do not trust the order of events

Stripe does not promise to deliver events in the order they happened. A payment_intent.succeeded can arrive before the checkout.session.completed that created the context for it. Design handlers so each one can stand alone: look up the order by a reference you stored in the payment's metadata, and tolerate a missing record by releasing the job back to the queue to retry shortly.

$order = Order::where('stripe_payment_intent', $intentId)->first();

if (! $order) {
    $this->release(30); // try again in 30 seconds
    return;
}

Test locally with the Stripe CLI

You do not need to deploy to test a webhook. The CLI forwards live test-mode events to your machine and prints the signing secret to use:

stripe listen --forward-to localhost:8000/api/webhooks/stripe
stripe trigger payment_intent.succeeded

Then replay the same event twice and confirm the order is only fulfilled once. That one test catches the most expensive bug in the whole integration.

Checklist

  • Verify the signature against the raw body.
  • Return 2xx quickly and queue the real work.
  • Record event ids with a unique index.
  • Make order updates conditional on current state.
  • Never rely on event order; tolerate missing records and retry.
  • Log failures with the event id so support can trace them.

The redirect makes the customer feel finished. The webhook is what actually finishes the order.