Errors

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

Every error response uses one envelope:

{
  "error": {
    "type": "validation_error",
    "message": "The request was invalid.",
    "param": "to"
  }
}
FieldMeaning
error.typeStable machine-readable code. Branch on this.
error.messageHuman-readable explanation. May change over time — never parse it.
error.paramValidation 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.

Validation failures are 422, not 400. The only 400 in the catalog is invalid_idempotency_key.

Catalog

StatustypeMeaningTypical fix
400invalid_idempotency_keyIdempotency-Key header present but not 1–256 charactersUse a key between 1 and 256 characters
401missing_api_keyNo Authorization headerSend Authorization: Bearer <key>
401invalid_api_keyMalformed Authorization header, or an unknown or truncated mf_ keyCheck for copy-paste truncation; mint a new key in the dashboard
401invalid_session_tokenThe bearer does not start with mf_ and is not a valid dashboard sessionAPI requests need an mf_ key — check what you pasted
403invalid_api_keyThe key exists but has been revokedSwitch to the replacement key from rotation
403insufficient_scopeThe credential lacks the scope the route requiresMint a key with the needed scope; manage:* operations need a dashboard session
403forbiddenThe account is paused ("This account is paused.")Sending stays refused until the pause is lifted — do not retry automatically
403unverified_domain_requiredA live send while your org has no verified sending domainVerify a domain, or send in test mode
404not_foundThe resource does not exist, belongs to another org or environment, or is past your plan's retention windowCheck the id and the key's test/live environment
409conflictThe request conflicts with the resource's state, such as canceling an email that is already sendingFetch the current state and reconcile
409invalid_idempotent_requestAn Idempotency-Key was reused with a different bodyUse a fresh key for each distinct request
409concurrent_idempotent_requestsA request with the same Idempotency-Key is still in flightWait for the original to finish, then retry with the same key
422validation_errorThe body failed validation; param names the fieldFix the field named by param
429rate_limit_exceededThe request rate limit was exceededHonor retry-after, back off with jitter
429daily_quota_exceededThe rolling 24-hour send cap is spentHonor retry-after
429monthly_quota_exceededThe monthly allowance (free) or overage ceiling (paid) was crossedUpgrade the plan or wait for the new month — do not retry-loop
429spend_cap_exceededProjected overage spend crossed your monthly spend capRaise the spend cap or wait — do not retry-loop
500internal_server_errorAn unexpected server fault; no internals are leakedRetry with backoff. Idempotency keys replay only successful (2xx) responses, so a retried 500 re-executes the request
503service_unavailableA dependency was unavailable (for example the idempotency store); the request had no side effectsRetry 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.

Handling errors

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

The Node SDK 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):

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.