Concepts

Errors

Failures return a non-2xx status and a body with the same shape every time; the code inside it is a stable contract your client can branch on.

The error envelope

{
  "error": {
    "type": "permission_error",
    "code": "insufficient_scope",
    "message": "This endpoint requires the \"subscribers:write\" scope.",
    "doc_url": "https://www.mailneo.co/documentation/api/getting-started#insufficient_scope",
    "request_id": "req_..."
  },
  "meta": { "request_id": "req_..." }
}
  • code — the stable contract. A code may be added, but an existing one is never renamed or repurposed. Branch on this.
  • type — the broad category (invalid_request_error, authentication_error, permission_error, not_found_error, conflict_error, rate_limit_error, quota_error, api_error), useful for a default handler.
  • message — written for a human reading a log. Not stable; do not parse it.
  • param — present when one field is at fault, and names it.
  • doc_url — a link to this documentation, anchored to the exact code.
  • request_id — in both error and meta, so a client written against either placement finds it.
import { MailneoApiError } from "@mailneo/sdk";

try {
  await client.subscribers.create({ email });
} catch (err) {
  if (err instanceof MailneoApiError) {
    // Branch on the stable code, never on the message.
    switch (err.code) {
      case "resource_already_exists":
        return updateInstead(); // it exists and you own it — PATCH it
      case "rate_limit_exceeded":
        return retryAfter(err.retryAfterMs);
      default:
        log.error({ code: err.code, requestId: err.requestId });
        throw err;
    }
  }
  throw err;
}

request_id and support

Every response — success or failure — carries meta.request_id. It is how we find your request in the logs, so log it on your side and quote it in support requests. On a 500, the message is a fixed string on purpose: the real cause is recorded server-side against the request_id and never returned to the caller.

The codes, by status

The catalogue is declared ahead of the features that raise from it, so a code you have never seen may simply not be reachable yet. Individual endpoints do not document their own error responses: this taxonomy applies uniformly, which is the point of having one.

HTTP 400

CodeTypeWhen
validation_failedinvalid_request_errorA parameter or body field failed validation. param names the offending field.
unsupported_parameterinvalid_request_errorA parameter this endpoint does not accept.
idempotency_key_requiredinvalid_request_errorThe endpoint requires an Idempotency-Key header and none was sent.
invalid_jsoninvalid_request_errorThe request body is not valid JSON. Raised by the body parser before the endpoint runs.
version_unsupportedinvalid_request_errorMailneo-Version named a version that does not exist. Never a silent fallback — a client pinned to a version it does not get would misparse every response.
cursor_invalidinvalid_request_errorThe cursor could not be decoded. Restart from the first page — every decode failure collapses to this one code so a sync client has exactly one branch to write.
filter_unsupportedinvalid_request_errorA filter this endpoint does not accept. Retrying will not help; fix the caller.
sort_unsupportedinvalid_request_errorA sort value outside this endpoint's allowed set. Retrying will not help; fix the caller.
expand_unsupportedinvalid_request_errorAn expand value this endpoint does not accept.

HTTP 401

CodeTypeWhen
api_key_missingauthentication_errorNeither an X-API-Key nor an Authorization: Bearer header was present.
api_key_malformedauthentication_errorThe token is not a well-formed key. The embedded checksum is verified before any database lookup, so a truncated copy-paste fails here.
api_key_invalidauthentication_errorNo key matches the token. Also returned when two different credentials are presented in one request — that is refused outright rather than resolved to one of them.
api_key_expiredauthentication_errorThe key is past its expiry date.
api_key_revokedauthentication_errorThe key was revoked. Issue a new one; revocation is not reversible.
api_key_environment_mismatchauthentication_errorA mk_test_ token was presented against a live key record, or the reverse. A leaked test secret can never be replayed against live data.
invalid_tokenauthentication_errorAn OAuth bearer token was presented but is expired, revoked, or not valid for this resource. RFC 6750's name, kept distinct from api_key_invalid because every OAuth client branches on it. The 401 carries a WWW-Authenticate challenge pointing at the authorization server, so a client that lost its configuration can rediscover it rather than hitting a dead end.

HTTP 402

CodeTypeWhen
plan_upgrade_requiredquota_errorThe team's plan does not include API access. The API requires Professional or higher.
quota_exceededquota_errorA plan usage ceiling was reached. GET /usage reports the current period's consumption so an integration can throttle itself instead of discovering the ceiling here.

HTTP 403

CodeTypeWhen
insufficient_scopepermission_errorThe key does not hold the scope this endpoint requires. The message names the missing scope — scopes are public documentation, so it is safe to say which one.
role_insufficientpermission_errorThe team member who created the key no longer has the role its scopes depend on, or has left the team. Effective scopes are re-resolved on every request.
forbidden_scope_pairpermission_errorThe key holds a scope combination that is banned. Checked at request time as well as at mint time.
send_not_permittedpermission_errorThis credential cannot send mail: it is a test key (test keys never send), or it was minted with requires_approval and the out-of-band approval flow is not yet available over the API.
send_domain_not_allowedpermission_errorThe key's send_domains allowlist does not cover every address the campaign would send from. An empty allowlist means the key cannot send at all, whatever its scopes — mint a key that names the domains it may send from.
complaint_rate_exceededpermission_errorThe team's complaint rate over the last 30 days is at or above 0.20% of delivered mail, so API sends are paused. Review list hygiene and consent; the breaker lifts as the rate falls.

HTTP 404

CodeTypeWhen
not_foundnot_found_errorThe record does not exist, or exists and belongs to another team. Those two cases are deliberately indistinguishable: a 403 on an id you do not own would confirm the id exists.

HTTP 409

CodeTypeWhen
idempotency_key_reusedconflict_errorThis Idempotency-Key was already used with a different request. Keys are fingerprinted over method, path, parameters and body.
request_in_flightconflict_errorA request with this Idempotency-Key is still running. Retry after the interval in Retry-After.
confirmation_requiredconflict_errorA destructive endpoint needs a confirmation token before it will run.
confirmation_staleconflict_errorThe confirmation token no longer matches the state it was issued against. Re-read and re-confirm.
confirmation_invalidconflict_errorThe confirmation token is not recognised.
send_plan_changedconflict_errorThe campaign no longer matches the plan the confirmation token was issued against — a recipient, suppression, content or sending-account change landed between preview and confirm. Call send-preview again, review the new impact, and confirm with the new token.
campaign_not_readyconflict_errorThe campaign is a draft but not sendable: it is missing a subject, content, a sending account, or has no eligible recipients after suppression filtering. Complete the draft and preview again.
sending_domain_unverifiedconflict_errorA sending account referenced by the campaign is not ACTIVE, or no longer exists in your team. Reconnect or verify the account in the app, then preview again.
resource_already_existsinvalid_request_errorA record with that natural key already exists in your team — for a contact, the email address. Distinct from 404 because the resource DOES exist and you DO own it, so the remedy is to PATCH it rather than re-POST. Use the bulk endpoint if you want upsert semantics.
resource_limit_reachedconflict_errorA per-team ceiling on this resource is full — today, the maximum number of webhook endpoints. Deliberately not a 402: the limit is a fixed platform constant, so upgrading your plan will not lift it. Delete one of the existing resources and retry the same request unchanged.
resource_state_invalidconflict_errorThe resource exists and you own it, but it is not in a state that permits this operation — replaying a delivery to a webhook endpoint that is disabled, for example. Nothing about the request is wrong; put the resource into the right state and send the same request again.

HTTP 413

CodeTypeWhen
payload_too_largeinvalid_request_errorThe request body exceeded the accepted size.

HTTP 415

CodeTypeWhen
unsupported_media_typeinvalid_request_errorThe request used a content encoding the server does not accept.

HTTP 429

CodeTypeWhen
send_cap_exceededrate_limit_errorThe key's daily send cap has no headroom for this send. The cap is counted across the key's whole rotation lineage — rotating a key never resets it — and resets at UTC midnight; Retry-After carries the wait.
rate_limit_exceededrate_limit_errorToo many requests. Retry-After gives the wait in whole seconds.

HTTP 500

CodeTypeWhen
internal_errorapi_errorSomething failed on our side. The message is a fixed string; the cause is logged against request_id and never returned.

HTTP 503

CodeTypeWhen
service_unavailableapi_errorA dependency is temporarily unavailable. Retry after the interval in Retry-After.
A missing record and someone else's record look the same. Asking for an id that belongs to another team returns not_found, not a permission error — a 403 on an id you do not own would confirm the id exists, which is an existence oracle across customers. Do not read a 404 as proof that a record was deleted.

Rate limits

  • Per key: 600 requests per 60 seconds. GET /usage separately reports your plan's documented allowance under api.requests_per_minute, which on lower plans is the smaller of the two — pace against whichever is lower, and treat RateLimit-Remaining as the authoritative live figure.
  • Per address, before authentication: 120 failed attempts per 60 seconds. Successful requests are not counted, so sharing an egress address with other customers — as every hosted automation platform does — cannot throttle you.
HeaderMeaning
RateLimit-LimitRequests allowed in the window.
RateLimit-RemainingRequests left in it.
RateLimit-ResetSeconds until the window resets.
Retry-AfterWhole seconds to wait. Sent on rate_limit_exceeded, send_cap_exceeded, request_in_flight and service_unavailable — honour it wherever it appears.

The legacy X-RateLimit-* spellings are sent alongside these for older clients. Prefer the unprefixed names.

What to retry

Honour Retry-After when present, exponential backoff with jitter otherwise. Retry rate_limit_exceeded, service_unavailable and internal_error. Do not retry an invalid_request_error unchanged — it will fail identically every time. Retry a write only under the same Idempotency-Key, so the retry replays instead of repeating; the idempotency page covers the semantics, and the SDK follows exactly these rules out of the box.