Back to Blog

Webhook Design Patterns: Reliability, Security, and Retry Strategies

Build production-ready webhooks with HMAC signature verification, idempotent processing, retry logic with exponential backoff, and dead letter queues. Includes

Viprasol Tech Team
March 17, 2026
11 min read

Webhook Design Patterns: Reliability, Security, and Retry Strategies

Webhooks seem simple โ€” a third party calls your URL when something happens. But production webhook processing is where naive implementations fall apart. Duplicate deliveries crash idempotent-free handlers. Signature skipping lets attackers forge events. Missing retry logic means lost data when your server is briefly down.

This guide covers the patterns that make webhooks reliable enough for financial transactions, subscription billing, and payment processing โ€” the places where dropped events have real business consequences.


Webhooks vs Polling: When Each Makes Sense

ApproachLatencyServer LoadComplexityUse When
PollingHigh (interval-bound)High (wasted requests)LowNo webhook support from provider
Long pollingLowMediumMediumProvider supports it
WebhooksNear-real-timeLow (event-driven)MediumProvider pushes events
WebSocketsReal-timeMediumHighBidirectional, high-frequency
Server-Sent EventsReal-timeLowLowOne-way, browser-to-server not needed

For third-party integrations (Stripe, GitHub, Shopify, Twilio), webhooks are almost always the right choice. For internal service communication, consider a message queue (SQS, Kafka) instead โ€” you get replay, backpressure, and better observability.


Pattern 1: Signature Verification (HMAC-SHA256)

Every production webhook must verify the signature before processing. Without this, any attacker who knows your endpoint URL can forge events โ€” including "payment succeeded" events that trigger fulfillment.

Stripe-style HMAC verification in Node.js:

// webhooks/verifySignature.ts
import crypto from 'crypto';

interface WebhookVerificationResult {
  valid: boolean;
  timestamp?: number;
  error?: string;
}

export function verifyStripeSignature(
  rawBody: Buffer,
  signatureHeader: string,
  webhookSecret: string,
  toleranceSeconds = 300  // Reject events older than 5 minutes
): WebhookVerificationResult {
  // Signature format: t=timestamp,v1=signature1,v1=signature2
  const parts = signatureHeader.split(',');
  const timestampPart = parts.find(p => p.startsWith('t='));
  const v1Signatures = parts
    .filter(p => p.startsWith('v1='))
    .map(p => p.slice(3));

  if (!timestampPart || v1Signatures.length === 0) {
    return { valid: false, error: 'Malformed signature header' };
  }

  const timestamp = parseInt(timestampPart.slice(2), 10);
  const age = Math.floor(Date.now() / 1000) - timestamp;

  if (age > toleranceSeconds) {
    return { valid: false, error: `Event too old: ${age}s` };
  }

  // Compute expected signature
  const signedPayload = `${timestamp}.${rawBody.toString('utf8')}`;
  const expected = crypto
    .createHmac('sha256', webhookSecret)
    .update(signedPayload, 'utf8')
    .digest('hex');

  // Constant-time comparison to prevent timing attacks
  const isValid = v1Signatures.some(sig => {
    try {
      return crypto.timingSafeEqual(
        Buffer.from(sig, 'hex'),
        Buffer.from(expected, 'hex')
      );
    } catch {
      return false;
    }
  });

  return isValid
    ? { valid: true, timestamp }
    : { valid: false, error: 'Signature mismatch' };
}

Critical: You must read the raw body before any JSON parsing. Express's json() middleware destroys the raw body. Use this instead:

// app.ts
app.post('/webhooks/stripe', 
  express.raw({ type: 'application/json' }),  // Raw body preserved
  async (req, res) => {
    const result = verifyStripeSignature(
      req.body,                                // Buffer, not parsed object
      req.headers['stripe-signature'] as string,
      process.env.STRIPE_WEBHOOK_SECRET!
    );

    if (!result.valid) {
      console.warn('Webhook signature invalid:', result.error);
      return res.status(400).json({ error: result.error });
    }

    const event = JSON.parse(req.body.toString());
    await processWebhookEvent(event);
    res.json({ received: true });
  }
);

๐ŸŒ Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different โ€” senior engineers only, direct Slack access, and a 5.0โ˜… Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript โ€” production-grade stack
  • Fixed-price contracts โ€” no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

Pattern 2: Idempotent Event Processing

Webhook providers guarantee at-least-once delivery โ€” not exactly-once. Stripe may deliver the same event twice if your server returned a 500 or timed out. Your handler must be safe to call multiple times with the same event.

// webhooks/idempotency.ts
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

export async function processOnce<T>(
  eventId: string,
  handler: () => Promise<T>
): Promise<{ result: T | null; duplicate: boolean }> {
  // Try to claim the event ID
  try {
    await prisma.processedWebhookEvent.create({
      data: {
        eventId,
        processedAt: new Date(),
      },
    });
  } catch (err: any) {
    // Unique constraint violation = already processed
    if (err.code === 'P2002') {
      console.info(`Duplicate webhook event ${eventId} โ€” skipping`);
      return { result: null, duplicate: true };
    }
    throw err;
  }

  // First time seeing this event โ€” process it
  try {
    const result = await handler();
    return { result, duplicate: false };
  } catch (err) {
    // Remove the lock so it can be retried
    await prisma.processedWebhookEvent.delete({ where: { eventId } });
    throw err;
  }
}

// Usage
async function handlePaymentSucceeded(event: Stripe.Event) {
  await processOnce(event.id, async () => {
    const session = event.data.object as Stripe.PaymentIntent;
    await prisma.order.update({
      where: { stripePaymentIntentId: session.id },
      data: { status: 'PAID', paidAt: new Date() },
    });
    await sendOrderConfirmationEmail(session.metadata.orderId);
  });
}

The Prisma schema for the idempotency table:

model ProcessedWebhookEvent {
  eventId     String   @id
  processedAt DateTime
  
  @@index([processedAt])  // For cleanup jobs
}

Clean up old records with a nightly job: DELETE FROM processed_webhook_events WHERE processed_at < NOW() - INTERVAL '30 days'.


Pattern 3: Async Processing with a Queue

Never do heavy work synchronously in a webhook handler. Webhook providers have short timeout windows (Stripe: 30s, GitHub: 10s). If your handler takes too long, the provider retries โ€” triggering duplicate processing.

The correct pattern:

Webhook arrives โ†’ validate signature โ†’ enqueue job โ†’ respond 200
                                          โ†“
                                    Worker processes job asynchronously

With BullMQ (Redis-backed):

// webhooks/queue.ts
import { Queue, Worker } from 'bullmq';
import Redis from 'ioredis';

const connection = new Redis(process.env.REDIS_URL!);

export const webhookQueue = new Queue('webhooks', {
  connection,
  defaultJobOptions: {
    attempts: 5,
    backoff: {
      type: 'exponential',
      delay: 1000,  // 1s, 2s, 4s, 8s, 16s
    },
    removeOnComplete: { age: 86400 },   // Keep 24h of completed jobs
    removeOnFail: { count: 100 },        // Keep last 100 failed jobs
  },
});

// Webhook handler โ€” just enqueue
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
  const result = verifyStripeSignature(req.body, req.headers['stripe-signature'] as string, process.env.STRIPE_WEBHOOK_SECRET!);
  if (!result.valid) return res.status(400).json({ error: result.error });

  await webhookQueue.add('stripe-event', {
    eventId: req.headers['stripe-signature']?.split(',')[0],
    payload: JSON.parse(req.body.toString()),
  });

  res.json({ received: true });  // Respond immediately
});

// Worker โ€” process asynchronously
const worker = new Worker('webhooks', async (job) => {
  const { eventId, payload } = job.data;

  switch (payload.type) {
    case 'payment_intent.succeeded':
      await handlePaymentSucceeded(payload);
      break;
    case 'customer.subscription.deleted':
      await handleSubscriptionCancelled(payload);
      break;
    case 'invoice.payment_failed':
      await handlePaymentFailed(payload);
      break;
    default:
      console.info(`Unhandled event type: ${payload.type}`);
  }
}, { connection });

worker.on('failed', (job, err) => {
  console.error(`Webhook job ${job?.id} failed after ${job?.attemptsMade} attempts:`, err);
});

๐Ÿš€ Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4โ€“8 weeks, full platforms in 3โ€“5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

Pattern 4: Sending Webhooks (Outbound)

If your platform sends webhooks to customers, you need the same reliability guarantees on the sending side.

// webhooks/sender.ts
interface WebhookDelivery {
  id: string;
  endpoint: string;
  secret: string;
  payload: object;
  attemptNumber: number;
}

async function deliverWebhook(delivery: WebhookDelivery): Promise<boolean> {
  const timestamp = Math.floor(Date.now() / 1000);
  const body = JSON.stringify(delivery.payload);
  const signature = crypto
    .createHmac('sha256', delivery.secret)
    .update(`${timestamp}.${body}`)
    .digest('hex');

  try {
    const res = await fetch(delivery.endpoint, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Webhook-Timestamp': timestamp.toString(),
        'X-Webhook-Signature': `v1=${signature}`,
        'X-Webhook-Delivery-Id': delivery.id,
        'User-Agent': 'YourApp-Webhook/1.0',
      },
      body,
      signal: AbortSignal.timeout(10_000),  // 10s timeout
    });

    await logDeliveryAttempt(delivery.id, res.status, delivery.attemptNumber);
    return res.status >= 200 && res.status < 300;
  } catch (err) {
    await logDeliveryAttempt(delivery.id, 0, delivery.attemptNumber, String(err));
    return false;
  }
}

// Retry schedule: immediately, +1m, +5m, +30m, +2h, +8h, +24h
const RETRY_DELAYS_MS = [0, 60_000, 300_000, 1_800_000, 7_200_000, 28_800_000, 86_400_000];

Expose a delivery log in your dashboard so customers can see webhook history, retry manually, and debug failures. This alone cuts support tickets significantly.


Security Checklist

CheckWhy It Matters
โœ… HMAC-SHA256 signature verificationPrevents forged events
โœ… Timestamp tolerance (โ‰ค5 min)Prevents replay attacks
โœ… Constant-time comparisonPrevents timing oracle attacks
โœ… Idempotency by event IDPrevents double-processing
โœ… Raw body for signaturePrevents signature bypass via re-serialization
โœ… Respond 200 before processingPrevents provider retry storms
โœ… Rotate secrets after exposureLimits breach window
โœ… Allowlist provider IPs (optional)Defense in depth (Stripe publishes IPs)

Implementation Cost

ScopeEffortCost Estimate
Basic webhook receiver (signature + idempotency)8โ€“16 hours$1,200โ€“2,400
Full async processing with BullMQ16โ€“24 hours$2,400โ€“3,600
Outbound webhook system with delivery logs24โ€“40 hours$3,600โ€“6,000
Full platform (receive + send + dashboard)60โ€“100 hours$9,000โ€“15,000

Senior backend developer rates: $120โ€“150/hr (US). Offshore: $40โ€“70/hr.


Working With Viprasol

We've built webhook infrastructure for payment platforms, e-commerce integrations, and SaaS products handling millions of events per month. Our implementations include signature verification, idempotency, async queuing, retry logic, and delivery dashboards โ€” built to handle the edge cases that production traffic exposes.

โ†’ Discuss your integration requirements with our backend team.


See Also

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

Need a Modern Web Application?

From landing pages to complex SaaS platforms โ€” we build it all with Next.js and React.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse โ‰ฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ€” from architecture through deployment.