Docs
Signature verification
Every webhook is signed in the Standard Webhooks format. Receivers verify it with the official libraries.
Request format
POST https://example.com/webhooks
content-type: application/json
webhook-id: msg_2Zq8…
webhook-timestamp: 1790000000
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
user-agent: Webhook Admin/1
{ "type": "invoice.paid", "timestamp": "2026-09-27T01:32:05.000Z", "data": { "invoice_id": "inv_88", "amount": 128000 } }| Header | Value |
|---|---|
webhook-id | Message ID (msg_…). Unchanged across retries |
webhook-timestamp | Send time (Unix seconds) |
webhook-signature | v1,<base64>. Two, space-separated, while a secret is being rotated |
The body is { "type", "timestamp", "data" }, where data is the payload of POST /v1/messages.
Official libraries
| Language | Package |
|---|---|
| JavaScript / TypeScript | npm install standardwebhooks |
| Python | pip install standardwebhooks |
| Go | go get github.com/standard-webhooks/standard-webhooks/libraries/go |
| Ruby | gem install standardwebhooks |
| Java / Kotlin | com.standardwebhooks:standardwebhooks (Maven Central) |
| Rust | cargo add standardwebhooks |
| C# | dotnet add package StandardWebhooks.StandardWebhooks |
| PHP・Elixir | github.com/standard-webhooks/standard-webhooks |
The secret is the endpoint’s secret (whsec_…). Pass it to the library as is.
import express from 'express';
import { Webhook } from 'standardwebhooks';
const wh = new Webhook(process.env.WEBHOOK_SECRET); // whsec_…
const app = express();
// Verify against the raw body, before any JSON parsing
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
try {
const event = wh.verify(req.body, req.headers);
console.log(event.type, event.data);
res.sendStatus(200);
} catch {
res.sendStatus(400);
}
});
app.listen(3000);import os
from flask import Flask, request
from standardwebhooks import Webhook
wh = Webhook(os.environ["WEBHOOK_SECRET"]) # whsec_…
app = Flask(__name__)
@app.post("/webhooks")
def webhooks():
try:
event = wh.verify(request.get_data(), dict(request.headers))
except Exception:
return "", 400
print(event["type"], event["data"])
return "", 200The official libraries reject signatures whose timestamp is more than 5 minutes off.
Signing scheme
- The signed content is
{webhook-id}.{webhook-timestamp}.{body}, using the body exactly as received - The key is the base64-decoded part after
whsec_ - Base64-encode the HMAC-SHA256 result and prefix it with
v1, - The request is authentic if any signature in
webhook-signaturematches
Without a library (Node.js)
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(secret, headers, body) {
const id = headers['webhook-id'];
const ts = headers['webhook-timestamp'];
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = createHmac('sha256', key).update(`${id}.${ts}.${body}`).digest('base64');
// During rotation, signatures are space-separated
return headers['webhook-signature'].split(' ').some((s) => {
const [version, sig] = s.split(',');
return version === 'v1' && sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
});
}Responses and retries
- Any 2xx is a success. Other responses, a 15-second timeout or a connection failure are retried.
- Attempts are made immediately, then after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours (up to 8 attempts in total).
- Redirects are not followed.
- The same
webhook-idcan arrive more than once. Drop IDs you have already processed. - Endpoints failing for 5 days are disabled automatically.
Rotating secrets
After you rotate with POST /v1/endpoints/{id}/rotate-secret or in the dashboard, deliveries carry a signature with the old secret too for 24 hours. Switch the receiver to the new secret within that window.