Signature schemes
Schemes are drivers registered by name: default, rfc9421 and your own (webhooks.schemes, or Webhooks::schemes()->extend() in a service provider’s boot()). An endpoint stores the scheme’s name; an unknown name answers 401 unknown_scheme, and webhooks:doctor reports it. Every HMAC — verifying and signing — runs through Sentinel’s Sentinel::macs(): constant time over every candidate secret, no early exit. Webhooks never computes or compares a MAC itself, and a scheme never sees a secret.
default — Standard Webhooks v1
The open Standard Webhooks format, byte for byte. The same scheme verifies inbound requests and signs outbound deliveries, so existing Standard Webhooks libraries verify your deliveries as they are:
| Header | Value |
|---|---|
webhook-id | The delivery id: 1–255 bytes of visible ASCII. Ours: msg_<26-char ULID>, the same on every attempt and redelivery. |
webhook-timestamp | Integer Unix seconds (UTC) of this attempt — digits only. |
webhook-signature | One or more v1,<base64> entries separated by one space — standard base64 (with = padding) of the 32-byte HMAC-SHA256. Unknown versions are ignored. |
content-type | application/json (outbound). |
key = base64_decode(secret without the "whsec_" prefix)
signed_content = webhook-id + "." + webhook-timestamp + "." + body # body = the exact raw bytes
signature = "v1," + base64(HMAC_SHA256(key, signed_content))No canonicalisation, no re-serialised JSON, no added newline: verify the raw body. A secret without the whsec_ prefix (imported from a sender that hands out plain strings) is its own bytes. On the inbound side:
- All three headers must be present — else missing_signature, missing_timestamp or missing_id.
- webhook-timestamp matches ^[0-9]{1,12}$ (invalid_timestamp) and is within ± tolerance of now — 300 s by default, past and future (stale_timestamp).
- At most 8 entries and 4 096 bytes in webhook-signature; any v1 entry matching any currently valid secret — current, and the previous one during a rotation grace — accepts.
- webhook-id is the idempotency key.
Test vectors
| secret | webhook-id | webhook-timestamp | body | webhook-signature |
|---|---|---|---|---|
whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw | msg_p5jXN8AQM9LWM0D4loKWxJek | 1614265330 | {"test": 2432232314} | v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= |
whsec_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8= | msg_01JCDEF0000000000000000000 | 1794139200 | {"type":"invoice.paid","timestamp":"2026-11-08T12:00:00Z","data":{"invoice":42}} | v1,dBcYxACDimm+8xx9Ykx71LVUgk5H4STQXxAUHZ5xcNk= |
| whsec_ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8= (new) or the row above’s (old) | as above | as above | as above | v1,Nwl36Rvbn1xO29I4VEpYVwe7HJ3vZuLKGz6KoEVZDFI= v1,dBcYxACDimm+8xx9Ykx71LVUgk5H4STQXxAUHZ5xcNk= |
The first is Standard Webhooks’ published vector; the third shows a rotation, signed with the new and the old secret. The package’s suite recomputes each with plain hash_hmac.
rfc9421 — HTTP message signatures
Inbound, the endpoint verifies through Sentinel’s HTTP signatures against its scheme_options.profile (default webhooks.rfc9421.profile). Sentinel’s max_age and clock_skew replace the tolerance, its nonce store the replay check and its content digest the body check. The signing key must be imported into Sentinel bound to this endpoint — a key owned by anything else answers 401 key_not_bound:
$endpoint = Webhooks::inbound()->create(scheme: 'rfc9421', options: new InboundEndpointOptions(
schemeOptions: ['profile' => 'partners', 'id_header' => 'webhook-id'],
))->endpoint;
Sentinel::keys()->ring('http')->import('acme-2026', Algorithm::Ed25519, $partnerPublicKeyPem, owner: $endpoint);- The delivery id comes from scheme_options.id_header (default webhook-id), else Idempotency-Key, else the signature’s nonce.
- It counts as signed only when that header is among the signature’s covered components. Sentinel’s default profile covers @method @authority @path (+ content-digest), so add the id header to the profile’s — and the sender’s — components; webhooks:doctor warns otherwise.
- The package’s replay protection and body binding rely on require_nonce and require_content_digest; webhooks:doctor flags a profile that turns either off.
- Outbound RFC 9421 endpoints sign with a Sentinel key this app can sign with (scheme_options.key_id, else webhooks.rfc9421.outbound_key_id), covering @method @authority @path content-digest content-type webhook-id webhook-timestamp, plus @query when the URL has a query string. No key id: the delivery fails with signing, no request.
Your own scheme
interface SignatureScheme
{
/** @throws WebhookSignatureException */
public function verify(IncomingWebhook $request, EndpointSecrets $secrets): VerifiedWebhook;
}
interface SignsOutboundWebhooks // optional: the scheme can sign outbound deliveries too
{
public function sign(OutgoingWebhook $message, EndpointSecrets $secrets): SignatureHeaders;
}- IncomingWebhook carries headers (lower-cased, first value), body (raw bytes), receivedAt, endpointKey, toleranceSeconds, schemeOptions and the request; header($name).
- EndpointSecrets never exposes a secret: matches($message, $macBytes, SecretEncoding, Algorithm = HmacSha256): ?SecretSlot and sign($message, SecretEncoding, Algorithm): list<string> (raw MACs, current first) — both through Sentinel.
- SecretEncoding::StandardWebhooks reads the bytes behind whsec_ (or a plain secret’s bytes); SecretEncoding::Raw uses the string’s own bytes, whsec_ included. The scheme picks it — Stripe signing secrets start with whsec_ but are used raw.
- Return VerifiedWebhook(deliveryId, timestamp, eventType, idIsSigned, slot, replayProtected: false, commit: null). Set idIsSigned only when the MAC covers the id you return; a verdict without an id is never treated as signed. Set replayProtected only when the scheme itself refuses a second use of the exact request; commit is a closure that records that use, run in the transaction that stores the delivery.
- Throw WebhookSignatureException::because(RejectionReason::…). Implement SignsOutboundWebhooks too and the scheme signs outbound deliveries — and SignedWebhookRequest signs your test requests with it.
Generate a recipe
The package ships no provider drivers. webhooks:make-scheme writes a scheme class into app/Webhooks/Schemes from a recipe — github, stripe or blank — and the class is your own code to adapt:
php artisan webhooks:make-scheme github # app/Webhooks/Schemes/GitHubScheme.php
php artisan webhooks:make-scheme stripe # app/Webhooks/Schemes/StripeScheme.php
php artisan webhooks:make-scheme blank MySenderScheme// a service provider's boot()
Webhooks::schemes()->extend('github', GitHubScheme::class);
Webhooks::inbound()->create(scheme: 'github', secret: $secretYouEnteredInGitHub, handler: 'github');GitHub-style: X-Hub-Signature-256: sha256=<hex HMAC-SHA256(body)>, X-GitHub-Delivery and X-GitHub-Event; the key is the secret’s own bytes. There is no timestamp, so replay protection is the dedupe alone — under such a scheme a byte-identical body is a duplicate for as long as the first delivery’s row is kept, and a sender that legitimately repeats an identical body must make it differ. What the github recipe writes:
<?php
declare(strict_types=1);
namespace App\Webhooks\Schemes;
use RoundlyConsulting\Crypto\Codec\InvalidEncodingException;
use RoundlyConsulting\Crypto\Facades\Crypto;
use RoundlyConsulting\Webhooks\Contracts\SignatureScheme;
use RoundlyConsulting\Webhooks\Contracts\SignsOutboundWebhooks;
use RoundlyConsulting\Webhooks\DataTransferObjects\IncomingWebhook;
use RoundlyConsulting\Webhooks\DataTransferObjects\OutgoingWebhook;
use RoundlyConsulting\Webhooks\DataTransferObjects\SignatureHeaders;
use RoundlyConsulting\Webhooks\DataTransferObjects\VerifiedWebhook;
use RoundlyConsulting\Webhooks\Enums\RejectionReason;
use RoundlyConsulting\Webhooks\Enums\SecretEncoding;
use RoundlyConsulting\Webhooks\Exceptions\WebhookSignatureException;
use RoundlyConsulting\Webhooks\Schemes\EndpointSecrets;
final readonly class GitHubScheme implements SignatureScheme, SignsOutboundWebhooks
{
public function verify(IncomingWebhook $request, EndpointSecrets $secrets): VerifiedWebhook
{
$header = $request->header('x-hub-signature-256') ?? throw WebhookSignatureException::because(RejectionReason::MissingSignature);
try {
$mac = str_starts_with($header, 'sha256=') ? Crypto::hexDecode(substr($header, 7)) : '';
} catch (InvalidEncodingException) {
$mac = '';
}
$slot = $secrets->matches($request->body, $mac, SecretEncoding::Raw) ?? throw WebhookSignatureException::because(RejectionReason::InvalidSignature);
return new VerifiedWebhook($request->header('x-github-delivery'), null, $request->header('x-github-event'), false, $slot);
}
public function sign(OutgoingWebhook $message, EndpointSecrets $secrets): SignatureHeaders
{
return new SignatureHeaders([
'X-Hub-Signature-256' => 'sha256='.Crypto::hexEncode($secrets->sign($message->body, SecretEncoding::Raw)[0]),
'X-GitHub-Delivery' => $message->id,
'X-GitHub-Event' => $message->eventType,
]);
}
}Stripe-style: Stripe-Signature: t=<ts>,v1=<hex HMAC-SHA256("t.body")>; the key is the whole whsec_… string as raw bytes. The event id sits inside the signed body, so it counts as signed; a body without a string id claims no signed id, and the pipeline falls back to an unsigned Idempotency-Key or refuses with 400. What the stripe recipe writes:
<?php
declare(strict_types=1);
namespace App\Webhooks\Schemes;
use Carbon\CarbonImmutable;
use RoundlyConsulting\Crypto\Codec\InvalidEncodingException;
use RoundlyConsulting\Crypto\Facades\Crypto;
use RoundlyConsulting\Webhooks\Contracts\SignatureScheme;
use RoundlyConsulting\Webhooks\Contracts\SignsOutboundWebhooks;
use RoundlyConsulting\Webhooks\DataTransferObjects\IncomingWebhook;
use RoundlyConsulting\Webhooks\DataTransferObjects\OutgoingWebhook;
use RoundlyConsulting\Webhooks\DataTransferObjects\SignatureHeaders;
use RoundlyConsulting\Webhooks\DataTransferObjects\VerifiedWebhook;
use RoundlyConsulting\Webhooks\Enums\RejectionReason;
use RoundlyConsulting\Webhooks\Enums\SecretEncoding;
use RoundlyConsulting\Webhooks\Exceptions\WebhookSignatureException;
use RoundlyConsulting\Webhooks\Schemes\EndpointSecrets;
final readonly class StripeScheme implements SignatureScheme, SignsOutboundWebhooks
{
/** The most `v1=` entries checked, and the longest header — each entry costs a MAC per secret. */
private const int MAX_ENTRIES = 8;
private const int MAX_HEADER_BYTES = 4096;
public function verify(IncomingWebhook $request, EndpointSecrets $secrets): VerifiedWebhook
{
$header = $request->header('stripe-signature') ?? throw WebhookSignatureException::because(RejectionReason::MissingSignature);
$parts = [];
if (strlen($header) > self::MAX_HEADER_BYTES) {
throw WebhookSignatureException::because(RejectionReason::InvalidSignature);
}
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', $part, 2), 2, '');
$parts[$key][] = $value;
}
$timestamp = $parts['t'][0] ?? '';
if (preg_match('/^\d{1,12}$/', $timestamp) !== 1) {
throw WebhookSignatureException::because(RejectionReason::InvalidTimestamp);
}
if (abs($request->receivedAt->getTimestamp() - (int) $timestamp) > $request->toleranceSeconds) {
throw WebhookSignatureException::because(RejectionReason::StaleTimestamp);
}
if (count($parts['v1'] ?? []) > self::MAX_ENTRIES) {
throw WebhookSignatureException::because(RejectionReason::InvalidSignature);
}
foreach ($parts['v1'] ?? [] as $hex) {
try {
$slot = $secrets->matches($timestamp.'.'.$request->body, Crypto::hexDecode($hex), SecretEncoding::Raw);
} catch (InvalidEncodingException) {
continue;
}
if ($slot !== null) {
$event = json_decode($request->body, true);
$id = is_array($event) && is_string($event['id'] ?? null) ? $event['id'] : null;
// Signed only when the body carries it; without one the id is an unsigned header.
return new VerifiedWebhook($id, CarbonImmutable::createFromTimestampUTC((int) $timestamp), null, $id !== null, $slot);
}
}
throw WebhookSignatureException::because(RejectionReason::InvalidSignature);
}
public function sign(OutgoingWebhook $message, EndpointSecrets $secrets): SignatureHeaders
{
$timestamp = (string) $message->timestamp->getTimestamp();
$signatures = array_map(static fn (string $mac): string => 'v1='.Crypto::hexEncode($mac), $secrets->sign($timestamp.'.'.$message->body, SecretEncoding::Raw));
return new SignatureHeaders(['Stripe-Signature' => 't='.$timestamp.','.implode(',', $signatures)]);
}
}Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.