Errors
What a failed Mailcrate API call looks like — the validation body and its per-field map, the stable code to branch on, and the x-request-id header to quote in support requests.
Every failed call answers with a JSON body and an x-request-id header. Two things are worth knowing before you write your error handling: which field to branch on (code, where it is present — never the human message), and which field to show a user (message).
The Envelope
Every error body carries at least statusCode and message. Most also carry error, and errors raised by a business rule carry code.
| Field | Always present | What it is |
|---|---|---|
statusCode | Almost always — a few older endpoints omit it, see One Legacy Shape | The HTTP status, repeated in the body |
message | Yes | Human-facing copy. A string, or an array of strings for validation failures |
error | Usually | Normally the HTTP reason phrase ("Bad Request", "Conflict"). Overloaded — see below |
code | Only on business-rule errors | Stable, machine-readable identifier. This is the field to branch on |
errors | Only on validation failures | Per-field map of what was rejected |
Anything else you see at the top level is a per-error detail — limit, documents, expectedVersion, bucket. These are specific to one code and documented with the endpoint that raises it.
`error` is not a machine code
error predates code and is inconsistent by now: it is the HTTP reason phrase on most responses, but "RATE_LIMITED" on a 429 and a handful of legacy strings elsewhere. It is kept as-is so existing integrations keep working. Read code in new code, and treat error as display or logging material.
One Legacy Shape
Not every error body is built by the same machinery. A few older endpoints publish a hand-rolled body carrying error and message and no statusCode key, and some raise a status with fields the table above does not describe. Read the HTTP status from the response itself rather than trusting the body to carry it, and treat any single field as optional unless you have seen it on the endpoint you are calling.
Validation Errors
A request body that fails validation is rejected with 400 Bad Request, the flat message array, and an errors map. Sending to an address that is not an email address answers:
curl -X POST https://api.mailcrate.io/public/sending/mail \
-H "Authorization: Api-Key $MAILCRATE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"contactEmail": "ada@example", "templateId": "welcome"}'{
"message": ["contactEmail must be a valid email address."],
"error": "Bad Request",
"statusCode": 400,
"errors": {
"contactEmail": ["contactEmail must be a valid email address."]
}
}A missing required property is reported exactly like a malformed one, so do not expect the map's keys to be a subset of what you sent.
errors is additive. The message array is unchanged, still carries every failure, and is still the compatible thing to read — a client written before errors existed keeps working untouched. Reach for errors when you want to put a message next to the input that caused it.
Some details that matter when you render these:
- Keys are property paths, dotted, with array indices, so a nested failure names exactly one input. In a batch send, a bad address in the third message is
messages.2.contactEmail, notmessages. - A value can fail more than one rule. Every key maps to an array, even when it holds a single message.
- Messages are the constraint text verbatim, prefix and all.
errors.emailis["email must be an email"]— the property name is not stripped. If your form labels the field itself, strip the prefix client-side. - A parent that failed only through its children gets no entry of its own. If
address.cityis invalid, you will findaddress.cityin the map and noaddresskey.
Contact Property Values
Values written to typed contact properties are checked the same way and produce the same shape, keyed by properties.<slug>:
{
"message": [
"Property \"favourite_number\": \"blue\" is not a valid number (examples: \"42\", \"-3.5\", \"1e3\")"
],
"error": "Bad Request",
"statusCode": 400,
"errors": {
"properties.favourite_number": [
"\"blue\" is not a valid number (examples: \"42\", \"-3.5\", \"1e3\")"
]
}
}Note the one asymmetry: here the errors values are the bare reason, while the message entries carry the Property "<slug>": prefix. The map key already names the property, so repeating it would only put the slug next to an input already labelled with it.
Business Rule Errors
When a request is well-formed but a rule says no, the body carries a code:
{
"statusCode": 403,
"error": "Forbidden",
"code": "WORKSPACE_LIMIT_REACHED",
"message": "You can own at most 3 workspaces. Leave or transfer one first, or contact support if you need more.",
"limit": 3
}code is SCREAMING_SNAKE_CASE, prefixed with the module it belongs to, and never renamed once shipped — that is the whole point of it. Branch on code. Do not match on message, which is human copy and is reworded whenever the copy is improved.
Extra fields sit at the top level, not nested under details. Above, limit is the ceiling you hit; other codes carry other fields (expectedVersion, documents, and so on), so read them by name rather than assuming a fixed shape.
A representative sample — not the full list, which grows with the API:
code | Status | Meaning |
|---|---|---|
WORKSPACE_LIMIT_REACHED | 403 | You already own the maximum number of workspaces |
WORKSPACE_ALREADY_MEMBER | 409 | That user is already in the workspace |
ORGANIZATION_NOT_FOUND | 404 | No organization with that id in this context |
IDEMPOTENCY_KEY_REUSED | 409 | The key was already used for a different request body |
JOURNEY_DRAFT_CONFLICT | 409 | The journey draft changed under you; reload and retry |
CAMPAIGN_FOLLOW_UP_TOO_SOON | 400 | Opens are still arriving; availableAt says when a resend to non-openers opens up |
LEGAL_ACCEPTANCE_REQUIRED | 400 | Documents in the response's documents array must be accepted first |
DB_UNIQUE_VIOLATION | 409 | A uniqueness constraint rejected the write |
Treat an unrecognised code as its HTTP status, and show message.
Rate Limits
429 Too Many Requests carries its own shape. The per-API-key limiter on /public/* names the budget you spent in bucket and repeats the wait in both retryAfterSeconds and retryAfter — that is the one documented under Rate Limits.
Mailcrate raises other 429s that do not carry bucket:
- The sign-in and password-reset abuse limiter answers
statusCode,error,messageandretryAfter, and sets theRetry-Afterheader. Its copy is identical for every address on purpose, so it cannot be used to work out whether an account exists. - The sending quota is one of the legacy shapes:
error("daily-send-limit-reached"or"monthly-send-limit-reached"),messageandretryAfterSeconds— nostatusCode, and noRetry-Afterheader, because it is raised below the layer that sets one.
So do not write a retry helper that assumes one field. Read Retry-After when it is there, fall back to retryAfter or retryAfterSeconds in the body, and back off on your own schedule if neither is.
Server Errors
A 500 is deliberately opaque and carries no error and no code:
{
"statusCode": 500,
"message": "Internal server error"
}Nothing about the internal failure reaches you. Quote the x-request-id of the failed call when reporting it, which is how the same request is found in Mailcrate's logs.
Request IDs
Every response carries an x-request-id header — successes and failures alike, including 404s for routes that do not exist. The only exception is a CORS preflight 204, which is answered before the id is assigned.
x-request-id: 3f9a1c2e-6b7d-4a10-9d3e-0c2f5b8e4a71Quote this id in a support request. It is the fastest way to find one specific call, and for a 500 it is the only detail that identifies it.
You may also send your own, which is how a trace survives the hop from your system into Mailcrate:
curl -i https://api.mailcrate.io/public/workspace \
-H "Authorization: Api-Key <api_key>" \
-H "x-request-id: order-sync-4821"An inbound id is echoed back after filtering, because it is attacker-controlled text that ends up in logs and response headers:
- Characters outside
A-Z a-z 0-9 . _ : @ -are stripped, not rejected.abc defcomes back asabcdef. - Longer than 128 characters is truncated.
- If nothing usable survives, the id is discarded and a fresh UUID is generated. You always get an id back; it is not always the one you sent, so read the response header rather than assuming.
The header is CORS-exposed (Access-Control-Expose-Headers) and accepted inbound, so browser-based clients can both read it and set it.
What To Branch On
In order:
- The HTTP status — retry logic, auth refresh, and backoff all key off it.
code, when present, for a specific rule you handle specially.errors, on a400, to attach messages to individual form fields.messagefor display only. Never match on its text.
And log x-request-id alongside whatever you record, so a failed call can be traced later.
export class MailcrateError extends Error {
constructor(
readonly status: number,
readonly code: string | undefined,
readonly fields: Record<string, string[]> | undefined,
readonly requestId: string | null,
message: string,
) {
super(message);
}
}
export async function mailcrate(path: string, init: RequestInit = {}) {
const res = await fetch(`https://api.mailcrate.io${path}`, {
...init,
headers: {
Authorization: `Api-Key ${process.env.MAILCRATE_API_KEY}`,
'Content-Type': 'application/json',
...init.headers,
},
});
if (res.ok) return res.json();
const body = await res.json().catch(() => ({}));
const message = Array.isArray(body.message) ? body.message.join('; ') : body.message;
throw new MailcrateError(res.status, body.code, body.errors, res.headers.get('x-request-id'), message ?? res.statusText);
}