11 · MailMarco API
Errors & limits
One envelope for every failure, a small set of status codes, and two independent limiting mechanisms that are easy to confuse.
The error envelope
Every non-2xx response is JSON with at least these three fields:
{
"statusCode": 400,
"error": "Bad Request",
"message": "Validation failed"
}Depending on the failure, two more fields may appear:
| Field | When | Contains |
|---|---|---|
details | Schema validation failed | An array of { path, message }, one entry per offending field. |
constraint | A uniqueness or reference conflict | The database constraint name. The conflicting value is never echoed back — it may belong to another tenant. |
{
"error": "plan_limit_exceeded",
"metric": "emails_sent",
"limit": 50000,
"message": "Plan limit reached for emails_sent"
}The coded errors, in full:
| error | Status | Meaning |
|---|---|---|
plan_limit_exceeded | 402 | Monthly plan allowance exhausted. Carries metric and limit. |
org_suspended | 403 | The organization is paused; sending is blocked. |
warmup_cap_reached | 429 | Today's warm-up cap is used up. |
Status codes
| Status | Meaning | Retry? |
|---|---|---|
200 | Success on GET, DELETE, PATCH, and POST routes that declare it. | — |
201 | Success on POST. This is the default, so action-style routes like /cancel, /verify and /rotate-secret answer 201, not 200. | — |
202 | A send was accepted and queued. Not yet delivered. | — |
400 | Validation failed, or a domain rule rejected the request (unverified domain, suppressed recipient, infected attachment, invalid token). | No — fix the request. |
401 | Missing, malformed, unknown or revoked credential. | No — fix the credential. |
402 | Plan limit reached. | Not this period. Upgrade or wait for the month to roll over. |
403 | Valid credential, forbidden state: paused organization, non-admin on an admin route, or an attachment that is not scan-clean. | No. |
404 | No such resource in your organization. | No. |
409 | Conflict: the resource already exists, or the state transition is not allowed (cancelling a message that is not scheduled). | No. |
429 | Rate limit or warm-up cap. The two are distinguishable by the body. | Yes, after the indicated wait. |
500 | Unhandled server error. The response body carries no detail. | Yes, with backoff. |
Validation errors
A schema violation is always a 400 with message: "Validation failed" and a details array. path is dot-joined, so a nested or indexed field reads naturally.
{
"statusCode": 400,
"error": "Bad Request",
"message": "Validation failed",
"details": [
{ "path": "to", "message": "Invalid email" },
{ "path": "subject", "message": "subject must not contain CR or LF characters" },
{ "path": "attachments.0.content", "message": "attachment exceeds 10485760 bytes" }
]
}Some rules are cross-field and report against the object rather than one path — for example subject is required unless a template is provided on a send, or supply exactly one of `contacts` or `csv` on a contact import.
Conflicts and references
{
"statusCode": 409,
"error": "Conflict",
"message": "Resource already exists",
"constraint": "domains_domain_unique"
}A reference to something that does not exist comes back as a 400 with message: "Referenced resource does not exist" and the same constraint field. Neither response echoes the value you sent.
Rate limits
Request-rate limiting is applied per route with a fixed window, and only on the routes listed below. There is no global request rate limit on the send path today — sending volume is governed by plan limits and warm-up caps instead.
| Route | Limit | Keyed by |
|---|---|---|
POST /v1/auth/signup | 5 per hour | Client IP |
POST /v1/auth/login | 10 per minute | Client IP |
POST /v1/domains/{id}/verify | 10 per hour | Organization |
{
"statusCode": 429,
"error": "Too Many Requests",
"message": "Rate limit exceeded. Try again later.",
"retryAfterSeconds": 2847
}Windows are fixed, not sliding: the counter resets wholesale when the window expires. There is no Retry-After HTTP header — the hint is in the body.
Resource bounds
These are enforced at the schema boundary, so exceeding one is always a 400 and never a partial write.
| Bound | Value | Where |
|---|---|---|
| Attachments per message | 20 | Emails |
| Attachment size (decoded) | 10 MiB each | Emails |
| Total attachment size (decoded) | 25 MiB | Emails |
| Emails per batch | 500 | Emails |
| Custom header names | ^[A-Za-z0-9-]+$ | Emails |
| Pagination limit | 1–100, default 25 (broadcast recipients allow up to 200, default 100) | List routes |
| Contacts per import | 10,000 rows | Marketing |
| CSV import document | 8 MiB | Marketing |
| Contact custom properties | 100 keys, keys ≤ 64 chars, string values ≤ 1024 chars | Marketing |
| Segment rule depth | 5 levels | Marketing |
| Segment rule nodes | 50 | Marketing |
in | 200 entries | Marketing |
| Preference-center topics per update | 200 | Marketing |
| Broadcast subject | 1–998 characters | Marketing |
| Webhook delivery attempts | 5, then dead | Webhooks |
| Reputation history window | 1–90 days (DMARC reports: 1–365) | Deliverability |
Retrying safely
- Always send an
Idempotency-KeyonPOST /v1/emails. It makes a timeout safe to retry, and a replay costs nothing. - Retry
429afterretryAfterSecondswhen present; otherwise treat it as “not until tomorrow”. - Retry
500with exponential backoff and a cap. - Never retry
400,401,403,404or409unchanged — nothing about the request will succeed on the second attempt. - The batch route ignores the idempotency header. Retry individual failed indices, not the whole batch.