Error Handling & Webhook Security
How to handle failures gracefully, and how to verify that a webhook delivery genuinely came from this API.
What's safe to retry
| error.code | Retry? | Strategy |
|---|---|---|
| RATE_LIMIT_EXCEEDED (429) | Yes | Exponential backoff — you're sending faster than your plan allows |
| INTERNAL_ERROR (500) / SERVICE_UNAVAILABLE (503) | Yes | Exponential backoff with a small number of attempts, then fall back (see Checking at Signup Without Blocking the Flow for what to do when a check can't complete) |
| INVALID_REQUEST / INVALID_EMAIL (400) | No | The request itself is malformed — fix the payload, retrying won't help |
| AUTHENTICATION_REQUIRED / INVALID_API_KEY / API_KEY_REVOKED (401) | No | Fix the credential — retrying with the same key will fail the same way |
| QUOTA_EXCEEDED (402) | No, not immediately | Your monthly allowance is used up — decide whether to fail open, fail closed, or upgrade, not retry in a loop |
A minimal retry-with-backoff helper
async function checkEmailWithRetry(email: string, attempt = 0): Promise<CheckResult> {
const res = await fetch("https://api.emailriskradar.com/v1/email/check", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.EMAIL_RISK_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (res.ok) return res.json();
const { error } = await res.json();
const retryable = error.code === "RATE_LIMIT_EXCEEDED" || error.code === "INTERNAL_ERROR" || error.code === "SERVICE_UNAVAILABLE";
if (retryable && attempt < 3) {
await new Promise((r) => setTimeout(r, 2 ** attempt * 250)); // 250ms, 500ms, 1000ms
return checkEmailWithRetry(email, attempt + 1);
}
throw new Error(`Email check failed: ${error.code} - ${error.message}`);
}Verifying webhook signatures
Every webhook delivery includes an X-Webhook-Signature header — an HMAC-SHA256 hex digest of the raw request body, signed with the secret shown once when the endpoint was created. Verify it before trusting the payload, using a constant-time comparison:
import crypto from "node:crypto";
function isValidSignature(rawBody: string, signature: string, secret: string): boolean {
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
// Lengths must match before timingSafeEqual - it throws on mismatched buffer lengths.
if (signature.length !== expected.length) return false;
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
app.post("/webhooks/email-risk", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.header("X-Webhook-Signature") ?? "";
const rawBody = req.body.toString("utf8"); // must be the raw, unparsed body - not JSON.stringify(req.body)
if (!isValidSignature(rawBody, signature, process.env.WEBHOOK_SECRET!)) {
return res.status(401).send("Invalid signature");
}
const event = JSON.parse(rawBody);
// ... handle event.event === "bulk.completed" / "bulk.failed"
res.status(200).send("ok");
});The signature is computed over the raw request body, not the parsed-and-re-serialized JSON — those two are not guaranteed to produce identical bytes (key ordering, whitespace), so verifying against a re-serialized body can fail even for a genuine delivery. Make sure your framework gives you the raw body before any JSON-parsing middleware touches it.
Handle duplicate webhook deliveries
Treat webhook handlers as idempotent — a delivery can be retried by the sender, so the same job_id and event may arrive more than once. Key any side effects (updating a record, sending a notification) off job_id so a replay is a safe no-op rather than double-processing.