cboxdk / laravel-webhook-signature
Cbox Webhook Signature — verification-only webhook signature checking for Laravel, with drivers for GitHub, Stripe, Slack, Shopify, Standard Webhooks, Twilio, Mailgun and Postal. No migrations, no queue, no models. Secret rotation, replay protection and send-time outbound signing included.
Package info
github.com/cboxdk/laravel-webhook-signature
pkg:composer/cboxdk/laravel-webhook-signature
Requires
- php: ^8.4
- ext-json: *
- guzzlehttp/promises: ^2.0 || ^3.0
- illuminate/contracts: ^12.0 || ^13.0
- illuminate/http: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
- psr/http-message: ^1.1 || ^2.0
- psr/log: ^3.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.30
- orchestra/testbench: ^10.0 || ^11.0
- pestphp/pest: ^4.0 || ^5.0
This package is auto-updated.
Last update: 2026-08-05 10:28:50 UTC
README
Webhook signature verification for Laravel, with drivers for the providers you actually receive from — and outbound signing that uses the same code.
composer require cboxdk/laravel-webhook-signature
Receiving
// config/webhook-signature.php 'endpoints' => [ 'github' => ['scheme' => 'github', 'secrets' => [env('GITHUB_WEBHOOK_SECRET')]], ],
Route::post('/webhooks/github', GithubController::class) ->middleware('webhook.signature:github');
$webhook = VerifiedWebhook::fromRequestOrFail($request); $webhook->json(); // the payload that was actually signed $webhook->idempotencyKey(); // the provider's event id $webhook->secretId; // which secret verified — watch this during a rotation
Sending
Http::webhookSignature('outbound')->post($subscriber->url, $payload);
Signed by Guzzle middleware at send time, so the bytes that are signed are the bytes that are sent. No encoded body to keep in a variable and hand to two places.
Bundled schemes
| Name | Signed bytes | Digest | Encoding |
|---|---|---|---|
github |
raw body | SHA-256 | hex |
stripe |
{timestamp}.{body} |
SHA-256 | hex |
slack |
v0:{timestamp}:{body} |
SHA-256 | hex |
shopify |
raw body | SHA-256 | base64 |
standard-webhooks |
{id}.{timestamp}.{body} |
SHA-256 | base64 |
twilio |
URL + sorted parameters | SHA-1 | base64 |
mailgun |
{timestamp}{token} |
SHA-256 | hex |
postal |
raw body (RSA, public key) | SHA-256 / SHA-1 | base64 |
cbox |
{timestamp}.{body} |
SHA-256 | hex |
Anything else: describe it in config with the generic HMAC driver, or register a class.
What it does that a hand-rolled verifier does not
Secret rotation. You cannot rotate a webhook secret atomically — sender and receiver deploy separately — so an endpoint holds a set of live secrets, and reports which one verified so you know when the old one can safely go.
Replay defence. Timestamp binding where the provider supports it, plus optional single-use enforcement against a shared store. Off by default, because a guard against a per-node cache reports success while enforcing nothing.
Typed failure reasons. "Our secret is missing" is distinguishable from "someone sent a bad signature" — in your logs, and in the status code: 401 for the caller's mistake, 500 for ours, so a provider retries a misconfigured receiver rather than discarding events.
The provider details. Stripe sends several valid signatures during a rotation. The
Standard Webhooks secret is base64 behind a whsec_ prefix. Shopify is base64 where the
others are hex. Twilio signs the URL, sorted byte-wise, with SHA-1. GitHub's legacy SHA-1
header is refused rather than accepted alongside SHA-256, and Postal's SHA-256 header —
when present — is the only one checked, so a corrupted strong signature cannot be
downgraded to the weak one.
Testing helpers that sign with the production code path:
$this->fakeWebhookEndpoint('github', 'github', 'test-secret'); $this->postSignedWebhook('/webhooks/github', 'github', ['action' => 'opened'])->assertOk(); $this->postUnsignedWebhook('/webhooks/github', ['action' => 'opened'])->assertUnauthorized();
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; a library that made them
again would be something to fit around rather than something to drop in.
Conformance
Every bundled scheme is tested against a signature produced by an implementation other than this one — a round trip cannot catch a misread specification, because both halves are wrong the same way.
github, slack, standard-webhooks and twilio use the worked examples their providers
publish; the HMAC primitive is checked against RFC 4231 vectors. stripe, shopify,
mailgun, postal and cbox are checked against signatures generated by the provider's
own SDK, by the openssl CLI, or by the independently written signer already deployed in the
Cbox billing service. docs/security/conformance.md names
the source for each.
Nothing here is hand-rolled cryptography: MACs use PHP's hash_hmac() and hash_equals()
in one class, and the asymmetric path uses openssl_verify() in another.
Requirements
PHP 8.4+, Laravel 12 or 13. See docs/requirements.md.
Documentation
docs/index.md — quickstart, per-provider recipes, rotation, replay protection, extension points, threat model.
Development
composer qa # pint, phpstan (level max), pest, license check, security audit
License
MIT. See LICENSE.