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 timestamp, to see whether this attempt was charged — GET /v1/usage gives the balance but not the per-debit detail that answers the question. |
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 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; treat it as a 5xx on that route.
Error code reference
| 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 verification option has the wrong type (e.g. "deep_verify": "false" — booleans must be JSON booleans) |
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 |
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 | A paid plan or payment is required for this operation |
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) |
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 |
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 — see "Unrouted requests" below |
METHOD_NOT_ALLOWED | 405 | No | HTTP method not supported on this endpoint. Only the batch and file routes answer this; an unrouted method elsewhere is a 404 NOT_FOUND |
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 |
BATCH_SIZE_EXCEEDED | 413 | No | Batch or file exceeds your plan’s per-batch email cap |
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 |
GATEWAY_TIMEOUT | 504 | Yes | Upstream verification timed out — retry or poll the job |
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 and the auth endpoints (login, registration, password reset) 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 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. 405 METHOD_NOT_ALLOWED is only returned by the batch and file verification handlers. Treat both as "fix the request", not as a missing resource.
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
// 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.
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 }),
});
const data = await response.json();
if (data.success) return data.data;
const { code, message, retryable, retry_after_seconds } = data.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) {
const waitSeconds = retry_after_seconds ?? 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.