Guides
Error codes
When an API request fails, the response includes an error code and message to help you understand and handle the issue.
Error response format
All error responses follow this structure (request_id is included when available; quote it when contacting support):
{
"success": false,
"error": {
"code": "ERROR_CODE",
"message": "Human-readable error description",
"request_id": "abc123...",
"retryable": false
}
}Branch on code, never on message: the message is written for people and may be reworded. The retry fields described next are optional, and each is left out rather than guessed when the server does not know the answer.
Deciding whether to retry
| Field | Present when | Meaning |
|---|---|---|
retryable | The answer does not depend on the case | true: the identical request, sent again after a wait with nothing else changed, can succeed. false: it will fail the same way until the request, the key, the plan, the balance or the resource changes. Absent for codes answered for reasons on both sides, such as INTERNAL_ERROR and CONFLICT. |
retry_after_seconds | The server knows how long to wait | Whole seconds to wait before retrying. The Retry-After header carries the same number. |
retry_requires_same_idempotency_key | A 5xx on a charging route that accepts Idempotency-Key, for a request that sent one | Repeat it only with the same Idempotency-Key: an attempt under the key that was charged is replayed rather than bought again, and one that was not is simply run. |
retry_may_charge_again | A 5xx, or a refusal decided after the charge, on a charging route for a request with no Idempotency-Key to reuse (the route takes none, or none was sent), where the charge cannot be ruled out | No key marks a repeat as the same request (some of these routes forgive an identical repeat for a short window, none promises to), so a retry may be charged a second time. Check GET /v1/usage/credits/transactions, which lists each debit with its amount and timestamp, to see whether a charge landed at that moment; GET /v1/usage gives the balance but not the per-debit detail. A ledger row names no request, so this settles it only when nothing else on the account was spending at the same time. |
retryable is decided per code (the Retry column below), and a few responses override their code where they know better: a 503 for a feature not enabled in this deployment is not retryable, and a 400 for downloading a job that is still running is.
Retries that could cost credits
Routes that charge credits can fail after the charge. POST /v1/verify, POST /v1/verify/batch, POST /v1/verify/file, POST /v1/deliverability/check, both email-finder routes (single and bulk) and the three paid activity lookups accept an Idempotency-Key header: send one on every call, and reuse it on the retry. A timeout answered by the gateway rather than by Kawaa carries no envelope at all (see Gateway timeouts); treat it as a 5xx on that route.
Error code reference
These are the codes an integration is most likely to meet. It is not every code the API can send: account administration and the CRM integrations have codes of their own. The full list is the ErrorCode schema in the OpenAPI document.
| Code | HTTP | Retry | Description |
|---|---|---|---|
BAD_REQUEST | 400 | No | Invalid request parameters, malformed JSON, or missing required fields (details in the message) |
RESET_TOKEN_INVALID | 400 | No | The password-reset token sent to POST /v1/auth/reset-password is malformed, expired or already used. Request a new reset email |
INVALID_WEBHOOK_SECRET | 400 | No | webhook_secret is empty, longer than 256 characters, or supplied without webhook_url |
WEBHOOK_URL_INVALID | 400 | No | Webhook URL is malformed, not HTTPS, or targets a private/reserved address |
INVALID_OPTION | 400 | No | A field inside options is not one the route accepts (a misspelled option name, or timeout_ms on the batch and file routes), or has the wrong type (e.g. "deep_verify": "false"; booleans must be JSON booleans). An unknown top-level field is a BAD_REQUEST instead |
INVALID_CURSOR | 400 | No | The pagination cursor sent to a list route (cursor; next_key on GET /v1/jobs/{id} for a batch or file job when results are included; offset on GET /v1/jobs, /v1/audit-log, /v1/notifications and /v1/support/conversations; starting_after on GET /v1/invoices) cannot be used by that listing: it does not decode, is not in the shape the route issues, belongs to another account or resource, or lies outside the requested date window. Pass back the previous page's value unchanged, or start again from the first page without it. A single verification's job has one result and ignores next_key, so any value there answers 200 |
INVALID_IDEMPOTENCY_KEY | 400 | No | The Idempotency-Key header, once trimmed, is empty, longer than 255 characters or has a comma or a character outside printable ASCII (a space included), or the header was sent twice (a repeated header arrives joined by commas). Refused before anything is charged |
UNAUTHORIZED | 401 | No | Missing or invalid API key or access token |
API_KEY_REVOKED | 401 | No | This API key was deleted or deactivated. Create a new key in the dashboard |
API_KEY_SUSPENDED | 401 | No | Keys are suspended while the subscription is lapsed. Restore billing and the same key works again |
EMAIL_VERIFICATION_REQUIRED | 401 | No | The account’s email address has not been verified yet. Sign-in (POST /v1/auth/login) answers it with 403 once the password is right. Open the link in the verification email, or ask POST /v1/auth/resend-verification for a new one |
SESSION_EXPIRED | 401 | No | The dashboard session has expired. Sign in again |
OAUTH_TOKEN_EXPIRED | 401 | No | An OAuth access token issued to a connected app has passed its one-hour lifetime. The app refreshes it with its refresh token and retries; an app that was not issued a refresh token, or whose refresh is refused, signs in again |
INSUFFICIENT_CREDITS | 402 | No | Not enough credits for this operation |
PAYMENT_REQUIRED | 402 | No | The card on file was declined for a plan change, and nothing was changed. Update the payment method, then try again. Not a credit balance problem (that is INSUFFICIENT_CREDITS) and not a plan limit (that is PLAN_LIMIT_EXCEEDED) |
FORBIDDEN | 403 | No | Access denied for a reason no more specific code covers |
INSUFFICIENT_SCOPE | 403 | No | The credential is valid but not scoped for this route. An API key: issue one with the scope the message names. An app connected by signing in: connect it again asking for that scope as well as the ones it already has (a new sign-in replaces them; they are listed in granted_scopes), unless the message says a connected app can never be given it; then use an API key with every scope the message names, or have the account owner do it in the dashboard |
PLAN_LIMIT_EXCEEDED | 403 | No | Your plan’s limit for this feature has been reached (webhook endpoints, team seats, connected integrations, active tracking links on Free) |
PLAN_UPGRADE_REQUIRED | 403 | No | Your plan does not include this feature at all. No API key reaches it, whatever its scopes. The remedy is a plan change |
CROSS_SITE_SESSION_REFUSED | 403 | No | A request carried the dashboard’s sign-in cookie but was not made by the dashboard, so the sign-in was not used and nothing was charged. Either another website or another page started it, or it spends credits and was opened outside the dashboard: a link in a mail app or Slack, a bookmark, or the address bar. Signing in from another website or page creates no session either. A request with an API key or access token never meets this |
REAUTHENTICATION_REQUIRED | 403 | No | Creating an API key, approving an app’s sign-in, adding a webhook, changing a webhook’s URL or events, or turning back on a webhook Kawaa switched off because the key or app that created it was revoked or disconnected, from a dashboard session more than 15 minutes old. Sign in again and repeat it. Doing any of these with an API key never needs this |
SUB_ACCOUNT_MANAGED | 403 | No | A white-label sub-account called a route for something the account that provisioned it manages, such as billing or account settings. The message says what to use instead; the sub-account’s own verification, jobs and usage keep working |
NOT_FOUND | 404 | No | Resource not found (e.g. unknown job ID or expired uploaded file). Also the answer for a method or path the API does not route at all, including a wrong method on a real path (see "Unrouted requests" below) |
IDEMPOTENT_JOB_EXPIRED | 410 | No | This Idempotency-Key already ran the same request on POST /v1/verify, /v1/verify/batch or /v1/verify/file, and that job has reached the end of the data-retention period of the plan it was submitted under while the key's receipt (kept at least 30 days) still stands. Nothing was charged for this attempt and the same key keeps answering this. Send the request with a new key to run it again |
EXPORT_EXPIRED | 410 | No | The data export requested from GET /v1/exports/{id}/download has passed its retention window. Request a new export |
CONFLICT | 409 | Depends | Request conflicts with the current state of the resource |
IDEMPOTENCY_KEY_CONFLICT | 409 | No | This Idempotency-Key was already used on this route for a different request (another address, list, file or option). Refused before anything is charged. Send the original request unchanged to get its outcome, or use a new key for a new request |
BATCH_SIZE_EXCEEDED | 413 | No | The request is more than one job takes. Nothing was charged: split it and send the parts. It covers more distinct addresses than your plan’s per-batch cap (100 on Free up to 10,000 on Enterprise); more than 10,000 entries in a batch’s emails array, counting duplicates and invalid ones; a file over 50 MB; and a file that could not be read within 20 seconds of the request arriving. Only the plan cap rises with a higher plan, and the message says when one would take more |
PAYLOAD_TOO_LARGE | 413 | No | Request body is larger than the endpoint accepts |
RATE_LIMITED | 429 | Yes | Too many requests. Wait retry_after_seconds (or the Retry-After header) when present |
INTERNAL_ERROR | 500 | Depends | Unexpected server error |
SERVICE_UNAVAILABLE | 503 | Yes | Service temporarily unavailable |
WEBHOOK_DNS_TEMPORARY_FAILURE | 503 | Yes | Webhook URL hostname could not be resolved (transient DNS failure). Safe to retry |
INTEGRATION_NOT_CONFIGURED | 503 | No | The requested integration provider is not configured/available yet |
RESULT_SUPERSEDED | 503 | No | GET /v1/jobs/{id} with results, or GET /v1/jobs/{id}/download, found a result an older version of the verifier produced and will not serve it. A finished job does not change, so asking again gives the same answer. Verify those addresses again (a new job, charged as usual) to get current verdicts |
GATEWAY_TIMEOUT | 504 | Yes | Upstream verification timed out. Retry or poll the job |
Gateway timeouts
A request still running after about 29 seconds is answered by the API gateway, not by Kawaa, and so is one the gateway could not hand to the API at all. Either way the answer is a 503 whose whole body is:
{"message":"Service Unavailable"}It has no success, error or request_id, so there is no code to branch on: read the status, and treat the missing envelope as the sign. It is not a 504. The request may still have run to the end, so retry with backoff, and on a route that charges credits only with the same Idempotency-Key. A 503 that does carry the envelope is Kawaa’s own and says in retryable whether waiting helps. The one 504 with a body is Kawaa’s own, from POST /v1/verify/free: GATEWAY_TIMEOUT with a job_id to send back.
Errors from the MCP endpoint
A tool call made through the MCP server is a REST call made with your key, so it can fail with any code above. The endpoint also answers three codes of its own before a message reaches the API. They arrive as a JSON-RPC error: the code is in error.data.code, the wait in error.data.retry_after_seconds, and the HTTP Retry-After header carries the same number.
| Code | HTTP | Retry | Description |
|---|---|---|---|
MCP_CREDENTIAL_RATE_LIMITED | 429 | Yes | This credential has used its own MCP budget: 30 requests every 10 seconds and 120 a minute, counted per API key or access token wherever the requests come from. Wait retry_after_seconds and retry. |
MCP_IP_RATE_LIMITED | 429 | Yes | The network address has used its budget. Requests with no credential, or with one that is not a Kawaa key or access token, share one per address (30 every 10 seconds, 120 a minute); requests with a credential also share an address-wide ceiling ten times that, which only a very busy shared address reaches. Wait retry_after_seconds and retry; sending your credential gives you a budget of your own. |
MCP_RATE_LIMIT_UNAVAILABLE | 503 | Yes | The MCP endpoint could not record the request against its budget, so it refused it rather than let it through unmetered. Nothing was run or charged. Retry after 60 seconds. |
Handling errors
Client errors
These errors indicate a problem with the request. Check the error message, fix the issue, and retry.
Rate limited
When the server knows how long to wait, the body carries error.retry_after_seconds and the Retry-After header carries the same number. Per-key API throttling, the auth endpoints (login, registration, password reset) and the MCP endpoint’s own budgets set both: wait that long, then retry. Other 429s may carry neither; back off with exponential, jittered delays.
Unrouted requests
A method-and-path combination the API does not route (for example GET /v1/verify or GET /v1/verify/batch, or a typo in the path) is answered by the gateway with 404 NOT_FOUND and a message naming the method and path, not with 405, on every REST route. Treat it as "fix the request", not as a missing resource. The one 405 is the MCP endpoint’s: GET or DELETE /mcp answers 405 with Allow: POST, as a JSON-RPC error.
Server errors
Server-side issues. Retry with exponential backoff when retryable is true, and read the charge fields first on a route that charges credits (see below). If the problem persists, contact support.
Example: error handling in Node.js
// Node.js 20 or later, where fetch and crypto are globals.
// One key per logical request, reused on every retry of it:
// POST /v1/verify charges, and the key is what stops a retry paying twice.
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function verifyEmail(email, idempotencyKey = crypto.randomUUID(), attempt = 1) {
const response = await fetch('https://api.kawaa.com/v1/verify', {
method: 'POST',
headers: {
'X-Api-Key': process.env.KAWAA_API_KEY,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({ email }),
});
// A gateway timeout or outage answers before Kawaa does: no { success, error }
// envelope, and sometimes not JSON at all.
const data = await response.json().catch(() => null);
if (response.ok && data?.success) return data.data;
const error = data?.error ?? {
code: 'HTTP_' + response.status,
message: response.statusText || 'No error body',
// Without an envelope, only a 5xx or a 429 can succeed on a retry. The
// same Idempotency-Key makes retrying a 5xx safe on this charging route.
retryable: response.status >= 500 || response.status === 429,
};
const { code, message, retryable, retry_after_seconds } = error;
if (code === 'INSUFFICIENT_CREDITS') {
throw new Error('Please add credits to continue');
}
if (code === 'BAD_REQUEST') {
// e.g. malformed email input: skip it
return { status: 'skipped', reason: message };
}
// Only retry what the API says can succeed. An absent retryable means
// "it depends", so do not loop on it.
if (retryable === true && attempt < 4) {
// The server's wait when it gives one (body or Retry-After header),
// otherwise exponential backoff.
const retryAfter = retry_after_seconds ?? Number(response.headers.get('Retry-After'));
const waitSeconds = retryAfter > 0 ? retryAfter : 2 ** attempt;
await sleep(waitSeconds * 1000);
return verifyEmail(email, idempotencyKey, attempt + 1);
}
throw new Error(code + ': ' + message);
}Need help?
If you're experiencing persistent errors, check our status page for any ongoing issues, or contact support.