http clients and apis
Webhook Reliability And Security
A junior developer does not need to design a whole event platform, but they should understand the moving parts well enough to implement a provider's webhook guide safely and review common mistakes.
Receiving and sending
Webhook work has two sides:
- Receiving webhooks means your application exposes an endpoint and another service calls it.
- Sending webhooks means your application calls a customer's or partner's endpoint when something changes.
Receiving and sending use the same HTTP concepts, but the reliability concerns are mirrored. A receiver must verify and deduplicate. A sender must sign requests, retry temporary failures, and show delivery history to support staff or customers.
Signature verification
Signatures prove that the body was produced by someone who knows the shared secret. The signature must be calculated from the raw body exactly as received. If you decode JSON and encode it again, whitespace and key order can change, which changes the signature.
<?php
declare(strict_types=1);
function signWebhook(string $timestamp, string $rawBody, string $secret): string
{
return hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
}
function verifyWebhookSignature(
string $timestamp,
string $rawBody,
string $receivedSignature,
string $secret
): bool {
$expected = signWebhook($timestamp, $rawBody, $secret);
return hash_equals($expected, $receivedSignature);
}
$body = '{"id":"evt_123","type":"invoice.paid"}';
$timestamp = '1779969600';
$secret = 'webhook_secret';
$signature = signWebhook($timestamp, $body, $secret);
var_dump(verifyWebhookSignature($timestamp, $body, $signature, $secret));
// Prints:
// bool(true)
Provider formats differ. Some send t=timestamp,v1=signature, some send plain HMAC values, and some use asymmetric signatures. Follow the provider's exact documentation.
Replay protection
A valid old webhook should not be accepted forever. If an attacker captures a signed request and sends it again months later, the signature may still match unless the scheme includes a timestamp.
<?php
declare(strict_types=1);
function timestampWithinTolerance(int $eventTimestamp, int $now, int $toleranceSeconds): bool
{
return abs($now - $eventTimestamp) <= $toleranceSeconds;
}
$now = 1779969900;
var_dump(timestampWithinTolerance(1779969600, $now, 300));
var_dump(timestampWithinTolerance(1779960000, $now, 300));
// Prints:
// bool(true)
// bool(false)
Timestamp checks do not replace idempotency. They stop very old signed payloads being replayed; idempotency stops the same valid event being processed twice.
Idempotency and storage
The receiver should store the provider event ID with a unique constraint. The first insert accepts the event. Later inserts for the same event ID are treated as duplicates.
In plain PHP, the idea can be modelled with an array:
<?php
declare(strict_types=1);
function storeIncomingEvent(string $eventId, array &$eventStore): string
{
if (isset($eventStore[$eventId])) {
return 'duplicate';
}
$eventStore[$eventId] = [
'status' => 'received',
'attempts' => 0,
];
return 'stored';
}
$events = [];
echo storeIncomingEvent('evt_123', $events) . PHP_EOL;
echo storeIncomingEvent('evt_123', $events) . PHP_EOL;
// Prints:
// stored
// duplicate
In a real application, this belongs in durable storage, not memory. The database record should keep the event ID, type, provider, received time, processing status, attempt count, and enough non-secret metadata to debug it.
Retry behaviour
Receivers should return a 2xx response once the event is accepted for processing. If the provider receives a timeout or 5xx, it will usually retry. If the event is malformed or the signature is invalid, retrying the same payload will not help.
Senders should retry temporary failures with backoff. They should not retry forever. After a maximum number of attempts, the delivery should be marked failed and moved to a dead-letter state where a developer or support workflow can inspect it.
<?php
declare(strict_types=1);
function deliveryState(int $statusCode, int $attempt, int $maxAttempts): string
{
if ($statusCode >= 200 && $statusCode < 300) {
return 'delivered';
}
$temporaryFailure = in_array($statusCode, [408, 429, 500, 502, 503, 504], true);
if ($temporaryFailure && $attempt < $maxAttempts) {
return 'retry';
}
return 'dead-letter';
}
echo deliveryState(503, 1, 5) . PHP_EOL;
echo deliveryState(503, 5, 5) . PHP_EOL;
echo deliveryState(400, 1, 5) . PHP_EOL;
// Prints:
// retry
// dead-letter
// dead-letter
Logs and observability
Webhook logs should help answer practical questions:
- Did the provider call the endpoint?
- Was the signature valid?
- Which event ID and type was received?
- Was it a duplicate?
- Was processing queued, completed, retried, or failed?
- Which delivery attempt failed and why?
Do not log secrets, full auth headers, payment card data, or excessive personal data. Keep the raw body only if the product, legal, and security requirements allow it.
Dead-letter handling
A dead-letter item is an event or delivery that could not be processed after the normal retry policy. It should not silently disappear.
Useful dead-letter records include:
- Event or delivery ID.
- Provider or destination.
- Event type.
- Last status code or exception class.
- Attempt count.
- First and last failure time.
- A safe summary of the failure.
A good admin or support workflow lets someone inspect the failure, fix configuration if needed, and replay the event deliberately.
What to check
Before moving on, make sure you can:
- Explain why signatures should use the raw body.
- Add timestamp tolerance for replay protection.
- Store provider event IDs for idempotency.
- Separate acceptance of the webhook from slow processing.
- Describe when a delivery should be retried or moved to dead-letter handling.
Practice
Practice: Harden A Webhook
Write a PHP function that decides whether an incoming webhook should be accepted, rejected, ignored as a duplicate, or rejected as too old.
Requirements
- Verify a timestamped HMAC signature built from
timestamp.body. - Reject timestamps outside a five-minute tolerance.
- Decode the JSON body with exceptions enabled.
- Require a non-empty event ID.
- Treat duplicate event IDs as accepted but ignored.
- Return a status code, message, and internal action such as
queue,ignore, orreject.
Show solution
This solution combines the important receiver checks without doing slow business work inside the request.
<?php
declare(strict_types=1);
function signedWebhookValue(string $timestamp, string $rawBody, string $secret): string
{
return hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
}
function handleIncomingWebhook(
string $rawBody,
string $timestamp,
string $receivedSignature,
string $secret,
int $now,
array &$seenEventIds
): array {
$eventTime = filter_var($timestamp, FILTER_VALIDATE_INT);
if ($eventTime === false || abs($now - $eventTime) > 300) {
return ['status' => 400, 'action' => 'reject', 'message' => 'Webhook timestamp is outside tolerance.'];
}
$expectedSignature = signedWebhookValue($timestamp, $rawBody, $secret);
if (!hash_equals($expectedSignature, $receivedSignature)) {
return ['status' => 401, 'action' => 'reject', 'message' => 'Webhook signature is invalid.'];
}
try {
$event = json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException) {
return ['status' => 400, 'action' => 'reject', 'message' => 'Webhook JSON is invalid.'];
}
$eventId = is_array($event) ? trim((string) ($event['id'] ?? '')) : '';
if ($eventId === '') {
return ['status' => 400, 'action' => 'reject', 'message' => 'Webhook event id is required.'];
}
if (isset($seenEventIds[$eventId])) {
return ['status' => 200, 'action' => 'ignore', 'message' => 'Duplicate event ignored.'];
}
$seenEventIds[$eventId] = true;
return ['status' => 202, 'action' => 'queue', 'message' => 'Event accepted for processing.'];
}
$secret = 'webhook_secret';
$now = 1779969900;
$timestamp = (string) $now;
$body = '{"id":"evt_123","type":"invoice.paid"}';
$signature = signedWebhookValue($timestamp, $body, $secret);
$seen = [];
$examples = [
handleIncomingWebhook($body, $timestamp, $signature, $secret, $now, $seen),
handleIncomingWebhook($body, $timestamp, $signature, $secret, $now, $seen),
handleIncomingWebhook($body, (string) ($now - 1000), signedWebhookValue((string) ($now - 1000), $body, $secret), $secret, $now, $seen),
handleIncomingWebhook($body, $timestamp, 'wrong', $secret, $now, $seen),
];
foreach ($examples as $result) {
echo $result['status'] . ' ' . $result['action'] . ' - ' . $result['message'] . PHP_EOL;
}
// Prints:
// 202 queue - Event accepted for processing.
// 200 ignore - Duplicate event ignored.
// 400 reject - Webhook timestamp is outside tolerance.
// 401 reject - Webhook signature is invalid.
This pattern gives the caller a quick response and gives the application a clear internal action. In a real project, accepted events would be inserted into durable storage and picked up by a queue worker.