Webhook Integration in PHP: Building a Secure and Reliable Receiver

Webhook receivers are a common attack surface in PHP applications. A webhook is an HTTP callback: a remote server sends an HTTP POST request to your application when something happens on their end, such as a payment being confirmed, a shipping status changing, or a user action triggering a notification.

Building a webhook receiver in PHP sounds straightforward. Accept the POST, process the data, respond. In practice, reliability issues arise quickly: duplicate deliveries, slow processing causing timeouts, and security vulnerabilities that allow spoofed webhook calls. This guide covers how to build a webhook receiver in PHP that verifies incoming requests, handles failures gracefully, and processes webhook payloads without data loss.

Why Webhook Security Matters

Any endpoint that accepts webhook calls without proper 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 third-party service you integrate with. The consequences range from incorrect data in your database to financial losses if an attacker can trigger duplicate charges or falsify transaction records.

Every reputable webhook provider signs its requests, and your receiver must verify those signatures before acting on the payload. Understanding input validation and output encoding is foundational to securing any PHP application that accepts external data, including webhook endpoints. A structured approach to PHP security helps protect against common vulnerabilities that can affect webhook receivers just as they affect other entry points in your application.

Verifying the Webhook Signature with HMAC-SHA256

The most common signature scheme used by webhook providers is HMAC-SHA256. The provider computes a hash of the raw request body using a shared secret and includes it in a header, typically named something like X-Signature-256 or Stripe-Signature. Your receiver computes the same hash and rejects the request if they do not match.

$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_SIGNATURE_256'] ?? '';
$secret = getenv('WEBHOOK_SECRET');
$expected = hash_hmac('sha256', $payload, $secret);

if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit('Invalid signature');
}

The hash_equals function is timing-safe, which prevents timing attacks that could be used to guess the expected signature by observing response times. Always use hash_equals rather than a direct string comparison when verifying cryptographic signatures in PHP.

Note: Always read the raw request body using php://input rather than $_POST. Webhook providers typically send JSON payloads, and the raw body gives you access to the exact bytes that were signed. If you parse the payload using $_POST first, you may lose the original raw data needed for signature verification.

Adding Timestamp Verification to Prevent Replay Attacks

Some providers use a timestamp header in addition to the signature to prevent replay attacks. The timestamp is included in the signature computation, and requests with timestamps older than a threshold are rejected. This stops attackers from capturing and replaying valid webhook requests that they have intercepted.

$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_256'] ?? '';
$expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);

The tolerance of 300 seconds (5 minutes) accounts for clock drift between your server and the provider's server. Webhooks outside this window are rejected, reducing the window of opportunity for replay attacks. You can adjust this tolerance based on your specific provider's recommendations and your system's tolerance for delayed deliveries.

Responding Quickly and Processing Asynchronously

Webhook providers typically expect a response within a few seconds. If your processing logic is slow, the provider considers the delivery failed and retries. This creates a frustrating loop where slow processing causes repeated retries that slow your system further.

The solution is to acknowledge the webhook immediately, then process it asynchronously. Your endpoint should accept the request, verify the signature, store the payload, and return a 200 status code as fast as possible. The actual processing happens in a background worker that runs independently of the incoming request.

// Acknowledge immediately
http_response_code(200);
ob_end_clean();

// Store payload for async processing
$payload = file_get_contents('php://input');
$queueDir = '/var/webhooks/queue/';

if (!is_dir($queueDir)) {
    mkdir($queueDir, 0750, true);
}

file_put_contents($queueDir . uniqid('wh_') . '.json', $payload);
flush();

This pattern acknowledges the webhook before any processing happens. The payload is stored in a queue directory. A separate background worker reads from the queue and processes each webhook asynchronously, at its own pace.

Important: Always return a 200 status code after successfully receiving and storing the webhook. If you return an error status code, the provider will retry, and you may end up processing the same webhook multiple times. The only exception is when you deliberately want the provider to retry, such as when your queue storage is full.

Idempotency: Handling Duplicate Deliveries Gracefully

Webhook providers retry failed deliveries. If your receiver does not handle duplicates, the same event is processed multiple times, leading to duplicate charges, duplicate database records, or incorrect application state. Idempotency ensures that processing the same webhook twice produces the same result as processing it once.

The standard approach is to track processed event IDs. Each webhook payload includes a unique event ID, typically found in a field like id or event_id. Store processed IDs in a database table or a Redis set:

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]);
}

Use INSERT IGNORE (MySQL) or ON CONFLICT DO NOTHING (PostgreSQL) to handle the case where the same event arrives twice before the first insertion completes. The second insert is silently ignored rather than causing an error. This race-condition handling is essential when processing high-volume webhooks.

Combine this with a check at the start of your processing logic:

function processWebhookPayload(array $data): void
{
    $eventId = $data['id'] ?? null;
    
    if ($eventId === null) {
        throw new InvalidArgumentException('Missing event ID');
    }
    
    if (isEventProcessed($eventId)) {
        return; // Already processed, skip silently
    }
    
    // Process the webhook
    handleEvent($data);
    
    // Mark as processed
    markEventProcessed($eventId);
}

This pattern prevents duplicate processing even if the same webhook arrives through different delivery attempts or is processed by multiple worker instances simultaneously. It is a fundamental safeguard for any production webhook integration.

Parsing and Validating the Payload

After the signature is verified and duplicate checking is in place, parse the JSON payload and validate its structure before acting on it. Never assume the payload is well-formed. Network errors, encoding issues, or provider bugs can send malformed data to your endpoint.

$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['id']) || !isset($data['type']) || !isset($data['data'])) {
    http_response_code(400);
    exit('Missing required fields');
}

$eventType = $data['type'];
$eventId = $data['id'];
$eventData = $data['data'];

Validate the event type before processing. Use a switch statement that handles known event types and silently ignores unknown ones, rather than throwing an error for unexpected event types. This approach prevents your system from breaking when providers add new event types that you have not yet accounted for:

switch ($eventType) {
    case 'payment.completed':
        handlePaymentCompleted($eventData, $eventId);
        break;
    case 'payment.failed':
        handlePaymentFailed($eventData, $eventId);
        break;
    case 'subscription.renewed':
        handleSubscriptionRenewed($eventData, $eventId);
        break;
    default:
        // Log unknown events but do not error
        error_log("Unknown webhook event type: " . $eventType);
        break;
}

When handling webhook events that modify database records, always use prepared statements to prevent SQL injection. Even though the data comes from a verified webhook, it is good practice to treat all external input as potentially malicious. Validating and sanitizing input protects your application from edge cases and unexpected data formats that could cause issues in your processing logic.

Logging Webhook Activity for Debugging and Auditing

Log every incoming webhook for debugging and auditing purposes. Store the raw payload, the headers, the event ID, the processing result, and any errors that occur. This creates an audit trail that proves invaluable when debugging issues, investigating suspicious activity, or reconstructing application state after an incident.

function logWebhook(
    string $eventId,
    string $eventType,
    string $payload,
    string $headers,
    string $status,
    ?string $errorMessage = null
): void {
    $pdo = getDatabase();
    $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, $headers, $status, $errorMessage]);
}

Logging the raw payload allows you to replay webhook events if processing fails or the database needs to be rebuilt. The headers log helps debug signature verification issues by showing exactly what was received. Keeping these logs separate from your main application logs makes it easier to search and filter when investigating webhook-specific issues.

Background Processing with a Cron Worker

A simple queue-based webhook processor can be implemented as a cron job that runs every minute, processes queued payloads, and removes them on success. This approach is reliable, requires minimal infrastructure, and works well for small to medium traffic applications.

First, set up the cron job:

# /etc/cron.d/process-webhooks
* * * * * php /var/www/html/process-webhooks.php 2>&1

Then implement the worker script:

<?php

$queueDir = '/var/webhooks/queue/';
$processedDir = '/var/webhooks/processed/';
$lockFile = '/var/webhooks/lock';

// Prevent overlapping runs
if (file_exists($lockFile) && filemtime($lockFile) > time() - 50) {
    exit('Already running');
}
touch($lockFile);

$files = glob($queueDir . 'wh_*.json');

foreach ($files as $file) {
    $payload = file_get_contents($file);
    $data = json_decode($payload, true);
    
    try {
        processWebhookPayload($data);
        rename($file, $processedDir . basename($file));
    } catch (Exception $e) {
        error_log("Webhook processing failed: " . $e->getMessage());
        
        // Move old failed webhooks to failed directory
        if (filemtime($file) < time() - 3600) {
            rename($file, $processedDir . 'failed_' . basename($file));
        }
    }
}

unlink($lockFile);

The lock file prevents the cron job from running overlapping instances if processing takes more than a minute. Failed webhooks are moved to the failed directory after an hour so they do not block the queue indefinitely. Regular monitoring of the failed directory helps you identify patterns in failures and fix underlying issues.

Warning: Before testing webhook processing in a production environment, ensure you have recent backups of your database and a clear rollback plan. Processing webhook events can modify application state, and mistakes in your processing logic can lead to unintended changes. Test thoroughly in a staging environment first.

Production Considerations

When moving from a simple implementation to a production-ready webhook receiver, several additional factors matter. Taking time to address these before going live reduces the risk of incidents and makes ongoing maintenance easier.

Queue Storage and Reliability

The filesystem queue shown here works for low-to-medium traffic sites. For high-volume applications, a proper message queue provides better reliability, retry semantics, and dead-letter handling. Solutions like RabbitMQ, Beanstalkd, or AWS SQS integrate well with PHP applications and offer features like priority queues, delayed delivery, and programmatic retry policies that a filesystem-based approach cannot easily provide.

Error Handling and Notifications

Set up monitoring for your webhook queue. If webhooks start accumulating faster than they are processed, or if the error rate increases, you need to know immediately. Configure alerts for queue depth exceeding thresholds and error rate spikes. A webhook queue that grows without bound can indicate a processing failure that needs immediate attention.

Testing Your Webhook Receiver

Use your provider's webhook testing tools to send sample events during development. Tools like Stripe CLI allow you to trigger webhooks locally, which makes it easier to test your signature verification and processing logic without going through the full deployment cycle. Testing with real events from your provider is more reliable than constructing test payloads manually, as it ensures your verification logic handles the actual data format correctly.

Two-Factor Authentication for Admin Interfaces

If your webhook processing includes an admin interface for monitoring or manual intervention, adding two-factor authentication provides an additional layer of security. Even if your admin credentials are compromised, a second authentication factor significantly reduces the risk of unauthorized access. PHP supports various two-factor authentication methods that can be integrated into existing authentication systems.

Putting It Together

A reliable PHP webhook receiver combines several practices: verifying signatures using HMAC-SHA256 with timing-safe comparison, acknowledging requests immediately and processing asynchronously, tracking processed event IDs for idempotency, validating payload structure before acting, and logging all activity for debugging. The background processing pattern with a queue and a worker process ensures that slow processing does not cause provider timeouts and that duplicate deliveries do not cause duplicate side effects.

This architecture handles high volumes, survives server restarts, and provides an audit trail for every webhook event. If you are integrating with webhook providers and need help reviewing your current implementation or building a production-ready solution, you can get in touch with details of your setup and requirements.