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"
}
}
| 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.
Validation failures are 422, not 400. The only 400 in the catalog is invalid_idempotency_key.
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, 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.
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.