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.