NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Webhooks for Laravel

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}}
HeaderMeaning
webhook-idThe message id (msg_ + 26 characters). The same on every retry and on a manual resend — deduplicate on it.
webhook-timestampWhen this attempt was signed, in Unix seconds (UTC). A new value on every attempt.
webhook-signatureOne 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-AgentThe 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 seconds

Use 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

StackThe raw bytes
Node — Expressapp.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => …) → req.body (Buffer)
Node — Fastifyfastify.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => done(null, body)) → request.body
Python — Flaskrequest.get_data() — before touching request.json
Python — Djangorequest.body
Go — net/httpbody, err := io.ReadAll(r.Body)
Ruby — Railsrequest.raw_post
PHP — plainfile_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 False
valid = 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
end
valid = 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:

secretwebhook-idwebhook-timestampbodywebhook-signature
whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSwmsg_p5jXN8AQM9LWM0D4loKWxJek1614265330{"test": 2432232314}v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=
whsec_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=msg_01JCDEF00000000000000000001794139200{"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 aboveas aboveas abovev1,Nwl36Rvbn1xO29I4VEpYVwe7HJ3vZuLKGz6KoEVZDFI= v1,dBcYxACDimm+8xx9Ykx71LVUgk5H4STQXxAUHZ5xcNk=

What to answer

You answerThe 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 GoneStops 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 errorRetries 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 settings

Show 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 crypto

By 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.