Skip to content
← All packages

Webhook Signature for Laravel

Webhook signature verification for Laravel, with drivers for the providers you actually receive from: GitHub, Stripe, Slack, Shopify, Standard Webhooks, Twilio, Mailgun and Postal. Secret rotation, replay defence, and outbound signing that uses the same code.

Every webhook you receive has to be proven to come from who it claims to. That check is short enough that most codebases write it inline, and different enough per provider that they write it again for the next one. This package does the verification, ships a driver for each provider's actual scheme, and signs your outbound webhooks with the same code.

Two lines to guard an endpoint

Declare the endpoint and its secrets in config/webhook-signature.php, then put the webhook.signature middleware on the route. Inside the controller, VerifiedWebhook::fromRequestOrFail($request) hands you the payload that was actually signed, the provider's event id for idempotency, and which secret verified it.

The providers, as they actually behave

Nine bundled schemes, each signing something different. GitHub and Shopify sign the raw body, Stripe and Slack prefix a timestamp, Standard Webhooks prefixes an id and a timestamp, Twilio signs the URL with its parameters sorted byte-wise, and Mailgun signs a timestamp and token rather than the body at all. Most are hex, Shopify and Twilio are base64. Postal is not an HMAC: it is RSA against a public key. Anything else is either a config entry against the generic HMAC driver or a class you register.

What A Hand-Rolled Verifier Misses

Secret Rotation

You cannot rotate a webhook secret atomically, because sender and receiver deploy separately. An endpoint holds a set of live secrets, and the verified webhook reports which one matched, so you know when the old one is genuinely unused.

Replay Defence

Timestamp binding where the provider supports it, plus optional single-use enforcement against a shared store. Off by default, deliberately: a guard backed by a per-node cache reports success while enforcing nothing.

Typed Failure Reasons

A missing secret on your side is not the same as a bad signature from the caller, and the status code says so: 401 for their mistake, 500 for yours. A provider retries a misconfigured receiver rather than discarding the event.

No Silent Downgrades

GitHub's legacy SHA-1 header is refused rather than accepted alongside SHA-256, and where Postal sends a SHA-256 header it is the only one checked, so a corrupted strong signature cannot fall back to the weak one.

Signed At Send Time

Http::webhookSignature() signs through Guzzle middleware, so the bytes signed are the bytes sent. There is no encoded body to hold in a variable and hand to two places that can disagree.

Testing Helpers

fakeWebhookEndpoint, postSignedWebhook and postUnsignedWebhook drive the production signing path, so a passing test exercises the same code the provider will hit.

Checked against someone else's implementation

A round trip cannot catch a misread specification, because the signer and the verifier are wrong in the same way. So every bundled scheme is tested against a signature produced by something other than this package: the worked examples GitHub, Slack, Standard Webhooks and Twilio publish, RFC 4231 vectors for the HMAC primitive itself, and for the rest, signatures generated by the provider's own SDK, the openssl CLI, or an independently written signer already running in production. The docs name the source for each one.

None of the cryptography is hand-rolled. The MAC path is PHP's hash_hmac and hash_equals in a single class, and the asymmetric path is openssl_verify in another.

What it deliberately is not

No migrations, no models, no queued jobs, no webhook_calls table. Storing deliveries and processing them are decisions your application has already made, and a library that made them again would be something to fit around rather than something to drop in.