Errors, rate limits & signatures
What the API returns when things go wrong, how to stay within limits, and how to verify webhook authenticity.
Error envelope
Every error returns a structured, machine-readable object. The error.code is stable and documented below; each error also carries a doc_url that deep-links to its entry on this page, plus a request_id (also in the X-Request-Id header) to quote when contacting support.
{
"error": {
"type": "rate_limit_error",
"code": "quota_exceeded",
"message": "Monthly check quota exceeded. Upgrade your plan or wait for the next billing period.",
"doc_url": "https://leakjar.com/docs/errors#quota_exceeded",
"resetAt": "2026-07-01T00:00:00.000Z",
"upgradeUrl": "https://leakjar.com/pricing/password-protect?ref=429",
"usage": { "used": 10000, "limit": 10000 }
},
"request_id": "req_8f3c1a9b2d..."
}Error codes
| code | HTTP | type | Meaning / action |
|---|---|---|---|
| malformed_request | 400 | invalid_request_error | Body isn't valid JSON or required fields are missing. Fix the request; do not retry as-is. |
| invalid_prefix | 400 | invalid_request_error | Range prefix isn't 5 uppercase hex chars. Send the first 5 hex chars of the SHA-1, uppercased. |
| batch_too_large | 400 | invalid_request_error | More than 1000 emails in one /check call. Split into batches of ≤1000. |
| missing_bearer | 401 | authentication_error | Authorization header missing or malformed. Send `Authorization: Bearer lj_…`. |
| invalid_api_key | 401 | authentication_error | Key is invalid, expired, or revoked. Check the key in the dashboard; rotate if needed. |
| missing_scope | 403 | permission_error | Key lacks the required scope. Issue a key with the `bpd` scope. |
| quota_exceeded | 429 | rate_limit_error | Monthly check quota exceeded. Honor Retry-After; upgrade the plan. |
| upstream_error | 502 | api_error | Breach datastore lookup failed. Retry with exponential backoff + jitter. |
| not_configured | 500 | api_error | Backend datastore not configured. Transient; contact support if it persists. |
Rate-limit headers
Every API response (not just 429s) carries usage headers so you can self-throttle before hitting a limit:
RateLimit-Limit— your monthly quota.RateLimit-Remaining— checks left this period.RateLimit-Reset— unix seconds when the window resets.Retry-After— seconds to wait (on429only).X-LeakJar-Tier— your current plan tier.X-Request-Id— trace id for support (also in error bodies).
Retry strategy
Retry 5xx and 429 (after Retry-After); never retry 4xx other than 429. Use exponential backoff with jitter.
async function withRetry(fn, max = 4) {
for (let attempt = 0; ; attempt++) {
const res = await fn();
if (res.ok) return res;
const retryable = res.status === 429 || res.status >= 500;
if (!retryable || attempt >= max) return res;
const retryAfter = Number(res.headers.get("Retry-After")) || 0;
const backoff = retryAfter * 1000 || 2 ** attempt * 250 + Math.random() * 250;
await new Promise((r) => setTimeout(r, backoff));
}
}Verifying webhook signatures
Each webhook POST carries an X-LeakJar-Signature header of the form t=<unix>,v1=<hex>. The signature is HMAC-SHA256(secret, "<t>.<rawBody>"). Reject requests whose timestamp is more than 5 minutes old (replay protection) or whose signature doesn’t match.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(",").map((p) => p.trim().split("=")),
);
const t = parts.t, v1 = parts.v1;
if (!t || !v1) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // replay
const expected = createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
return timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}See Webhooks for the event catalog and payload shapes.