Back to Blog

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.

Viprasol Tech Team
June 15, 2027
12 min read

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

Cost and Timeline Estimates

ScopeTeamTimelineCost Range
HMAC signing + verification1 devHalf a day$150โ€“300
Delivery worker + retry + dead letter1 dev2โ€“3 days$800โ€“1,500
Endpoint management UI + dispatch1 dev2 days$600โ€“1,200

See Also


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)} 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.

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 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.

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.