Skip to main content

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):

Error response
{
  "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

FieldPresent whenMeaning
retryableThe answer does not depend on the casetrue: 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_secondsThe server knows how long to waitWhole seconds to wait before retrying. The Retry-After header carries the same number.
retry_requires_same_idempotency_keyA 5xx on a charging route that accepts Idempotency-Key, for a request that sent oneRepeat 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_againA 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 outNo 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_chargedThe 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 itAlways 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_urlA 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_atWith confirmation_url, when the provider gave an expiryWhen 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.

RouteWith an Idempotency-KeyWithout one
Blacklist and DMARC checks: POST /v1/blacklist/check, POST /v1/dmarc/check and a monitor's manual checkThe 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/monitorsThe 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/improveA 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 verificationA 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}/runThe 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.

CodeHTTPRetryDescription
BAD_REQUEST400NoInvalid request parameters, malformed JSON, or missing required fields (details in the message)
RESET_TOKEN_INVALID400NoThe password-reset token sent to POST /v1/auth/reset-password is malformed, expired or already used. Request a new reset email
INVALID_WEBHOOK_SECRET400Nowebhook_secret is empty, longer than 256 characters, or supplied without webhook_url
WEBHOOK_URL_INVALID400NoWebhook URL is malformed, not HTTPS, or targets a private/reserved address
INVALID_OPTION400NoA 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_CURSOR400NoThe 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_KEY400NoThe 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
UNAUTHORIZED401NoMissing or invalid API key or access token
API_KEY_REVOKED401NoThe 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_SUSPENDED401NoKeys are suspended while the subscription is lapsed. Restore billing and the same key works again
EMAIL_VERIFICATION_REQUIRED401NoThe 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_EXPIRED401NoThe dashboard session has expired. Sign in again
OAUTH_TOKEN_EXPIRED401NoAn 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_CREDITS402NoNot enough credits for this operation
PAYMENT_REQUIRED402NoThe 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)
FORBIDDEN403NoAccess denied for a reason no more specific code covers
INSUFFICIENT_SCOPE403NoThe 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_EXCEEDED403NoYour plan’s limit for this feature has been reached (webhook endpoints, team seats, connected integrations, active tracking links on Free)
PLAN_UPGRADE_REQUIRED403NoYour 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_REFUSED403NoA 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_REQUIRED403NoCreating 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_MANAGED403NoA 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_FOUND404NoResource 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_EXPIRED410NoThis 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_EXPIRED410NoThe 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_EXPIRED410NoThe data export requested from GET /v1/exports/{id}/download has passed its retention window. Request a new export
CONFLICT409DependsRequest conflicts with the current state of the resource
IDEMPOTENCY_KEY_CONFLICT409NoThis 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_EXCEEDED413NoThe 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_LARGE413NoRequest body is larger than the endpoint accepts
RATE_LIMITED429YesToo many requests. Wait retry_after_seconds (or the Retry-After header) when present
INTERNAL_ERROR500DependsUnexpected server error
SERVICE_UNAVAILABLE503YesService temporarily unavailable
WEBHOOK_DNS_TEMPORARY_FAILURE503YesWebhook URL hostname could not be resolved (transient DNS failure). Safe to retry
INTEGRATION_NOT_CONFIGURED503NoThe requested integration provider is not configured/available yet
RESULT_SUPERSEDED503NoGET /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_TIMEOUT504YesUpstream 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:

Gateway timeout (HTTP 503)
{"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.

CodeHTTPRetryDescription
MCP_CREDENTIAL_RATE_LIMITED429YesThis 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_LIMITED429YesThe 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_UNAVAILABLE503YesThe 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

4xx

Client errors

These errors indicate a problem with the request. Check the error message, fix the issue, and retry.

429

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.

404

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.

5xx

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
// 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.