SaaS Webhook Signatures: HMAC Verification, Replay Attack Prevention, and Delivery Retry
Build production-grade webhook infrastructure for SaaS. Covers HMAC-SHA256 signature generation and verification, timestamp-based replay attack prevention, webhook delivery retry with exponential backoff, dead-letter queue, and TypeScript implementation.
Webhooks without signatures are unauthenticated HTTP calls โ any party that knows your endpoint URL can forge events. HMAC-SHA256 signatures let your customers verify that a webhook came from your platform and hasn't been tampered with. Timestamp validation closes the replay attack window: even if a valid signed request is captured, it can't be replayed 10 minutes later.
Signature Generation
// lib/webhooks/signing.ts
import { createHmac, timingSafeEqual } from "crypto";
const REPLAY_WINDOW_SECONDS = 300; // 5 minutes
export function generateWebhookSignature(
payload: string, // JSON-serialized event body
secret: string, // Per-endpoint signing secret
timestamp: number = Math.floor(Date.now() / 1000)
): { signature: string; timestamp: number } {
// Signed string format: "timestamp.payload"
// Same format used by Stripe โ easy for customers to implement
const signedString = `${timestamp}.${payload}`;
const signature = createHmac("sha256", secret)
.update(signedString)
.digest("hex");
return { signature: `v1=${signature}`, timestamp };
}
export function verifyWebhookSignature(params: {
payload: string;
signature: string; // Value of X-Webhook-Signature header
timestamp: string; // Value of X-Webhook-Timestamp header
secret: string;
}): { valid: boolean; reason?: string } {
const { payload, signature, timestamp, secret } = params;
// 1. Check timestamp is present and numeric
const ts = parseInt(timestamp, 10);
if (isNaN(ts)) {
return { valid: false, reason: "Invalid timestamp" };
}
// 2. Check replay window
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - ts) > REPLAY_WINDOW_SECONDS) {
return { valid: false, reason: `Timestamp outside replay window (ยฑ${REPLAY_WINDOW_SECONDS}s)` };
}
// 3. Recompute expected signature
const signedString = `${ts}.${payload}`;
const expectedSig = `v1=${createHmac("sha256", secret).update(signedString).digest("hex")}`;
// 4. Constant-time comparison (prevents timing attacks)
const receivedBuf = Buffer.from(signature);
const expectedBuf = Buffer.from(expectedSig);
if (receivedBuf.length !== expectedBuf.length) {
return { valid: false, reason: "Signature mismatch" };
}
const match = timingSafeEqual(receivedBuf, expectedBuf);
return match ? { valid: true } : { valid: false, reason: "Signature mismatch" };
}
Database Schema
-- Webhook endpoints configured by customers
CREATE TABLE webhook_endpoints (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
url TEXT NOT NULL,
secret TEXT NOT NULL, -- Store encrypted, not plaintext
event_types TEXT[] NOT NULL DEFAULT '{}', -- Empty = all events
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Delivery log: one row per delivery attempt
CREATE TABLE webhook_deliveries (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
endpoint_id UUID NOT NULL REFERENCES webhook_endpoints(id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'delivered', 'failed', 'dead')),
attempt_count INTEGER NOT NULL DEFAULT 0,
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_error TEXT,
response_status INTEGER,
response_body TEXT,
delivered_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX ON webhook_deliveries (status, next_attempt_at)
WHERE status IN ('pending', 'failed');
CREATE INDEX ON webhook_deliveries (endpoint_id, created_at DESC);
๐ SaaS MVP in 8 Weeks โ Seriously
We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment โ all handled by one senior team.
- Week 1โ2: Architecture design + wireframes
- Week 3โ6: Core features built + tested
- Week 7โ8: Launch-ready on AWS/Vercel with CI/CD
- Post-launch: Maintenance plans from month 3
Delivery Worker with Exponential Backoff
// workers/webhook-delivery.ts
import { prisma } from "@/lib/prisma";
import { generateWebhookSignature } from "@/lib/webhooks/signing";
import { decrypt } from "@/lib/crypto/encrypt"; // AES-256-GCM decryption
const MAX_ATTEMPTS = 7;
const BACKOFF_DELAYS = [10, 30, 60, 300, 1800, 7200, 86400]; // seconds
function nextAttemptDelay(attempt: number): number {
return (BACKOFF_DELAYS[attempt] ?? BACKOFF_DELAYS[BACKOFF_DELAYS.length - 1]) * 1000;
}
export async function processWebhookDeliveries(): Promise<void> {
// Claim up to 10 pending deliveries using FOR UPDATE SKIP LOCKED
const deliveries = await prisma.$queryRaw<{ id: string; endpoint_id: string }[]>`
SELECT id, endpoint_id
FROM webhook_deliveries
WHERE status IN ('pending', 'failed')
AND next_attempt_at <= NOW()
ORDER BY next_attempt_at ASC
LIMIT 10
FOR UPDATE SKIP LOCKED
`;
await Promise.all(deliveries.map(attemptDelivery));
}
async function attemptDelivery(row: { id: string; endpoint_id: string }): Promise<void> {
const delivery = await prisma.webhookDelivery.findUniqueOrThrow({
where: { id: row.id },
include: { endpoint: true },
});
if (!delivery.endpoint.isActive) {
await prisma.webhookDelivery.update({
where: { id: row.id },
data: { status: "dead", lastError: "Endpoint deactivated" },
});
return;
}
const payloadString = JSON.stringify(delivery.payload);
const secret = await decrypt(delivery.endpoint.secret);
const { signature, timestamp } = generateWebhookSignature(payloadString, secret);
let responseStatus: number | null = null;
let responseBody: string | null = null;
let lastError: string | null = null;
let success = false;
try {
const response = await fetch(delivery.endpoint.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Webhook-Signature": signature,
"X-Webhook-Timestamp": String(timestamp),
"X-Webhook-Event": delivery.eventType,
"X-Webhook-Delivery": delivery.id,
},
body: payloadString,
signal: AbortSignal.timeout(10_000), // 10s timeout
});
responseStatus = response.status;
responseBody = await response.text().catch(() => null);
success = response.ok; // 2xx = success
if (!success) {
lastError = `HTTP ${response.status}: ${responseBody?.slice(0, 200)}`;
}
} catch (err) {
lastError = err instanceof Error ? err.message : "Unknown error";
}
const newAttemptCount = delivery.attemptCount + 1;
if (success) {
await prisma.webhookDelivery.update({
where: { id: delivery.id },
data: {
status: "delivered",
attemptCount: newAttemptCount,
responseStatus,
responseBody,
deliveredAt: new Date(),
},
});
} else if (newAttemptCount >= MAX_ATTEMPTS) {
// Move to dead letter
await prisma.webhookDelivery.update({
where: { id: delivery.id },
data: {
status: "dead",
attemptCount: newAttemptCount,
responseStatus,
lastError,
},
});
} else {
// Schedule next retry with exponential backoff
const nextAt = new Date(Date.now() + nextAttemptDelay(newAttemptCount));
await prisma.webhookDelivery.update({
where: { id: delivery.id },
data: {
status: "failed",
attemptCount: newAttemptCount,
nextAttemptAt: nextAt,
responseStatus,
lastError,
},
});
}
}
Dispatching a Webhook Event
// lib/webhooks/dispatch.ts
import { prisma } from "@/lib/prisma";
export async function dispatchWebhookEvent(
workspaceId: string,
eventType: string,
payload: Record<string, unknown>
): Promise<void> {
// Find all active endpoints subscribed to this event type
const endpoints = await prisma.webhookEndpoint.findMany({
where: {
workspaceId,
isActive: true,
OR: [
{ eventTypes: { isEmpty: true } }, // Empty = all events
{ eventTypes: { has: eventType } }, // Subscribed to this type
],
},
select: { id: true },
});
if (endpoints.length === 0) return;
// Create one delivery record per endpoint
await prisma.webhookDelivery.createMany({
data: endpoints.map((ep) => ({
endpointId: ep.id,
eventType,
payload: { event: eventType, data: payload, sentAt: new Date().toISOString() },
nextAttemptAt: new Date(), // Deliver immediately
})),
});
}
๐ก The Difference Between a SaaS Demo and a SaaS Business
Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments โ with architecture that does not need to be rewritten at 1,000 users.
- Multi-tenant PostgreSQL with row-level security
- Stripe subscriptions, usage billing, annual plans
- SOC2-ready infrastructure from day one
- We own zero equity โ you own everything
Backoff Schedule Reference
| Attempt | Delay | Cumulative |
|---|---|---|
| 1 (initial) | Immediate | 0s |
| 2 | 10s | 10s |
| 3 | 30s | 40s |
| 4 | 1 min | ~1.7 min |
| 5 | 5 min | ~7 min |
| 6 | 30 min | ~37 min |
| 7 | 2 hrs | ~2.6 hrs |
| Dead letter | 24 hrs | ~26 hrs |
Cost and Timeline Estimates
| Scope | Team | Timeline | Cost Range |
|---|---|---|---|
| HMAC signing + verification | 1 dev | Half a day | $150โ300 |
| Delivery worker + retry + dead letter | 1 dev | 2โ3 days | $800โ1,500 |
| Endpoint management UI + dispatch | 1 dev | 2 days | $600โ1,200 |
See Also
- SaaS API Analytics
- SaaS Background Jobs
- PostgreSQL FOR UPDATE SKIP LOCKED
- AWS SQS Message Processing
- SaaS Customer Health Score
Working With Viprasol
Webhook security requires three things: HMAC-SHA256 signatures (not MD5, not SHA1), constant-time comparison (prevents timing side-channel attacks), and replay window validation (5 minutes is standard โ Stripe uses the same). Our team builds the full stack: signing library, delivery worker with FOR UPDATE SKIP LOCKED, 7-attempt exponential backoff, dead-letter on exhaustion, and endpoint management API.
What we deliver:
generateWebhookSignature:v1=${hmac-sha256(timestamp.payload)}formatverifyWebhookSignature: parseInt timestamp, ยฑ300s window check,timingSafeEqualcomparisonwebhook_deliveries: status pending/delivered/failed/dead, attempt_count, next_attempt_at, partial index on pending+failedprocessWebhookDeliveries:FOR UPDATE SKIP LOCKEDLIMIT 10,Promise.all(deliveries.map(attemptDelivery))attemptDelivery: AES decrypt secret, generateSignature, fetch with 10s AbortSignal.timeout, HTTP 2xx = successBACKOFF_DELAYS: [10,30,60,300,1800,7200,86400] seconds;deadstatus at attempt 7dispatchWebhookEvent: findMany active endpoints, OR isEmpty/has eventType, createMany delivery records
Talk to our team about your webhook infrastructure โ
Or explore our SaaS development services.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Building a SaaS Product?
We've helped launch 50+ SaaS platforms. Let's build yours โ fast.
Free consultation โข No commitment โข Response within 24 hours
Add AI automation to your SaaS product?
Viprasol builds custom AI agent crews that plug into any SaaS workflow โ automating repetitive tasks, qualifying leads, and responding across every channel your customers use.