Guide for receivers
This section is written for the people who receive webhooks from an app built on Webhooks for Laravel — your customers. Hand it to them and adapt the names. No Laravel knowledge is needed: any language that can compute an HMAC-SHA256 can verify these messages.
Every webhook is an HTTP POST with a JSON body, signed with a secret only you and the sender know. The format is Standard Webhooks v1: existing Standard Webhooks libraries verify it as it is, and the snippets below do the same in a dozen lines.
What arrives
POST /your/webhook/url HTTP/1.1
Content-Type: application/json
User-Agent: Acme Webhooks
webhook-id: msg_01JCDEF0000000000000000000
webhook-timestamp: 1794139200
webhook-signature: v1,dBcYxACDimm+8xx9Ykx71LVUgk5H4STQXxAUHZ5xcNk=
{"type":"invoice.paid","timestamp":"2026-11-08T12:00:00Z","data":{"invoice":42}}| Header | Meaning |
|---|---|
webhook-id | The message id (msg_ + 26 characters). The same on every retry and on a manual resend — deduplicate on it. |
webhook-timestamp | When this attempt was signed, in Unix seconds (UTC). A new value on every attempt. |
webhook-signature | One or more v1,<base64> entries separated by a space. During a secret rotation there are two — one per secret, the new one first. Accept the message if any entry matches. |
User-Agent | The sender’s name (configurable) — informational only, never trust it. |
The body is an envelope. type is the event type you subscribed to, timestamp is when the event happened (RFC 3339, UTC — order events by it, not by arrival), data is the event itself. A sender may switch the envelope off and send the data as is; the signature works the same.
{"type": "invoice.paid", "timestamp": "2026-11-08T12:00:00Z", "data": {"invoice": 42}}The secret
You were shown the secret once, when the endpoint was created: whsec_ followed by base64. The HMAC key is the base64-decoded bytes after the prefix (32 random bytes). A secret without the whsec_ prefix — one you chose and gave the sender — is used as its own bytes. Keep it like a password. During a rotation the sender signs with both the old and the new secret for a while (a day by default), so you can switch at your own pace.
Verifying — the algorithm
key = base64_decode(secret without "whsec_") # or the secret's own bytes
signed_content = webhook-id + "." + webhook-timestamp + "." + raw_body
expected = base64(HMAC-SHA256(key, signed_content))
valid = any entry "v1,<x>" in webhook-signature where <x> == expected (constant-time compare)
and |now - webhook-timestamp| <= 300 secondsUse the raw body bytes exactly as they arrived. Never re-serialise parsed JSON (key order, spacing and unicode escapes change), never trim it, never let a framework decode it first.
Getting the raw body
| Stack | The raw bytes |
|---|---|
| Node — Express | app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => …) → req.body (Buffer) |
| Node — Fastify | fastify.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => done(null, body)) → request.body |
| Python — Flask | request.get_data() — before touching request.json |
| Python — Django | request.body |
| Go — net/http | body, err := io.ReadAll(r.Body) |
| Ruby — Rails | request.raw_post |
| PHP — plain | file_get_contents('php://input') |
| PHP — Laravel | $request->getContent() — keep TrimStrings and ConvertEmptyStringsToNull off the route |
PHP
Each function below returns true only for a fresh, correctly signed message. They are the exact code the package’s test suite runs against its published test vectors and against real deliveries.
<?php
declare(strict_types=1);
function verifyWebhook(string $secret, string $id, string $timestamp, string $signature, string $body, ?int $now = null): bool
{
// "whsec_" + base64 → the decoded bytes; any other secret is used as its own bytes.
$key = str_starts_with($secret, 'whsec_') ? base64_decode(substr($secret, 6), true) : $secret;
if ($key === false || preg_match('/^\d{1,12}$/', $timestamp) !== 1 || abs(($now ?? time()) - (int) $timestamp) > 300) {
return false;
}
$expected = base64_encode(hash_hmac('sha256', $id.'.'.$timestamp.'.'.$body, $key, true));
// Several "v1,<base64>" entries during a secret rotation: any one may match.
foreach (explode(' ', $signature) as $entry) {
[$version, $mac] = array_pad(explode(',', $entry, 2), 2, '');
if ($version === 'v1' && hash_equals($expected, $mac)) {
return true;
}
}
return false;
}$valid = verifyWebhook($secret, $_SERVER['HTTP_WEBHOOK_ID'], $_SERVER['HTTP_WEBHOOK_TIMESTAMP'], $_SERVER['HTTP_WEBHOOK_SIGNATURE'], file_get_contents('php://input'));Node
const crypto = require('node:crypto');
function verifyWebhook(secret, id, timestamp, signature, rawBody, now = Math.floor(Date.now() / 1000)) {
// "whsec_" + base64 → the decoded bytes; any other secret is used as its own bytes.
const key = secret.startsWith('whsec_') ? Buffer.from(secret.slice(6), 'base64') : Buffer.from(secret, 'utf8');
if (!/^\d{1,12}$/.test(timestamp) || Math.abs(now - Number(timestamp)) > 300) {
return false;
}
const expected = crypto.createHmac('sha256', key).update(`${id}.${timestamp}.`).update(rawBody).digest();
// Several "v1,<base64>" entries during a secret rotation: any one may match.
return signature.split(' ').some((entry) => {
const [version, mac = ''] = entry.split(',');
const given = Buffer.from(mac, 'base64');
return version === 'v1' && given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}
module.exports = { verifyWebhook };app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verifyWebhook(process.env.WEBHOOK_SECRET, req.get('webhook-id'), req.get('webhook-timestamp'), req.get('webhook-signature'), req.body);
if (!ok) return res.status(401).end();
res.status(204).end(); // then process req.body asynchronously
});Python
import base64
import hashlib
import hmac
import re
import time
def verify_webhook(secret, msg_id, timestamp, signature, raw_body, now=None):
# "whsec_" + base64 → the decoded bytes; any other secret is used as its own bytes.
key = base64.b64decode(secret[6:]) if secret.startswith("whsec_") else secret.encode()
now = time.time() if now is None else now
if not re.fullmatch(r"\d{1,12}", timestamp) or abs(now - int(timestamp)) > 300:
return False
signed = f"{msg_id}.{timestamp}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
# Several "v1,<base64>" entries during a secret rotation: any one may match.
for entry in signature.split(" "):
version, _, mac = entry.partition(",")
if version == "v1" and hmac.compare_digest(expected, mac):
return True
return Falsevalid = verify_webhook(secret, request.headers["webhook-id"], request.headers["webhook-timestamp"], request.headers["webhook-signature"], request.get_data())Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"regexp"
"strconv"
"strings"
"time"
)
var unixSeconds = regexp.MustCompile(`^\d{1,12}$`)
func VerifyWebhook(secret, id, timestamp, signature string, body []byte, now time.Time) bool {
// "whsec_" + base64 → the decoded bytes; any other secret is used as its own bytes.
key := []byte(secret)
if strings.HasPrefix(secret, "whsec_") {
decoded, err := base64.StdEncoding.DecodeString(secret[6:])
if err != nil {
return false
}
key = decoded
}
if !unixSeconds.MatchString(timestamp) {
return false
}
signedAt, _ := strconv.ParseInt(timestamp, 10, 64)
if age := now.Unix() - signedAt; age > 300 || age < -300 {
return false
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte(id + "." + timestamp + "."))
mac.Write(body)
expected := mac.Sum(nil)
// Several "v1,<base64>" entries during a secret rotation: any one may match.
for _, entry := range strings.Split(signature, " ") {
version, encoded, _ := strings.Cut(entry, ",")
given, err := base64.StdEncoding.DecodeString(encoded)
if version == "v1" && err == nil && hmac.Equal(given, expected) {
return true
}
}
return false
}body, _ := io.ReadAll(r.Body)
valid := VerifyWebhook(secret, r.Header.Get("webhook-id"), r.Header.Get("webhook-timestamp"), r.Header.Get("webhook-signature"), body, time.Now())Ruby
require 'base64'
require 'openssl'
def verify_webhook(secret, id, timestamp, signature, raw_body, now = Time.now.to_i)
# "whsec_" + base64 → the decoded bytes; any other secret is used as its own bytes.
key = secret.start_with?('whsec_') ? Base64.strict_decode64(secret[6..]) : secret
return false unless timestamp.match?(/\A\d{1,12}\z/) && (now - timestamp.to_i).abs <= 300
expected = OpenSSL::HMAC.digest('SHA256', key, "#{id}.#{timestamp}.".b + raw_body.b)
# Several "v1,<base64>" entries during a secret rotation: any one may match.
signature.split(' ').any? do |entry|
version, mac = entry.split(',', 2)
given = Base64.decode64(mac.to_s)
version == 'v1' && given.bytesize == expected.bytesize && OpenSSL.secure_compare(given, expected)
end
rescue ArgumentError
false
endvalid = verify_webhook(secret, request.headers['webhook-id'], request.headers['webhook-timestamp'], request.headers['webhook-signature'], request.raw_post)Ruby 3.0 or newer, for OpenSSL.secure_compare.
Test vectors
Check your implementation against these before going live, with now set to the timestamp:
| secret | webhook-id | webhook-timestamp | body | webhook-signature |
|---|---|---|---|---|
whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw | msg_p5jXN8AQM9LWM0D4loKWxJek | 1614265330 | {"test": 2432232314} | v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= |
whsec_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8= | msg_01JCDEF0000000000000000000 | 1794139200 | {"type":"invoice.paid","timestamp":"2026-11-08T12:00:00Z","data":{"invoice":42}} | v1,dBcYxACDimm+8xx9Ykx71LVUgk5H4STQXxAUHZ5xcNk= |
| whsec_ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8= (new) or the row above’s (old) | as above | as above | as above | v1,Nwl36Rvbn1xO29I4VEpYVwe7HJ3vZuLKGz6KoEVZDFI= v1,dBcYxACDimm+8xx9Ykx71LVUgk5H4STQXxAUHZ5xcNk= |
What to answer
| You answer | The sender |
|---|---|
| Any 2xx within 10 seconds (connect: 5 s) | Counts it delivered. Answer fast and process afterwards — a slow answer is a timeout, and a timeout is retried. |
410 Gone | Stops sending to this endpoint at once (it is disabled) — the way to unsubscribe. |
| 429 or 503 with Retry-After (seconds or an HTTP date) | Waits at least that long (up to a day) before the next try. |
| Anything else — another 4xx, 5xx, a 3xx, a timeout, a refused connection, a TLS error | Retries with growing pauses: 8 tries over about 24 hours (1 min, 4 min, 16 min, 1 h, 4 h, then every 9 h, with jitter). |
After the last try the message is marked failed. If deliveries keep failing — five messages in a row by default — the sender may switch your endpoint off and tell its team; it can resend everything that failed once you are back. Only the first 4 KB of your answer is kept in the sender’s log, so don’t put anything secret in it.
Rules of thumb
- Deduplicate on webhook-id. A message can arrive more than once — a retry after your answer got lost, a manual resend. Store the ids you have processed (a unique index works) and answer a repeat with 2xx without doing the work again.
- Order by the body’s timestamp, not by arrival: retries mean a later event can arrive first.
- Verify before you parse, and refuse a message whose timestamp is more than 5 minutes away from your clock — keep your clock in sync with NTP.
- Rotate without downtime: when the sender rotates, accept both secrets until the old one is gone — the signature header carries an entry for each.
- Test it: the sender can send a webhook.ping event whatever you subscribed to.
{"type":"webhook.ping","data":{"ping":true}}Receiving these in another Laravel app
An app with Webhooks for Laravel receives them out of the box: create an inbound endpoint with the secret you were given, and give the sender its URL.
$created = Webhooks::inbound()->create(handler: 'billing', secret: $secretTheSenderShowedYou);
$created->url; // the address to paste into the sender's settingsShow your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.