Back to Blog

Webhook Testing: How to Guarantee Delivery, Retry Logic, and Correct Event Ordering

Webhooks are the nervous system of event-driven architectures — but they are also the most likely integration point to fail silently. A webhook that times out, delivers events out of order, or duplicates on retry can corrupt your application's state. This guide covers automated testing of every failure mode.

Published

6 min read

Reading time

Webhook Testing: How to Guarantee Delivery, Retry Logic, and Correct Event Ordering

Webhooks carry critical business events: payment confirmations, subscription activations, user signups, order completions. When they work, they're invisible. When they fail, they fail in the most insidious way possible — your application's state diverges from the provider's state, and nobody notices until a customer complains.

The specific challenges: webhooks are delivered over public HTTP, they can arrive out of order, they will be retried on timeout or error, and they must be secured against spoofing. Every one of these characteristics creates testable failure modes.


Webhook Flow Architecture

sequenceDiagram
    participant Provider as Provider (Paddle/Stripe)
    participant Handler as Your Webhook Handler
    participant DB as Database
    participant Queue as Job Queue

    Provider->>Handler: POST /webhooks/paddle { event: "subscription.created" }
    Handler->>Handler: 1. Verify signature (HMAC)
    Handler->>Handler: 2. Check idempotency key
    Handler->>DB: 3. Store raw event
    Handler->>Queue: 4. Enqueue for processing
    Handler-->>Provider: 200 OK (fast response)
    Queue->>DB: 5. Process: activate user subscription

    Note over Provider,Handler: If 200 not received in 30s,<br/>Provider retries up to 10×

Test Infrastructure: Local Webhook Testing

The biggest challenge in webhook testing is receiving actual HTTP POST requests from external providers. For local and CI testing, use a mock webhook server:

// tests/helpers/webhook-server.ts
import http from 'http';
import { EventEmitter } from 'events';

interface WebhookRequest {
  headers: Record<string, string | string[]>;
  body: unknown;
  timestamp: Date;
}

export class MockWebhookServer extends EventEmitter {
  private server: http.Server;
  private receivedRequests: WebhookRequest[] = [];
  public port: number;

  constructor(port = 0) {
    super();
    this.port = port;

    this.server = http.createServer((req, res) => {
      let body = '';
      req.on('data', (chunk) => (body += chunk.toString()));
      req.on('end', () => {
        const request: WebhookRequest = {
          headers: req.headers as Record<string, string>,
          body: JSON.parse(body),
          timestamp: new Date(),
        };

        this.receivedRequests.push(request);
        this.emit('webhook', request);

        // Respond 200 to acknowledge
        res.writeHead(200);
        res.end('OK');
      });
    });
  }

  async start(): Promise<number> {
    return new Promise((resolve) => {
      this.server.listen(0, () => {
        this.port = (this.server.address() as { port: number }).port;
        resolve(this.port);
      });
    });
  }

  async waitForWebhook(timeoutMs = 5000): Promise<WebhookRequest> {
    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => reject(new Error('Webhook not received')), timeoutMs);
      this.once('webhook', (req) => {
        clearTimeout(timer);
        resolve(req);
      });
    });
  }

  getReceivedRequests = () => this.receivedRequests;

  async stop(): Promise<void> {
    return new Promise((resolve) => this.server.close(() => resolve()));
  }
}

Test 1: Webhook Signature Verification

// tests/webhooks/signature.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { createHmac } from 'crypto';
import { verifyPaddleWebhookSignature } from '../../lib/billing/paddle-webhook';

const WEBHOOK_SECRET = 'test-webhook-secret-123';

function createSignedPayload(body: object, secret: string) {
  const payload = JSON.stringify(body);
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const signedString = `${timestamp}:${payload}`;
  const signature = createHmac('sha256', secret).update(signedString).digest('hex');

  return {
    payload,
    headers: {
      'paddle-signature': `ts=${timestamp};h1=${signature}`,
    },
  };
}

describe('Paddle webhook signature verification', () => {
  it('accepts a valid signature', () => {
    const { payload, headers } = createSignedPayload(
      { event_type: 'subscription.created', data: { id: 'sub_123' } },
      WEBHOOK_SECRET,
    );

    expect(() => verifyPaddleWebhookSignature(payload, headers, WEBHOOK_SECRET)).not.toThrow();
  });

  it('rejects a tampered payload', () => {
    const { headers } = createSignedPayload(
      { event_type: 'subscription.created', data: { id: 'sub_123' } },
      WEBHOOK_SECRET,
    );

    // Tamper with payload after signing
    const tamperedPayload = JSON.stringify({
      event_type: 'subscription.created',
      data: { id: 'sub_ATTACKER_ID' },
    });

    expect(() => verifyPaddleWebhookSignature(tamperedPayload, headers, WEBHOOK_SECRET)).toThrow(/invalid signature/i);
  });

  it('rejects an expired timestamp (replay attack)', () => {
    const body = { event_type: 'subscription.created', data: { id: 'sub_123' } };
    const payload = JSON.stringify(body);

    // Create signature with a timestamp 6 minutes ago (beyond 5-min tolerance)
    const oldTimestamp = Math.floor(Date.now() / 1000) - 360;
    const signedString = `${oldTimestamp}:${payload}`;
    const signature = createHmac('sha256', WEBHOOK_SECRET).update(signedString).digest('hex');

    const headers = { 'paddle-signature': `ts=${oldTimestamp};h1=${signature}` };

    expect(() => verifyPaddleWebhookSignature(payload, headers, WEBHOOK_SECRET)).toThrow(/timestamp too old/i);
  });
});

Test 2: Idempotency (Duplicate Delivery Handling)

Webhook providers retry on any non-2xx response. Your handler must be idempotent:

// tests/webhooks/idempotency.test.ts

test('duplicate webhook delivery does not double-activate subscription', async () => {
  const subscriptionEvent = {
    event_type: 'subscription.created',
    event_id: 'evt_UNIQUE_ID_123', // Provider's idempotency key
    data: {
      subscription_id: 'sub_456',
      customer_email: 'test@example.com',
      plan: 'pro',
    },
  };

  // Deliver the same event twice
  const [response1, response2] = await Promise.all([
    request.post('/webhooks/paddle', {
      data: subscriptionEvent,
      headers: signWebhookPayload(subscriptionEvent),
    }),
    request.post('/webhooks/paddle', {
      data: subscriptionEvent,
      headers: signWebhookPayload(subscriptionEvent),
    }),
  ]);

  // Both should return 200 (idempotent)
  expect(response1.status()).toBe(200);
  expect(response2.status()).toBe(200);

  // But user should only have ONE subscription record
  const subscriptions = await getSubscriptionsForEmail('test@example.com');
  expect(subscriptions).toHaveLength(1);
  expect(subscriptions[0].plan).toBe('pro');
});

Test 3: Event Ordering

Events can arrive out of order. A subscription.updated might arrive before subscription.created if there's network variability:

// tests/webhooks/ordering.test.ts

test('out-of-order events are handled correctly', async ({ request }) => {
  const baseData = { subscription_id: 'sub_789', customer_email: 'order-test@example.com' };

  // Send "updated" before "created" (out of order)
  await request.post('/webhooks/paddle', {
    data: { event_type: 'subscription.updated', event_id: 'evt_2', data: { ...baseData, plan: 'enterprise' } },
    headers: signWebhookPayload({}),
  });

  await request.post('/webhooks/paddle', {
    data: { event_type: 'subscription.created', event_id: 'evt_1', data: { ...baseData, plan: 'pro' } },
    headers: signWebhookPayload({}),
  });

  // The system should resolve to a consistent final state
  // (implementation-dependent: could use event timestamps or sequence numbers)
  const subscription = await getSubscription('sub_789');

  // Should not be undefined or in an inconsistent state
  expect(subscription).not.toBeNull();
  expect(['pro', 'enterprise']).toContain(subscription!.plan);

  // Most importantly: no 500 errors, no data corruption
});

Test 4: Retry Behavior Simulation

// tests/webhooks/retry.test.ts

test('webhook handler is resilient to temporary processing failures', async () => {
  let attemptCount = 0;

  // Mock the downstream service to fail the first 2 times
  server.use(
    http.post('/api/internal/activate-subscription', (req, res, ctx) => {
      attemptCount++;
      if (attemptCount < 3) {
        return res(ctx.status(503), ctx.text('Service temporarily unavailable'));
      }
      return res(ctx.status(200), ctx.json({ activated: true }));
    }),
  );

  // Deliver webhook
  const response = await request.post('/webhooks/paddle', {
    data: subscriptionCreatedEvent,
    headers: signWebhookPayload(subscriptionCreatedEvent),
  });

  // Webhook handler should return 200 quickly (event stored, processing is async)
  expect(response.status()).toBe(200);

  // Wait for retry logic to succeed
  await waitForCondition(() => getSubscriptionStatus('sub_123') === 'active', { timeout: 10_000 });

  expect(attemptCount).toBe(3); // Failed twice, succeeded on third
});

Related articles: Also see API testing best practices that apply to webhook endpoint validation, testing real-time event delivery alongside webhook retry logic, and integrating webhook delivery tests into your CI/CD quality gates.


Webhook Testing Checklist

Test Priority Automate?
Valid signature accepted 🔴 Critical ✅ Yes
Invalid signature rejected 🔴 Critical ✅ Yes
Replay attack rejected (stale timestamp) 🔴 Critical ✅ Yes
Duplicate event idempotency 🔴 Critical ✅ Yes
Out-of-order event handling 🟡 High ✅ Yes
Slow handler timeout behavior 🟡 High ✅ Yes
Empty/malformed payload 🟡 High ✅ Yes
Unknown event type handling 🟡 High ✅ Yes
Provider retry simulation 🟡 High ✅ Yes
Downstream service failure recovery 🟡 High ✅ Yes

Webhooks are deceptively simple — they're just HTTP POST requests — but the failure modes are broad and the consequences of a missed webhook in a billing or auth context can be severe.

Monitor your webhook endpoints and integration health continuously: Try ScanlyApp free and set up automated health checks that verify your integrations are functioning correctly.

Related Posts

API Cost Optimisation: How Engineering Teams Cut Cloud Spend by 40%
DevOps & Infrastructure
7 min read

API Cost Optimisation: How Engineering Teams Cut Cloud Spend by 40%

Cloud API costs are the silent killer of SaaS unit economics. AI APIs in particular can generate unexpected bills when called without budgets, caching, or rate limiting. This guide covers a systematic approach to auditing, controlling, and testing your API cost assumptions before they become business-critical surprises.

Chaos Engineering: Break Your System on Purpose Before Your Users Do It for You
DevOps & Infrastructure
6 min read

Chaos Engineering: Break Your System on Purpose Before Your Users Do It for You

Chaos engineering deliberately breaks things before they break on their own — in a controlled environment, with observability, and with a hypothesis. This guide covers practical chaos experiments for SaaS applications: network latency injection, dependency failure simulation, and building confidence that your system degrades gracefully under real-world failure conditions.