API Reference
Getting Started
Everything the Mailneo API does that is not specific to one endpoint: how you authenticate, what a response looks like, how you page through a large collection, how you sync only what changed, and what to do when a request fails. Read this once; then the endpoint reference only has to tell you about the endpoint.
Before You Start
You will need
- A team on the Professional plan or higher. The API is not available on lower plans, and a request from one is refused with
plan_upgrade_required. - An API key, created under Settings → API keys. The secret is shown once, at creation. It is stored hashed, so we cannot show it to you again — if you lose it, rotate the key.
- The scopes the endpoints you want need. A key only ever holds the scopes you grant it, narrowed by what your own role permits.
Your first request
curl "https://api.mailneo.co/api/v2/me" \ -H "X-API-Key: $MAILNEO_API_KEY"
GET /me needs no scope at all, which makes it the right thing to call first: it tells you which team the key belongs to, which scopes it actually has, and which API version it is pinned to.
Authentication
Two equivalent headers
Send your key either way — pick whichever your HTTP client makes easy:
X-API-Key: mk_live_...Authorization: Bearer mk_live_...
Sending both is fine only if they are identical. Two different credentials in one request is never legitimate, so it is refused with api_key_invalid rather than resolved to one of them.
Key format
mk_live_<keyId>_<secret><checksum>
mk_live_for live keys,mk_test_for test keys. A whole token is 68 characters.- The last 6 characters are a checksum. A truncated paste is rejected before we touch the database, so a mangled key fails fast and unambiguously with
api_key_malformed. - Only
mk_live_<keyId>is ever displayed back to you. It contains no character of the secret.
Live and test are separate
A test token presented against a live key record — or the reverse — is api_key_environment_mismatch, never a fallback. A test secret that leaks cannot be replayed against live data, and test keys can never send mail.
A key is not a password
- Keep it server-side. Anything you put in a browser, a mobile app or a public repository is compromised.
- Its permissions follow its creator. If that person's role is narrowed, or they leave the team, the key narrows or stops working with
role_insufficient— effective scopes are re-resolved continuously rather than frozen at creation, and a role change takes effect within a minute. - Revocation is immediate and is not reversible.
Scopes
Every endpoint declares the one scope it requires, and the check runs before the endpoint does. A key missing that scope gets insufficient_scope, and the message names the scope it needed — scopes are public documentation, so telling you which one is missing costs nothing and saves a support ticket.
Scopes on the published surface
| Scope | Grants |
|---|---|
| accounts:read | Accounts |
| analytics:read | Analytics |
| campaigns:read | Campaigns |
| contacts:delete | Contacts |
| contacts:read | Contacts |
| contacts:write | Contacts |
| lists:read | Lists |
| lists:write | Lists |
| newsletters:read | Newsletters |
| segments:read | Segments |
| segments:write | Segments |
| subscribers:delete | Subscribers |
| subscribers:read | Lists, Subscribers |
| subscribers:write | Subscribers |
| suppressions:delete | Suppressions |
| suppressions:read | Suppressions |
| suppressions:write | Suppressions |
| templates:read | Templates |
| templates:write | Templates |
| webhooks:read | Webhooks |
| webhooks:write | Webhooks |
Grant the narrowest set that does the job. There is deliberately no scope for the unified inbox and no scope that writes email account credentials — a permission that does not exist cannot be mis-granted.
The Response Envelope
Every successful response has the same two top-level keys. The payload is always under data; everything about the response rather than the resource is under meta.
A list response, in outline
{
"data": [ /* ... */ ],
"meta": {
"request_id": "req_...",
"has_more": true,
"next_cursor": "cur_...",
"synced_through": "2026-08-03T11:59:55Z"
}
}What meta carries
| Field | Meaning |
|---|---|
| request_id | On every response, success or failure. Quote it in support requests — it is how we find your request in the logs. |
| has_more | Lists only. Whether another page exists. |
| next_cursor | Lists only. Pass it back as cursor for the next page, or null when there is not one. |
| synced_through | The watermark to store for your next run. Meaningful only on endpoints that accept updated_since; ignore it elsewhere. |
There is no total count
Deliberately. An exact count over a mutating multi-million-row table is a full scan on every page, and it would be stale by the time you read it. Use has_more to decide whether to keep going.
Pagination
List endpoints are cursor-paginated. There is no page number and no offset: cursors are keyset-based, so inserting a row while you are halfway through a collection cannot make you skip or repeat one the way an offset would.
Parameters
| Parameter | Type | Behaviour |
|---|---|---|
| limit | integer | Rows per page. Above the maximum is a validation error, not a silent clamp. default 25 · 1–100 |
| cursor | string | The next_cursor value from the previous page. max 256 chars |
| sort | "updated_at" | "created_at" | "last_activity_at" | Which timestamp orders the page. The allowed values differ per endpoint — the reference lists them. default "updated_at" |
| order | "asc" | "desc" | Direction of travel through the sort key. default "desc" |
The types and limits above are read from the specification. Each endpoint's own reference page lists the exact set it accepts — sort in particular is per-endpoint, not universal.
Treat cursors as opaque
A cursor is a cur_-prefixed string. It is not signed and holds no secret, but its encoding is not part of the contract: store it, send it back, and do not parse it.
Every way a cursor can be wrong — truncated, edited, from a different sort order — produces the single code cursor_invalid. One code means your sync client needs exactly one branch: discard the saved cursor and restart from the first page.
Incremental Sync
Walking every contact on every run is wasteful and will hit the rate limit long before it hits correctness problems. Endpoints that support it accept updated_since and return a watermark in meta.synced_through.
The sync loop
# First run: no watermark, walk every page. curl "https://api.mailneo.co/api/v2/contacts?limit=100" \ -H "X-API-Key: $MAILNEO_API_KEY" # Follow meta.next_cursor until meta.has_more is false, then store the # LAST meta.synced_through you saw. curl "https://api.mailneo.co/api/v2/contacts?limit=100&cursor=$CURSOR" \ -H "X-API-Key: $MAILNEO_API_KEY" # Every run after that: only what changed. curl "https://api.mailneo.co/api/v2/contacts?updated_since=$SYNCED_THROUGH" \ -H "X-API-Key: $MAILNEO_API_KEY"
Three rules that keep a sync correct
- Upsert by id, never insert. The watermark is held 5 seconds behind real time so a row written during your request cannot slip between two runs. The cost of that safety is that you will occasionally see a row you already have.
- Store the watermark only after the last page. Saving it mid-walk and crashing loses every row you had not read yet.
- Deletes are invisible. A deleted row has no timestamp to be newer than your watermark, so it simply stops appearing. If your system needs to mirror deletions, reconcile with a full walk periodically.
Not every list supports it
Where the underlying record has no modification timestamp, updated_since is absent rather than accepted-and-ignored — an endpoint that accepted it without honouring it would let you build a poll that silently missed rows forever. The reference shows exactly which parameters each endpoint takes.
Errors
Failures return a non-2xx status and a body with the same shape every time.
An error response
{
"error": {
"type": "permission_error",
"code": "insufficient_scope",
"message": "This endpoint requires the \"contacts:read\" scope.",
"doc_url": "https://www.mailneo.co/documentation/api/getting-started#insufficient_scope",
"request_id": "req_..."
},
"meta": { "request_id": "req_..." }
}Branch on code, not message
codeis a stable contract. A code may be added, but an existing one is never renamed or repurposed.typeis the broad category, useful for a default handler.messageis written for a human reading a log. It is not stable — do not parse it.paramappears when one field is at fault, and names it.request_ididentifies the request in our logs. Log it.
Authentication
| Code | Status | When |
|---|---|---|
| api_key_missing | 401 | Neither an X-API-Key nor an Authorization: Bearer header was present. |
| api_key_malformed | 401 | 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 | 401 | 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 | 401 | The key is past its expiry date. |
| api_key_revoked | 401 | The key was revoked. Issue a new one; revocation is not reversible. |
| api_key_environment_mismatch | 401 | 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 | 401 | 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. |
Permission
| Code | Status | When |
|---|---|---|
| insufficient_scope | 403 | 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 | 403 | 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 | 403 | The key holds a scope combination that is banned. Checked at request time as well as at mint time. |
| plan_upgrade_required | 402 | The team's plan does not include API access. The API requires Professional or higher. |
| send_not_permitted | 403 | A test key reached an endpoint that sends mail. Test keys never send. |
Request
| Code | Status | When |
|---|---|---|
| validation_failed | 400 | A parameter or body field failed validation. param names the offending field. |
| unsupported_parameter | 400 | A parameter this endpoint does not accept. |
| idempotency_key_required | 400 | The endpoint requires an Idempotency-Key header and none was sent. |
| invalid_json | 400 | The request body is not valid JSON. Raised by the body parser before the endpoint runs. |
| payload_too_large | 413 | The request body exceeded the accepted size. |
| unsupported_media_type | 415 | The request used a content encoding the server does not accept. |
| version_unsupported | 400 | 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. |
List parameters
| Code | Status | When |
|---|---|---|
| cursor_invalid | 400 | 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 | 400 | A filter this endpoint does not accept. Retrying will not help; fix the caller. |
| sort_unsupported | 400 | A sort value outside this endpoint's allowed set. Retrying will not help; fix the caller. |
| expand_unsupported | 400 | An expand value this endpoint does not accept. |
Conflict
| Code | Status | When |
|---|---|---|
| idempotency_key_reused | 409 | This Idempotency-Key was already used with a different request. Keys are fingerprinted over method, path, parameters and body. |
| request_in_flight | 409 | A request with this Idempotency-Key is still running. Retry after the interval in Retry-After. |
| confirmation_required | 409 | A destructive endpoint needs a confirmation token before it will run. |
| confirmation_stale | 409 | The confirmation token no longer matches the state it was issued against. Re-read and re-confirm. |
| confirmation_invalid | 409 | The confirmation token is not recognised. |
Limits
| Code | Status | When |
|---|---|---|
| rate_limit_exceeded | 429 | Too many requests. Retry-After gives the wait in whole seconds. |
| quota_exceeded | 402 | 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. |
Generic
| Code | Status | When |
|---|---|---|
| resource_already_exists | 409 | 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 | 409 | 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 | 409 | 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. |
| not_found | 404 | 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. |
| internal_error | 500 | Something failed on our side. The message is a fixed string; the cause is logged against request_id and never returned. |
| service_unavailable | 503 | A dependency is temporarily unavailable. Retry after the interval in Retry-After. |
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: the taxonomy above applies uniformly, which is the point of having one.
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 that the id exists, which is an existence oracle across customers. Do not read a 404 as proof that a record was deleted.
Idempotency
Endpoints that change state accept an Idempotency-Key header so a network timeout does not force you to choose between losing the write and doing it twice. Send a fresh unique value — a UUID is ideal — per logical operation, and reuse the same one on every retry of it.
How a replay is decided
- The key is fingerprinted together with the method, path, parameters and body. Same key and same request within 24 hours replays the original response, with
Idempotent-Replayed: trueon it. - Same key with a different request is
idempotency_key_reused— that mismatch is a bug in the caller, not something to guess at. - Same key while the first attempt is still running is
request_in_flight, withRetry-Aftertelling you when to try again. A stalled attempt is reclaimed after 90 seconds. - Keys are scoped to your team and may be up to 255 characters.
Where the header has no effect
It is ignored on GET and DELETE, which are already safe to repeat.
Rate Limits
Two limits
- Per key: the limiter admits 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.
Headers on every response
| 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, and also on 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.
Backing off
Honour Retry-After when it is present, and use exponential backoff with jitter otherwise. Retry rate_limit_exceeded, service_unavailable and internal_error. Do not retry a invalid_request_error unchanged — it will fail identically every time.
Versioning
The path (https://api.mailneo.co/api/v2) moves only for a structural break. Everything smaller — a renamed field, a narrowed enum, a changed default — moves on a dated version header instead.
Mailneo-Version
- A version is pinned to your key when the key is created and never moves on its own. A key minted today keeps today's behaviour until you opt it up.
- Send
Mailneo-Versionto override it for one request — this is how you test a new shape before re-pinning. - An unrecognised value is
version_unsupported, never a silent fallback. A client that asked for a version it did not get would misparse every response. - The resolved version comes back on every authenticated response in the same header. Requests rejected before authentication completes do not carry it.
- A key created today is pinned to
2026-08-15. Every version that has ever shipped stays callable:2026-08-15.
Verifying Webhooks
Everything above describes requests you send us. This describes the requests we send you. Register an endpoint with POST https://api.mailneo.co/api/v2/webhooks and the response carries a signing secret starting whsec_.
The secret is returned once
It appears in the body of the POST that creates the endpoint and never again — not on a read, not on an update, not behind a query parameter. It is encrypted at rest and we cannot show it to you afterwards. Store it before you do anything else. If you lose it, delete the endpoint and register a new one; the replacement gets a new secret, so update your consumer at the same time.
Headers on every delivery
X-Mailneo-Signature—t=<timestamp>,v1=<hex>. The hex is HMAC-SHA256 of<timestamp>.<raw body>keyed with your secret.X-Mailneo-Timestamp— the same unix-seconds value that is inside the signature.X-Mailneo-Event— the catalog event name, so you can route without parsing the body.X-Mailneo-Event-Id— deduplicate on this. It is stable across every retry and across a manual replay, so seeing it twice means you have already handled this event.X-Mailneo-Replay—truewhen the delivery was triggered by hand rather than by the original event.
Verify against the raw request body, before any JSON parsing. Re-serializing changes the bytes — key order, whitespace, number formatting — and the signature will not match.
Verify a delivery (Node)
import crypto from "node:crypto";
// rawBody must be the unparsed bytes — express.raw({ type: "application/json" }).
function verify(rawBody, headers, secret) {
const header = headers["x-mailneo-signature"] ?? "";
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=", 2))
);
const t = Number(parts.t);
if (!Number.isFinite(t)) return false;
// Both directions: a far-future timestamp is a forgery signal too.
const age = Math.abs(Math.floor(Date.now() / 1000) - t);
if (age > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(parts.v1 ?? "", "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Two checks, not one
- Compare digests in constant time. A plain
===leaks how much of a forged signature was correct, one byte at a time. - Reject a timestamp more than 300 seconds from now, in both directions. Without it a captured delivery stays replayable forever, and a far-future timestamp is equally a forgery signal.
Retries and failures
- Answer
2xxas soon as you have durably accepted the event. Do the work afterwards — a slow handler burns the delivery timeout and gets retried. - Anything else is retried on a backoff ladder. A
4xxother than408or429is treated as permanent and not retried, because it will fail the same way next time. - An endpoint that fails continuously is switched off automatically and the team owner is emailed. Read
disabled_reasonon the endpoint to tell that apart from someone turning it off, and re-enable withPATCH { "active": true }. - The delivery log records the status, your response body and the error for every attempt. It does not include the event payload — fetch the object from its own endpoint using the event id if you need it.