Skip to content
View as Markdown

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 ​

CodeCauseFix
(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_RECIPIENTSMore than 10 recipients across to, cc and bcc on /send.Send a batch.
BATCH_TOO_LARGEMore than 1,000 recipients in a batch.Put them in an audience and send with audience_id.
IDEMPOTENCY_KEY_IN_FLIGHTAn earlier request with the same Idempotency-Key is still running.Wait a moment and retry with the same key.
INVALID_IDThe ID in the URL is not a number.—
INVALID_STATUSDeleting a batch that is still pending or running.Wait until it finishes.

401 Unauthorized ​

BodyCauseFix
{"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 ​

CodeCauseFix
NO_CONNECTIONNo from was sent and you have no connections yet.Add one on the Connections page.
CONNECTION_NOT_FOUNDfrom is not one of your connections, or that connection is disabled or paused.Check the address and the connection's status.
CONNECTION_SCOPE_MISMATCHThe key is locked to a connection and from is a different one.Drop from or use the right key.
CONNECTION_PAUSEDYour provider kept rejecting sends, so the connection was paused.Wait for it to resume, or check it in the dashboard.
FEATURE_NOT_AVAILABLEThe 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 ​

CodeCause
BATCH_NOT_FOUNDNo batch with this ID on your account.
RESOURCE_NOT_FOUNDtemplate_id in a batch does not exist.
({"error": "Audience not found"} etc.)No audience, member or template with this ID.

429 Too Many Requests ​

CodeCauseWhen to retry
RATE_LIMITEDThe connection is pacing sends, or your SMTP provider pushed back.After details.retryAfter seconds.
QUOTA_EXCEEDEDThe connection's own daily or hourly quota is used up.At details.resetsAt.
DAILY_LIMIT_REACHEDThe Free plan's 100 emails a day.Tomorrow (00:00 UTC), or upgrade.

500 Internal Server Error ​

CodeCause
INTERNAL_ERRORUsually 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: 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 and let MarleyFetch do the retrying.

The SDK throws a MarleyFetchError with status, code, message and retryAfter.