Stripe in PHP: Checkout Sessions and Webhook Verification Done Right
The raw-body problem bites PHP integrations harder than most. Here's the correct shape.
Zeeshan
Founder · Published
The raw-body problem bites PHP integrations harder than most. Here's the correct shape.
Zeeshan
Founder · Published
PHP has a first-class Stripe SDK and the happy path is short. The part that reliably goes wrong is webhook signature verification, because several PHP frameworks parse and re-serialize the request body before your handler ever sees it, and a re-serialized body will not match the signature.
<?php
require 'vendor/autoload.php';
$stripe = new \Stripe\StripeClient(getenv('STRIPE_SECRET_KEY'));
$session = $stripe->checkout->sessions->create([
'mode' => 'payment',
'line_items' => [[
'price' => 'price_123',
'quantity' => 1,
]],
'success_url' => getenv('SITE_URL') . '/success?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => getenv('SITE_URL') . '/cancel',
]);
header('Location: ' . $session->url, true, 303);
exit;The 303 status is deliberate: it forces the browser to follow with GET, which avoids re-submitting the form if the customer navigates back.
Stripe signs the exact bytes it sent. Read the body with file_get_contents('php://input') and hand those bytes to the SDK untouched: do not json_decode first and re-encode.
<?php
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
try {
$event = \Stripe\Webhook::constructEvent(
$payload,
$signature,
getenv('STRIPE_WEBHOOK_SECRET')
);
} catch (\Stripe\Exception\SignatureVerificationException $e) {
http_response_code(400);
exit;
}
// Acknowledge fast, then do the slow work out of band.
http_response_code(200);
if ($event->type === 'checkout.session.completed') {
enqueue_fulfillment($event->data->object->id);
}The signature failure has a distinctive smell: it works perfectly with a locally replayed payload and fails intermittently in production. That is almost always a middleware or proxy touching the bytes, not a wrong secret.
Stripe retries a webhook if it does not receive a 2xx quickly. If fulfillment involves sending mail, generating a PDF, or calling a slow third party, push that onto a queue and return 200 immediately. Otherwise a slow job produces retries, and retries produce duplicate fulfillment unless your handler is idempotent.
Stripe insights, monthly
One email a month, no spam, unsubscribe anytime.
No spam, unsubscribe anytime.
Let Stripe own the billing state and Django own the entitlement. Mixing the two is where it goes wrong.
The Elements provider, the client secret lifecycle, and the re-render bug that breaks most first attempts.
Shopify Payments covers standard storefronts well: here's where it stops being enough.