How to Handle Webhook Retries and Idempotency

JsonHook retries failed deliveries automatically. Learn how to make your webhook handler idempotent so retried deliveries are processed exactly once — no duplicates, no missed emails.

Table of Contents
  1. Overview
  2. Prerequisites
  3. Step-by-Step Instructions
  4. Code Example
  5. Common Pitfalls

Overview

Webhook delivery is a best-effort push operation. Network blips, temporary server errors, and deployment restarts mean your handler may be unavailable when JsonHook first tries to deliver. To ensure no emails are lost, JsonHook retries failed deliveries according to an exponential backoff schedule:

  • Attempt 1: Immediate
  • Attempt 2: 1 minute later
  • Attempt 3: 5 minutes later
  • Attempt 4: 30 minutes later
  • Attempt 5: 2 hours later

After all 5 attempts, the delivery is marked as permanently failed and appears in your delivery log for manual replay.

Idempotency is the property that processing the same event multiple times has the same effect as processing it once. Because retries deliver the same payload more than once, your handler must be idempotent to avoid duplicate records, double charges, or repeated notifications.

Prerequisites

For idempotent retry handling you need:

  • A database or fast key-value store to record which delivery IDs have already been processed
  • The deliveryId field from the JsonHook payload (or the X-JsonHook-Delivery-Id request header)
  • Understanding of which operations in your handler are safe to repeat vs. which must run exactly once

Never Lose an Email Delivery

Automatic retries with exponential backoff. Built in on all plans.

Get Free API Key

Step-by-Step Instructions

Implement idempotent webhook handling in 4 steps:

  1. Extract the delivery ID from the payload or header:
    const deliveryId = payload.deliveryId;
    // or: req.headers["x-jsonhook-delivery-id"]
  2. Check if already processed before doing any work:
    const exists = await redis.get(`processed:${deliveryId}`);
    if (exists) {
      console.log(`Skipping duplicate delivery: ${deliveryId}`);
      return res.sendStatus(200);
    }
  3. Process the email (create DB record, call API, send notification, etc.)
  4. Mark as processed atomically with your business logic using a DB transaction or Redis SET NX:
    await redis.set(`processed:${deliveryId}`, "1", "EX", 86400 * 7); // 7-day TTL

Code Example

Complete idempotent handler using Redis for delivery ID tracking:

import express from "express";
import crypto from "crypto";
import Redis from "ioredis";

const app = express();
const redis = new Redis(process.env.REDIS_URL!);
app.use(express.raw({ type: "application/json" }));

app.post("/webhooks/email", async (req, res) => {
  // 1. Verify signature
  const sig = req.headers["x-jsonhook-signature"] as string;
  const expected = crypto
    .createHmac("sha256", process.env.JSONHOOK_SECRET!)
    .update(req.body).digest("hex");
  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    return res.sendStatus(401);
  }

  const { email, deliveryId } = JSON.parse(req.body.toString());

  // 2. Idempotency check using Redis SET NX (atomic)
  const key = `jh:processed:${deliveryId}`;
  const isNew = await redis.set(key, "1", "EX", 604800, "NX"); // 7-day TTL
  if (!isNew) {
    console.log(`Duplicate delivery ${deliveryId} — skipping`);
    return res.sendStatus(200); // Acknowledge so JsonHook stops retrying
  }

  // 3. Process the email (exactly once)
  try {
    await createLeadFromEmail(email);
  } catch (err) {
    // Remove the idempotency key so this delivery can be retried
    await redis.del(key);
    console.error("Processing failed:", err);
    return res.sendStatus(500); // Signal JsonHook to retry
  }

  // 4. Success
  res.sendStatus(200);
});

app.listen(3000);

Common Pitfalls

Retry and idempotency pitfalls:

  • Setting the idempotency key before processing succeeds. If you mark the delivery as processed and then the processing fails, the retry will skip it. Set the key inside a transaction after confirming success, or use the delete-on-failure pattern shown in the code example above.
  • No TTL on idempotency keys. Without a TTL, your processed delivery set grows forever. Set a TTL longer than JsonHook's retry window (2 hours) but not so long it consumes excessive memory — 7 days is a good default.
  • Returning 500 after successful processing. If you process the email successfully but return a non-2xx code (e.g., a crash after processing), JsonHook will retry. Your idempotency key will prevent double-processing on the retry, but it is still a wasted retry. Return 200 immediately after confirming success.
  • Not differentiating between retriable and non-retriable errors. Return 500 for retriable errors (temporary DB unavailability, downstream API timeout) so JsonHook retries. Return 200 for non-retriable situations (unrecognized email format, validation failure) to acknowledge receipt without triggering retries.
  • Using the message ID instead of the delivery ID for idempotency. The same email message (same Message-ID) can be delivered through different channels. Use deliveryId (unique per JsonHook delivery attempt) not email.messageId (unique per email message).

Frequently Asked Questions

How long does JsonHook retry failed deliveries?

JsonHook retries up to 5 times over approximately 2 hours and 36 minutes total. The retry schedule is: immediate, +1 min, +5 min, +30 min, +2 hours. After all retries are exhausted, the delivery is marked permanently failed and is available for manual replay via the dashboard or API.

What triggers a retry?

Any of the following triggers a retry: a non-2xx HTTP response code (3xx, 4xx, 5xx), a connection timeout (your server did not respond within 10 seconds), or a connection refused error. A 200 response — even with an error in the body — is treated as a successful delivery and is not retried.

Can I disable retries for a specific address?

Yes. When creating or updating a JsonHook address, set "retryEnabled": false to disable automatic retries. Deliveries that fail will immediately go to the failed state in the log without retrying. This is useful for idempotency-critical pipelines where you prefer a clear failure over an automatic retry.

Is Redis required for idempotency, or can I use a database?

Any durable storage works for idempotency key tracking: Redis, PostgreSQL, MySQL, SQLite, DynamoDB, etc. Redis is preferred for its atomic SET NX operation and built-in TTL, which simplify the implementation. With a relational database, use an INSERT with a unique constraint on the delivery ID and handle the unique constraint violation as the duplicate detection signal.