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 appears on rate_limit_exceeded, daily_quota_exceeded, account_under_review and volume_limit_reached; the ratelimit-* triple appears on rate_limit_exceeded alone and is stripped from every other 429. 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 |
| 402 | payment_past_due | Live sending is paused because a payment is past due. Test-mode keys keep working | Update your payment method on the Billing page of your Mailfully dashboard; sending resumes as soon as the payment goes through |
| 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; see API key security |
| 403 | forbidden | The account is paused ("This account is paused.") | Sending stays refused until the pause is lifted; do not retry automatically. To appeal, email contact@mailfully.com; see Appeals |
| 403 | account_deleted | The account has been deleted. Refused in both environments | Do not retry; a deleted account cannot be restored. If you did not expect this, email contact@mailfully.com |
| 403 | unverified_domain_required | A live send while your org has no verified sending domain | Verify a domain, or send in test mode; see Live sending is refused |
| 403 | account_suspended | A live send from an account whose setup was never completed | Verify a sending domain; this clears itself, no ticket needed |
| 403 | account_rejected | A staff member declined the account, so live sending is refused | If you believe it is a mistake, email contact@mailfully.com; only a person can undo this |
| 403 | email_above_quota | A received email arrived over the monthly ceiling or spend cap, and usage is still over | Wait for the new month, upgrade, or raise the spend cap; the email unlocks then; see Mail received over the limit |
| 403 | domain_limit_reached | POST /v1/domains while the org already holds as many domains as its plan allows | Delete a domain you no longer use (Delete a domain) or upgrade the plan; every domain counts, pending ones included; see Adding a domain was refused |
| 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; for a domain add, see Adding a domain was refused |
| 409 | domain_parent_taken | Another organization already verified a domain under the same registrable parent | Legitimate for agencies and subsidiaries — write to contact@mailfully.com for an allowlist entry; see Adding a domain was refused |
| 409 | domain_not_verified | Turning receiving on for a domain that is not verified | Verify the domain first |
| 409 | mx_conflict | Turning receiving on for a domain that already has another provider's MX records | Receive on a subdomain such as inbound.acme.com; see MX record conflicts |
| 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; for param: "from", see Live sending is refused |
| 422 | domain_reserved | POST /v1/domains named mailfully.app, mailfully.ai, mailfully.net, or a name under one | Use a domain you own |
| 422 | domain_not_owned | POST /v1/domains named a domain nobody can own, such as gmail.com or a name under wordpress.com; param is name | Use a domain you own |
| 422 | marketing_missing_postal_address | A marketing send while the org has no physical postal address on file | Add a postal address in the dashboard; CAN-SPAM requires one in commercial mail |
| 422 | marketing_single_recipient | A marketing send named more than one recipient across to/cc/bcc | Send one recipient per message — one-click unsubscribe cannot say who opted out of a shared message |
| 422 | recipient_unsubscribed | The recipient of a marketing send opted out of marketing from your org | Stop mailing them; transactional mail to the same address still works |
| 429 | rate_limit_exceeded | The request rate limit was exceeded | Honor retry-after, back off with jitter; see Rate limits and quotas |
| 429 | daily_quota_exceeded | The rolling 24-hour send cap is spent | Back off; retry-after is a coarse floor, not a schedule; see Rate limits and quotas |
| 429 | account_under_review | A new account's live sending is bounded while staff review it | Wait for the review (24 hours on average); do not retry-loop; see New accounts |
| 429 | volume_limit_reached | The account reached its daily sending volume limit. Test-mode keys keep working | Contact contact@mailfully.com to raise it; room also frees up as the rolling 24-hour window moves; see Rate limits and quotas |
| 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; see Quotas and plan limits |
| 429 | marketing_quota_exceeded | The separate monthly marketing allowance was crossed | On Free, at most 1,000 of the monthly emails can be marketing; paid plans have no separate marketing limit. Wait for the new month or upgrade; transactional sends are unaffected; see Quotas and plan limits |
| 429 | spend_cap_exceeded | Projected overage spend crossed your monthly spend cap | Raise the spend cap or wait; do not retry-loop; see Quotas and plan limits |
| 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 | dns_lookup_failed | The MX lookup behind turning receiving on failed; nothing changed | Retry |
| 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 |
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.
See Troubleshooting, which routes domain verification, DNS, refused sends, and delivery failures to the page that fixes them, matched by error type or by symptom.
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-shipped",
});
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":
case "account_suspended":
// one-time setup problem: verify a sending domain
break;
case "account_under_review":
case "volume_limit_reached":
case "payment_past_due":
case "monthly_quota_exceeded":
case "marketing_quota_exceeded":
case "spend_cap_exceeded":
// Retrying right away won't help. Alert a person. The 24-hour
// limits clear as the window moves; the monthly ones last until the
// next month or a plan change.
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.
