The envelope
Every failure returns the same three fields, at every status. error is stable and safe to branch on; message is written for a human reading a log and may change.
Some errors add fields — a rejected batch item carries its index, a throttled account carries the rate that caused it. Treat anything beyond the three below as advisory.
{
"error": "recipient_not_allowlisted",
"message": "Key allowlist is [@example.com]. Recipient is outside it.",
"docs": "https://www.sadasend.com/docs/errors#recipient_not_allowlisted"
}Authentication and scope
| Code | Status | Means |
|---|---|---|
| missing_api_key | 401 | No Authorization header. Send Authorization: Bearer sada_… |
| invalid_api_key | 401 | The key does not exist, or was revoked. Keys are shown once at creation and cannot be recovered |
| unauthenticated | 401 | A dashboard session was required and none was present. API keys do not authenticate these routes |
| insufficient_scope | 403 | The key is valid but its scope set does not include this operation. Scopes are fixed at creation — mint a new key rather than widening one |
| api_key_expired | 401 | The API key has passed its expiration date (expires_at). Mint a new key or update the expiration in the dashboard |
| ip_not_allowed | 403 | The client IP address is not permitted by this key’s CIDR IP allowlist (ip_allowlist) |
| forbidden_origin | 403 | Origin header from an untrusted browser domain was rejected on API or MCP connection |
| key_revoked | 409 | The key that originally sent this message has since been revoked, so it cannot be used to resend it |
Sending
The codes worth handling explicitly. A send that fails with any of these has not been queued and will not be retried for you.
| Code | Status | Means |
|---|---|---|
| missing_from | 400 | No from address |
| missing_to | 400 | No recipient |
| invalid_from | 400 | The from address is not a valid address, or its domain is not one of yours |
| invalid_source | 400 | Header X-SadaSend-Source must be one of: agent, backend, automation, system |
| invalid_agent_name | 400 | Header X-SadaSend-Agent must not exceed 128 characters |
| domain_not_found | 403 | The sending domain is not on this account |
| domain_not_verified | 403 | The domain exists but SPF, DKIM or DMARC has not verified. Nothing sends from an unverified domain |
| recipient_not_allowlisted | 403 | The key carries a recipient allowlist and this recipient is outside it. This is the guardrail working |
| sandbox_limit_reached | 403 | The shared onboarding domain has a small lifetime send cap per account, so one signup cannot spend the shared reputation. Verify your own domain to lift it |
| recipient_suppressed | 422 | The address is on your suppression list — a previous hard bounce or complaint. Removing it is a dashboard action, deliberately |
| attachment_too_large | 422 | A single attachment exceeds the 10MB individual file size limit |
| attachments_too_large | 422 | Combined attachments on a single message exceed the 25MB total limit |
| invalid_attachment | 422 | Attachment is missing required filename or content, or base64 data is malformed |
| quota_exceeded | 402 | Account has used all monthly email credits on its current plan. Upgrade to resume sending |
| account_suspended | 403 | Account is suspended due to excessive complaint rate or policy restriction. Contact support |
| payload_too_large | 413 | The request payload exceeds the maximum allowed size (25MB for mail, 1MB for API bodies) |
| account_throttled | 429 | The account is over its complaint or bounce threshold and is rate-limited, not stopped. Transactional mail continues at a reduced rate |
| rate_limit_exceeded | 429 | Rate limit exceeded for this API key or client IP address |
| daily_limit_reached | 429 | The plan’s daily recipient limit. Only the free plan has one. retryAfterSeconds says when, and it is a rolling day rather than a calendar one |
| idempotency_key_reused | 409 | This Idempotency-Key was used for a different request body. Reuse it only to retry the identical send |
| idempotency_key_in_progress | 409 | The first request with this key has not finished. Retry shortly — do not send a new key |
| attachment_storage_failed | 503 | The attachment could not be stored. The send was not accepted; retry is safe |
Batches, logs and message actions
| Code | Status | Means |
|---|---|---|
| empty_batch | 400 | The batch contained no messages |
| batch_too_large | 400 | Over the per-call limit. Split the batch |
| missing_emails | 400 | The batch body had no emails array |
| invalid_status | 400 | Unknown status in a log filter |
| invalid_date | 400 | A date filter could not be parsed |
| invalid_cursor | 400 | The pagination cursor is malformed or from a different query. Start the listing again |
| message_not_found | 404 | No message with that id on this account |
| not_cancellable | 409 | The send has already left the queue. Only a scheduled send can be cancelled |
| body_unavailable | 409 | The rendered body has aged past your plan retention, so it cannot be read or resent |
| no_originating_key | 409 | The message has no surviving key to resend it with |
Domains, keys and approvals
| Code | Status | Means |
|---|---|---|
| invalid_domain | 400 | Not a valid domain name |
| domain_exists | 409 | That domain is already on the account |
| domain_quota_exceeded | 403 | The plan sending domain limit has been reached. Upgrade to add more domains |
| history_not_found | 404 | Deleted domain record was not found in audit history for 1-click restore |
| restore_failed | 500 | Failed to restore deleted domain and its preserved DKIM records |
| invalid_scope | 400 | Unknown scope name in a key request |
| invalid_mode | 400 | Unknown key mode. Modes are live, approval and test — an approval-mode key holds every send for a human decision |
| invalid_rate_limit_max | 400 | rate_limit_max must be an integer between 1 and 1,000,000 |
| invalid_rate_limit_window | 400 | rate_limit_window must be one of: 1s, 1m, 1h, 1d |
| invalid_expires_at | 400 | Expiration date must be a valid future ISO 8601 timestamp |
| invalid_ip_allowlist | 400 | IP allowlist must contain valid IPv4/IPv6 addresses or CIDR subnets |
| invalid_default_source | 400 | Default source must be one of: agent, backend, automation, system |
| invalid_allowlist | 400 | Recipient allowlist cannot be an empty array. Provide addresses or omit for all |
| key_not_found | 404 | No key with that id on this account |
| invalid_decision | 400 | An approval decision must be an approval or a rejection |
| already_decided | 409 | That held send has already been approved or rejected. Approvals are not reversible |
Webhooks
| Code | Status | Means |
|---|---|---|
| missing_url | 400 | No endpoint URL |
| invalid_url | 400 | The URL could not be parsed |
| insecure_url | 400 | Endpoints must be https. Signed events are not sent in the clear |
| no_events | 400 | Subscribe to at least one event |
| unknown_event | 400 | Unrecognised event name |
| invalid_id | 400 | The webhook id is not a valid id |
| invalid_json | 400 | The request body was not valid JSON |
| missing_target | 400 | Provide either webhookId or url to simulate delivery |
| unsupported_protocol | 400 | Simulation target URL must use http or https (e.g. http://localhost:PORT/webhook) |
| invalid_range | 400 | A replay window that is malformed or longer than the maximum |
| webhook_not_found | 404 | No endpoint with that id on this account |
| webhook_disabled | 409 | The endpoint is disabled — repeated failures disable one automatically. Re-enable it before replaying |
Templates and suppressions
| Code | Status | Means |
|---|---|---|
| missing_name | 400 | A template needs a name |
| name_too_long | 400 | Template or API key name exceeds 100 characters |
| missing_subject | 400 | A template needs a subject |
| missing_content | 400 | A template needs html or text |
| template_not_found | 404 | No template with that id on this account |
| template_too_large | 413 | The template body is over the size limit |
| template_render_failed | 422 | The template could not render with the variables given — usually a missing variable. The message names it |
| invalid_address | 400 | Not a valid email address |
| invalid_tag | 400 | A tag is too long, or uses characters outside letters, digits and . _ : - |
| too_many_tags | 400 | A message may carry ten tags |
| unsupported_engine | 400 | Templates render with Liquid. React Email renders in your own build — see /docs/react-email |
| suppression_not_found | 404 | That address is not suppressed |
Account, team and support
These come from the dashboard-facing routes rather than from an integration, but they use the same envelope.
| Code | Status | Means |
|---|---|---|
| invalid_body | 400 | The request body was not an object |
| nothing_to_update | 400 | A PATCH with no recognised fields |
| invalid_name | 400 | Name missing or too long |
| missing_org | 400 | Organisation name required |
| org_too_long | 400 | Organisation name over the limit |
| invalid_country | 400 | Not a recognised country code |
| invalid_phone | 400 | Phone number could not be parsed |
| invalid_website | 400 | Website is not a valid URL |
| invalid_industry | 400 | Not a recognised industry value |
| invalid_referral | 400 | Not a recognised referral source |
| invalid_account_type | 400 | Not a recognised account type |
| invalid_email | 400 | Not a valid email address |
| disposable_email | 400 | The address is a known disposable-mailbox provider |
| owner_only | 403 | Only the account owner may do this |
| account_not_found | 404 | No such account |
| seat_quota_reached | 402 | The plan seat limit is reached. Remove a member or move up a plan |
| webhook_quota_reached | 402 | The plan’s endpoint limit. Delete an endpoint or move up a plan |
| template_quota_reached | 402 | The plan’s template limit, counted in names — saving a new version of an existing template never counts against it |
| request_limit_reached | 429 | The plan’s daily API request allowance, counted per account rather than per key. Carries Retry-After |
| already_a_member | 409 | That address is already on the account |
| invitation_not_found | 404 | The invitation does not exist or has been withdrawn |
| missing_message | 400 | A ticket needs a message |
| message_too_long | 400 | The ticket message is over the limit |
| ticket_not_found | 404 | No ticket with that id on this account |
Free tools and sign-in
The tool checks are unauthenticated and rate-limited by source. The sign-in codes surface in the browser rather than in an integration.
| Code | Status | Means |
|---|---|---|
| unknown_check | 404 | Not one of the supported checks |
| invalid_selector | 400 | The DKIM selector is malformed |
| too_many_checks | 429 | Rate limit for unauthenticated checks. Wait and retry |
| missing_token | 400 | The sign-in link had no token |
| link_expired | 400 | The sign-in link has expired or was already used. Links are single-use |
| no_identity | 400 | The identity provider returned no usable identity |
| state_invalid | 400 | The sign-in state did not match. Start again |
| unsupported_provider | 400 | Unknown identity provider |
| oauth_not_configured | 501 | That provider is not configured on this deployment |