PHP cookbook · Intermediate

Receive and verify webhooks in PHP

Process payment provider webhooks safely: verify signatures, avoid duplicates and respond quickly.

Approach

Read the raw request body, verify the HMAC signature with the shared secret and a timestamp tolerance, deduplicate by event ID with a unique constraint, store the event and return 2xx quickly; process heavy work asynchronously.

Solution

<?php
declare(strict_types=1);

$payload = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_SIGNATURE'] ?? '';          // e.g. "t=1700000000,v1=hex..."
parse_str(str_replace(',', '&', $header), $parts);
$timestamp = (int) ($parts['t'] ?? 0);
$expected = hash_hmac('sha256', "{$timestamp}.{$payload}", getenv('WEBHOOK_SECRET'));

if (abs(time() - $timestamp) > 300 || !hash_equals($expected, (string) ($parts['v1'] ?? ''))) {
    http_response_code(400);
    exit('Invalid signature');
}

$event = json_decode($payload, true, flags: JSON_THROW_ON_ERROR);
try {
    $pdo->prepare('INSERT INTO webhook_events (event_id, type, payload, received_at) VALUES (?, ?, ?, NOW())')
        ->execute([$event['id'], $event['type'], $payload]);
} catch (PDOException $e) {
    if ($e->getCode() === '23505') { http_response_code(200); exit('Duplicate'); }   // already received
    throw $e;
}
http_response_code(200);   // a worker processes webhook_events asynchronously
echo 'ok';

Pitfalls

  • Verifying the signature against re-encoded JSON instead of the raw body.
  • Doing slow work before responding, causing provider timeouts and retries.
  • No replay protection (timestamp check).

Variations

  • Use the provider's SDK verifier (e.g., \Stripe\Webhook::constructEvent).
  • Process synchronously only when the work is tiny and idempotent.

Further reading: Stripe: Webhooks

Reference: https://docs.stripe.com/webhooks

More PHP recipes