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. |
nothing_charged | The server can confirm this attempt left no charge standing: for example a rate-limit refusal, a refusal a paying route decided before its charge, a debit that did not go through, or a charge that was refunded. Not every answer that charged nothing carries it | Always true: this attempt cost nothing. It says nothing about earlier attempts under the same Idempotency-Key, so keep reusing the key when the message says to. Its absence does not mean you were charged: many answers that charged nothing leave it out, an INSUFFICIENT_SCOPE refusal among them. |
confirmation_url | A 402 PAYMENT_REQUIRED for a move to a higher plan whose payment the bank asked to confirm (3D Secure) | The payment provider's https page where the upgrade's payment is made: the customer confirms it there, or, when the message says the payment did not go through, pays with another card. Nothing is charged and the plan does not change until the payment goes through. Absent when the card was declined without a confirmation request. |
confirmation_expires_at | With confirmation_url, when the provider gave an expiry | When that page stops working (ISO 8601, UTC): at most 23 hours after the request, and earlier when the billing period ends sooner. After it, the upgrade is dropped. Without it, treat the page as expiring within 23 hours. |
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), the three paid activity lookups, the blacklist and DMARC checks (POST /v1/blacklist/check, POST /v1/dmarc/check and a monitor’s manual check), the three email-composer routes, a manual Auto-Clean run (which requires one), white-label credit allocation (POST /v1/white-label/sub-accounts/{subAccountId}/allocate) and the CRM integrations’ six verification routes 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.
What a repeat costs
On every route that accepts an Idempotency-Key, only a repeat of the same request with the key the first attempt carried is sure not to be charged again, and only while the route keeps that key, which is from 15 minutes to a year depending on the route and plan (below, and on each route's page), and for good on white-label credit allocation, whose key is kept with the move in your credit history. After that, the same key can start a new paid request. A key added only on the retry is a new request and does not cover the first attempt. Without a key, some routes treat an identical repeat as the same purchase for a while, and that window differs from route to route; after it, the repeat is a new purchase. Some routes that charge credits do not read the header at all, so a key protects nothing there and only their own terms apply: creating a monitor (below), the domain lookup in the OpenAPI document and inbox placement tests. The verification, batch, deliverability, activity and white-label pages and the OpenAPI document give these terms for their routes. The routes below have no page of their own.
| Route | With an Idempotency-Key | Without one |
|---|---|---|
Blacklist and DMARC checks: POST /v1/blacklist/check, POST /v1/dmarc/check and a monitor's manual check | The same key and the same target (or monitor) returns the stored result with credits_used: 0 and idempotent_replay: true for at least 24 hours after the check completed. A check that was paid for but did not finish stays recoverable under its key with no time limit, and the retry finishes it without another charge. Once a check is refunded, the same key buys a new one. The same key with a different target or monitor is 409 IDEMPOTENCY_KEY_CONFLICT, before any charge. | Every call is a new 5-credit check. |
Creating a monitor: POST /v1/blacklist/monitors and POST /v1/dmarc/monitors | The header is not read: a repeat is treated as if it had none. | A create sent while another create for the same target is still running joins that create's charge, so a double submit charges 5 credits once and gets one monitor. Both answers carry the same monitor_id, and the one that did not charge says credits_used: 0 and idempotent_replay: true. After a create has finished, a repeat for the same target is settled by a fixed two-minute window on the clock, not counted from your first request, and answers the same way. Once the monitor exists, another create for the same target is refused with 400 before any charge. |
The email composer: POST /v1/compose, POST /v1/compose/subject and POST /v1/compose/improve | A key belongs to one of the three routes. The same key and the same inputs return what that key bought, with credits_used: 0 and idempotent_replay: true, for 30 days. The same key with different inputs is 409 IDEMPOTENCY_KEY_CONFLICT, before any charge. A new key is a new purchase, except for a compose without force_new whose inputs are already in your 30-day cache: that is answered free from the cache. | A compose without force_new is answered free from your 30-day cache when you have composed the same inputs before, with from_cache: true and no credits_used field. A force_new compose, a subject or an improvement sent again within 3 minutes of its result being stored returns that result with credits_used: 0 and idempotent_replay: true. Anything else is a new purchase. |
| The CRM integrations' verification routes: HubSpot contact and list, Salesforce contact, lead and campaign, and Mailchimp audience verification | A key belongs to one route. The same key and the same request (the same record, list or campaign, and for an audience the same options) answer from the first attempt's charge and job and do not charge again, without reading the record or list again when the job has a result, is still running or has failed. This lasts as long as the job is kept, 7 days for a contact or lead and 30 days for a list, campaign or audience; a repeat after that is 410 IDEMPOTENT_JOB_EXPIRED and is not charged. Once a verification is refunded in full because it failed, the same key buys a new one, up to four times; after that the key is refused with 409 IDEMPOTENCY_KEY_CONFLICT. A list, campaign or audience job refunded later because it stalled keeps answering under its key as that refunded job: send a new key to verify it again. The same key with a different record, list or options is 409 IDEMPOTENCY_KEY_CONFLICT, before any charge. Two identical contact or lead requests sent at the same moment share one charge. When the verification is queued, one copy queues it and the other is answered 409 CONFLICT with retryable: true until it has; when a stored result answers instead, the other copy can get 503 CRM_UPDATE_UNCERTAIN while the first writes it to the record. | The same record, list or campaign sent again within a fixed two-minute window on the clock, not counted from your first request, replays the first charge. After that it is a new purchase. Two identical contact or lead requests sent at the same moment share one charge. When the verification is queued, one copy queues it and the other is answered 409 CONFLICT with retryable: true until it has; when a stored result answers instead, the other copy can get 503 CRM_UPDATE_UNCERTAIN while the first writes it to the record. |
A manual Auto-Clean run: POST /v1/auto-clean/lists/{monitorId}/run | The key is kept with the saved run for 180 days, and it belongs to that list: the same key on another list starts that list's own run, charged as usual. A repeat on the same list returns the saved run and does not charge again. The answer has the same shape as the first one, with no credits_used or idempotent_replay field, so read the run's history for what it used and returned. | Refused with 400 INVALID_IDEMPOTENCY_KEY before any charge: this route requires a key. |
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 | The credential no longer works: it was deleted, revoked or deactivated, or a change on the account retired it. Create a new API key in the dashboard, or connect an app that signed in again. Two cases have another remedy, and their message names it: a sub-account key replaced by a key reset (use the key the reset returned), and a credential turned off while an account deletion is pending (use the cancellation link in the deletion email) |
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 payment for a plan change did not go through, and nothing was changed yet. When the response carries confirmation_url, the upgrade is held for payment on that page: open it before the time the message names (confirmation_expires_at) and confirm the payment, or pay with another card if the message says the payment did not go through. The plan changes once it is paid. Otherwise the card on file was declined: 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, regenerating a webhook’s secret, creating a white-label sub-account or resetting its key, 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 |
IDEMPOTENT_EXPORT_EXPIRED | 410 | No | The export accepted for this Idempotency-Key is no longer retained. This retry did not start another export. Use a new key for a new request. |
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 account’s per-batch cap (100 on Free, or 1,000 once a Free account has bought any credit pack, 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 that cap can rise, with a higher plan or, on Free, any credit pack, 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 when its route’s time limit at the API gateway runs out is answered by the gateway, not by Kawaa, and so is one the gateway could not hand to the API at all. The limit is about 29 to 30 seconds on most routes, every route that charges credits included. Routes meant to answer quickly have less: 15 seconds on sign-in, registration and password recovery (/v1/auth/), GET /v1/account, the API key routes (/v1/api-keys), the invoice routes, checkout and the billing portal, the CRM connection routes, GET /v1/jobs, GET /v1/jobs/{id} and POST /v1/jobs/{id}/cancel; 10 seconds on sign-out and the tracking pixel and link routes; and 5 seconds on the health checks (GET /health). 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.
The gateway answers two more failures itself, also without the envelope, and neither is run or charged: a request body over about 6 MB is a 413 whose whole body is {"message":"Request Entity Too Large"}, and a path with malformed percent-encoding (such as %E0%A4%A) is a 400 whose whole body is {"message":"Bad Request"}. An error whose body is not JSON at all (an HTML page) also comes from infrastructure in front of the API: handle it by its status.
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. A tool call can also be refused inside the MCP server with codes the REST API does not use, such as UNKNOWN_ARGUMENT, INVALID_ARGUMENT and MAX_CREDITS_EXCEEDED. Those arrive as a failed tool result rather than a JSON-RPC error, and are listed on the MCP page.
| 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.