DEV3 Technologies home

Stripe Checkout session and verified webhook

Create a hosted Stripe Checkout session over plain HTTP and handle the signed webhook that confirms the payment, with replay protection.

LaravelPaymentsstripecheckoutwebhookpayments

Code

<?php

namespace App\Http\Controllers;

use App\Models\Invoice;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Http;

class CheckoutController extends Controller
{
    public function __invoke(Invoice $invoice): RedirectResponse
    {
        abort_unless($invoice->status === 'pending', 409);

        $session = Http::asForm()
            ->withToken(config('services.stripe.secret'))
            ->baseUrl('https://api.stripe.com/v1')
            ->timeout(15)
            ->post('/checkout/sessions', [
                'mode' => 'payment',
                'success_url' => route('invoices.show', $invoice).'?paid=1',
                'cancel_url' => route('invoices.show', $invoice),
                'client_reference_id' => $invoice->number,
                'metadata[invoice]' => $invoice->number,
                'line_items[0][quantity]' => 1,
                'line_items[0][price_data][currency]' => strtolower($invoice->currency),
                'line_items[0][price_data][unit_amount]' => $invoice->total_cents,
                'line_items[0][price_data][product_data][name]' => "Invoice {$invoice->number}",
            ])
            ->throw()
            ->json();

        return redirect()->away($session['url']);
    }
}

How it works

What it solves

Hosted Checkout keeps card data off your server, but you still need two pieces of glue: something that asks Stripe for a session and sends the customer there, and an endpoint that trusts only Stripe's signed callback before marking an order paid. This pair does exactly that with the plain HTTP client, so there is no SDK to version-match.

How it works

CheckoutController posts a form-encoded session request with the amount in minor units and stores your own invoice number in client_reference_id and metadata. The redirect URL comes straight from the response.

StripeWebhookController reads the raw body (never the parsed JSON), rebuilds the t=...,v1=... signature header as an HMAC over timestamp.payload, compares with hash_equals, and rejects anything older than five minutes. Only after that does it decode the event and branch on its type. The paid branch flips the invoice inside an update ... where status = pending so a second delivery of the same event changes nothing.

Adapting it

Swap the Invoice model and markPaid call for your order table. Add more match arms for refunds or failed payments. For subscriptions change mode to subscription and pass a price id instead of price_data.

Pitfalls

  • Exclude the webhook route from CSRF protection, but keep it POST only.
  • Verify against the raw request content; re-encoded JSON will never match the signature.
  • Return 200 for event types you ignore, otherwise Stripe retries them for days.
  • Rotate the signing secret through config, never hard code it.

How to use it

  1. Put both controllers in app/Http/Controllers.
  2. Add 'stripe' => ['secret' => env('STRIPE_SECRET'), 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET')] to config/services.php.
  3. Register Route::post('/checkout/{invoice}', CheckoutController::class) and Route::post('/webhooks/stripe', StripeWebhookController::class).
  4. Exclude webhooks/stripe in the preventRequestForgery / CSRF exceptions.
  5. Create an Invoice model with status, number and total_cents, then point your Stripe dashboard webhook at the route.

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets