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.
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_4fN2q8s9GxW3bD7hJkL0mNpRKey anatomy:
ic_— product prefix, always.live_ortest_— 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:
- Create a new key. Push it to your secret manager under a new version.
- Deploy services with dual-key fallback: try new key, fall back to old on 401.
- 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:
- read —
GETon 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 customRead 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.
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.