<?php
namespace App\Controller;
use App\Message\ProcessStripeEvent;
use App\Webhook\StripeSignatureVerifier;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Annotation\Route;
use Psr\Log\LoggerInterface;
class WebhookController extends AbstractController
{
public function __construct(
private readonly StripeSignatureVerifier $verifier,
private readonly MessageBusInterface $bus,
private readonly LoggerInterface $logger,
) {
}
#[Route('/webhooks/stripe', name: 'stripe_webhook', methods: ['POST'])]
public function handle(Request $request): Response
{
$payload = $request->getContent();
$signature = $request->headers->get('Stripe-Signature', '');
if (!$this->verifier->verify($payload, $signature)) {
$this->logger->warning('Rejected Stripe webhook: bad signature');
return new JsonResponse(['error' => 'invalid signature'], Response::HTTP_BAD_REQUEST);
}
$event = json_decode($payload, true, 512, JSON_THROW_ON_ERROR);
$this->bus->dispatch(new ProcessStripeEvent(
$event['id'],
$event['type'],
$event['data']['object'] ?? [],
));
return new JsonResponse(['received' => true], Response::HTTP_OK);
}
}
<?php
namespace App\Webhook;
class StripeSignatureVerifier
{
public function __construct(
private readonly string $signingSecret,
private readonly int $tolerance = 300,
) {
}
public function verify(string $payload, string $header): bool
{
[$timestamp, $signatures] = $this->parseHeader($header);
if ($timestamp === null || $signatures === []) {
return false;
}
if (abs(time() - $timestamp) > $this->tolerance) {
return false;
}
$signedPayload = "{$timestamp}.{$payload}";
$expected = hash_hmac('sha256', $signedPayload, $this->signingSecret);
foreach ($signatures as $candidate) {
if (hash_equals($expected, $candidate)) {
return true;
}
}
return false;
}
private function parseHeader(string $header): array
{
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
$pair = explode('=', trim($part), 2);
if (count($pair) !== 2) {
continue;
}
[$key, $value] = $pair;
if ($key === 't') {
$timestamp = (int) $value;
} elseif ($key === 'v1') {
$signatures[] = $value;
}
}
return [$timestamp, $signatures];
}
}
<?php
namespace App\MessageHandler;
use App\Message\ProcessStripeEvent;
use App\Service\InvoicePaymentReconciler;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
class ProcessStripeEventHandler
{
public function __construct(
private readonly Connection $connection,
private readonly InvoicePaymentReconciler $reconciler,
private readonly LoggerInterface $logger,
) {
}
public function __invoke(ProcessStripeEvent $event): void
{
try {
$this->connection->insert('processed_webhook_events', [
'event_id' => $event->id,
'processed_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'),
]);
} catch (UniqueConstraintViolationException) {
$this->logger->info('Skipping duplicate Stripe event', ['id' => $event->id]);
return;
}
match ($event->type) {
'payment_intent.succeeded' => $this->reconciler->markPaid($event->object),
'payment_intent.payment_failed' => $this->reconciler->markFailed($event->object),
'charge.refunded' => $this->reconciler->markRefunded($event->object),
default => $this->logger->debug('Unhandled Stripe event', ['type' => $event->type]),
};
}
}
This snippet shows how a Stripe-style webhook endpoint is built in Symfony as three collaborating pieces: a thin controller, a dedicated signature verifier, and an asynchronous message handler. The core idea is that a webhook receiver must do the minimum synchronous work required to authenticate the request, then hand off the actual business logic to a background worker so the HTTP response is fast and Stripe never times out and retries needlessly.
In WebhookController, the raw request body is read via getContent() rather than the parsed request, because signature verification must run against the exact bytes Stripe hashed — any re-encoding would break the HMAC. The controller delegates to StripeSignatureVerifier::verify(), and on failure returns a 400, which tells Stripe the payload was rejected. On success it decodes the event, dispatches a ProcessStripeEvent message onto the bus, and immediately returns 200. Acknowledging before processing is deliberate: Stripe treats a 2xx as delivery success, so the real work happens off the request cycle.
StripeSignatureVerifier implements the Stripe-Signature scheme. It splits the header into a timestamp t and one or more v1 signatures, rebuilds the signed payload as "{$timestamp}.{$payload}", and compares HMAC-SHA256 digests using hash_equals() to avoid timing attacks. The timestamp tolerance check rejects replays of old captured requests. Note the loop over multiple v1 values, which supports Stripe's rolling secret rotation where two secrets are valid at once.
ProcessStripeEventHandler is a Messenger handler marked #[AsMessageHandler]. Idempotency is enforced here, not in the controller: because Stripe may deliver the same event more than once, the handler inserts the event id into a processed_webhook_events table and treats a unique-constraint violation as a signal that the event was already handled, so it returns early. This makes reprocessing safe even if the worker crashes mid-way and Messenger retries the message.
The trade-off is eventual consistency — callers must not assume the side effect is complete when the 200 returns. In exchange the endpoint stays resilient to slow downstream systems, retries, and duplicate deliveries, which is exactly what a production payment integration needs.
Related snips
payload = {
sub: user.id,
iss: 'https://auth.example.com',
aud: 'codesnips-api',
exp: 15.minutes.from_now.to_i,
iat: Time.now.to_i,
JWT issuance and verification without common footguns
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
#!/usr/bin/env bash
set -euo pipefail
export VAULT_ADDR="https://vault.internal:8200"
export VAULT_TOKEN="${VAULT_TOKEN:?missing VAULT_TOKEN}"
Secrets management with environment isolation and Vault
import { randomBytes, createHash } from "crypto";
import jwt from "jsonwebtoken";
import { RefreshTokenStore } from "./store";
const ACCESS_SECRET = process.env.ACCESS_SECRET!;
const ACCESS_TTL = "15m";
JWT access + refresh token rotation (conceptual)
package files
import (
"context"
"time"
Presigned S3 upload URLs (AWS SDK v2)
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.