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>
  • t is when poolse signed this attempt, in Unix seconds.
  • v1 is HMAC-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

  1. Read the raw request body as bytes, before any JSON parsing.
  2. Split the header on , and take the t= and v1= values.
  3. Reject the request if t is more than 5 minutes from your clock.
  4. Compute HMAC-SHA256(secret, t + "." + body) and hex-encode it.
  5. Compare it with v1 in constant time. Reject on mismatch.
  6. 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. Use crypto.timingSafeEqual, hmac.compare_digest or hmac.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.

Verify webhook signatures · poolse docs