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.
Polling Versus Webhooks
Polling means your application repeatedly asks the provider whether anything changed. Webhooks invert that relationship: the provider calls you when it has an event. Webhooks can reduce latency and wasted requests, but they also require a public endpoint, verification, idempotency, observability, and support tooling.
Polling is still useful when a provider cannot call your system, when strict firewalls prevent inbound traffic, or when a reconciliation job must confirm final state. Many production integrations use both: webhooks for prompt updates and polling or scheduled reconciliation to recover missed events.
Do not assume delivery is exactly once. The provider may send duplicates, deliver events out of order, retry after timeouts, or pause during an incident. The receiver should be designed around at-least-once delivery unless the provider explicitly documents something stronger.
Configure The Provider Deliberately
A webhook integration usually starts in the provider dashboard or API. Record:
- endpoint URL
- subscribed event types
- signing secret or public key
- environment, such as test or production
- retry policy documented by the provider
- payload version or API version
- contact or alert destination for delivery failures
Subscribe only to events your application understands. A catch-all subscription can flood the receiver, leak unnecessary data, and create support noise. Add event types deliberately as features require them.
Keep test and production secrets separate. A production endpoint should not accept test-mode events unless explicitly designed to do so. Likewise, a staging endpoint should not process production payments or user data.
Read The Raw Body Once
Signature verification normally depends on the exact raw request body. Middleware that parses JSON, normalizes whitespace, changes character encoding, or rebuilds the body before verification can break the signature.
In a framework, identify where the raw body is available and preserve it for verification. After verification, decode JSON and map the payload. If the raw body must be stored for later debugging, consider data sensitivity, retention, encryption, and access control.
<?php
declare(strict_types=1);
function verifyThenDecode(string $rawBody, string $signature, string $secret): array
{
$expected = hash_hmac('sha256', $rawBody, $secret);
if (!hash_equals($expected, $signature)) {
throw new RuntimeException('Invalid signature.');
}
$decoded = json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR);
if (!is_array($decoded)) {
throw new InvalidArgumentException('Webhook body must be a JSON object.');
}
return $decoded;
}
The example uses a simple HMAC scheme. Real providers may include timestamp prefixes, multiple signatures, or asymmetric verification.
Map Provider Payloads To Internal Events
Provider event names and shapes belong at the integration boundary. The rest of the application should receive an internal event or command with the fields it needs.
<?php
declare(strict_types=1);
final readonly class InvoicePaid
{
public function __construct(
public string $providerEventId,
public string $invoiceId,
public int $amountPennies,
public string $currency,
) {
}
}
function mapInvoicePaid(array $payload): InvoicePaid
{
if (($payload['type'] ?? null) !== 'invoice.paid') {
throw new InvalidArgumentException('Unsupported event type.');
}
$data = $payload['data'] ?? null;
if (!is_array($data)) {
throw new InvalidArgumentException('Event data is required.');
}
return new InvoicePaid(
providerEventId: (string) ($payload['id'] ?? ''),
invoiceId: (string) ($data['invoice_id'] ?? ''),
amountPennies: (int) ($data['amount'] ?? 0),
currency: (string) ($data['currency'] ?? ''),
);
}
Validate required fields before creating the internal event. Do not let provider-specific arrays spread through billing, access control, reporting, and email services.
Accept Quickly, Process Later
The receiver's job is usually to verify, validate, deduplicate, store, and enqueue. Slow business workflows should run after the HTTP response. This prevents provider timeouts and makes processing retryable under your control.
A useful receiver record stores the provider, event ID, type, received time, raw payload reference or safe summary, processing status, and error summary. A queue worker can load that record and perform business work.
Returning 202 Accepted is appropriate when work is accepted but not complete. Returning 200 OK can also be fine when the provider only cares about a successful acknowledgement. The important rule is that a 2xx response should mean the application has durably accepted responsibility for the event.
Do not return 2xx before durable storage if losing the process would lose the event. In-memory arrays are fine for teaching examples, not for production receivers.
Ordering Is Not Guaranteed
A subscription cancellation may arrive before the final invoice payment event. A retry of an older event may arrive after a newer event has been processed. Network delivery does not necessarily match business chronology.
Use event timestamps and current provider state carefully. Some handlers can be idempotent state setters, such as marking an invoice paid by ID. Others need to compare versions or fetch the provider's current object before acting.
Avoid business logic that assumes "the previous webhook must already have run." If ordering is essential, store events and process them through a per-resource sequence with explicit rules for gaps and stale events.
Unknown Event Types
Providers add event types and fields over time. The receiver should have a policy for unknown event types:
- reject them with a client error if the endpoint subscription should never send them
- accept and ignore them if the provider may legitimately send unhandled types
- store them for review when integration ownership needs visibility
For a public provider dashboard, the best answer is often to subscribe only to supported types. For a partner integration where subscriptions are less precise, accepting and ignoring unknown types can prevent pointless retries.
Log unknown types safely so the team can update the subscription or implement support intentionally.
Endpoint Exposure And Routing
A webhook endpoint is public by design, but it should still be narrow. Use a route specific to the provider and environment, such as /webhooks/payment-provider, instead of a general endpoint that tries to infer everything from the body.
Rate limits, payload size limits, request method restrictions, and TLS are baseline controls. IP allow-lists can help when the provider publishes stable ranges, but they should not replace signature verification because IP ranges change and traffic may pass through proxies.
Avoid putting the signing secret in the URL. URLs appear in logs, browser history, proxy traces, and support screenshots.
Local Testing And Fixtures
Tunnels and provider CLIs are useful, but saved fixtures are more reliable for tests. Keep representative payloads for successful events, missing fields, unknown event types, duplicates, invalid signatures, and malformed JSON.
A fixture should include the raw body exactly as signed. If the signature scheme uses a timestamp, tests should inject a fixed clock or regenerate signatures during the test.
Do not build receivers from memory. Provider payloads often contain nested objects, nullable fields, livemode flags, and version-specific details that examples in a lesson cannot predict.
Reconciliation
Even a good webhook system can miss events due to misconfiguration, downtime, provider incidents, or a bug in your receiver. A scheduled reconciliation job can compare local state with the provider's API for important resources such as payments, subscriptions, and refunds.
Reconciliation should be safe and idempotent. It does not replace webhooks; it gives the business a way to repair missed delivery and prove that local state matches the source of truth.
What To Check
Before moving on, make sure you can:
- explain why webhooks are different from polling
- configure provider event types, environment, and secrets deliberately
- verify the raw request body before trusting payload data
- decode and validate the event shape
- map provider payloads into internal events or commands
- handle duplicate and unknown events safely
- return quickly only after durable acceptance
- avoid assuming delivery order
- test with raw-body fixtures and provider-specific examples
- plan reconciliation for important business state
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.
Practice: Map Provider Events
Create an integration-boundary mapper for an invoice.paid webhook.
Task
Build an InvoicePaid value object and a mapper that accepts a decoded provider payload. The mapper should:
- require event type
invoice.paid - require
id,data.invoice_id,data.amount, anddata.currency - reject missing or invalid fields with clear exceptions
- return an internal
InvoicePaidobject
Demonstrate one valid payload and one unsupported event type.
Check Your Work
Explain why provider arrays should not be passed through the whole application.
Show solution
The mapper keeps provider-specific structure at the edge of the integration.
<?php
declare(strict_types=1);
final readonly class InvoicePaid
{
public function __construct(
public string $providerEventId,
public string $invoiceId,
public int $amountPennies,
public string $currency,
) {
}
}
function mapInvoicePaid(array $payload): InvoicePaid
{
if (($payload['type'] ?? null) !== 'invoice.paid') {
throw new InvalidArgumentException('Unsupported event type.');
}
$eventId = $payload['id'] ?? null;
$data = $payload['data'] ?? null;
if (!is_string($eventId) || $eventId === '' || !is_array($data)) {
throw new InvalidArgumentException('Event id and data are required.');
}
if (!isset($data['invoice_id'], $data['amount'], $data['currency'])) {
throw new InvalidArgumentException('Invoice id, amount, and currency are required.');
}
if (!is_string($data['invoice_id']) || !is_int($data['amount']) || !is_string($data['currency'])) {
throw new InvalidArgumentException('Invoice payload fields have invalid types.');
}
return new InvoicePaid($eventId, $data['invoice_id'], $data['amount'], $data['currency']);
}
$event = mapInvoicePaid([
'id' => 'evt_123',
'type' => 'invoice.paid',
'data' => ['invoice_id' => 'inv_456', 'amount' => 1999, 'currency' => 'GBP'],
]);
echo $event->invoiceId . ' ' . $event->amountPennies . PHP_EOL;
try {
mapInvoicePaid(['id' => 'evt_999', 'type' => 'customer.created', 'data' => []]);
} catch (InvalidArgumentException $exception) {
echo $exception->getMessage() . PHP_EOL;
}
// Prints:
// inv_456 1999
// Unsupported event type.
Application services can now work with InvoicePaid instead of depending on the provider's raw array shape.
Practice: Design Receiver Flow
Design the first HTTP request path for a payment-provider webhook.
Task
Write a short implementation note that covers:
- raw body capture
- signature verification
- JSON decoding and schema validation
- event ID deduplication
- durable event storage
- queueing slow processing
- status codes for accepted, duplicate, invalid signature, malformed JSON, unknown event type, and temporary storage failure
- what should be logged without leaking secrets
Do not perform billing updates directly inside the receiver.
Show solution
Capture the raw body before any JSON parser modifies it. Verify the provider signature against that exact body and the provider-specific header scheme. Reject invalid signatures with 401 or 403 according to the application's convention and log only safe metadata such as provider name, route, request ID, and failure category.
Decode JSON with exceptions enabled. Validate required fields such as event ID, type, timestamp, and data object. Malformed JSON or missing required fields should return 400. Unknown event types should either return 400 when the subscription is wrong, or 200/202 ignored when the provider may legitimately send unhandled events; document the chosen policy.
Insert the provider event ID into durable storage with a unique constraint. A duplicate should return 200 because the event was already accepted. The first insert stores provider, event type, received time, processing status, payload reference or safe summary, and correlation ID.
Queue business work after storage succeeds and return 202 Accepted. If durable storage or queueing is temporarily unavailable, return 500 so the provider retries later. The receiver should not update billing, send emails, or call other APIs inline.
Logs should include event ID after validation, event type, action (accepted, duplicate, rejected, failed), status, and internal request ID. Do not log signing secrets, full auth headers, card details, or unnecessary personal data.