http clients and apis
Webhooks
A webhook is an HTTP request sent by another system when something happens. Instead of your application polling an API every few minutes, the provider calls your endpoint when an event is ready.
Common examples include:
- A payment provider sends
payment.succeeded. - An email service sends
message.bounced. - A Git hosting service sends
pushorpull_request.opened. - A CRM sends
contact.updated. - A subscription platform sends
invoice.paidorsubscription.cancelled.
From PHP's point of view, receiving a webhook looks like receiving any other HTTP request. The difference is operational: the request comes from another service, may be retried, may arrive more than once, and must not be trusted just because it reached your URL.
The basic flow
A reliable webhook receiver usually does this:
- Read the raw request body.
- Verify the sender, usually with a signature header.
- Decode and validate the JSON payload.
- Check whether this event has already been received.
- Store the event or enqueue work.
- Return a quick
2xxresponse when accepted.
The endpoint should be fast. Slow business work such as sending emails, updating several tables, calling another API, or generating files is often better done in a queue after the webhook has been accepted.
Event shape
Webhook payloads vary by provider, but most include an event ID, event type, timestamp, and data object.
<?php
declare(strict_types=1);
$payload = <<<'JSON'
{
"id": "evt_123",
"type": "invoice.paid",
"created_at": "2026-05-28T12:00:00Z",
"data": {
"invoice_id": "inv_456",
"amount": 1999,
"currency": "GBP"
}
}
JSON;
$event = json_decode($payload, true, flags: JSON_THROW_ON_ERROR);
echo $event['id'] . ' ' . $event['type'] . PHP_EOL;
// Prints:
// evt_123 invoice.paid
Do not assume every provider uses the same names. Read the provider documentation and map their payload into your own internal representation.
Verify before trusting
A public webhook URL can be called by anyone who finds it. Providers commonly sign the raw request body with a shared secret and send the signature in a header. Your application recomputes the signature and compares it using hash_equals().
<?php
declare(strict_types=1);
function validWebhookSignature(string $rawBody, string $receivedSignature, string $secret): bool
{
$expected = hash_hmac('sha256', $rawBody, $secret);
return hash_equals($expected, $receivedSignature);
}
$rawBody = '{"id":"evt_123","type":"invoice.paid"}';
$secret = 'webhook_secret';
$signature = hash_hmac('sha256', $rawBody, $secret);
var_dump(validWebhookSignature($rawBody, $signature, $secret));
var_dump(validWebhookSignature($rawBody, 'wrong', $secret));
// Prints:
// bool(true)
// bool(false)
Signature schemes differ. Some include timestamps, prefixes, multiple signatures, or versioned headers. The job skill is to follow the provider's exact scheme and to verify the raw body, not a re-encoded JSON array.
Idempotency
Webhook providers retry when they do not receive a successful response. A provider may also send the same event more than once during incidents or migrations. Your receiver must be idempotent, meaning the same event can be accepted twice without doing the business action twice.
<?php
declare(strict_types=1);
function receiveEvent(array $event, array &$seenEventIds): string
{
$eventId = (string) ($event['id'] ?? '');
if ($eventId === '') {
return 'reject: missing event id';
}
if (isset($seenEventIds[$eventId])) {
return 'accept: duplicate ignored';
}
$seenEventIds[$eventId] = true;
return 'accept: queued for processing';
}
$seen = [];
echo receiveEvent(['id' => 'evt_123'], $seen) . PHP_EOL;
echo receiveEvent(['id' => 'evt_123'], $seen) . PHP_EOL;
// Prints:
// accept: queued for processing
// accept: duplicate ignored
In a real application, the duplicate check usually belongs in a database table with a unique index on the provider event ID.
Status codes
The status code tells the sender whether to retry.
- Return
200,202, or another2xxwhen the event was accepted. - Return
400for malformed JSON or a missing required field. - Return
401or403for invalid signatures, depending on the application's convention. - Return
409only if the conflict means the request cannot be accepted. - Return
500only for a genuine server failure where a retry may help.
Many teams return 2xx for duplicate events because the original event was already accepted and retrying will not help.
Local development
During development, webhook providers cannot usually reach localhost directly. Teams often use a tunnelling tool, a provider CLI, or recorded fixtures. Regardless of the tool, keep example payloads in the codebase's tests or docs so the endpoint is not developed from memory.
What to check
Before moving on, make sure you can:
- Explain why webhooks are different from polling.
- Verify a webhook before trusting the payload.
- Decode and validate the event shape.
- Handle duplicate events safely.
- Return a response quickly and move slow work out of the request path.
Practice
Practice: Receive A Webhook
Write a small PHP webhook receiver function that accepts a raw JSON body, a signature header, and a shared secret.
Requirements
- Verify the signature using
hash_hmac()andhash_equals(). - Decode JSON with exceptions enabled.
- Require an event
idandtype. - Ignore duplicate event IDs.
- Return a status code and message for accepted, duplicate, invalid signature, and invalid JSON cases.
- Keep slow business work out of the receiver; it should only decide whether the event can be accepted.
Show solution
<?php
declare(strict_types=1);
function webhookSignature(string $rawBody, string $secret): string
{
return hash_hmac('sha256', $rawBody, $secret);
}
function receiveWebhook(
string $rawBody,
string $receivedSignature,
string $secret,
array &$seenEventIds
): array {
$expectedSignature = webhookSignature($rawBody, $secret);
if (!hash_equals($expectedSignature, $receivedSignature)) {
return ['status' => 401, 'message' => 'Invalid webhook signature.'];
}
try {
$event = json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException) {
return ['status' => 400, 'message' => 'Webhook body must be valid JSON.'];
}
$eventId = is_array($event) ? (string) ($event['id'] ?? '') : '';
$eventType = is_array($event) ? (string) ($event['type'] ?? '') : '';
if ($eventId === '' || $eventType === '') {
return ['status' => 400, 'message' => 'Webhook event id and type are required.'];
}
if (isset($seenEventIds[$eventId])) {
return ['status' => 200, 'message' => 'Duplicate webhook ignored.'];
}
$seenEventIds[$eventId] = true;
return ['status' => 202, 'message' => 'Webhook accepted for processing.'];
}
$secret = 'webhook_secret';
$seen = [];
$validBody = '{"id":"evt_123","type":"invoice.paid"}';
$validSignature = webhookSignature($validBody, $secret);
$examples = [
receiveWebhook($validBody, $validSignature, $secret, $seen),
receiveWebhook($validBody, $validSignature, $secret, $seen),
receiveWebhook($validBody, 'wrong', $secret, $seen),
receiveWebhook('{bad json', webhookSignature('{bad json', $secret), $secret, $seen),
];
foreach ($examples as $result) {
echo $result['status'] . ' ' . $result['message'] . PHP_EOL;
}
// Prints:
// 202 Webhook accepted for processing.
// 200 Duplicate webhook ignored.
// 401 Invalid webhook signature.
// 400 Webhook body must be valid JSON.
The duplicate event returns 200 because the event has already been accepted. Returning an error there would invite the provider to retry something that cannot usefully change.