Verify webhook signatures
Anyone who knows your webhook URL can send it a request. The
X-Poolse-Signature header proves a request came from poolse. Check it
before you act on the payload.
The scheme
X-Poolse-Signature: t=<unix seconds>,v1=<signature>
tis when poolse signed this attempt, in Unix seconds.v1isHMAC-SHA256(secret, "<t>.<raw body>"), hex-encoded in lowercase (64 characters).- The signed string is
t, a literal., then the request body exactly as received.
The secret is the signing_secret poolse returned once when you
created the webhook (or the dashboard showed you in Save the signing
secret). Use it exactly as shown, as a string; don't base64-decode it.
Keep it in your backend's secret store. poolse can't show it again; to
replace it, create a new webhook.
Each retry is signed again with a new t, so check the timestamp.
Steps
- Read the raw request body as bytes, before any JSON parsing.
- Split the header on
,and take thet=andv1=values. - Reject the request if
tis more than 5 minutes from your clock. - Compute
HMAC-SHA256(secret, t + "." + body)and hex-encode it. - Compare it with
v1in constant time. Reject on mismatch. - Only then parse the JSON.
Node.js
import crypto from 'node:crypto';
export function verifyWebhook(
rawBody: string | Buffer,
signatureHeader: string | null,
secret: string,
toleranceSeconds = 5 * 60,
): boolean {
const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(signatureHeader ?? '');
if (!match) return false;
const [, ts, sig] = match;
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(ts)) > toleranceSeconds) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${ts}.`)
.update(rawBody)
.digest();
const received = Buffer.from(sig, 'hex');
// timingSafeEqual throws when lengths differ, so check first.
if (received.length !== expected.length) return false;
return crypto.timingSafeEqual(received, expected);
}
Next.js route handler
import { NextResponse } from 'next/server';
import { verifyWebhook } from '@/lib/poolse-webhooks';
export async function POST(req: Request) {
const rawBody = await req.text();
const signature = req.headers.get('x-poolse-signature');
if (!verifyWebhook(rawBody, signature, process.env.POOLSE_WEBHOOK_SECRET!)) {
return new NextResponse('invalid signature', { status: 401 });
}
const event = JSON.parse(rawBody);
// ... handle event.type / event.data
return NextResponse.json({ ok: true });
}
With Express, use express.raw({ type: 'application/json' }) on this
route so you get the body as a Buffer instead of parsed JSON.
Python (Flask)
import hashlib
import hmac
import os
import re
import time
from flask import Flask, abort, request
app = Flask(__name__)
PATTERN = re.compile(r'^t=(\d+),v1=([0-9a-f]{64})$')
def verify_webhook(raw_body: bytes, header: str | None, secret: str, tolerance: int = 300) -> bool:
m = PATTERN.match(header or '')
if not m:
return False
ts, sig = m.group(1), m.group(2)
if abs(int(time.time()) - int(ts)) > tolerance:
return False
expected = hmac.new(secret.encode(), ts.encode() + b'.' + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(sig, expected)
@app.post('/webhooks/poolse')
def handle():
if not verify_webhook(request.get_data(), request.headers.get('X-Poolse-Signature'),
os.environ['POOLSE_WEBHOOK_SECRET']):
abort(401)
event = request.get_json()
# ... handle event['type'] / event['data']
return {'ok': True}
Go
package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"regexp"
"strconv"
"time"
)
var pattern = regexp.MustCompile(`^t=(\d+),v1=([0-9a-f]{64})$`)
func Verify(rawBody []byte, header, secret string, tolerance time.Duration) bool {
m := pattern.FindStringSubmatch(header)
if m == nil {
return false
}
ts, err := strconv.ParseInt(m[1], 10, 64)
if err != nil {
return false
}
if time.Since(time.Unix(ts, 0)).Abs() > tolerance {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(m[1] + "."))
mac.Write(rawBody)
expected := mac.Sum(nil)
received, err := hex.DecodeString(m[2])
if err != nil {
return false
}
return hmac.Equal(received, expected)
}
hmac.Equal is constant-time and returns false for different lengths.
Common mistakes
- Comparing with
===or==. A normal string compare stops at the first different character, which leaks timing. Usecrypto.timingSafeEqual,hmac.compare_digestorhmac.Equal. - Leaving out
t. The signed string is"<t>.<body>", not just the body. - A wide tolerance. Five minutes is enough. A tolerance of hours lets a captured request be replayed for hours.
- Using the secret of another webhook. Each webhook has its own secret.
After verifying: handle duplicates
Delivery is at least once, so the same delivery can arrive twice. Use
X-Poolse-Event-Id (the same as the body's id) as an idempotency key:
const id = req.headers.get('x-poolse-event-id')!;
if (await db.processedDeliveries.has(id)) {
return new Response('ok');
}
await doTheWork(event);
await db.processedDeliveries.add(id);
return new Response('ok');
A failed delivery is retried 1 minute, 5 minutes, 30 minutes, 2 hours
and 12 hours after each failure, with the same id. A Replay from
the dashboard or the API is a new delivery with a new id. Answer within
10 seconds, or poolse counts the attempt as failed. See
Webhooks concepts.