Appearance
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 nocode.Body validation failures return
400with 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. |
BATCH_TOO_LARGE | More than 1,000 recipients in a batch. | Put them in an audience 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 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 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 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. |
({"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 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. If the first attempt was sent, you get its result back instead of a second email. 429: waitdetails.retryAfterseconds (or untildetails.resetsAt), then retry.500: your SMTP server refused the message. Retrying the same request rarely helps; fix the cause inmessagefirst. A retry with the same key replays the failure as200with"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 and let MarleyFetch do the retrying.
The SDK throws a MarleyFetchError with status, code, message and retryAfter.