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 botherrorandmeta, 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
| Code | Type | When |
|---|---|---|
| validation_failed | invalid_request_error | A parameter or body field failed validation. param names the offending field. |
| unsupported_parameter | invalid_request_error | A parameter this endpoint does not accept. |
| idempotency_key_required | invalid_request_error | The endpoint requires an Idempotency-Key header and none was sent. |
| invalid_json | invalid_request_error | The request body is not valid JSON. Raised by the body parser before the endpoint runs. |
| version_unsupported | invalid_request_error | Mailneo-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_invalid | invalid_request_error | The 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_unsupported | invalid_request_error | A filter this endpoint does not accept. Retrying will not help; fix the caller. |
| sort_unsupported | invalid_request_error | A sort value outside this endpoint's allowed set. Retrying will not help; fix the caller. |
| expand_unsupported | invalid_request_error | An expand value this endpoint does not accept. |
HTTP 401
| Code | Type | When |
|---|---|---|
| api_key_missing | authentication_error | Neither an X-API-Key nor an Authorization: Bearer header was present. |
| api_key_malformed | authentication_error | The 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_invalid | authentication_error | No 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_expired | authentication_error | The key is past its expiry date. |
| api_key_revoked | authentication_error | The key was revoked. Issue a new one; revocation is not reversible. |
| api_key_environment_mismatch | authentication_error | A 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_token | authentication_error | An 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
| Code | Type | When |
|---|---|---|
| plan_upgrade_required | quota_error | The team's plan does not include API access. The API requires Professional or higher. |
| quota_exceeded | quota_error | A 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
| Code | Type | When |
|---|---|---|
| insufficient_scope | permission_error | The 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_insufficient | permission_error | The 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_pair | permission_error | The key holds a scope combination that is banned. Checked at request time as well as at mint time. |
| send_not_permitted | permission_error | This 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_allowed | permission_error | The 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_exceeded | permission_error | The 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
| Code | Type | When |
|---|---|---|
| not_found | not_found_error | The 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
| Code | Type | When |
|---|---|---|
| idempotency_key_reused | conflict_error | This Idempotency-Key was already used with a different request. Keys are fingerprinted over method, path, parameters and body. |
| request_in_flight | conflict_error | A request with this Idempotency-Key is still running. Retry after the interval in Retry-After. |
| confirmation_required | conflict_error | A destructive endpoint needs a confirmation token before it will run. |
| confirmation_stale | conflict_error | The confirmation token no longer matches the state it was issued against. Re-read and re-confirm. |
| confirmation_invalid | conflict_error | The confirmation token is not recognised. |
| send_plan_changed | conflict_error | The 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_ready | conflict_error | The 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_unverified | conflict_error | A 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_exists | invalid_request_error | A 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_reached | conflict_error | A 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_invalid | conflict_error | The 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
| Code | Type | When |
|---|---|---|
| payload_too_large | invalid_request_error | The request body exceeded the accepted size. |
HTTP 415
| Code | Type | When |
|---|---|---|
| unsupported_media_type | invalid_request_error | The request used a content encoding the server does not accept. |
HTTP 429
| Code | Type | When |
|---|---|---|
| send_cap_exceeded | rate_limit_error | The 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_exceeded | rate_limit_error | Too many requests. Retry-After gives the wait in whole seconds. |
HTTP 500
| Code | Type | When |
|---|---|---|
| internal_error | api_error | Something failed on our side. The message is a fixed string; the cause is logged against request_id and never returned. |
HTTP 503
| Code | Type | When |
|---|---|---|
| service_unavailable | api_error | A dependency is temporarily unavailable. Retry after the interval in Retry-After. |
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 /usageseparately reports your plan's documented allowance underapi.requests_per_minute, which on lower plans is the smaller of the two — pace against whichever is lower, and treatRateLimit-Remainingas 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.
| Header | Meaning |
|---|---|
| RateLimit-Limit | Requests allowed in the window. |
| RateLimit-Remaining | Requests left in it. |
| RateLimit-Reset | Seconds until the window resets. |
| Retry-After | Whole 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.