Building a PHP Webhook Receiver: A Practical Guide
A webhook is an HTTP callback that lets a remote server notify your application when something happens. Payment processors send them when a transaction completes. Shipping providers send them when a package status changes. External services send them when data updates. Your application receives an HTTP POST request and must respond correctly while processing the payload securely.
Building a webhook receiver in PHP sounds straightforward. Accept the POST request, process the data, send a response. In practice, several reliability issues surface quickly: duplicate deliveries that cause duplicate database records, slow processing that triggers provider timeouts, and security vulnerabilities that allow attackers to spoof webhook calls.
This guide walks through building a PHP webhook receiver that verifies signatures correctly, acknowledges requests promptly, handles failures gracefully, and processes payloads without data loss.
Why Webhook Reliability Matters
Webhook failures cost money and cause data inconsistencies. A missed payment confirmation webhook might mean a customer pays but never receives their product. A duplicate payment webhook might mean charging a customer twice. An unverified webhook endpoint might allow an attacker to trigger actions in your system by sending fake requests.
Providers expect your endpoint to respond within a few seconds. If it takes longer, they mark the delivery as failed and retry. If your endpoint responds with an error code, they retry. If your endpoint times out, they retry. Understanding how your provider handles retries helps you design a receiver that works reliably under these conditions.
Verifying Webhook Signatures
Any endpoint that accepts webhook calls without verification is vulnerable to spoofed requests. An attacker who can send requests to your webhook endpoint can trigger actions in your system by pretending to be the payment processor, shipping provider, or any service you integrate with.
Every reputable webhook provider signs its requests, and your receiver must verify those signatures. The most common signature scheme uses HMAC-SHA256. The provider computes a hash of the raw request body using a shared secret and includes it in a header, typically named X-Signature or similar. Your receiver computes the same hash and compares it against the received signature.
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$secret = getenv('WEBHOOK_SECRET');
$expected = hash_hmac('sha256', $payload, $secret);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit('Invalid signature');
}
Using hash_equals is important here. It performs a timing-safe string comparison, which prevents timing attacks where an attacker measures response times to gradually guess the expected signature. Always use hash_equals rather than a direct string comparison for security-sensitive comparisons.
Timestamp Validation Against Replay Attacks
Some providers include a timestamp header alongside the signature to prevent replay attacks. In a replay attack, an attacker intercepts a valid webhook and sends it again later, perhaps after a customer has been charged again or a subscription has been renewed twice.
The timestamp lets you reject webhooks that are too old. If the timestamp is more than five minutes in the past, reject the request. The timestamp is usually included in the signature computation itself, which prevents attackers from modifying just the timestamp header:
$timestamp = $_SERVER['HTTP_X_TIMESTAMP'] ?? '';
$timestamp_int = (int) $timestamp;
if (abs(time() - $timestamp_int) > 300) {
http_response_code(401);
exit('Timestamp too old');
}
$signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
This additional check adds a layer of protection against replay attacks, but it does not guarantee complete security. Webhook security depends on proper secret management, consistent signature verification, and regular monitoring of webhook activity.
Responding Quickly, Processing Asynchronously
Webhook providers enforce timeout limits. Most expect your endpoint to respond within two to five seconds. If your processing logic involves database queries, external API calls, or complex computations, you risk hitting that timeout.
When a provider considers a delivery failed due to timeout, it retries the webhook. Retries create duplicate processing risk. The solution is to acknowledge the webhook immediately, then handle processing asynchronously.
// Acknowledge immediately to the provider
http_response_code(200);
ob_end_clean();
// Store the payload for async processing
$payload = file_get_contents('php://input');
$queueDir = '/var/webhooks/queue/';
$filename = $queueDir . 'wh_' . uniqid() . '.json';
file_put_contents($filename, $payload);
flush();
This pattern acknowledges the webhook before any business logic runs. The raw payload gets stored in a queue directory, and a separate background worker handles the actual processing. The provider sees a successful response and stops retrying.
For higher-throughput applications, a proper message queue system makes more sense than a filesystem queue. Systems like RabbitMQ, Beanstalkd, or AWS SQS provide reliability, scalability, and better error handling than flat files on disk. The principle stays the same regardless of queue implementation: accept and acknowledge the webhook, then process it out of band.
Why This Architecture Works
Separating acknowledgment from processing gives you several advantages. Your endpoint responds quickly enough to avoid provider timeouts. Your business logic can run as slowly as needed without affecting delivery status. Failed processing attempts do not cause retries because the provider already received a success response. You can monitor the queue independently and alert on processing backlogs.
Idempotency: Handling Duplicate Deliveries
Webhook providers retry failed deliveries. Network issues, server restarts, slow responses, and HTTP errors all trigger retries. Without idempotency protection, the same event gets processed multiple times, leading to duplicate charges, duplicate database records, or incorrect application state.
Idempotency means processing the same webhook twice produces the same result as processing it once. The standard approach tracks processed event IDs. Every webhook payload includes a unique event identifier, usually in the payload JSON or a header. Store these IDs and check them before processing:
function isEventProcessed(string $eventId): bool {
$pdo = getDatabase();
$stmt = $pdo->prepare(
'SELECT 1 FROM processed_webhooks WHERE event_id = ? LIMIT 1'
);
$stmt->execute([$eventId]);
return (bool) $stmt->fetch();
}
function markEventProcessed(string $eventId): void {
$pdo = getDatabase();
$stmt = $pdo->prepare(
'INSERT IGNORE INTO processed_webhooks (event_id, processed_at) VALUES (?, NOW())'
);
$stmt->execute([$eventId]);
}
The INSERT IGNORE syntax (MySQL) or ON CONFLICT DO NOTHING (PostgreSQL) handles race conditions where the same event arrives twice before the first insertion completes. The second insert gets silently ignored rather than causing an error.
When processing a webhook, check the ID first. If it is already processed, acknowledge the request but skip processing:
if (isEventProcessed($eventId)) {
http_response_code(200);
exit('Already processed');
}
Parsing and Validating the Payload
After signature verification and before any business logic, parse the JSON payload and validate its structure. Never assume the payload is well-formed or contains the fields you expect.
$payload = file_get_contents('php://input');
$data = json_decode($payload, true);
if (json_last_error() !== JSON_ERROR_NONE) {
http_response_code(400);
exit('Invalid JSON');
}
if (!isset($data['event_type'], $data['event_id'], $data['data'])) {
http_response_code(400);
exit('Missing required fields');
}
$eventType = $data['event_type'];
$eventId = $data['event_id'];
$eventData = $data['data'];
Always check the event type before processing. Use a switch statement that handles known event types explicitly and handles unknown ones gracefully. A strict approach might throw an error for unknown types, but a more resilient approach logs unknown events and returns success without processing:
switch ($eventType) {
case 'payment.completed':
handlePaymentCompleted($eventId, $eventData);
break;
case 'payment.failed':
handlePaymentFailed($eventId, $eventData);
break;
case 'subscription.renewed':
handleSubscriptionRenewed($eventId, $eventData);
break;
default:
error_log("Unknown webhook event type: $eventType");
break;
}
Logging unknown event types helps you identify new event types from your provider that you might need to handle in future updates.
Logging Webhook Activity
Store a record of every incoming webhook for debugging, auditing, and recovery. Log the raw payload, relevant headers, the event ID, the processing result, and any errors that occur.
$stmt = $pdo->prepare('
INSERT INTO webhook_logs
(event_id, event_type, payload, headers, status, error_message, received_at)
VALUES (?, ?, ?, ?, ?, ?, NOW())
');
$stmt->execute([
$eventId,
$eventType,
$payload,
json_encode([
'signature' => $_SERVER['HTTP_X_SIGNATURE'] ?? '',
'timestamp' => $_SERVER['HTTP_X_TIMESTAMP'] ?? '',
]),
$status,
$errorMessage
]);
Logging the raw payload is essential. It lets you replay webhook events if processing fails, if the database needs rebuilding, or if you discover a bug in your processing logic and need to reprocess historical events. The headers log helps debug signature verification issues when providers change their header formats.
Background Processing with a Cron Worker
A simple queue-based webhook processor can run as a cron job that executes every minute, reads queued payloads, processes each one, and moves processed files to a different directory:
# /etc/cron.d/process-webhooks
* * * * * php /var/www/html/process-webhooks.php >> /var/log/webhooks.log 2>&1
<?php
$queueDir = '/var/webhooks/queue/';
$processedDir = '/var/webhooks/processed/';
$failedDir = '/var/webhooks/failed/';
$lockFile = '/var/webhooks/lock';
if (file_exists($lockFile) && filemtime($lockFile) > time() - 50) {
exit('Previous run still in progress');
}
touch($lockFile);
$files = glob($queueDir . 'wh_*.json');
foreach ($files as $file) {
$payload = file_get_contents($file);
$data = json_decode($payload, true);
try {
processWebhook($data);
rename($file, $processedDir . basename($file));
} catch (Exception $e) {
error_log("Webhook processing failed: " . $e->getMessage());
$mtime = filemtime($file);
if ($mtime < time() - 3600) {
rename($file, $failedDir . 'failed_' . basename($file));
}
}
}
unlink($lockFile);
The lock file prevents overlapping cron runs. If processing takes longer than a minute, the next cron execution sees the lock file and exits early. Failed webhooks move to a dedicated directory after an hour so they do not block the queue while you investigate what went wrong.
Deployment Automation for Webhook Processors
Automating your deployment pipeline ensures webhook processors stay updated and run reliably. A Bash deployment script can handle code updates, queue directory setup, permission configuration, and service restarts consistently across environments.
Security Considerations
Webhook endpoints present an attack surface that deserves careful attention. An unprotected endpoint accepts any POST request, which means attackers can probe it with crafted payloads to learn about your application internals.
Beyond signature verification, consider these protective measures. Restrict access to your webhook endpoint by IP if your provider publishes a list of their webhook IPs. Use HTTPS for your webhook endpoint URL to prevent man-in-the-middle attacks intercepting the payload or signature. Rotate your webhook secret periodically and store it securely, never in version control.
The OWASP Top 10 for business web applications covers many of the risks that apply to webhook receivers, including injection attacks, broken authentication, and security misconfiguration. Understanding these risks helps you design more resilient integrations.
When Providers Use Different Signature Schemes
Not all providers use HMAC-SHA256. Some use RSA signatures where the provider signs with a private key and you verify with their public key. Some use JWT tokens. Some use custom header formats. Always read your provider's documentation carefully and implement exactly what they specify.
If you are integrating with Stripe or similar payment providers, they provide official SDKs and detailed documentation that handle most of the signature verification logic for you. Using official libraries reduces implementation errors and keeps your integration up to date when providers change their security mechanisms.
Testing Your Webhook Receiver
Test your implementation thoroughly before relying on it for real events. Most providers offer a webhook testing interface in their dashboard that lets you send sample events to your endpoint. Use this to verify your signature verification works, your idempotency logic prevents duplicates, and your error handling behaves correctly.
Local development requires a tool to receive webhooks. Tools like ngrok create a public URL that tunnels to your local development server, letting providers send webhooks to your local machine. Alternatively, services like webhook.site generate a temporary URL you can configure as your webhook endpoint during testing.
Write unit tests for your processing logic. Mock the webhook payload, pass it through your parser and validator, and assert that the correct business logic gets triggered. Tests catch bugs early and protect against regressions when you update your code.
Monitoring Webhook Health
Set up monitoring for your webhook queue and processing health. Track metrics like queue depth (how many webhooks are waiting to be processed), processing latency (how long between webhook arrival and processing completion), error rates (how many webhooks fail to process), and retry counts (how often the same webhook gets processed).
Alert when queue depth grows beyond a threshold, when error rates spike, or when processing latency exceeds acceptable limits. Regular monitoring catches problems before they affect customers or cause data inconsistencies.
Key Takeaways
A reliable PHP webhook receiver combines several practices that work together. Signature verification using HMAC-SHA256 with timing-safe comparison prevents spoofed requests. Immediate acknowledgment followed by asynchronous processing avoids provider timeouts. Tracking processed event IDs ensures idempotency and prevents duplicate side effects. Payload validation before acting on data protects against malformed requests. Comprehensive logging supports debugging, auditing, and recovery.
This architecture handles production volumes, survives server restarts, and provides an audit trail for every webhook event. Whether you are integrating payment processing, shipping notifications, or third-party data updates, these patterns apply broadly and help you build integrations that work reliably over time.
If you need help reviewing your webhook integration or want to discuss how this approach fits your specific setup, you can get in touch with details about your current implementation, the providers you integrate with, and the reliability challenges you are facing.