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.codeRetry?Strategy
RATE_LIMIT_EXCEEDED (429)YesExponential backoff — you're sending faster than your plan allows
INTERNAL_ERROR (500) / SERVICE_UNAVAILABLE (503)YesExponential 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)NoThe request itself is malformed — fix the payload, retrying won't help
AUTHENTICATION_REQUIRED / INVALID_API_KEY / API_KEY_REVOKED (401)NoFix the credential — retrying with the same key will fail the same way
QUOTA_EXCEEDED (402)No, not immediatelyYour 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

with-retry.ts
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:

verify-webhook.ts
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.

We use Google Analytics to understand site traffic, and only load it if you accept — nothing runs before you choose. Signing in still stores a strictly necessary session token regardless. See the Privacy Policy for details.