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
deliveryIdfield from the JsonHook payload (or theX-JsonHook-Delivery-Idrequest 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 KeyStep-by-Step Instructions
Implement idempotent webhook handling in 4 steps:
- Extract the delivery ID from the payload or header:
const deliveryId = payload.deliveryId; // or: req.headers["x-jsonhook-delivery-id"] - 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); } - Process the email (create DB record, call API, send notification, etc.)
- 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) notemail.messageId(unique per email message).