Search by

sugarcommerce / webhooks

arraypress

Webhook signing, verification and retry scheduling. Stripe-style t=,v1= signatures with replay protection, constant-time comparison, and a retry policy that distinguishes the receiver's problems from your own. Zero dependencies.

Package info

github.com/SugarCommerce/webhooks

Homepage

Issues

pkg:composer/sugarcommerce/webhooks

Statistics

Installs: 9

Dependents: 2

Suggesters: 0

Stars: 0

1.3.0 2026-10-22 09:27 UTC

This package is auto-updated.

Last update: 2026-10-22 15:44:56 UTC


README

Webhook signing, verification and retry scheduling. Stripe-style t=,v1= signatures with replay protection, constant-time comparison, and a retry policy that distinguishes the receiver's problems from your own. Zero dependencies.

Why

A webhook is an unauthenticated POST from the internet claiming to be from you. Without a signature, anyone who learns the endpoint URL can send "payment succeeded" and get a licence issued.

Three mistakes make a signature useless, and all three are common:

  • Signing the payload alone. Without a timestamp inside the signed material, a captured request replays forever.
  • Comparing with ===. String comparison short-circuits on the first differing byte, which leaks the correct prefix through timing and lets an attacker recover the signature byte by byte.
  • Retrying everything. A 404 or a 401 will fail identically on the twentieth attempt. Retrying them buries the real failures and hammers a receiver that already said no.

Features

  • ✍️ Stripe-compatible format — t=1234567890,v1=abc…, so existing receivers already know how to verify it.
  • ⏰ Replay protection — the timestamp is inside the signed material and checked against a tolerance.
  • ⏱️ Constant-time comparison — hash_equals, always.
  • 🔑 Multiple valid signatures — several v1= values in one header, so a secret can be rotated without downtime.
  • 🔁 Sane retry policy — exponential backoff with jitter, and a status-aware decision about whether to retry at all.
  • 🫡 Respects Retry-After — in both its forms, seconds and an HTTP-date, so a receiver telling you when to come back is honoured.
  • 🩺 Endpoint health — switches off a receiver that has failed often and for long, never one that had a bad minute.

Requirements

PHP 8.5+

Installation

composer require sugarcommerce/webhooks

Sending

use SugarCommerce\Webhooks\Signature;

$payload   = json_encode( $event );
$signature = Signature::sign( $payload, $endpoint_secret );

$response = $client->post( $endpoint_url, $payload, [
    'Content-Type'         => 'application/json',
    Signature::HEADER      => $signature,   // X-Webhook-Signature
] );

Sign the exact bytes you send. Re-encoding the payload after signing — even a whitespace difference — invalidates the signature, and it is the most common reason a correct implementation on both ends still fails.

Receiving

$result = Signature::verify(
    file_get_contents( 'php://input' ),
    $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '',
    $secret
);

if ( $result->failed_check() ) {
    http_response_code( 400 );
    error_log( 'Rejected webhook: ' . $result->reason );
    exit;
}

$timestamp = $result->timestamp;

Read the raw body, not a parsed one. json_decode then json_encode will not reproduce the original bytes — key order, unicode escaping and float formatting all change — and the signature will not match.

Tolerance

Signature::verify( $payload, $header, $secret, tolerance: 600 );

The default is 300 seconds, matching Stripe. It exists because clocks drift and a queued delivery may arrive late; widening it widens the window in which a captured request can be replayed. Do not disable it.

Rotating a secret

Sending, sign with both at the same timestamp — each v1 covers it:

$t      = time();
$header = Signature::sign( $payload, $new_secret, $t )
    . ',' . explode( ',', Signature::sign( $payload, $old_secret, $t ) )[1];

Both v1= values are checked, so receivers can move to the new secret at their own pace. Drop the old one once they have.

Receiving, accept both while the sender switches:

Signature::verify( $payload, $header, [ $new_secret, $old_secret ] );

Replays inside the window

A genuine request captured and resent within the tolerance still verifies. Store each event's id and refuse one already processed; the tolerance only bounds how long those ids need keeping. A negative tolerance fails every request rather than switching the check off, and a header with two t= values is malformed.

Retrying

use SugarCommerce\Webhooks\RetryPolicy;

$policy = new RetryPolicy( max_attempts: 6, base_delay: 60, max_delay: 86400, jitter: 25 );

if ( $policy->should_retry( $response->status, $attempt ) ) {
    $seconds = $policy->delay( $attempt, RetryPolicy::retry_after( $response->header( 'retry-after' ) ) );
    $queue->push( $delivery, delay: $seconds );
}

Retry-After comes in two forms, 120 and Wed, 21 Oct 2026 07:28:00 GMT, and receivers use both. retry_after() reads either (and the two obsolete date forms RFC 9110 still asks a recipient to accept) as seconds from now; a date in the past is 0, and anything unreadable is null, which leaves the policy's own curve in charge. The usual is_numeric() check throws the date away, which is exactly when the receiver said something useful.

For a row that keeps its own failure count rather than a status code — a sweep that retries until something works — the same policy answers when to stop and when it is next due:

$sweep = new RetryPolicy( max_attempts: 12, base_delay: 60, max_delay: 6 * 3600, jitter: 0 );

if ( $sweep->exhausted( $tries ) ) {
    give_up( $row );
} else {
    retry_at( $row, $sweep->next_attempt_at( $tries, time() ) );
}
Status Retried Why
2xx No It worked.
408, 429 Yes Timeout or rate limit — temporary by definition.
5xx Yes The receiver is broken, not the request.
Other 4xx No The request is wrong and will stay wrong.
Transport failure Yes Nothing was served, so nothing was refused.
$policy->schedule();   // [60, 120, 240, 480, 960] — the whole ladder, for a UI

Jitter is not cosmetic. When a receiver comes back after an outage, every queued delivery retries at the same instant and knocks it over again. A percentage of random spread turns a thundering herd into a ramp.

Endpoint health

Retrying a delivery is one decision; giving up on a receiver altogether is another. Posting for ever to a host that has been gone since spring is how a sender ends up on a blocklist, and switching one off over a blip is how a customer loses a week of events.

use SugarCommerce\Webhooks\EndpointHealth;

$health = new EndpointHealth( $endpoint['failures'], $endpoint['failing_since'] );
$health = $delivered ? $health->succeeded() : $health->failed( time() );

save_endpoint( $endpoint['id'], array(
    'failures'      => $health->failures,
    'failing_since' => $health->failing_since,
    'status'        => $health->disabled ? 'disabled' : 'active',
) );

Two conditions, not one: at least MAX_FAILURES (20) failures in a row and DISABLE_AFTER (a week) since the first. Failures are counted per delivery, so twenty queued events meeting a one-minute outage are twenty failures in a minute; a breaker on the count alone trips over that. And consecutive: one success clears the record. Both thresholds can be passed to failed(). It holds no storage — read the two numbers from wherever an endpoint lives and write them back.

Testing

composer install
composer test

80 tests — signature round trips, tampered payloads and headers, expired and future timestamps, multi-signature rotation, malformed headers of every shape, the full backoff ladder, Retry-After in seconds and in all three date forms, and when an endpoint is (and is not) switched off.

License

GPL-2.0-or-later