API9 min read

Auth, rate limits and error handling — the full contract

The unsexy part of every API integration. Getting auth, rate-limit headers and error semantics right is the difference between a reliable integration and one that pages you at 2am.

If you read one document before writing the integration, make it this one. What follows is the complete contract for auth, rate limits and error responses on the Inbox Check API — the parts that determine whether your integration is reliable or fragile.

Why this matters

Most integration bugs aren't in the happy path. They're in the 429 you didn't retry, the webhook you replayed, the key you rotated without grace period. Read this once; save yourself the postmortem.

Authentication model

Bearer tokens. No OAuth, no sessions, no signed requests. One header on every call:

Authorization: Bearer ic_live_4fN2q8s9GxW3bD7hJkL0mNpR

Key anatomy:

  • ic_ — product prefix, always.
  • live_ or test_ — environment.
  • 24 alphanumeric characters — 128 bits of entropy.

The first 11 characters (ic_live_4fN) are the public prefix, safe to log. The remainder is the secret and must never leave a secret store. On key creation we show the full secret exactly once; lose it, regenerate.

Key generation and rotation

Generate in Settings → API keys. For rotation:

  1. Create a new key. Push it to your secret manager under a new version.
  2. Deploy services with dual-key fallback: try new key, fall back to old on 401.
  3. After deploy propagates (minutes to hours), revoke the old key.

Keys never expire automatically. A key revoked via the UI starts returning 401 within 30 seconds globally.

Key scoping

Three scopes, combinable:

  • readGET on tests, webhook configs, usage. Safe for dashboards.
  • write — create tests, update webhook configs. What CI jobs need.
  • webhook-sign — issued to servers that only verify webhooks. Cannot create or read tests.

Use the least-privileged scope. A read-only key in your support dashboard, a write-only key in CI, a webhook-sign-only key in the receiver service. Blast radius of a leak scales with scope.

Rate limits per plan

Limits apply per API key, measured in a rolling 60-second window:

Plan         Tests/min   Tests/month   Concurrent tests
-----        ---------   -----------   ----------------
Free              2            30            1
Starter           5         1,000            3
Scale            10         5,000           10
Enterprise      custom      custom        custom

Read endpoints (GET /api/tests/:id, GET /api/me) have a separate, much higher limit: 60 requests per minute across all plans. Polling a test every 15 seconds never hits it.

Rate-limit headers

Every response carries the current state of your window, so clients don't need to estimate:

HTTP/1.1 200 OK
X-RateLimit-Limit:     10
X-RateLimit-Remaining:  7
X-RateLimit-Reset:     1718712120
Content-Type: application/json

{ ... }

X-RateLimit-Reset is a Unix timestamp. When Remaining hits 0, the next request will 429 with a Retry-After header:

HTTP/1.1 429 Too Many Requests
Retry-After: 23
X-RateLimit-Limit:     10
X-RateLimit-Remaining:  0
X-RateLimit-Reset:     1718712143

{
  "error": {
    "code": "rate_limited",
    "message": "10 tests/minute exceeded on this key",
    "requestId": "req_01HG2..."
  }
}

Recommended retry + backoff

A good client honours Retry-After on 429 and 503, and backs off exponentially on 5xx:

async function request(url, opts, attempt = 0) {
  const r = await fetch(url, opts);
  if (r.ok) return r.json();

  if (r.status === 429 || r.status === 503) {
    const retryAfter = Number(r.headers.get('Retry-After')) || 30;
    await sleep(retryAfter * 1000);
    if (attempt < 5) return request(url, opts, attempt + 1);
  }

  if (r.status >= 500 && attempt < 4) {
    const backoff = Math.min(2 ** attempt * 1000 + Math.random() * 1000, 30_000);
    await sleep(backoff);
    return request(url, opts, attempt + 1);
  }

  throw new Error(`${r.status} ${await r.text()}`);
}

Never retry 4xx other than 408, 429, and 425. A 400 or 422 won't fix itself by waiting; a 401 needs a new key, not a new attempt.

Idempotency keys

POST /api/tests supports an Idempotency-Key header. Reuse the same key within 24 hours and you'll get the same response for the same test — no duplicate. Essential when your CI job retries on network timeout and you don't want to burn two credits:

curl -X POST https://check.live-direct-marketing.online/api/tests \
  -H "Authorization: Bearer $IC_KEY" \
  -H "Idempotency-Key: ci-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \
  -H "Content-Type: application/json" \
  -d '{ "senderDomain": "news.example.com", "html": "<p>hi</p>" }'

Use a deterministic key derived from the cause of the request, not a random UUID — the whole point is that retries produce the same key.

Error response shape

Every 4xx and 5xx response has the same envelope:

{
  "error": {
    "code":      "invalid_request",
    "message":   "subject must be a non-empty string",
    "field":     "subject",
    "docsUrl":   "https://check.live-direct-marketing.online/docs/errors#invalid_request",
    "requestId": "req_01HG2NR4B5F8X9WVQ6Z1A2C4D7"
  }
}

requestId is the ID to include in support requests. field is set on 422 to point at the offending input. code is a stable, machine-readable identifier — never parse message.

HTTP status code map

200  OK                   Successful GET / action idempotent re-hit.
201  Created              POST /api/tests created a new test.
400  Bad Request          Malformed JSON, missing body.
401  Unauthorized         Missing / invalid Bearer token.
402  Payment Required     Quota exceeded for the month.
403  Forbidden            Key scope doesn’t allow this action.
404  Not Found            Test id doesn’t exist or belongs to another account.
409  Conflict             Idempotency-Key reused with different payload.
422  Unprocessable        Valid JSON, invalid field (see .field in body).
425  Too Early            Test still queued; try in a few seconds (SSE only).
429  Too Many Requests    Rate limit hit, honour Retry-After.
500  Server Error         Our fault, retry with backoff.
503  Service Unavailable  Planned maintenance or upstream ISP outage.

Specific error classes with remediation

rate_limited (429)

Honour Retry-After. If you're hitting this regularly on a nightly monitor, spread tests over time with setTimeout between launches rather than Promise.all.

quota_exceeded (402)

Monthly quota used up. Upgrade the plan or wait for the reset date (first of the month, UTC). Check GET /api/me for usage.resetsAt.

invalid_html (422)

The html body is either empty, over 1 MB, or contains characters we can't transport. Rendered templates over 1 MB are almost always a sign of embedded base64 images — host those on a CDN.

sender_not_allowed (403)

The senderDomain isn't verified on your account. Add it in Settings → Sender domains, confirm the DNS TXT challenge, retry.

webhook_signature_invalid (401 on your side)

Not an API error — a webhook your server rejected. Usually caused by parsing the body before verifying the signature (JSON parsers reformat whitespace; HMAC fails). Verify on the raw bytes.

Always log requestId

Whenever your code logs an API error, include error.requestId. It takes a minute to find the full trace from a request ID; without it, support debugging turns into guesswork.

Webhook signature verification

Every webhook carries X-IC-Signature: sha256=<hex>. Compute HMAC-SHA256 of the raw body with your webhook secret and constant-time compare:

import crypto from 'node:crypto';

export function verify(rawBody, headerSig, secret) {
  const expected = 'sha256=' +
    crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(headerSig || '');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Two non-negotiable rules. One: verify against the raw body, not the parsed JSON. Two: use timingSafeEqual, not ===. String equality leaks via timing side-channels.

Frequently asked questions

Why 402 instead of 429 for quota?

429 means 'slow down, try again soon'. 402 means 'you are out of allowance, waiting won't help until billing resets or the plan is upgraded'. Different remediation, different status code. Clients that treat 402 as 429 will hot-loop uselessly.

How do I know if a key is leaked?

Check GET /api/me/audit (enterprise) or Settings → API keys → key details → recent IPs. Requests from unexpected IPs, regions, or UA strings are a red flag. Rotate immediately if in doubt.

Can I use the same key from browser JavaScript?

No. Bearer tokens in client JavaScript are equivalent to publishing the secret. Proxy through your backend. We offer short-lived session tokens (5 min) for server-issued UI flows; email support if you need them.

What happens if my clock is off when verifying webhooks?

The signature itself is time-free HMAC, so clock skew doesn't break verification. The X-IC-Timestamp header lets you optionally reject replays older than, say, 5 minutes, but that check requires your server clock to be synced to within a minute.
Related reading

Check your deliverability across 20+ providers

Gmail, Outlook, Yahoo, Mail.ru, Yandex, GMX, ProtonMail and more. Real inbox screenshots, SPF/DKIM/DMARC, spam engine verdicts. Free, no signup.

Run Free Test →

Unlimited tests · 20+ seed mailboxes · Live results · No account required