Skip to content

Webhook infrastructure

How Standard Webhooks signs webhooks

The Standard Webhooks spec signs "{webhook-id}.{webhook-timestamp}.{body}" with HMAC-SHA256 (v1) or Ed25519 (v1a); the key is the base64 part of a whsec_ secret.

Checked against Standard Webhooks's documentation on October 31, 2026. Standard Webhooks webhook docs

How the signature works

Headerwebhook-signature: v1,{signature} [v1,{signature} …]
Signed content{webhook-id}.{webhook-timestamp}.{raw body}
AlgorithmHMAC-SHA256 (v1) or Ed25519 (v1a)
Encodingbase64
Keywhsec_ followed by base64 of 24 to 64 random bytes; the decoded bytes are the key
Where to find itThe endpoint's signing secret (whsec_…) from the sending service's webhook settings.
Replay protectionThe spec asks for a tolerance without fixing one; the reference libraries use 5 minutes.
Secret rotationSeveral space-separated signatures may be sent during a key rotation.
  • Asymmetric v1a signatures use whpk_ public keys (whsk_ secret keys).

verified Source: github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md

Verification code

Generated from the scheme above and tested against a signed example request, a changed body and a wrong secret. Pass the raw request body, before any JSON or form parsing.

const crypto = require('node:crypto');

const TOLERANCE_SECONDS = 300;

/**
 * Verify a Standard Webhooks webhook. rawBody: the request body as a Buffer, exactly as received
 * (before any JSON parsing); headers: lower-case names, as in req.headers.
 */
function verifyWebhook(rawBody, headers, secret, url = '', method = 'POST') {
  rawBody = Buffer.from(rawBody);
  const header = headers['webhook-signature'] || '';
  const signatures = header.split(' ').map((s) => s.split(',')).filter(([v]) => v === 'v1').map(([, s]) => s);
  const ts = headers['webhook-timestamp'] || '';
  const signed = Buffer.concat([Buffer.from((headers['webhook-id'] || '') + '.' + ts + '.'), rawBody]);
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = crypto.createHmac('sha256', key).update(signed).digest('base64');
  const valid = signatures.some((sig) => {
    const a = Buffer.from(sig);
    const b = Buffer.from(expected);
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  });
  if (!valid) return false;
  const signedAt = Number(ts);
  return Number.isFinite(signedAt) && Math.abs(Date.now() / 1000 - signedAt) <= TOLERANCE_SECONDS;
}

// Express: keep the raw body for this route
// app.post('/webhooks/standard-webhooks', express.raw({ type: '*/*' }), (req, res) => {
//   if (!verifyWebhook(req.body, req.headers, process.env.STANDARD_WEBHOOKS_WEBHOOK_SECRET)) return res.sendStatus(401);
//   res.sendStatus(200); // then process the event asynchronously
// });
import base64
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300


def verify_webhook(raw_body: bytes, headers, secret: str, url: str = '', method: str = 'POST') -> bool:
    """Verify a Standard Webhooks webhook. raw_body: the request body exactly as received (bytes);
    headers: a mapping with lower-case names (Flask and Django headers are case-insensitive)."""
    header = headers.get('webhook-signature', '')
    signatures = [s.split(',', 1)[1] for s in header.split(' ') if s.startswith('v1,')]
    ts = headers.get('webhook-timestamp', '')
    signed = (headers.get('webhook-id', '') + '.' + ts + '.').encode() + raw_body
    key = base64.b64decode(secret.removeprefix('whsec_'))
    digest = hmac.new(key, signed, hashlib.sha256).digest()
    expected = base64.b64encode(digest).decode()
    if not any(hmac.compare_digest(sig, expected) for sig in signatures):
        return False
    try:
        signed_at = int(ts)
    except ValueError:
        return False
    return abs(time.time() - signed_at) <= TOLERANCE_SECONDS


# Flask: verify_webhook(request.get_data(), request.headers, SECRET)
<?php

const TOLERANCE_SECONDS = 300;

/**
 * Verify a Standard Webhooks webhook. $rawBody: file_get_contents('php://input'), before any decoding;
 * $headers: lower-case names, e.g. array_change_key_case(getallheaders()).
 */
function verify_webhook(string $rawBody, array $headers, string $secret, string $url = '', string $method = 'POST'): bool
{
    $header = (string) ($headers['webhook-signature'] ?? '');
    $signatures = [];
    foreach (explode(' ', $header) as $entry) {
        [$version, $sig] = array_pad(explode(',', $entry, 2), 2, '');
        if ($version === 'v1') {
            $signatures[] = $sig;
        }
    }
    $ts = (string) ($headers['webhook-timestamp'] ?? '');
    $signed = ($headers['webhook-id'] ?? '') . '.' . $ts . '.' . $rawBody;
    $key = base64_decode(preg_replace('/^whsec_/', '', $secret));
    $expected = base64_encode(hash_hmac('sha256', $signed, $key, true));
    $valid = false;
    foreach ($signatures as $sig) {
        $valid = $valid || hash_equals($expected, $sig);
    }
    if (! $valid) {
        return false;
    }
    $signedAt = (int) $ts;

    return $signedAt > 0 && abs(time() - $signedAt) <= TOLERANCE_SECONDS;
}
package webhooks

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"math"
	"net/http"
	"strconv"
	"strings"
	"time"
)

const toleranceSeconds = 300

// VerifyWebhook checks a Standard Webhooks webhook. rawBody is the request body exactly as received
// (io.ReadAll(r.Body) before any decoding).
func VerifyWebhook(rawBody []byte, h http.Header, secret, url, method string) bool {
	header := h.Get("webhook-signature")
	var signatures []string
	for _, entry := range strings.Split(header, " ") {
		if version, sig, ok := strings.Cut(entry, ","); ok && version == "v1" {
			signatures = append(signatures, sig)
		}
	}
	ts := h.Get("webhook-timestamp")
	signed := []byte{}
	signed = append(signed, h.Get("webhook-id")...)
	signed = append(signed, "."...)
	signed = append(signed, ts...)
	signed = append(signed, "."...)
	signed = append(signed, rawBody...)
	key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
	if err != nil {
		return false
	}
	mac := hmac.New(sha256.New, key)
	mac.Write(signed)
	expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
	valid := false
	for _, sig := range signatures {
		if hmac.Equal([]byte(sig), []byte(expected)) {
			valid = true
		}
	}
	if !valid {
		return false
	}
	n, err := strconv.ParseFloat(ts, 64)
	if err != nil {
		return false
	}
	signedAt := n
	return math.Abs(float64(time.Now().Unix())-signedAt) <= toleranceSeconds
}

Headers Standard Webhooks sends

HeaderMeaning
webhook-idMessage id (same on retries)
webhook-timestampUnix time in seconds
webhook-signatureSpace-separated v1,base64 (or v1a,…) signatures

Timeouts and retries

Response timeoutThe spec recommends senders time out after 15 to 30 seconds.
Counts as success2xx. 3xx is a failure, 410 Gone tells the sender to disable the endpoint, 429/502/504 ask it to slow down (honouring Retry-After).
RetriesRecommended: exponential back-off with jitter over several days, e.g. immediately, 5 s, 5 min, 30 min, 2 h, 5 h, 10 h, 14 h, 20 h and 24 h.
OrderingNot specified.
DuplicatesPossible; webhook-id stays the same across retries, so keep the ids you have seen.
DisablingSenders should notify and may disable endpoints after prolonged failure.

verified Source: github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md

Event types

The event is in type (in the JSON body, recommended by the spec). Examples:

invoice.paiduser.created

verified Source: github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md

Source IP addresses

Standard Webhooks doesn't publish the addresses it sends webhooks from, so verify the signature instead.

A specification, not a sender: each service publishes its own addresses (if any).

An allow-list only filters traffic: proxies, CDNs and shared cloud addresses mean the signature is still the real proof of origin.

Common mistakes

  • Using the whsec_ string as text instead of base64-decoding the part after whsec_.
  • Splitting webhook-signature on commas: entries are separated by spaces, and each is version,signature.
  • Following redirects or returning 3xx: senders treat it as a failure.

Stuck? Point the webhook at a free webhook inspector bin: it detects Standard Webhooks, checks the signature with your secret in the browser and shows the expected value next to the received one.

FAQ

How do I verify a Standard Webhooks webhook signature?

Compute HMAC-SHA256 (v1) or Ed25519 (v1a) over {webhook-id}.{webhook-timestamp}.{raw body} using signing secret (whsec_…), encode it as base64 and compare it in constant time with the value in webhook-signature (format: v1,{signature} [v1,{signature} …]). Then reject requests whose timestamp is more than 5 minutes away from your clock.

How long does Standard Webhooks wait for a webhook response?

The spec recommends senders time out after 15 to 30 seconds. Success means: 2xx. 3xx is a failure, 410 Gone tells the sender to disable the endpoint, 429/502/504 ask it to slow down (honouring Retry-After).

Does Standard Webhooks retry failed webhooks?

Recommended: exponential back-off with jitter over several days, e.g. immediately, 5 s, 5 min, 30 min, 2 h, 5 h, 10 h, 14 h, 20 h and 24 h.

What IP addresses does Standard Webhooks send webhooks from?

No. A specification, not a sender: each service publishes its own addresses (if any).

Why does my Standard Webhooks signature check fail?

Using the whsec_ string as text instead of base64-decoding the part after whsec_. Splitting webhook-signature on commas: entries are separated by spaces, and each is version,signature.

Other webhook providers

Stripe GitHub GitLab Bitbucket Cloud Shopify Slack Twilio Paddle Billing Zoom Linear Square PayPal SendGrid Mailgun Discord Svix Twitch EventSub Intercom HubSpot Jira Cloud Calendly Typeform