---
title: "Guide for receivers — Webhooks for Laravel | Roundly"
description: "A page to hand your customers: what arrives, the raw body per stack, verification in PHP, Node, Python, Go and Ruby, test vectors and what to answer."
url: https://roundly-consulting.com/open-source/docs/webhooks-for-laravel/receiver-guide
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Webhooks for Laravel](https://roundly-consulting.com/open-source/docs/webhooks-for-laravel.md)

# 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

```http
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.

```json
{"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

| 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
<?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;
}
```

```php
$valid = verifyWebhook($secret, $_SERVER['HTTP_WEBHOOK_ID'], $_SERVER['HTTP_WEBHOOK_TIMESTAMP'], $_SERVER['HTTP_WEBHOOK_SIGNATURE'], file_get_contents('php://input'));
```

## Node

```js
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 };
```

```js
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

```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
```

```python
valid = verify_webhook(secret, request.headers["webhook-id"], request.headers["webhook-timestamp"], request.headers["webhook-signature"], request.get_data())
```

## Go

```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
}
```

```go
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

```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
```

```ruby
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:

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

```json
{"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.

```php
$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](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

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

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/webhooks-for-laravel.md)
