Receiving webhooks
Receiving webhooks
Every recipe is the same three steps — declare an endpoint, protect the route, read the verified payload. What differs is the scheme name and one detail per provider.
GitHub
'github' => [
'scheme' => 'github',
'secrets' => [env('GITHUB_WEBHOOK_SECRET')],
],
Route::post('/webhooks/github', GithubController::class)
->middleware('webhook.signature:github');
No timestamp is bound, so there is no window. If replay matters here, enable
single-use enforcement — GitHub's
X-GitHub-Delivery gives the guard a stable key.
Stripe
'stripe' => [
'scheme' => 'stripe',
'secrets' => [env('STRIPE_WEBHOOK_SECRET')],
'tolerance' => 300,
],
Live and test mode are separate endpoints with separate secrets. During a Stripe-side rotation the header carries several signatures at once; nothing extra is needed, they are all checked.
The event id (evt_…) comes from the payload and is exposed as eventId — use it for your
own idempotency.
Slack
'slack' => [
'scheme' => 'slack',
'secrets' => [env('SLACK_SIGNING_SECRET')],
],
Slack sends form-encoded bodies for most events. Read the payload from the verified result, and remember that the signature covers the raw form string — anything that reparses and re-encodes it before verification breaks the check.
Shopify
'shopify' => [
'scheme' => 'shopify',
'secrets' => [env('SHOPIFY_WEBHOOK_SECRET')],
],
Base64, not hex — handled by the scheme, but worth knowing when comparing against a signature you computed by hand while debugging.
Standard Webhooks
'partner' => [
'scheme' => 'standard-webhooks',
'secrets' => [env('PARTNER_WEBHOOK_SECRET')], // the whsec_… string, verbatim
],
Both the webhook-* header names and the older svix-* spelling are accepted, since
senders migrated at different times and both are still in the wild.
Paste the secret exactly as the provider gave it, whsec_ prefix included. The scheme
strips the prefix and base64-decodes the remainder to get the HMAC key; handing it an
already-decoded value will not verify.
Twilio
'voice' => [
'scheme' => 'twilio',
'secrets' => [env('TWILIO_AUTH_TOKEN')],
],
The auth token is the secret — Twilio has no separate webhook secret.
Check your trusted proxies first. The signature covers the request URL, so behind a
load balancer with TrustProxies unconfigured, the URL your application reconstructs is not
the URL Twilio dialled, and every delivery fails. This is the single most common Twilio
integration failure.
Mailgun
'mailgun' => [
'scheme' => 'mailgun',
'secrets' => [env('MAILGUN_WEBHOOK_SIGNING_KEY')],
],
Verify-only — the package cannot sign Mailgun-shaped payloads, since the signature lives in the body. Note that Mailgun signs a timestamp and a token, not the payload, so the signature proves the delivery is genuinely Mailgun's but says nothing about the event data being unmodified beyond that.
Postal
'postal' => [
'scheme' => 'postal',
'secrets' => [env('POSTAL_WEBHOOK_PUBLIC_KEY')], // a PEM public key, not a secret
],
Copy the public key from your Postal installation's web interface, or fetch it from its
API. It is a public key: it is not confidential, and it cannot be used to forge a delivery.
Paste the full PEM including the -----BEGIN PUBLIC KEY----- lines.
Verify-only — the package cannot sign Postal deliveries, because signing requires the installation's private key, which is the property that makes the scheme worth having.
If your Postal installation rotates its key, list both while the change lands; the same rotation rules apply as for shared secrets.
Anything else
Describe it in config with the generic HMAC driver.
Reading the result
$webhook = VerifiedWebhook::fromRequestOrFail($request);
$webhook->json(); // the payload that was signed
$webhook->idempotencyKey(); // the provider's event id, or null
$webhook->secretId; // which secret verified — watch this during a rotation
Dispatch the work on a queue and return quickly. Providers time out, and a timeout is indistinguishable from a failure to them, so slow handlers produce duplicate deliveries.