---
title: Errors
description: The Mailfully error envelope, the catalog of error types by HTTP status, and how to handle them in code.
---

> **For AI agents:** the complete documentation index is at [llms.txt](/docs/llms.txt). Append `.md` to any page URL for its markdown version.

Every error response uses one envelope:

```json
{
  "error": {
    "type": "validation_error",
    "message": "The request was invalid.",
    "param": "to"
  }
}
```

| Field | Meaning |
|---|---|
| `error.type` | Stable machine-readable code. Branch on this. |
| `error.message` | Human-readable explanation. May change over time — never parse it. |
| `error.param` | Validation errors only: the dotted path of the first offending field. Batch failures are index-prefixed, like `[37].to`. |

Retry-relevant headers ride on the response itself: `retry-after` and the `ratelimit-*` headers on 429s. See [Rate limits](/concepts/rate-limits).

<Note>
Validation failures are `422`, not `400`. The only `400` in the catalog is `invalid_idempotency_key`.
</Note>

## Catalog

| Status | `type` | Meaning | Typical fix |
|---|---|---|---|
| 400 | `invalid_idempotency_key` | `Idempotency-Key` header present but not 1–256 characters | Use a key between 1 and 256 characters |
| 401 | `missing_api_key` | No `Authorization` header | Send `Authorization: Bearer <key>` |
| 401 | `invalid_api_key` | Malformed `Authorization` header, or an unknown or truncated `mf_` key | Check for copy-paste truncation; mint a new key in the dashboard |
| 401 | `invalid_session_token` | The bearer does not start with `mf_` and is not a valid dashboard session | API requests need an `mf_` key — check what you pasted |
| 403 | `invalid_api_key` | The key exists but has been revoked | Switch to the replacement key from rotation |
| 403 | `insufficient_scope` | The credential lacks the scope the route requires | Mint a key with the needed scope; `manage:*` operations need a dashboard session |
| 403 | `forbidden` | The account is paused ("This account is paused.") | Sending stays refused until the pause is lifted — do not retry automatically |
| 403 | `unverified_domain_required` | A live send while your org has no verified sending domain | [Verify a domain](/guides/verify-a-domain), or send in test mode |
| 404 | `not_found` | The resource does not exist, belongs to another org or environment, or is past your plan's retention window | Check the id and the key's `test`/`live` environment |
| 409 | `conflict` | The request conflicts with the resource's state, such as canceling an email that is already sending | Fetch the current state and reconcile |
| 409 | `invalid_idempotent_request` | An `Idempotency-Key` was reused with a different body | Use a fresh key for each distinct request |
| 409 | `concurrent_idempotent_requests` | A request with the same `Idempotency-Key` is still in flight | Wait for the original to finish, then retry with the same key |
| 422 | `validation_error` | The body failed validation; `param` names the field | Fix the field named by `param` |
| 429 | `rate_limit_exceeded` | The request rate limit was exceeded | Honor `retry-after`, back off with jitter |
| 429 | `daily_quota_exceeded` | The rolling 24-hour send cap is spent | Honor `retry-after` |
| 429 | `monthly_quota_exceeded` | The monthly allowance (free) or overage ceiling (paid) was crossed | Upgrade the plan or wait for the new month — do not retry-loop |
| 429 | `spend_cap_exceeded` | Projected overage spend crossed your monthly spend cap | Raise the spend cap or wait — do not retry-loop |
| 500 | `internal_server_error` | An unexpected server fault; no internals are leaked | Retry with backoff. Idempotency keys replay only successful (2xx) responses, so a retried 500 re-executes the request |
| 503 | `service_unavailable` | A dependency was unavailable (for example the idempotency store); the request had no side effects | Retry with backoff, reusing the same `Idempotency-Key` |

One subtlety worth knowing: `invalid_api_key` appears at both statuses. 401 means the key cannot be identified; 403 means a recognized key has been revoked. For why a bad credential surfaces as 401 even when a scope problem also exists, see [Authentication](/concepts/authentication).

## Handling errors

Branch on `error.type`, never on `error.message`. Messages are for humans and may change without notice.

The [Node SDK](/sdks/node) never throws. Every method returns a `{ data, error }` tuple, with the envelope's `type`, `message`, and `param` mapped onto the error object (transport failures have `statusCode: null`):

```typescript
const { data, error } = await mailfully.emails.send(payload, {
  idempotencyKey: "order-1234-attempt-1",
});

if (error) {
  switch (error.type) {
    case "rate_limit_exceeded":
    case "daily_quota_exceeded":
    case "service_unavailable":
      // transient: back off, then resend with the same idempotency key
      break;
    case "validation_error":
      // error.param names the offending field, e.g. "from" or "[3].to"
      break;
    case "unverified_domain_required":
      // one-time setup problem: verify a sending domain
      break;
    default:
      // log with error.statusCode, error.type, error.message
  }
} else {
  console.log(data.id);
}
```

The same pattern applies to raw HTTP: read the JSON body, switch on `error.type`, and treat the HTTP status as a coarse hint rather than the decision point.
