Verify webhook signatures
Verify webhook signatures
Every webhook delivery is signed with HMAC-SHA256 and the endpoint's secret. Each endpoint picks one of two signature schemes:
- Cbox — the default, and the only scheme before 1.23. Every existing endpoint stays on it, and its wire format has not changed.
- Standard Webhooks — the Standard Webhooks specification. Any Standard Webhooks library, in any language, verifies these deliveries without code written for this package.
The body is the same JSON envelope on both. Only the signature headers differ.
Which scheme to choose
| Cbox | Standard Webhooks | |
|---|---|---|
| Use it when | the receiver already verifies X-Cbox-Signature, or uses a Cbox ID SDK |
the receiver uses a Standard Webhooks library, or a language with no Cbox ID SDK, or a platform that verifies Standard Webhooks itself |
| Secret | 64 hex characters | whsec_ + base64 of 32 random bytes |
| HMAC key | the secret string, as written | the base64-decoded bytes after whsec_ |
| Signed string | {timestamp}.{body} |
{webhook-id}.{timestamp}.{body} |
| Signature encoding | lowercase hex | base64 |
| Idempotency key in a header | no (use the body's delivery_id) |
webhook-id |
For a new integration with nothing already built, choose Standard Webhooks. The message id is signed and sent as a header, so a receiver can dedupe a delivery before it parses the body.
What goes on the wire
Each delivery carries only its own scheme's headers, plus Content-Type: application/json. A Standard Webhooks delivery has no X-Cbox-* headers, and a Cbox
delivery has no webhook-* headers. Each receiver verifies exactly one scheme. Sending both
would add noise, and receivers would have to be told which set to ignore.
Cbox
X-Cbox-Timestamp: 1700000000
X-Cbox-Signature: t=1700000000,v1=<hex HMAC-SHA256 of "1700000000.<raw body>">
Standard Webhooks
webhook-id: 01HF3QK9Z8VN0T7M2XW5RB4YCD
webhook-timestamp: 1700000000
webhook-signature: v1,<base64 HMAC-SHA256 of "01HF3QK9Z8VN0T7M2XW5RB4YCD.1700000000.<raw body>">
webhook-idis the delivery id, the same value as the envelope'sdelivery_id. It stays the same on every retry of that delivery, so it is the idempotency key. Each endpoint gets its own delivery, so two endpoints receiving the same event see different ids.- The timestamp is when this attempt was sent, not when the event happened. A retry is signed again with a new timestamp, so it passes the receiver's tolerance window, while a captured old attempt does not.
webhook-signatureis a space-delimited list. Today it carries onev1,entry, because an endpoint has one secret at a time. Verify every entry the spec allows: the helper below and the Standard Webhooks libraries already do.
The envelope is {"type", "sequence", "data", "delivery_id"} on both schemes. The
specification also recommends a top-level timestamp for when the event happened. This
envelope does not carry one. That field is not part of the signature, and no Standard
Webhooks verifier depends on it.
Register an endpoint on Standard Webhooks
Registration through WebhookRegistry is unchanged and always uses the Cbox scheme. To
choose the scheme, use WebhookSigningSchemes:
use Cbox\Id\Webhooks\Contracts\WebhookSigningSchemes;
use Cbox\Id\Webhooks\Enums\SignatureScheme;
$registered = app(WebhookSigningSchemes::class)->registerWithScheme(
$organizationId,
'https://app.example.com/webhooks/cbox-id',
['membership.created', 'user.updated'],
SignatureScheme::StandardWebhooks,
);
$registered->secret; // "whsec_…" — shown once; hand it to the receiver
$registered->signatureScheme(); // SignatureScheme::StandardWebhooks
registerForEnvironmentWithScheme($url, $eventTypes, $scheme) is the platform-wide variant.
Like registerForEnvironment(), it is for operator callers only.
Move an existing endpoint
$endpoint = app(WebhookSigningSchemes::class)->changeSignatureScheme(
$endpointId,
$organizationId, // null for an environment-wide endpoint
SignatureScheme::StandardWebhooks,
); // null if this owner has no such endpoint
The change applies to the next attempt. A delivery that is still pending, or is waiting for a retry, is signed under the scheme the endpoint has when it is sent.
No new secret is minted, and the secret is not shown again. The receiver keeps the secret it already has:
- Cbox → Standard Webhooks: the hex secret becomes
'whsec_'.base64_encode($hexSecret). The conversion is lossless. The decoded key is exactly the 64 bytes the Cbox scheme already signed with, and 64 bytes is within the spec's 24–64 byte range.StandardWebhookSignature::secretFor($hexSecret)does the conversion. - Standard Webhooks → Cbox: the
whsec_…string itself is the Cbox key, as written. Pass it to a Cbox verifier unchanged.
Move the receiver to the new verifier before you switch the endpoint. Otherwise its deliveries fail verification until the receiver is updated. Those deliveries are retried, not lost.
Verify in PHP
The package ships both receiver halves. Each one throws InvalidWebhookSignature with a
stable $reason, and returns nothing when the delivery verifies.
use Cbox\Id\Webhooks\Exceptions\InvalidWebhookSignature;
use Cbox\Id\Webhooks\Support\StandardWebhookSignature;
use Illuminate\Http\Request;
public function __invoke(Request $request)
{
try {
StandardWebhookSignature::verify(
$request->getContent(), // the RAW body, never re-encoded JSON
$request->headers->all(), // names matched case-insensitively
config('services.cbox_id.webhook_secret'), // "whsec_…"
// tolerance: 300 seconds by default, in both directions
);
} catch (InvalidWebhookSignature $e) {
logger()->warning('webhook refused', ['reason' => $e->reason]);
return response('', 400);
}
// At-least-once delivery: dedupe on webhook-id before acting.
$id = $request->header('webhook-id');
if (! cache()->add("cbox-id-webhook:{$id}", true, now()->addDay())) {
return response('', 200); // already handled
}
$event = $request->json()->all(); // type, sequence, data, delivery_id
// …
return response('', 200);
}
For a Cbox-scheme endpoint, the same code uses CboxWebhookSignature::verify() with the hex
secret:
use Cbox\Id\Webhooks\Support\CboxWebhookSignature;
CboxWebhookSignature::verify($request->getContent(), $request->headers->all(), $hexSecret);
$e->reason |
Meaning |
|---|---|
missing_header |
A required header is absent or empty. |
invalid_timestamp |
The timestamp is not a positive integer of unix seconds. |
timestamp_too_old / timestamp_too_new |
Outside the tolerance window. A clock off by minutes shows up here. |
invalid_secret |
The secret is unusable. A Standard Webhooks secret must start with whsec_. If you pasted the hex secret, convert it with secretFor(). |
malformed_signature |
The signature header cannot be parsed. |
unsupported_version |
No v1 signature is present. |
signature_mismatch |
Wrong secret, or the body, id or timestamp changed after signing. A framework or proxy that rewrites the body causes this. |
Refuse the request whatever the reason. Answer with a 4xx, and the delivery is retried.
Unlike the reference libraries, StandardWebhookSignature::verify() requires the
whsec_ prefix. A hex secret is also valid base64, so an unprefixed secret would quietly
key the HMAC with the wrong bytes. Every delivery would then fail as a mismatch, with no
hint why.
Verify in other languages
Any Standard Webhooks library verifies these deliveries with the whsec_ secret exactly as
it was shown. The specification repository maintains libraries for JavaScript/TypeScript,
Python, Go, Rust, Java, Ruby, PHP, C# and Elixir:
import { Webhook } from "standardwebhooks";
const wh = new Webhook(process.env.CBOX_ID_WEBHOOK_SECRET); // "whsec_…"
wh.verify(rawBody, headers); // throws if the delivery does not verify
from standardwebhooks.webhooks import Webhook
wh = Webhook(os.environ["CBOX_ID_WEBHOOK_SECRET"]) # "whsec_…"
wh.verify(raw_body, headers)
Pass the raw body bytes the request arrived with. Most frameworks have a raw-body option for webhook routes.
How it is tested
- The Standard Webhooks signer and verifier are held to the specification's own published
vector: secret
whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw, idmsg_p5jXN8AQM9LWM0D4loKWxJek, timestamp1614265330, body{"test": 2432232314}→v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=. That vector is intests/Fixtures/Webhooks/standard-webhooks.json, next to a converted-hex-secret vector computed independently with OpenSSL. - Delivery tests capture the real outgoing request and verify it with the helper. They also
check that a retry keeps its
webhook-idand gets a fresh timestamp, that switching schemes changes the headers, and that a tampered body or timestamp fails. - The Cbox verifier is held to the cross-SDK fixture
tests/Fixtures/Webhooks/signature.json, the same file the Cbox ID SDKs verify against.
Not yet
- Secret rotation with an overlap window. An endpoint has one secret, so the header
carries one signature.
StandardWebhookSignature::sign()already accepts several secrets and emits onev1,entry for each, and both verifiers accept a list. Rotation itself is not built yet. - Asymmetric
v1a(ed25519) signatures. Not sent. Verifiers skipv1aentries.