Back to Blog

SaaS Webhook Signatures: HMAC Verification, Replay Attack Prevention

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.

Viprasol Tech Team
12 min read
Updated 2027

Quick answer. Unsigned webhooks are unauthenticated HTTP calls anyone can forge. Sign each payload with HMAC-SHA256 over a "timestamp.payload" string (the Stripe format) using a per-endpoint secret, and verify with timingSafeEqual. A 300-second timestamp window blocks replay attacks, rejecting captured requests replayed minutes later. 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

AttemptDelayCumulative
1 (initial)Immediate0s
210s10s
330s40s
41 min~1.7 min
55 min~7 min
630 min~37 min
72 hrs~2.6 hrs
Dead letter24 hrs~26 hrs

The Numbers

ScopeTeamTimelineCost Range
HMAC signing + verification1 devHalf a day150ドル–300
Delivery worker + retry + dead letter1 dev2–3 days800ドル–1,500
Endpoint management UI + dispatch1 dev2 days600ドル–1,200

Additional Resources


Partnering 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)} format
  • verifyWebhookSignature: parseInt timestamp, Β±300s window check, timingSafeEqual comparison
  • webhook_deliveries: status pending/delivered/failed/dead, attempt_count, next_attempt_at, partial index on pending+failed
  • processWebhookDeliveries: FOR UPDATE SKIP LOCKED LIMIT 10, Promise.all(deliveries.map(attemptDelivery))
  • attemptDelivery: AES decrypt secret, generateSignature, fetch with 10s AbortSignal.timeout, HTTP 2xx = success
  • BACKOFF_DELAYS: [10,30,60,300,1800,7200,86400] seconds; dead status at attempt 7
  • dispatchWebhookEvent: findMany active endpoints, OR isEmpty/has eventType, createMany delivery records

Talk to our team about your webhook infrastructure β†’

Or explore our SaaS development services.

SaaSWebhooksSecurityTypeScriptPostgreSQLHMACRetryQueue
Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 1000+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Building a SaaS Product?

We've helped launch 50+ SaaS platforms. Let's build yours β€” fast.

Free consultation β€’ No commitment β€’ Response within 24 hours

Viprasol Β· AI Agent Systems

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.

AltStyle γ«γ‚ˆγ£γ¦ε€‰ζ›γ•γ‚ŒγŸγƒšγƒΌγ‚Έ (->γ‚ͺγƒͺγ‚ΈγƒŠγƒ«) /