logo

How to Set Up Webhook Retries with Exponential Backoff

Webhooks fail. Networks timeout, servers restart, and endpoints go briefly offline. Without retries, each failure means lost data. This guide shows you how to configure reliable delivery with AsyncQueue.

Why Retries Matter

A single webhook delivery has roughly a 95-99% success rate. That sounds high until you send thousands per day — at 99%, you lose 10 out of every 1,000 deliveries.

Adding retries with exponential backoff pushes your effective success rate to near-100% for transient failures.

Step 1: Define your webhook endpoint

Create an endpoint that processes incoming webhooks and returns a 2xx status code:

app.post('/webhooks/payment-complete', async (req, res) => {
  const { orderId, amount, status } = req.body;

  await updateOrderStatus(orderId, status);

  res.status(200).json({ received: true });
});

AsyncQueue treats any non-2xx response as a failure and triggers a reattempt.

Step 2: Create a task with retry configuration

const task = await aq.tasks.create({
  callbackUrl: 'https://payment-provider.com/charge',
  payload: { orderId: 'order_123', amount: 4999 },
  webhookUrl: 'https://your-app.com/webhooks/payment-complete',
  retries: 5,
  backoff: 'exponential',
});

Step 3: Configure backoff parameters

Fine-tune the retry timing:

const task = await aq.tasks.create({
  callbackUrl: 'https://payment-provider.com/charge',
  payload: { orderId: 'order_123', amount: 4999 },
  webhookUrl: 'https://your-app.com/webhooks/payment-complete',
  retries: 5,
  backoff: 'exponential',
  backoffDelay: 1000,      // Start with 1 second
  backoffMultiplier: 2,    // Double each time
  maxBackoffDelay: 60000,  // Cap at 60 seconds
});

This produces retries at: 1s, 2s, 4s, 8s, 16s (capped at 60s).

Step 4: Handle idempotent processing

Retries mean your endpoint might receive the same payload twice. Your handler must be idempotent — deduplicate using the task ID:

app.post('/webhooks/payment-complete', async (req, res) => {
  const { taskId, orderId, status } = req.body;

  // Check if already processed
  const existing = await getProcessedTask(taskId);
  if (existing) {
    return res.status(200).json({ received: true, duplicate: true });
  }

  await updateOrderStatus(orderId, status);
  await markTaskProcessed(taskId);

  res.status(200).json({ received: true });
});

Step 5: Monitor retries in the dashboard

The AsyncQueue dashboard shows:

  • Retry count for each task
  • Failure reason and HTTP status code for each attempt
  • Timeline of all delivery attempts with timestamps
  • Dead-letter queue for tasks that exhausted all retries