Skip to content

API · Intermediate · 7 min read

Error handling and rate limits for the SimpleCold API

Every error the SimpleCold API returns, what causes it, and how to retry safely. Includes rate-limit headers, backoff code and a fail-open pattern.

Sep 10, 2026 · SimpleCold team

API errors sorted by what to do: a 400 means fix the request, a 401 means stop and alert, a 429 means wait and retry

An integration that works on the happy path and falls over on the first 429 isn't finished. This guide lists the errors the SimpleCold API returns, sorts them into "fix your request", "wait and retry" and "stop and alert", and gives you a small wrapper in Node.js and Python that does the right thing for each.

Response shapes

Successful responses use one envelope:

{
  "code": "SUCCESS",
  "message": "Successfully performed operation.",
  "payload": { },
  "requestId": "6a1e..."
}

Errors from the API itself use another:

{
  "code": "FAILURE",
  "message": "Invalid API key",
  "data": null
}

Input that fails validation (a malformed address, a missing field) is rejected before it reaches the service, with a 400 in a third shape. data lists the individual problems:

{
  "success": false,
  "message": "Please check your input and try again.",
  "data": [ { "path": ["email"], "message": "Invalid email address" } ]
}

Rate-limit responses have their own code, RATE_LIMITED, and say in data which limit you hit and how long to wait. The status and error fields are kept for older integrations, and error always equals message:

{
  "code": "RATE_LIMITED",
  "message": "Rate limit exceeded. Maximum 100 requests per minute per API key.",
  "data": {
    "policy": "api.single",
    "retryAfterSeconds": 1,
    "limit": 100,
    "scope": "apiKey",
    "blocked": false
  },
  "status": 429,
  "error": "Rate limit exceeded. Maximum 100 requests per minute per API key."
}

So read the error text as body.message ?? body.error, and always branch on the HTTP status code rather than on the body. Bulk downloads are the exception to the envelope on success: they return the raw CSV or JSON file.

Log requestId from successful responses alongside your own IDs. If you contact support about a specific result, it lets them find the request. Error bodies have no requestId, but every response carries the same value in the X-Request-Id header.

The errors you will see

400: fix the request

MessageCauseFix
Please check your input and try again. (details in data)The address failed the basic format check, or a field is missingDon't retry. Treat the address as invalid.
Maximum 50 emails per request is allowed.Batch over the limitSplit into chunks of 50
File is requiredBulk upload without a file fieldSend multipart with field name file
Email column not found in the file.No header named email, e-mail, email address, emailaddress, emails or mailRename the column
No email addresses found in the file.Header found, but every cell emptyCheck the export
Results are not available for this job.Download asked for a job in QUEUE or FAILEDPoll status first; retry a failed job from the dashboard
Job not found.Wrong or foreign job ID on downloadCheck the stored ID
Insufficient credits. or Please buy credits.Balance too low for the requestStop and alert a human

A 400 on the single endpoint for a malformed address is information, not a failure. In a sign-up form it means "show a format error". In a list cleaner it means "mark the row invalid".

Note that one malformed address makes a whole batch request fail. Run a cheap local check before batching, or catch the 400 and fall back to single calls for that chunk.

The credit errors deserve their own branch. Retrying won't help, and a script that loops on them will log thousands of identical failures overnight. Stop the job and notify someone.

401: stop and alert

MessageCause
Authorization header is requiredHeader missing
Authorization header must be in format: Bearer <token>Header present but malformed, e.g. missing Bearer or an extra space
Invalid API keyKey doesn't match, often a copy-paste error or an old value after the key was regenerated
API key is inactiveKey switched off in the dashboard
API key has expiredKey passed its expiry date

None of these fix themselves. Retrying a 401 just adds noise, and it can lock you out: after 30 requests with an invalid, inactive or expired key from one IP address in 15 minutes, that address gets a 429 even with a good key until the window passes. Fail fast and raise an alert. You can check a key at any time without spending credits with GET /api-validation/validate, which is a good startup check for long-running workers.

404

No check in progress for this email. from /api-validation/single/result means there is nothing pending for that address, usually because you already collected the result. It is a normal outcome. See handle processing results.

429: wait and retry

EndpointPer API keyPer organization
GET /api-validation/single and GET /api-validation/single/result (shared)100 per minute300 per minute
POST /api-validation/batch10 per minute30 per minute
POST /api-validation/bulk (upload)10 per minute30 per minute
GET /api-validation/bulk/:id/status120 per minute360 per minute
GET /api-validation/bulk/:id/download-result30 per minute90 per minute
GET /api-validation/validate60 per minutenone

Limits are counted per API key, and your organization has a cap of three keys' worth on each endpoint, so extra keys don't multiply the budget. Each limit is a token bucket: you can send a burst up to the full limit, then keep going at the steady rate.

Every response from a limited endpoint carries RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset (seconds until the bucket is full again) and RateLimit-Policy (every limit on the endpoint, for example 100;w=60, 300;w=60). A 429 also carries Retry-After, and the same number is in data.retryAfterSeconds. Wait that long before the next request. data.policy tells you which limit you hit and data.scope whether it was counted against the key (apiKey), the organization (org) or the IP address (ip).

Better still, avoid 429s by pacing. If RateLimit-Remaining reaches 0, wait out RateLimit-Reset before the next call rather than firing and getting rejected.

Bulk upload, status and download each have their own budget, so status polling can't use up your uploads. Even so, poll status at the time nextUpdateAt suggests, as shown in polling for results, or skip polling and subscribe to the bulk.finished webhook.

The full list, including the per-IP safety net and temporary blocks, is in the rate limits reference.

5xx and network errors: retry with backoff

Server errors return Internal Server Error with no detail. Retry with exponential backoff and jitter, a few times, then give up and alert. Network timeouts are the same.

Be careful with retries on POST /api-validation/bulk. If an upload times out on your side, the job may still have been created. Retrying blindly can start a second job and reserve credits twice. Check the dashboard, or record the time of the upload attempt and look for the job before retrying.

Single and batch calls are safe to retry. An address that's still pending doesn't charge again when you ask for it.

A wrapper in Node.js

// simplecold.mjs  (Node 18+)
const API = "https://simplecold.com/api/v1";
const KEY = process.env.SIMPLECOLD_API_KEY;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

export class SimpleColdError extends Error {
  constructor(status, message, kind) {
    super(message);
    this.status = status;
    this.kind = kind; // "request" | "auth" | "credits" | "not_found" | "server"
  }
}

function waitFromHeaders(res) {
  const retryAfter = Number(res.headers.get("retry-after"));
  if (retryAfter > 0) return retryAfter * 1000;
  const reset = Number(res.headers.get("ratelimit-reset"));
  if (reset > 0) return reset * 1000;
  return 60_000;
}

export async function call(path, { method = "GET", body, retries = 4 } = {}) {
  for (let attempt = 0; ; attempt++) {
    let res;
    try {
      res = await fetch(`${API}${path}`, {
        method,
        headers: {
          Authorization: `Bearer ${KEY}`,
          ...(body ? { "Content-Type": "application/json" } : {}),
        },
        body: body ? JSON.stringify(body) : undefined,
        signal: AbortSignal.timeout(30_000),
      });
    } catch (err) {
      if (attempt >= retries) throw new SimpleColdError(0, err.message, "server");
      await sleep(1000 * 2 ** attempt + Math.random() * 500);
      continue;
    }

    if (res.status === 429 && attempt < retries) {
      await sleep(waitFromHeaders(res));
      continue;
    }
    if (res.status >= 500 && attempt < retries) {
      await sleep(1000 * 2 ** attempt + Math.random() * 500);
      continue;
    }

    const data = await res.json().catch(() => ({}));
    if (res.ok) return data.payload;

    const msg = data.message ?? data.error ?? `HTTP ${res.status}`;
    if (res.status === 401) throw new SimpleColdError(401, msg, "auth");
    if (res.status === 404) throw new SimpleColdError(404, msg, "not_found");
    if (/credits/i.test(msg)) throw new SimpleColdError(res.status, msg, "credits");
    if (res.status >= 500 || res.status === 429) throw new SimpleColdError(res.status, msg, "server");
    throw new SimpleColdError(res.status, msg, "request");
  }
}

// usage
// const r = await call(`/api-validation/single?email=${encodeURIComponent(email)}`);
// const rs = await call("/api-validation/batch", { method: "POST", body: { emails } });

Callers then switch on err.kind: request marks the input invalid, auth and credits stop the process, not_found is usually fine, server gets retried later or fails open.

The same idea in Python

import os, time, random
import requests

API = "https://simplecold.com/api/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['SIMPLECOLD_API_KEY']}"


class SimpleColdError(Exception):
    def __init__(self, status, message, kind):
        super().__init__(message)
        self.status, self.kind = status, kind


def _wait(res):
    for h in ("Retry-After", "RateLimit-Reset"):
        v = res.headers.get(h)
        if v and v.isdigit() and int(v) > 0:
            return int(v)
    return 60


def call(path, method="GET", json=None, params=None, retries=4):
    for attempt in range(retries + 1):
        try:
            res = session.request(method, f"{API}{path}", json=json, params=params, timeout=30)
        except requests.RequestException as e:
            if attempt == retries:
                raise SimpleColdError(0, str(e), "server")
            time.sleep(2 ** attempt + random.random())
            continue
        if res.status_code == 429 and attempt < retries:
            time.sleep(_wait(res)); continue
        if res.status_code >= 500 and attempt < retries:
            time.sleep(2 ** attempt + random.random()); continue
        try:
            data = res.json()
        except ValueError:
            data = {}
        if res.ok:
            return data.get("payload")
        msg = data.get("message") or data.get("error") or f"HTTP {res.status_code}"
        if res.status_code == 401:
            raise SimpleColdError(401, msg, "auth")
        if res.status_code == 404:
            raise SimpleColdError(404, msg, "not_found")
        if "credits" in msg.lower():
            raise SimpleColdError(res.status_code, msg, "credits")
        kind = "server" if res.status_code >= 500 or res.status_code == 429 else "request"
        raise SimpleColdError(res.status_code, msg, kind)

Fail open where people are waiting

When verification sits in front of a person, such as a sign-up form or a checkout, decide what happens if SimpleCold can't answer. For almost every product the right answer is to let the person through and verify later. A short outage on our side or a mistyped key on yours should cost you a few unchecked addresses, not a few hours of failed sign-ups. The sign-up guide shows this.

In batch pipelines the opposite holds. Nobody is waiting, so fail closed: leave rows unchecked, alert, and resume later.

A checklist before you ship

  • Timeout set above 12 seconds on single calls and above 25 seconds on batch calls.
  • 401 and credit errors stop the process and alert someone.
  • 429 waits for Retry-After, never retries at once.
  • Bulk uploads are not retried blindly.
  • processing results are collected later, not treated as errors.
  • Test addresses cover each branch. See test your integration with test emails.

The API docs list the errors per endpoint.

Check your list before you send it

Start with 200 free credits. Pay per credit after that.