Mailfully logo

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 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

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
402payment_past_dueLive sending is paused because a payment is past due. Test-mode keys keep workingUpdate your payment method on the Billing page of your Mailfully dashboard; sending resumes as soon as the payment goes through
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; see API key security
403forbiddenThe 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
403account_deletedThe account has been deleted. Refused in both environmentsDo not retry; a deleted account cannot be restored. If you did not expect this, email contact@mailfully.com
403unverified_domain_requiredA live send while your org has no verified sending domainVerify a domain, or send in test mode; see Live sending is refused
403account_suspendedA live send from an account whose setup was never completedVerify a sending domain; this clears itself, no ticket needed
403account_rejectedA staff member declined the account, so live sending is refusedIf you believe it is a mistake, email contact@mailfully.com; only a person can undo this
403email_above_quotaA received email arrived over the monthly ceiling or spend cap, and usage is still overWait for the new month, upgrade, or raise the spend cap; the email unlocks then; see Mail received over the limit
403domain_limit_reachedPOST /v1/domains while the org already holds as many domains as its plan allowsDelete 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
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; for a domain add, see Adding a domain was refused
409domain_parent_takenAnother organization already verified a domain under the same registrable parentLegitimate for agencies and subsidiaries — write to contact@mailfully.com for an allowlist entry; see Adding a domain was refused
409domain_not_verifiedTurning receiving on for a domain that is not verifiedVerify the domain first
409mx_conflictTurning receiving on for a domain that already has another provider's MX recordsReceive on a subdomain such as inbound.acme.com; see MX record conflicts
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; for param: "from", see Live sending is refused
422domain_reservedPOST /v1/domains named mailfully.app, mailfully.ai, mailfully.net, or a name under oneUse a domain you own
422domain_not_ownedPOST /v1/domains named a domain nobody can own, such as gmail.com or a name under wordpress.com; param is nameUse a domain you own
422marketing_missing_postal_addressA marketing send while the org has no physical postal address on fileAdd a postal address in the dashboard; CAN-SPAM requires one in commercial mail
422marketing_single_recipientA marketing send named more than one recipient across to/cc/bccSend one recipient per message — one-click unsubscribe cannot say who opted out of a shared message
422recipient_unsubscribedThe recipient of a marketing send opted out of marketing from your orgStop mailing them; transactional mail to the same address still works
429rate_limit_exceededThe request rate limit was exceededHonor retry-after, back off with jitter; see Rate limits and quotas
429daily_quota_exceededThe rolling 24-hour send cap is spentBack off; retry-after is a coarse floor, not a schedule; see Rate limits and quotas
429account_under_reviewA new account's live sending is bounded while staff review itWait for the review (24 hours on average); do not retry-loop; see New accounts
429volume_limit_reachedThe account reached its daily sending volume limit. Test-mode keys keep workingContact contact@mailfully.com to raise it; room also frees up as the rolling 24-hour window moves; see Rate limits and quotas
429monthly_quota_exceededThe monthly allowance (free) or overage ceiling (paid) was crossedUpgrade the plan or wait for the new month; do not retry-loop; see Quotas and plan limits
429marketing_quota_exceededThe separate monthly marketing allowance was crossedOn 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
429spend_cap_exceededProjected overage spend crossed your monthly spend capRaise the spend cap or wait; do not retry-loop; see Quotas and plan limits
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
503dns_lookup_failedThe MX lookup behind turning receiving on failed; nothing changedRetry
503service_unavailableA dependency was unavailable (for example the idempotency store); the request had no side effectsRetry 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.