MailcrateDocs

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.

FieldAlways presentWhat it is
statusCodeAlmost always — a few older endpoints omit it, see One Legacy ShapeThe HTTP status, repeated in the body
messageYesHuman-facing copy. A string, or an array of strings for validation failures
errorUsuallyNormally the HTTP reason phrase ("Bad Request", "Conflict"). Overloaded — see below
codeOnly on business-rule errorsStable, machine-readable identifier. This is the field to branch on
errorsOnly on validation failuresPer-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:

Terminal
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"}'
400 Bad Request
{
  "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, not messages.
  • 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.email is ["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.city is invalid, you will find address.city in the map and no address key.

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:

codeStatusMeaning
WORKSPACE_LIMIT_REACHED403You already own the maximum number of workspaces
WORKSPACE_ALREADY_MEMBER409That user is already in the workspace
ORGANIZATION_NOT_FOUND404No organization with that id in this context
IDEMPOTENCY_KEY_REUSED409The key was already used for a different request body
JOURNEY_DRAFT_CONFLICT409The journey draft changed under you; reload and retry
CAMPAIGN_FOLLOW_UP_TOO_SOON400Opens are still arriving; availableAt says when a resend to non-openers opens up
LEGAL_ACCEPTANCE_REQUIRED400Documents in the response's documents array must be accepted first
DB_UNIQUE_VIOLATION409A 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, message and retryAfter, and sets the Retry-After header. 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"), message and retryAfterSeconds — no statusCode, and no Retry-After header, 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-0c2f5b8e4a71

Quote 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 def comes back as abcdef.
  • 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:

  1. The HTTP status — retry logic, auth refresh, and backoff all key off it.
  2. code, when present, for a specific rule you handle specially.
  3. errors, on a 400, to attach messages to individual form fields.
  4. message for display only. Never match on its text.

And log x-request-id alongside whatever you record, so a failed call can be traced later.

mailcrate.ts
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);
}

On this page