The signature covers the full webhook payload: id, delivered_at, event. Two signature algorithms are supported depending on the keyType:
ED25519 (Recommended)
- Create a payload object:
id, delivered_at, event
- JSON.stringify(payload)
- Encode the JSON string to Base64
- Sign using ED25519 (ed25519.signAsync)
- Encode the signature to Base64
Legacy (Deprecated)
- Create a payload object:
id, delivered_at, event
- JSON.stringify(payload)
- Encode the JSON string to Base64
- SHA256 hash (privateKey + data)
- Encode the hash to Base64
Security Details:
- The signature covers all fields, including id and delivered_at
- A unique id prevents replay attacks
- Timestamp validation prevents accepting outdated webhooks (16-minute window)
- The 16-minute window accounts for retries (immediate + 5 min + 10 min) + network buffer
- ED25519 provides cryptographic authenticity
Retry Logic
- First attempt: Immediately
- Second attempt: delay of 300 seconds (5 minutes)
- Third attempt: delay of 600 seconds (10 minutes)
- Error: task is removed from the queue and logged
IMPORTANT: Always verify webhooks to prevent forgery and replay attacks.
Full Verification Algorithm
import * as ed25519 from '@noble/ed25519';
interface WebhookPayload {
id: string;
delivered_at: string;
event: {
event_type: string;
timestamp: string;
data: any;
};
signature: string;
}
async function verifyWebhook(
webhook: WebhookPayload,
publicKey: string,
seenWebhookIds: Set<string>
): Promise<boolean> {
// 1. Replay Protection: Check if webhook ID already processed
if (seenWebhookIds.has(webhook.id)) {
throw new Error('Replay attack detected: webhook ID already processed');
}
// 2. Timestamp Validation: Reject webhooks older than 16 minutes or from future
// Max delivery time: 15 min (immediate + 5min retry + 10min retry) + 1min network buffer
const deliveredAt = new Date(webhook.delivered_at);
const now = Date.now();
const timeDiff = Math.abs(now - deliveredAt.getTime());
if (timeDiff > 960000) { // 16 minutes
throw new Error('Webhook timestamp outside acceptable window');
}
// 3. Signature Verification: Verify signature covers entire payload
const payload = {
id: webhook.id,
delivered_at: webhook.delivered_at,
event: webhook.event
};
// Reconstruct signed data (same as server)
const json = JSON.stringify(payload);
const data = Buffer.from(json, 'utf8').toString('base64');
// Decode signature and public key
const signatureBytes = Buffer.from(webhook.signature, 'base64');
const publicKeyBytes = Buffer.from(publicKey, 'hex');
const dataBytes = Buffer.from(data, 'utf8');
// Verify with ED25519
const isValid = await ed25519.verifyAsync(signatureBytes, dataBytes, publicKeyBytes);
if (!isValid) {
throw new Error('Invalid signature: webhook authentication failed');
}
// 4. Store webhook ID to prevent future replay
seenWebhookIds.add(webhook.id);
return true;
}
ED25519 Verification Steps
- Replay protection - check
webhook.id that it has not been seen before
- Timestamp validation - validate delivery_at within 16 minutes (maximum delivery time + buffer)
- Payload reconstruction -
id, delivery_at, event
- Signature verification - ed25519.verifyAsync (signature, payload, publicKey)
- Store the webhook ID - store it to prevent replay in the future”