Skip to content

Sending signed webhooks

Sending signed webhooks

Http::webhookSignature('outbound')->post($subscriber->url, $payload);

That is the whole API. No headers to attach, no encoded body to keep in a variable.

Why it signs at send time

The signature is applied by Guzzle middleware, at the moment the request goes out — so the bytes that are signed are, by construction, the bytes that are sent.

The obvious alternative is to sign at the call site and attach the headers:

// Don't. The body now exists in two places.
$body = json_encode($payload);
Http::withHeaders(Webhooks::sign('outbound', $body)->all())
    ->withBody($body, 'application/json')
    ->post($url);

That works right up until someone simplifies it to ->post($url, $payload) and the client re-encodes the array. The two encodings differ in key order and escaping, the receiver rejects everything, and nothing in the code looks wrong. It is the outbound twin of verifying $request->all() instead of the raw body.

Signing at send time removes the possibility rather than documenting it.

The same applies to the URL: schemes that sign it — Twilio — get the URI that is actually being dialled, so ->asForm()->post($url, $params) signs correctly with no extra work.

Configure the endpoint

'endpoints' => [
    'outbound' => [
        'scheme'  => 'standard-webhooks',
        'secrets' => [
            'current'  => env('OUTBOUND_WEBHOOK_SECRET'),
            'previous' => env('OUTBOUND_WEBHOOK_SECRET_OLD'),
        ],
    ],
],

Signing uses the first secret. During a rotation the new one leads, so outbound traffic moves to it immediately while your own inbound verification still accepts both.

Chaining

The macro returns an ordinary PendingRequest, so everything else composes:

Http::webhookSignature('outbound')
    ->timeout(5)
    ->retry(3, 200)
    ->withHeader('X-Tenant', $tenant->id)
    ->post($subscriber->url, $payload);

Pass a timestamp as the second argument when you need a fixed one — replaying a stored delivery, or a test that does not want to freeze the clock:

Http::webhookSignature('outbound', $delivery->created_at->timestamp)->post(...);

Which scheme to publish

Prefer standard-webhooks for anything new. It is an open specification with implementations in several languages, so your subscribers can verify with a library rather than hand-writing a verifier from your documentation — which is where their bugs, and your support load, come from.

Use cbox when you are integrating with an existing Cbox deployment whose receivers are already verifying that format.

Per-subscriber secrets

Each subscriber needs their own secret; one shared secret across subscribers means any of them can forge deliveries to the others. Endpoints hold your configuration rather than per-tenant data, so resolve the subscriber's secret yourself and use the scheme directly:

use Cbox\WebhookSignature\Contracts\SignsWebhooks;
use Cbox\WebhookSignature\ValueObjects\{Secret, WebhookMessage};

$scheme = Webhooks::scheme('standard-webhooks');
$body = json_encode($payload, JSON_THROW_ON_ERROR);

if ($scheme instanceof SignsWebhooks) {
    $headers = $scheme->sign(WebhookMessage::make($body), new Secret($subscriber->secret), now()->timestamp);

    Http::withHeaders($headers->all())
        ->withBody($body, 'application/json')
        ->post($subscriber->url);
}

Encode once here — $body is signed and sent, and the array is never handed to the client. This is the path where the footgun is real, which is why the macro exists for everything else.

What to tell your subscribers

  1. Verify before parsing. Compute the MAC over the raw body bytes, then decode.
  2. Compare in constant time. hash_equals in PHP, hmac.compare_digest in Python, crypto.timingSafeEqual in Node.
  3. Enforce the window. Reject anything more than five minutes from now, in either direction.
  4. Deduplicate on the message id. It is signed, so it cannot be forged, and it is stable across retries.
  5. Expect two valid signatures during a rotation, and accept a delivery matching either.

If they are on PHP, they can install this package and point it at your scheme rather than implementing any of that.

Retries

Retry on network failure and on 5xx. Do not retry a 401 — that is the receiver telling you the signature was wrong, and retrying an incorrectly signed delivery produces the same refusal.

The one exception is worth knowing from the receiving side too: this package answers 500, not 401, when its own secret is missing, precisely so a misconfigured receiver gets retried rather than having its events discarded.

Testing outbound deliveries

Verify what was sent, rather than asserting a header exists:

Http::fake();

Http::webhookSignature('outbound')->post('https://example.test/hook', ['event' => 'ping']);

Http::assertSent(function (Request $request) {
    Webhooks::verify('outbound', WebhookMessage::make($request->body(), $request->headers()));

    return true;
});

If the middleware ever signed something other than what it sent, that verification throws.