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

ScopeGrants
accounts:readAccounts
analytics:readAnalytics
campaigns:readCampaigns
contacts:deleteContacts
contacts:readContacts
contacts:writeContacts
lists:readLists
lists:writeLists
newsletters:readNewsletters
segments:readSegments
segments:writeSegments
subscribers:deleteSubscribers
subscribers:readLists, Subscribers
subscribers:writeSubscribers
suppressions:deleteSuppressions
suppressions:readSuppressions
suppressions:writeSuppressions
templates:readTemplates
templates:writeTemplates
webhooks:readWebhooks
webhooks:writeWebhooks

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

FieldMeaning
request_idOn every response, success or failure. Quote it in support requests — it is how we find your request in the logs.
has_moreLists only. Whether another page exists.
next_cursorLists only. Pass it back as cursor for the next page, or null when there is not one.
synced_throughThe 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

ParameterTypeBehaviour
limitintegerRows per page. Above the maximum is a validation error, not a silent clamp. default 25 · 1–100
cursorstringThe 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

  • code is a stable contract. A code may be added, but an existing one is never renamed or repurposed.
  • type is the broad category, useful for a default handler.
  • message is written for a human reading a log. It is not stable — do not parse it.
  • param appears when one field is at fault, and names it.
  • request_id identifies the request in our logs. Log it.

Authentication

CodeStatusWhen
api_key_missing401Neither an X-API-Key nor an Authorization: Bearer header was present.
api_key_malformed401The 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_invalid401No 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_expired401The key is past its expiry date.
api_key_revoked401The key was revoked. Issue a new one; revocation is not reversible.
api_key_environment_mismatch401A 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_token401An 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

CodeStatusWhen
insufficient_scope403The 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_insufficient403The 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_pair403The key holds a scope combination that is banned. Checked at request time as well as at mint time.
plan_upgrade_required402The team's plan does not include API access. The API requires Professional or higher.
send_not_permitted403A test key reached an endpoint that sends mail. Test keys never send.

Request

CodeStatusWhen
validation_failed400A parameter or body field failed validation. param names the offending field.
unsupported_parameter400A parameter this endpoint does not accept.
idempotency_key_required400The endpoint requires an Idempotency-Key header and none was sent.
invalid_json400The request body is not valid JSON. Raised by the body parser before the endpoint runs.
payload_too_large413The request body exceeded the accepted size.
unsupported_media_type415The request used a content encoding the server does not accept.
version_unsupported400Mailneo-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

CodeStatusWhen
cursor_invalid400The 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_unsupported400A filter this endpoint does not accept. Retrying will not help; fix the caller.
sort_unsupported400A sort value outside this endpoint's allowed set. Retrying will not help; fix the caller.
expand_unsupported400An expand value this endpoint does not accept.

Conflict

CodeStatusWhen
idempotency_key_reused409This Idempotency-Key was already used with a different request. Keys are fingerprinted over method, path, parameters and body.
request_in_flight409A request with this Idempotency-Key is still running. Retry after the interval in Retry-After.
confirmation_required409A destructive endpoint needs a confirmation token before it will run.
confirmation_stale409The confirmation token no longer matches the state it was issued against. Re-read and re-confirm.
confirmation_invalid409The confirmation token is not recognised.

Limits

CodeStatusWhen
rate_limit_exceeded429Too many requests. Retry-After gives the wait in whole seconds.
quota_exceeded402A 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

CodeStatusWhen
resource_already_exists409A 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_reached409A 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_invalid409The 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_found404The 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_error500Something failed on our side. The message is a fixed string; the cause is logged against request_id and never returned.
service_unavailable503A 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: true on 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, with Retry-After telling 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 /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.

Headers on every response

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, 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-Version to 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 true when 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 2xx as 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 4xx other than 408 or 429 is 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_reason on the endpoint to tell that apart from someone turning it off, and re-enable with PATCH { "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.

Next Steps