# Errors

Errors use standard HTTP status codes. Most bodies look like this:

```json
{
  "code": "RATE_LIMITED",
  "message": "Account you@yourcompany.com is pacing sends. Retry in 4s.",
  "details": { "retryAfter": 4 }
}
```

Branch on `code`; `message` is written for humans and may change. `details` is present only
when there is something useful to add.

Two other shapes appear:

- **Authentication failures and some audience/template checks** return
  `{"error": "<message>"}` with no `code`.
- **Body validation failures** return `400` with the list of problems:

  ```json
  {
    "success": false,
    "error": {
      "name": "ZodError",
      "issues": [{ "code": "invalid_type", "path": ["subject"], "message": "Required" }]
    }
  }
  ```

A robust client checks the status first, then reads `code`, falling back to `error`.

## Error codes

### 400 Bad Request

| Code | Cause | Fix |
| --- | --- | --- |
| *(validation body)* | A required field is missing or has the wrong type, or neither `text_body` nor `html_body` was sent. | Read `error.issues[].path` and `.message`. |
| `TOO_MANY_RECIPIENTS` | More than 10 recipients across `to`, `cc` and `bcc` on `/send`. | Send a [batch](https://docs.marleyfetch.com/guides/batches.md). |
| `BATCH_TOO_LARGE` | More than 1,000 `recipients` in a batch. | Put them in an [audience](https://docs.marleyfetch.com/guides/audiences.md) and send with `audience_id`. |
| `IDEMPOTENCY_KEY_IN_FLIGHT` | An earlier request with the same `Idempotency-Key` is still running. | Wait a moment and retry with the same key. |
| `INVALID_ID` | The ID in the URL is not a number. | — |
| `INVALID_STATUS` | Deleting a batch that is still `pending` or `running`. | Wait until it finishes. |

### 401 Unauthorized

| Body | Cause | Fix |
| --- | --- | --- |
| `{"error": "Missing or invalid Authorization header"}` | No `Authorization: Bearer ...` header. | Add the header. |
| `{"error": "Invalid or expired API token"}` | The key is wrong, revoked or expired. | Check the key on the [API Keys](https://app.marleyfetch.com/tokens) page; create a new one if needed. |

### 403 Forbidden

| Code | Cause | Fix |
| --- | --- | --- |
| `NO_CONNECTION` | No `from` was sent and you have no connections yet. | Add one on the [Connections](https://app.marleyfetch.com/connections) page. |
| `CONNECTION_NOT_FOUND` | `from` is not one of your connections, or that connection is disabled or paused. | Check the address and the connection's status. |
| `CONNECTION_SCOPE_MISMATCH` | The key is [locked to a connection](https://docs.marleyfetch.com/guides/api-keys.md#locking-a-key-to-one-connection) and `from` is a different one. | Drop `from` or use the right key. |
| `CONNECTION_PAUSED` | Your provider kept rejecting sends, so the connection was paused. | Wait for it to resume, or check it in the dashboard. |
| `FEATURE_NOT_AVAILABLE` | The endpoint needs the Pro plan. `details.feature` names it. | [Upgrade](https://app.marleyfetch.com/account). |
| *(`{"error": "Access denied"}`)* | The audience or template belongs to another account. | — |
| *(`{"error": "Template limit reached"}`)* | Your plan's template limit is reached. | Delete a template or upgrade. |

### 404 Not Found

| Code | Cause |
| --- | --- |
| `BATCH_NOT_FOUND` | No batch with this ID on your account. |
| `RESOURCE_NOT_FOUND` | `template_id` in a batch does not exist. |
| *(`{"error": "Audience not found"}` etc.)* | No audience, member or template with this ID. |

### 429 Too Many Requests

| Code | Cause | When to retry |
| --- | --- | --- |
| `RATE_LIMITED` | The connection is pacing sends, or your SMTP provider pushed back. | After `details.retryAfter` seconds. |
| `QUOTA_EXCEEDED` | The connection's own daily or hourly [quota](https://docs.marleyfetch.com/guides/connections.md#quotas-and-pacing) is used up. | At `details.resetsAt`. |
| `DAILY_LIMIT_REACHED` | The Free plan's 100 emails a day. | Tomorrow (00:00 UTC), or upgrade. |

### 500 Internal Server Error

| Code | Cause |
| --- | --- |
| `INTERNAL_ERROR` | Usually your SMTP server refused the message or could not be reached; `message` carries its reason, e.g. `535 5.7.8 Username and Password not accepted`. Nothing was sent. |

## Retrying

- **No response at all** (timeout, dropped connection): retry with the same
  [`Idempotency-Key`](https://docs.marleyfetch.com/guides/sending.md#safe-retries-with-idempotency-key). If the first attempt was
  sent, you get its result back instead of a second email.
- **`429`**: wait `details.retryAfter` seconds (or until `details.resetsAt`), then retry.
- **`500`**: your SMTP server refused the message. Retrying the same request rarely helps; fix
  the cause in `message` first. A retry with the *same* key replays the failure as `200` with
  `"success": false` — use a new key for a new attempt.
- **Other `4xx`**: the request itself is wrong. Do not retry without changing it.

```ts
async function send(email: object, idempotencyKey: string, attempts = 3) {
  for (let attempt = 1; ; attempt++) {
    let res: Response
    try {
      res = await fetch('https://api.marleyfetch.com/v1/send', {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${process.env.MARLEYFETCH_API_KEY}`,
          'Content-Type': 'application/json',
          'Idempotency-Key': idempotencyKey,
        },
        body: JSON.stringify(email),
      })
    } catch (networkError) {
      if (attempt === attempts) throw networkError
      continue
    }

    const data = await res.json()
    if (res.ok && data.success) return data
    if (res.status !== 429 || attempt === attempts) {
      throw new Error(`${res.status} ${data.code ?? ''} ${data.message ?? data.error}`)
    }
    await new Promise(resolve => setTimeout(resolve, (data.details?.retryAfter ?? 5) * 1000))
  }
}
```

Or use [`/enqueue`](https://docs.marleyfetch.com/guides/sending.md#post-v1-enqueue) and let MarleyFetch do the retrying.

The [SDK](https://docs.marleyfetch.com/sdk.md) throws a `MarleyFetchError` with `status`, `code`, `message` and `retryAfter`.
