Skip to content

Webhook Delivery Guarantees — Complete Guide to Reliability

DodaTech Updated 2026-06-28 4 min read

In this tutorial, you will learn about Webhook Delivery Guarantees. We cover key concepts, practical examples, and best practices to help you master this topic.

Webhook delivery guarantees define at-least-once, exactly-once, and at-most-once semantics, with retry policies, idempotency keys, and dead-letter queues for reliable event delivery to consumers.

What You'll Learn

  • Delivery semantics: at-least-once vs exactly-once
  • Implementing retry policies with exponential backoff
  • Dead-letter queues for failed deliveries

Why It Matters

Without delivery guarantees, consumers cannot rely on webhook data. Missed events cause data inconsistencies, while duplicate events cause processing errors. Clear guarantees set expectations and build trust.

Real-World Use

Durga Antivirus Pro guarantees at-least-once delivery for threat alerts. Each event is retried up to 5 times with exponential backoff (1m, 5m, 15m, 30m, 1h). After 5 failures, the event moves to a dead-letter queue for manual inspection.

flowchart LR
    E["Event"] --> Q["Delivery Queue"]
    Q --> A["Attempt Delivery"]
    A -->|"Success"| D["Delivered"]
    A -->|"Fail"| R["Retry Queue"]
    R --> A
    R -->|"Max Retries"| DLQ["Dead Letter Queue"]
    style A fill:#dbeafe,stroke:#2563eb

Code Examples

import time
import requests
from datetime import datetime, timedelta

class WebhookDelivery:
    def __init__(self):
        self.max_retries = 5
        self.backoff_times = [60, 300, 900, 1800, 3600]  # seconds

    def deliver_with_retry(self, url, event):
        for attempt in range(self.max_retries):
            try:
                resp = requests.post(
                    url,
                    json=event,
                    headers={'Content-Type': 'application/json'},
                    timeout=10,
                )
                if resp.status_code in (200, 201, 204):
                    return True, attempt + 1
                if resp.status_code in (400, 422):
                    # Client error, no point retrying
                    return False, attempt + 1
            except requests.RequestException:
                pass

            if attempt < self.max_retries - 1:
                wait = self.backoff_times[attempt]
                print(f"Retry {attempt + 1} in {wait}s")
                time.sleep(wait)

        return False, self.max_retries

Expected output: Webhook retried with backoff; returns success status and attempt count.

// Idempotency key for exactly-once delivery
const express = require('express');
const app = express();

const processedEvents = new Set();

app.post('/webhook', express.json(), (req, res) => {
  const idempotencyKey = req.headers['idempotency-key'] || req.body.id;

  if (processedEvents.has(idempotencyKey)) {
    return res.status(200).json({ status: 'already_processed' });
  }

  // Process event
  processedEvents.add(idempotencyKey);
  console.log('Processing event:', req.body.type);

  res.status(200).json({ status: 'processed' });
});

// Clean up old keys periodically
setInterval(() => processedEvents.clear(), 86400000); // 24h

Expected output: Duplicate events with same idempotency key return success without reprocessing.

# Dead letter queue implementation
import json, time
from collections import deque

class DeadLetterQueue:
    def __init__(self):
        self.queue = deque(maxlen=1000)

    def add_failed(self, event, error, attempts):
        record = {
            'event': event,
            'error': str(error),
            'attempts': attempts,
            'timestamp': time.time(),
        }
        self.queue.append(record)
        self.persist_to_disk(record)

    def persist_to_disk(self, record):
        with open('dead_letter_queue.jsonl', 'a') as f:
            f.write(json.dumps(record) + '\n')

    def replay(self, consumer_url):
        successes = 0
        failures = 0
        while self.queue:
            record = self.queue.popleft()
            try:
                resp = requests.post(consumer_url, json=record['event'], timeout=10)
                if resp.ok:
                    successes += 1
                else:
                    failures += 1
            except Exception:
                failures += 1
        return successes, failures

Expected output: Failed events stored in dead letter queue for manual inspection or replay.

Common Mistakes

1. Promising Exactly-Once Without Idempotency

Exactly-once delivery requires consumer-side idempotency. The provider can only guarantee at-least-once at the transport level.

2. Retrying Forever

Without a maximum retry limit, a dead consumer causes infinite retries. Set a reasonable cap.

3. No Dead Letter Queue

Failed events that stop retrying are lost without a DLQ. Always persist undeliverable events.

4. Same Retry Interval

Constant retry interval (every 5s) DDoS the consumer. Use exponential backoff with jitter.

5. Ignoring Consumer 4xx Responses

A 400 response means the consumer cannot Process this event. Do not retry; move to DLQ.

Practice Questions

  1. What is the difference between at-least-once and exactly-once delivery?
  2. Why use exponential backoff for webhook retries?
  3. What is a dead letter queue and why is it needed?
  4. How does an idempotency key prevent duplicate processing?
  5. Why should 4xx responses not be retried?

Answers:

  1. At-least-once guarantees delivery with possible duplicates; exactly-once prevents duplicates via idempotency.
  2. Backoff prevents overwhelming the consumer; each retry waits longer, giving recovery time.
  3. A DLQ stores events that failed after max retries, preserving them for manual inspection and replay.
  4. The consumer stores processed keys and skips duplicates, ensuring exactly-once processing.
  5. 4xx indicates the consumer rejects the event (bad data, invalid format); retrying will not help.

Challenge: Implement a webhook delivery system with: 5 retry attempts with exponential backoff (1min, 5min, 15min, 30min, 1hr), a persistent dead-letter queue, and a replay endpoint for DLQ events.

FAQ

Can Webhooks guarantee exactly-once delivery?

: At the transport level, no. With consumer-side idempotency, you achieve exactly-once processing.

What is the recommended maximum retry count?

: 3-5 retries with exponential backoff over 1-24 hours total window.

How long should idempotency keys be stored?

: 24 hours to 7 days, matching the maximum delivery window.

What happens if the dead letter queue fills up?

: Oldest events are dropped or moved to cold storage. Alert when DLQ exceeds threshold.

Should webhook retries be synchronous or asynchronous?

: Asynchronous. Retry from a background queue, not blocking the event producer.

Mini Project

Build a webhook delivery engine with: configurable retry policy (max retries, backoff schedule), idempotency support via event IDs, dead-letter queue with persistent storage, delivery metrics (success rate, avg retries, DLQ count), and manual replay.

What's Next

Learn about Webhook security for authenticating deliveries, or explore Webhook dead letter queues for handling persistent delivery failures.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro