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 push or pull_request.opened.
  • A CRM sends contact.updated.
  • A subscription platform sends invoice.paid or subscription.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:

  1. Read the raw request body.
  2. Verify the sender, usually with a signature header.
  3. Decode and validate the JSON payload.
  4. Check whether this event has already been received.
  5. Store the event or enqueue work.
  6. Return a quick 2xx response 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 example
<?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 example
<?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 example
<?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 another 2xx when the event was accepted.
  • Return 400 for malformed JSON or a missing required field.
  • Return 401 or 403 for invalid signatures, depending on the application's convention.
  • Return 409 only if the conflict means the request cannot be accepted.
  • Return 500 only 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() and hash_equals().
  • Decode JSON with exceptions enabled.
  • Require an event id and type.
  • 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 example
<?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.