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

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 verification option has the wrong type (e.g. "deep_verify": "false" — booleans must be JSON booleans)
UNAUTHORIZED401NoMissing or invalid API key or access token
API_KEY_REVOKED401NoThis API key was deleted or deactivated — create a new key in the dashboard
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
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_REQUIRED402NoA paid plan or payment is required for this operation
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)
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
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 — see "Unrouted requests" below
METHOD_NOT_ALLOWED405NoHTTP method not supported on this endpoint. Only the batch and file routes answer this; an unrouted method elsewhere is a 404 NOT_FOUND
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
BATCH_SIZE_EXCEEDED413NoBatch or file exceeds your plan’s per-batch email cap
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
GATEWAY_TIMEOUT504YesUpstream verification timed out — retry or poll the job

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

404

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.

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