SendSure
API Guides
Updated 2026-07-078 min read

API Errors, Rate Limits, and Retries

Handle every error code, respect rate-limit headers, and implement the deferred-verification retry contract so your integration degrades gracefully.

API errors, rate limits, and retries

Production integrations are defined by how they behave when a request does not succeed on the first try. This guide covers the error format, every machine-readable error code, the rate-limit headers, and the retry contract for verifications that outlast the synchronous deadline.

Error format

Every error response includes a human-readable error message. Where a machine-readable code exists, it is returned in a code field alongside it:

{
  "error": "Insufficient credits",
  "code": "insufficient_credits",
  "credits_available": 0,
  "credits_required": 1
}

Authentication failures return a plain error (plus a message with detail) and no code — for example { "error": "Invalid API key", "message": "The provided API key is not valid or has been revoked" }. An explicitly supplied but invalid key is always a hard 401; it is never silently downgraded to the anonymous free tier.

The scoring endpoints (/api/scoring/*) use a structured envelope instead:

{
  "success": false,
  "error": {
    "code": "VAL_002",
    "message": "Invalid input",
    "details": { "message": "Maximum 1000 emails per batch", "max": 1000 }
  }
}

Error codes

| Code | HTTP | Endpoint | When it happens | | --- | --- | --- | --- | | insufficient_credits | 402 | POST /api/verify/single, POST /api/verify/batch | Your balance cannot cover the request. The body includes required and available credits. | | origin_not_allowed | 403 | POST /api/verify/single (widget keys) | The calling domain is not on the widget key's origin allowlist. | | daily_limit_reached | 429 | POST /api/verify/single (widget keys) | The widget key's configured daily verification cap was reached. | | NO_ARCHIVED_FILE | 404 | GET /api/jobs/{jobId}/original | No original file is stored for the list and it could not be reconstructed. | | FILE_EXPIRED | 410 | GET /api/jobs/{jobId}/original | The original upload passed the 30-day retention window and was deleted automatically. | | VAL_005 | 400 | POST /api/scoring/* | A required field is missing (no email or emails). | | VAL_002 | 400 | POST /api/scoring/* | Invalid input, e.g. more emails than the batch limit. | | SRV_001 | 500 | POST /api/scoring/* | Internal server error while scoring. |

Handle anything else by HTTP status: 400 bad request, 401 authentication, 404 not found, 409 conflict (e.g. a job already being resumed), 429 rate limited, 5xx server error.

Rate limits

Limits are per account and depend on your plan. Verification calls (/api/verify/single, /api/verify/batch) count against a dedicated verification limiter; everything else counts against the general API limiter.

Verification requests:

| Plan | Per minute | Per hour | | --- | --- | --- | | Free | 10 | 100 | | Pay-as-you-go | 50 | 1,000 | | Growth | 100 | 5,000 | | Enterprise | 500 | 20,000 |

A batch counts as one verification request regardless of how many emails it contains, so batching is up to 100x more rate-limit-efficient.

General API requests:

| Plan | Per minute | Per hour | | --- | --- | --- | | Free | 30 | 500 | | Pay-as-you-go | 60 | 2,000 | | Growth | 120 | 5,000 | | Enterprise | 300 | 20,000 |

Every rate-limited response carries these headers:

| Header | Meaning | | --- | --- | | X-RateLimit-Limit | Maximum requests allowed in the window | | X-RateLimit-Remaining | Requests remaining in the current window | | X-RateLimit-Reset | Seconds until the window resets | | Retry-After | On 429 only — seconds to wait before retrying |

When you get a 429, the body is { "error": "... exceeded", "message": "Too many requests. Limit: N/minute", "retryAfter": <seconds> }. Sleep for Retry-After seconds and retry — nothing was charged. The official SDKs do this automatically.

Deferred verifications: the retry contract

POST /api/verify/single is synchronous with a roughly 12-second server deadline. Most verdicts return in well under a second, but a stubborn mail server can keep a check running longer. Instead of timing out, the API returns 200 with a non-billable placeholder:

{
  "result": "unknown",
  "status": "unknown",
  "deferred": true,
  "deferred_reason": "verification_deadline",
  "reason": "Still verifying — try again in a moment."
}

The verification keeps running server-side and caches its verdict. Retry the exact same request after a few seconds — the retry joins the in-flight check rather than starting a new one, and returns the finished verdict as soon as it lands. You are never charged for a deferred placeholder, and a retry that lands on the cached verdict is not charged either.

Node.js:

async function verifyWithRetry(email, { attempts = 4, delayMs = 3000 } = {}) {
  for (let i = 0; i < attempts; i++) {
    const res = await fetch('https://api.sendsure.ai/api/verify/single', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-API-Key': process.env.SENDSURE_API_KEY,
      },
      body: JSON.stringify({ email }),
    });

    if (res.status === 429) {
      const wait = Number(res.headers.get('Retry-After') || 5);
      await new Promise((r) => setTimeout(r, wait * 1000));
      continue;
    }

    const result = await res.json();
    if (!res.ok) throw new Error(`${result.error} (${result.code ?? res.status})`);
    if (!result.deferred) return result;

    // Still verifying — an identical retry joins the in-flight check.
    await new Promise((r) => setTimeout(r, delayMs));
  }
  return { email, status: 'unknown', deferred: true }; // treat as unknown after the budget
}

Python:

import os
import time
import requests

def verify_with_retry(email: str, attempts: int = 4, delay: float = 3.0) -> dict:
    for _ in range(attempts):
        res = requests.post(
            "https://api.sendsure.ai/api/verify/single",
            json={"email": email},
            headers={"X-API-Key": os.environ["SENDSURE_API_KEY"]},
            timeout=30,
        )
        if res.status_code == 429:
            time.sleep(int(res.headers.get("Retry-After", 5)))
            continue
        res.raise_for_status()
        result = res.json()
        if not result.get("deferred"):
            return result
        # Still verifying — an identical retry joins the in-flight check.
        time.sleep(delay)
    return {"email": email, "status": "unknown", "deferred": True}

If the budget runs out, treat the address as unknown and re-check it later — the cached verdict will usually be waiting.

Retrying safely

Not every request is equally safe to fire twice. A practical policy:

  • 429 responses — always safe to retry after Retry-After. Nothing ran and nothing was charged.
  • Deferred 200 responses — always safe to retry the identical request. Deferred placeholders are non-billable, and the retry joins the in-flight check.
  • GET requests — idempotent by definition; retry freely with backoff.
  • POST /api/verify/single after a network timeout — safe to retry. If the first attempt actually completed, the verdict is cached and cached results are not charged again.
  • POST /api/verify/batch and POST /api/scoring/* after a network timeout — do not blind-retry. You cannot know whether the first attempt completed and charged. Reconcile first (check GET /api/user/credits or your usage log), or move lists to the bulk jobs flow, which is resumable and never double-charges.
  • 4xx responses other than 429 — do not retry unchanged; fix the request (or top up credits for a 402) first.

The official SDKs encode this policy already: rate limits are retried with a bounded budget, and billable requests are never auto-retried after a network failure or server error.

Webhooks retry too

Webhook deliveries are retried with exponential backoff on non-2xx responses, so your receiver must deduplicate by event id. See Webhooks and API quickstart for the idempotent-processing recipe and signature verification.

Keep reading

More docs from the api guides section.