Suppose a customer buys through a payment gateway or an external service. How does your site find out the payment succeeded? The first way is to ask that service every few seconds. This wastes resources and also learns about it late. A better way is for the service itself to message your site the moment the event happens. That message is a webhook.

A webhook is just an ordinary HTTP request (often a POST with a JSON body) to an address you have declared. It is simple, but because its address is public, if it isn't received correctly it can become a security hole. If you aren't familiar with API structure, first read what is a REST API.

How does a webhook work?#

  1. In the service's dashboard, you register your site's address (for example https://example.com/webhooks/payment) and the events you want.
  2. The service gives you a shared secret.
  3. When the event happens, the service sends a POST request, usually signing the body with that secret and putting the signature in a header.
  4. Your site verifies the signature, records the event and quickly replies with success.
  5. If a success response doesn't arrive, the service usually resends later.

The signature header name, the exact algorithm and its format differ in each service; always treat that service's documentation as the reference. The examples in this article show the common HMAC with SHA-256 pattern.

Step one: verify the signature#

The signature is usually an HMAC of the raw request body with the shared secret. It has three vital points:

  • Sign the raw body, not the decoded JSON. If you decode and re-encode it first, spaces or key order change and the signature won't match.
  • Compare with hash_equals. A normal comparison with == can leak information through response time.
  • Don't write the secret in code. Read it from an environment variable or a config file outside the repository; PHP code security also has notes on storing secrets.
PHP
<?php
declare(strict_types=1);

$secret    = getenv('WEBHOOK_SECRET') ?: '';
$payload   = file_get_contents('php://input');          // raw body
$signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';        // header name per the service's docs

if ($secret === '' || $signature === '') {
    http_response_code(400);
    exit;
}

$expected = hash_hmac('sha256', $payload, $secret);

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

$event = json_decode($payload, true);
if (!is_array($event) || empty($event['id']) || empty($event['type'])) {
    http_response_code(400);
    exit;
}

// ... record and process the event ...

http_response_code(200);
echo 'ok';

Some services send the signature as sha256=... or together with a timestamp; in that case you must parse the format per the documentation, and the signed string usually includes the timestamp too.

Step two: prevent replay attacks#

An attacker who intercepts a real signed request can send the same one again later. If the service sends the send time in the message or a header, check that it isn't older than a few minutes:

PHP
$timestamp = (int)($_SERVER['HTTP_X_TIMESTAMP'] ?? 0);
if (abs(time() - $timestamp) > 300) {   // five minutes
    http_response_code(400);
    exit;
}

Set this value and the header name per the service's documentation; not all services send a timestamp.

Step three: neutralize duplicate processing#

Services usually send "at least once", not "exactly once". So the same event may arrive twice, and if each time you mark the order "paid" and reduce stock, errors occur. The fix is to store the event ID with a uniqueness constraint:

SQL
CREATE TABLE webhook_events (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  event_id VARCHAR(100) NOT NULL,
  type VARCHAR(80) NOT NULL,
  payload JSON NOT NULL,
  processed_at DATETIME NULL,
  received_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY uq_event (event_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
PHP
<?php
// $pdo is a PDO connection with exception error mode
try {
    $stmt = $pdo->prepare(
        'INSERT INTO webhook_events (event_id, type, payload) VALUES (?, ?, ?)'
    );
    $stmt->execute([$event['id'], $event['type'], $payload]);
} catch (PDOException $e) {
    if ($e->getCode() === '23000') {      // uniqueness violation: already received
        http_response_code(200);
        exit('duplicate');
    }
    throw $e;
}

With the UNIQUE constraint, even if two requests arrive at the same time, only one is recorded. Also build the table following the principles of designing database tables and with a parameterized query.

Step four: reply fast, do the work later#

The sender's response deadline is short. This address should do only three things: verify the signature, record the event, reply 200. Do the main processing (sending email, updating inventory, calling another service) in a queue or scheduled job. In Laravel:

PHP
<?php
// routes/api.php
Route::post('/webhooks/payment', function (Request $request) {
    $payload   = $request->getContent();
    $signature = $request->header('X-Signature', '');
    $expected  = hash_hmac('sha256', $payload, config('services.payment.webhook_secret'));

    abort_unless(hash_equals($expected, $signature), 401);

    $event = json_decode($payload, true);
    abort_unless(is_array($event) && isset($event['id']), 400);

    ProcessPaymentEvent::dispatch($event);   // heavy work goes to the queue

    return response('ok', 200);
});

This route has no session and no logged-in user and is defined in api.php (without CSRF protection). Instead of CSRF, the signature protects it; so never remove the signature check.

Common mistakes#

MistakeConsequence
Accepting a webhook without checking the signatureAnyone can forge an event
Signing a rebuilt JSONEven a correct signature is rejected
Trusting the message data for an amount without matching it to the orderA forged amount or ID
Heavy processing in the same requestTimeouts and resends
No loggingImpossible to trace a problem

For important events like payments, in addition to the webhook you can also ask the service's API for the status once more, so you don't rely on a single message alone.

Summary#

  • A webhook means the other service notifies your site itself with an HTTP request.
  • Verify the raw body with HMAC and the shared secret, and compare with hash_equals.
  • If the service supports it, check the message time to prevent replay.
  • Store the event ID with a uniqueness constraint so duplicate processing doesn't happen.
  • Reply 200 quickly and hand heavy work to a queue.
  • Keep the shared secret in an environment variable, not in code or the repository.