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:
429responses — always safe to retry afterRetry-After. Nothing ran and nothing was charged.- Deferred
200responses — always safe to retry the identical request. Deferred placeholders are non-billable, and the retry joins the in-flight check. GETrequests — idempotent by definition; retry freely with backoff.POST /api/verify/singleafter 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/batchandPOST /api/scoring/*after a network timeout — do not blind-retry. You cannot know whether the first attempt completed and charged. Reconcile first (checkGET /api/user/creditsor your usage log), or move lists to the bulk jobs flow, which is resumable and never double-charges.4xxresponses other than429— do not retry unchanged; fix the request (or top up credits for a402) 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.