Building secure and repeatable webhook handlers in PHP

A production pattern for signature verification, idempotency, transaction boundaries, retries, and observable webhook processing.

PHPWebhooksSecurityAPIsMySQL
Secure webhook delivery flow from an external service to a PHP application

A webhook endpoint is not a normal form handler. Providers retry requests, delivery order can change, and the same event may arrive more than once. The handler therefore needs an explicit trust boundary and an idempotent processing model.

Verify the untouched request body

Read the raw body once, validate the provider signature with the documented algorithm, and compare signatures with hash_equals(). Enforce a short timestamp tolerance when the provider includes a signed timestamp. Parsing or re-serializing JSON before verification can change bytes and invalidate an otherwise correct signature.

Store the event before changing business state

Use the provider event ID as a unique database key. Insert the event and its processing state in a transaction. If the insert reports a duplicate, return the provider's successful acknowledgement without repeating the side effect. Keep secrets and full sensitive payloads out of general application logs.

A minimal event record

A practical inbox table normally stores the provider name, external event ID, event type, received timestamp, signature version, processing state, attempt count and a reference to a protected payload. Put a unique constraint on the provider and event ID. A status flag without a uniqueness constraint is not idempotency because two workers can still read “not processed” at the same time.

Keep receipt and business changes inside a deliberate transaction boundary. For a small synchronous handler, the transaction can claim the event and apply the state change together. For an asynchronous workflow, commit the durable inbox record first and let a worker claim it using an atomic update or locking strategy supported by the database. Do not hold a database transaction open while calling a slow third-party API.

Separate acknowledgement from slow work

Validate and persist quickly, then process expensive work through a queue where possible. Record attempt counts, the last error, and the final state. A failed worker should be safe to retry, and operational tools should make deliberately replaying an event visible and auditable.

A PHP verification boundary

The exact signing format belongs to the provider documentation, but the order of operations is consistent. Capture the body before a framework transforms it, retrieve the signature and timestamp headers, reject missing or malformed values, compute the expected signature with the configured secret, and compare in constant time.

<?php
$rawBody = file_get_contents('php://input');
$provided = $_SERVER['HTTP_X_PROVIDER_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $rawBody, $webhookSecret);

if ($provided === '' || !hash_equals($expected, $provided)) {
    http_response_code(401);
    exit;
}

This simplified example intentionally omits provider-specific prefixes, signed timestamps and key rotation. In production, validate the documented envelope exactly. Decode JSON only after signature verification, set a small maximum body size, and use JSON_THROW_ON_ERROR so malformed input takes an explicit path.

Model business events, not delivery order

Webhooks can arrive late or out of order. A “subscription updated” event may arrive after a “subscription cancelled” event even if the cancellation happened later. Where the provider supplies an authoritative object version or event creation time, compare it with the last applied version. For critical state, fetch the current object from the provider rather than blindly treating the latest delivery as the latest truth.

Unknown event types should usually be recorded and acknowledged after successful verification. Returning repeated errors for an event the application does not consume creates retry noise. Make the ignore decision visible in the event record so a newly supported type can be found and replayed if appropriate.

Choose response semantics deliberately

A provider normally considers any documented success status an acknowledgement. Return it only after the event is durably stored or safely applied. If the database is unavailable before that point, return a retryable failure. If the payload is valid but the event is irrelevant, acknowledge it. Permanent validation failures need the response behavior specified by the provider; endless retries do not repair a structurally impossible payload.

Keep the synchronous response small. Webhook endpoints are machine interfaces, so a correlation ID and stable status are usually enough. Detailed internal errors belong in protected logs and the event record, not in a public response that may expose database or account details.

Retry workers without multiplying side effects

Use bounded exponential backoff with jitter for transient failures. Set a maximum attempt count and move exhausted events to a review state rather than looping forever. Each downstream operation also needs an idempotency boundary: sending email, creating an invoice, granting access and publishing a queue message can all duplicate even when the inbound event itself is unique.

  • Use a stable idempotency key when the downstream API supports one.
  • Store an outbox record in the same transaction as the local state change.
  • Give each side effect a unique business key and check it atomically.
  • Record whether a timeout happened before or after the remote service accepted the request.

Operational controls and security

Secrets should live in environment or secret management, support rotation, and never appear in the frontend. If a provider publishes source IP ranges, filtering them can reduce noise but should complement signature verification, not replace it. Apply rate and body-size limits that allow legitimate bursts. Protect replay tools with administrator authorization, CSRF protection and an audit record.

Useful metrics include verification failures, accepted events by type, duplicate rate, queue delay, processing duration, retry count and oldest unprocessed event. Alert on sustained age or failure patterns rather than on every provider retry.

Test the failure paths

Tests should cover an invalid signature, stale timestamps, duplicate events, database rollbacks, out-of-order events, unknown event types, and a provider retry after a timeout. Those cases determine whether the integration remains correct under real network behavior.

Release checklist

  1. Use the provider CLI or test environment to capture signed fixtures.
  2. Verify the public route uses HTTPS and does not depend on a browser session or CSRF token.
  3. Send the same event concurrently and prove only one business transition occurs.
  4. Interrupt the worker between the local commit and every external side effect.
  5. Confirm logs redact secrets, authorization headers and sensitive payload fields.
  6. Document how an operator inspects, retries and permanently ignores an event.

A secure webhook handler is not complete when the happy path returns 200. It is complete when repeated, delayed, malformed and partially processed deliveries converge on one explainable business result.

Discuss a project