Errors & Policy Blocks
Every policy block reason and suppression reason explained.
When a send request is blocked by the policy engine, the response includes a reason field. A status: "pending_approval" response also carries a reason (why a human needs to decide) and a status: "scheduled" response may carry one too (why it was deferred rather than sent immediately). This page documents every reason the send path (molted send, threads reply, send batch, and the dry runs send simulate / propose) can actually return. The CLI's own guidance for each reason comes from one table, and a test checks that every reason in it appears on this page; the descriptions here are kept by hand. Journey steps and follow-ups record the same reasons on the send's trace (see Journeys).
For errors outside the send path (auth, scopes, approvals, team addresses and so on), see Other error codes.
Policy engine
| Reason | Description |
|---|---|
duplicate | A request with the same dedupeKey was already processed. The response's requestId is the ORIGINAL send, not a new one -- use a unique key per send. |
cooldown | The same recipient got the same template from the same mailbox too recently (the tenant's cooldownMinutes, 10 by default). Wait for the cooldown to expire, or use a different template. A reply that carries a threadId skips the cooldown when the recipient is that thread's contact (or the Reply-To its inbound mail resolves to) in the thread's own mailbox and they have written since your last message to them there: one reply per message received. Another recipient's thread id, or a thread with nothing new from them, doesn't. send simulate also returns cooldownExpiresAt. |
suppressed | The recipient is on the suppression list (e.g., from a prior bounce or complaint). See Suppression reasons below for the underlying cause. |
no_consent | This tenant has requireConsentForMarketing on and the recipient has no active consent on file. Off by default -- see Suppressions & Consent. Record consent with molted consent record, or check it first with molted consent check. |
disengaged | The workspace has disengaged auto-suppression on, and the recipient has had at least 5 sends (by default) and no open, click or reply in 90 days (by default), so automated sends are paused for them. Nothing self-serve turns this on today: no setting in the portal, API or CLI switches it, so you only see this reason on a workspace where Molted staff have set it up by hand. |
active_opportunity | The contact's deal stage is demo_scheduled, proposal, negotiation or contract_sent. Automated sends are paused to avoid interference. |
tenant_paused | Sending is paused for the whole workspace. A person, or an agent, paused it, and only a person resumes it; see Pause and resume sending. |
mailbox_paused | The sending mailbox is paused (e.g. reputation). Run molted mailboxes unpause <mailboxId>. |
template_not_approved | The template requires approval and has not been approved yet. |
template_lint_failed | The template failed content linting checks (e.g., missing unsubscribe link, spam triggers). |
negative_signal_budget_exceeded | 50 or more bounces plus complaints across the tenant in the last 24 hours. Every send is blocked until the count drops; molted budget shows negativeSignals. |
budget_exceeded / monthly_budget_exceeded | Present in the policy engine's type, but not set on the real send path today -- the tenant daily/monthly send budget context is never populated by a caller. |
Billing
| Reason | Description |
|---|---|
trial_not_activated | Your account hasn't picked a plan yet. Run molted billing setup --plan solo for a paid plan, or, only when the owner is a verified human (molted billing status lists activate_free in actions), molted billing activate-free for the free tier. |
subscription_expired | Your subscription has expired (grace period over, or the plan lapsed). Run molted billing status to check the plan, then molted billing setup --plan solo to renew. |
free_sends_paused | Checked with the send quota. Molted has paused sending for every account on the Free plan for now, for example while it deals with abuse on the shared sending infrastructure. It is not about your send and there is nothing to fix: retry later with a new dedupeKey. Paid plans keep sending. Journey steps and scheduled follow-ups wait and retry on their own; an approval stays pending (HTTP 429, error: "free_sends_paused") until sending resumes. |
Quota
Exceeding a send quota window blocks with the specific window's reason, never a generic rate_limited -- see Rate Limits for what counts toward each window.
| Reason | Description |
|---|---|
hourly_limit_exceeded | The hourly send quota is used up. |
daily_limit_exceeded | The daily send quota is used up. |
monthly_limit_exceeded | The monthly send quota is used up for this billing period. |
overage_cap_exceeded | Your plan's overage cap for this billing period was reached. |
rate_limited | Fallback only -- a quota/lease check that failed without a specific sub-reason. A real send (and its dry run, send simulate / dry_run) almost always returns one of the *_limit_exceeded reasons above instead. |
Warmup and domain throttling
| Reason | Description |
|---|---|
warmup_limit | The sending domain is still warming up and hit today's cap. Only transactional templates (id starting with _), sends with an explicit scheduledAt, and sends with deferOnWarmup: false block like this; other sends are deferred instead (warmup_defer, below). Check molted domains warmup <domainId>. |
warmup_horizon_exceeded | The sending domain's warmup schedule is booked out too far to defer this send; the response carries projectedDate. |
domain_throttled | Too many sends to this recipient's domain this hour -- a per-tenant, per-recipient-domain cap set by Molted operators (off by default), separate from the sending domain's own limits below. |
domain_daily_limit_exceeded / domain_hourly_limit_exceeded / domain_minute_limit_exceeded | The sending domain hit its own daily / hourly / per-minute rate limit. Check molted domains rate-limits <domainId>. |
domain_rate_limited | Fallback only, when the per-domain check fails without a specific sub-reason. |
Template and content
| Reason | Description |
|---|---|
template_not_found | The template slug or ID does not exist for this tenant. |
template_render_failed | The template failed to render (likely a missing required variable). |
missing_physical_address | Marketing emails require a physical mailing address (CAN-SPAM). Run molted settings update --physical-address "<address>" (or PATCH /v1/agent/config/settings / /v1/me/settings with physicalAddress). Setting one where none exists is not a loosening, so an agent key may do it directly; changing or clearing an existing one is human-gated (see Loosening a control). |
Mailbox and domain resolution
| Reason | Description |
|---|---|
no_verified_domain | No sender address could be resolved for this send at all. Rare: every account can send from the shared domain. |
mailbox_not_active | The mailbox is not active yet. Run molted mailboxes verify <id>. |
sender_domain_not_verified | This mailbox's custom domain isn't verified for your account (unverified, removed, or a domain the tenant doesn't own). Verify it with molted domains verify <domainId> (see Domains). |
mailbox_scope_denied (403) | The send names a mailbox your key isn't scoped to (the code is the error's message). |
thread_mailbox_mismatch (400) | The send's threadId is a thread of another mailbox than its mailboxId. A send into a thread goes out from that thread's mailbox: leave mailboxId out, or pass the thread's own. |
Mailbox create and restore errors
These are error codes from creating or restoring a mailbox, not send reasons:
| Code | Description |
|---|---|
mailbox_archived (409) | The address belongs to a mailbox deleted in the last 14 days. Restore it instead (POST /v1/agent/mailboxes/:id/restore, mailboxId is in the error). See Deleting a mailbox. |
human_archive (403) | A person deleted this mailbox, so only a person can restore it, from the portal. |
archive_expired (410) | The 14 days to restore a deleted mailbox are over; it is being deleted for good. |
mailbox_limit (403) | Creating or restoring the mailbox would go over your plan's mailbox limit. Delete another mailbox or upgrade first. |
domain_conflict (409) | The mailbox's domain belongs to another workspace. |
address_taken (409) | The address is already in use. |
Coordination and safety
| Reason | Description |
|---|---|
lease_conflict | Another agent currently holds a coordination lease on this contact. The response has requestId: null and leaseHolder ({ agentId, agentName, expiresAt }). Wait for the lease to expire or be released. Not checked on batch sends. |
canary_violation | A canary token (an internal probe value) came back in the outbound payload -- usually a prompt-injection attempt from the recipient. Checked on the mailbox-scoped outbound API (/v1/agent/outbound/send when it carries a threadId, and /v1/agent/outbound/reply), never on /v1/agent/request-send; the request ID is null. Blocks only while the safety setting blockCanaryViolations is on (the default); with it off, the thread is flagged and the send goes through. |
thread_taken_over | A human has taken over this thread, so this mailbox can't email the contact until they hand it back. A single (non-batch) send gets this as an HTTP 409 with { error, message, threadId } (plus contactEmail when the send went to that contact outside the thread) instead of a blocked body; a batch item gets it as an ordinary blocked result. |
Held for human approval (status: "pending_approval")
| Reason | Description |
|---|---|
autonomy_level | The mailbox's autonomy level requires approval for this recipient. |
thread_risk_flagged | Recent messages on this thread were flagged as high risk (possible prompt injection). |
content_check | The content check flagged this email (possible phishing-like request, leaked personal data, deceptive claims, or spam). Only fires where Molted has the outbound content hold switched on (a platform flag, off by default) and the tenant is in its rollout. |
The response also carries a human-readable message for each reason.
Review held items with molted overrides queue needs_approval; approving is human-only (molted auth login-link for a link to give them, or a human runs molted overrides approve <threadId>).
Deferred (status: "scheduled")
| Reason | Description |
|---|---|
warmup_defer | Deferred to a future warmup slot for the sending domain; the response carries scheduledAt. Nothing to do -- it sends automatically. |
A scheduled response with no reason means the caller passed an explicit scheduledAt.
Batch item errors (results[].status: "error")
These are validation failures on one recipient in a send batch call, not policy blocks -- the rest of the batch is unaffected.
| Reason | Description |
|---|---|
duplicate_in_batch | Two entries in the same batch call used the same dedupeKey. |
recipient_email_required | This batch entry has no (or an empty) recipient email. |
dedupe_key_required | This batch entry has no dedupeKey. |
invalid_humanize_style / invalid_humanize_provider | This entry's humanizeStyle or humanizer provider isn't a known value. |
internal_error | An unexpected error on this recipient specifically; other recognizable reasons pass through unchanged (an unrecognized error's message can also come through as the reason). Use molted trace <requestId> if a requestId is present. |
A whole batch over 500 entries is refused before anything runs: 400 { "error": "batch_too_large", "message", "field", "max": 500, "received" } (also on simulate-batch, batch/next-best-action and batch/classify-intent).
Single-send request errors
These are refused with an HTTP error before the policy engine runs, and nothing is sent:
| Status | Error | Cause |
|---|---|---|
400 | dedupe_key_required | /v1/agent/request-send without a dedupeKey (or idempotencyKey), or with a blank one. |
400 | invalid_humanize_style / invalid_humanize_provider | An unknown humanizeStyle or humanizer provider. |
400 | invalid_reply_to | A malformed replyTo. |
400 | scheduledAt must be a valid ISO 8601 timestamp / scheduledAt must be at least 60 seconds in the future / scheduledAt must be at most 1 year in the future / scheduledAt must be an ISO 8601 timestamp with an explicit UTC offset or Z / scheduledAt is not a real calendar date and time | A bad scheduledAt on request-send. |
400 | At most 20 attachments are allowed per send., Attachment(s) not found: ..., Attachment(s) not available: ..., Attachment(s) not yet scanned: ... | An attachments problem (see Storage). |
403 | pending_classification, inbound_quota_held, quarantined_requires_operator (as message) | Forwarding an inbound attachment that isn't readable yet. |
Suppression reasons
When a recipient is on the suppression list (suppressed block reason), the suppression was created for one of these reasons:
| Reason code | Description |
|---|---|
complaint | The recipient previously marked an email as spam. |
hard_bounce | A previous email to this address hard-bounced (e.g., address does not exist). |
soft_bounce | 3 soft bounces in 30 days. Added automatically and expires after 90 days. |
manual_dnc | The recipient was manually added to the do-not-contact list (including a person's "keep agents away" on a contact). |
legal_request | A suppression was added due to a legal request, including a GDPR contact deletion. |
role_account | The address is a role account (e.g., info@, admin@) that should not receive automated email. |
no_engagement | Added by the disengagement scan for a recipient with no engagement. The scan only runs for a workspace with disengaged auto-suppression on, which is not self-serve today; you can still add this reason yourself. |
domain_suppressed | The recipient's whole domain is suppressed. |
unsubscribe_link | The recipient used the hosted one-click unsubscribe link. Only the server sets this. |
unsubscribe_request | An inbound reply was classified (or approved) as an unsubscribe request. |
When you add a suppression yourself, reasonCode can be complaint, hard_bounce, manual_dnc, legal_request, role_account, domain_suppressed or no_engagement.
Validation errors
These errors are returned as 400 Bad Request before the policy engine runs. They indicate malformed input rather than a policy decision.
| Error | Cause | Fix |
|---|---|---|
recipientEmail must be an email | On POST /v1/send/request and the mailbox outbound routes (/v1/outbound/send, /v1/outbound/schedule-followup): the address is malformed. /v1/agent/request-send (what molted send uses) only checks that it is a string (recipientEmail must be a string). | Use a properly formatted email address (e.g. user@example.com). Validate before sending. |
templateId must be a string | templateId is not a string (and, on /v1/send/request and /v1/outbound/*, when it is missing). On /v1/agent/request-send it is optional and defaults to _default. | Pass a valid template slug or ID. |
dedupeKey must be a string / dedupeKey must not be empty / dedupeKey must contain at least one non-whitespace character | On /v1/send/request and /v1/outbound/*: dedupeKey is missing, not a string, or blank. On /v1/agent/request-send a missing key gives dedupe_key_required (above). | Pass a unique deduplication key. |
<field> must be a string / must be an object / must be an array / must be a boolean value / each value in nested property sends must be either object or array | On the agent routes (request-send, simulate-send, propose-email, classify-intent, next-best-action, schedule-followup, the batch/* routes, register, coordination/heartbeat, coordination/lease, coordination/consensus and its /vote, outcomes/ingest, segments, search, domains and domains/:id/rate-limits, adopt/invite, billing/setup-link, agent/humanizer/preview and templates/:id/render): a field has the wrong type, e.g. payload or filterGroup is not a JSON object, attachments or sends is not an array (or holds something other than objects), or humanize is a string. | Send the type the API reference shows. Fields the API does not know are ignored, not rejected. |
scheduledAt must be an ISO 8601 timestamp string / delayMinutes must be an integer number of minutes / cancelOnReply must be a boolean | On request-send and schedule-followup: scheduledAt is neither a string nor a number, delayMinutes is not a whole number, or cancelOnReply is not true/false. | Pass an ISO 8601 timestamp, a whole number of minutes, and a boolean. |
agent_bound_to_key | 403 on the heartbeat, lease, release-lease and consensus routes: your API key has no bound agent and the request names (or releases the lease of) an agent another API key is bound to. Only that key acts as it. | Use the key bound to that agent, or act as an agent no key is bound to. |
agent_mismatch | 403 on the heartbeat, lease, release-lease and consensus routes: your API key is bound to an agent (keyAgentId in the body) and the request names a different one, or tries to release another agent's lease. | Act as the key's own agent (omit the agent, or name it), or use a key bound to the agent you need. |
consensus_closed | 409 on coordination/consensus/:id/vote: the request is already approved, rejected or expired, or its timeoutAt has passed. The body is { "error": "consensus_closed", "status": "<current status>" } and the vote is not recorded. | Read the outcome with get_consensus / molted agents consensus get; create a new request if the decision needs to be taken again. |
durationMinutes must be a number of minutes / timeoutMinutes must be a number of minutes | On coordination/lease and coordination/consensus: durationMinutes/timeoutMinutes is neither a number nor a numeric string. Unlike delayMinutes above, fractions are fine (e.g. 1.5). | Pass a number (or a numeric string) of minutes; a whole number is not required. |
Validation errors return an array of messages describing each invalid field:
{
"statusCode": 400,
"message": ["recipientEmail must be an email"],
"error": "Bad Request"
}Human-only decisions
Approving, rejecting, releasing and editing held items is a decision for a person, not an agent. When an agent key makes one of these calls on an account that enforces this, the API answers 403. The kind names what is held:
kind | Routes |
|---|---|
send | PATCH /v1/send/approvals/:id |
thread | POST /v1/override/:threadId/approve, /reject, /edit; /delete-spam (classifier spam only) and /not-spam (when gated); POST /v1/threads/restore when the restore needs it |
held | POST /v1/override/held-messages/:messageId/release and /reject |
inbound | POST /v1/inbound/:id/approve and /reject |
inbound-intent | POST /v1/inbound/bulk-approve |
template | PATCH /v1/templates/approvals/:id |
The /v1/override/* routes are also reachable as /v1/agent/override/*.
{
"error": "human_decision_required",
"message": "This decision needs a human. Share the decisionUrl with a person on your team; they can approve or reject it in the Molted portal.",
"decisionUrl": "https://molted.email/app/decide/send/APPROVAL_ID",
"kind": "send",
"id": "APPROVAL_ID",
"reason": "enforce"
}Don't retry. Send the decisionUrl to a person on your team. reason is enforce, warn_expired (the account's 30-day warning period is over) or thread_taken_over (a human has taken the thread over; the message then says only a person can approve or release messages on it). See Approval Queues.
Loosening a control
Agents may make an account safer, never less safe. Pausing, lowering autonomy, tightening a safety check, turning the humanizer off and narrowing or revoking your own keys always work. Changes that loosen a control are human decisions, gated the same way as approvals: enforce answers 403, warn lets the call through with a Molted-Deprecation: human-gate header, off allows it.
| Control | Agents may | Needs a human |
|---|---|---|
| Pause | Pause a mailbox or the account | Resume a paused mailbox (POST /v1/agent/mailboxes/:id/unpause, or PATCH /v1/agent/mailboxes/:id with status: "active") or a paused account (POST /v1/ops/resume, or resolving its pause incident), also after an auto-pause |
| Autonomy | Lower it | Raise it (1 to 2, 2 to 3) |
| Safety settings and spam-filter rules | Turn a check on, pick a stricter action (from deliver to quarantine to reject), lower spamThreshold or maxLinksThreshold, add blockedKeywords, remove allowedSenders | The opposite of each |
| Humanizer (account, agent, or mailbox config) | Turn it off (a mailbox: config.humanizer.enabled: false) | Turn it on, or change its style while it is on (a provider value is accepted and ignored, and never gated). For a mailbox, "on" is its effective state given the account setting, and any change to its own style counts |
Account settings (PATCH /v1/agent/config/settings, see Workspace Settings) | Raise cooldownMinutes (0 to 1440); set physicalAddress when there is none; turn requireConsentForMarketing on | Lower cooldownMinutes; change or clear an existing physicalAddress; turn requireConsentForMarketing off |
| API keys | Mint keys within your own scope; narrow yourself or a key you minted; revoke yourself or a key you minted | An all-mailbox key from a scoped key, a mailbox or permission you don't have, widening a key, narrowing or revoking a key you didn't mint |
| Adoption invites | Invite within your own scope; revoke an invite (a mailbox-scoped key only those it created; others are a 404) | An invite wider than your own key |
| Suppressions | Add a suppression | Remove one (DELETE /v1/suppressions/:id or /v1/ops/suppressions/:id), un-suppress a whole domain (DELETE /v1/suppressed-domains/:domain), or lift a contact's "keep agents away" (DELETE /v1/me/contacts/:id/keep-away) |
| Domain warmup | Check warmup status | Skip warmup (POST /v1/agent/domains/:id/warmup/skip) -- can't be undone |
| Domain rate limits | Lower a limit | Raise a limit, or clear one to unlimited (PATCH /v1/agent/domains/:id/rate-limits) |
| Contacts | Create, update, sync | Delete a contact record (DELETE /v1/contacts/:id) -- irreversible, see Contacts |
| Mailbox address and catch-all | Send the value already stored | Change the address (case counts), or turn catchAll on or off, on PATCH /v1/agent/mailboxes/:id; create or clone onto an address another mailbox's catch-all receives; delete a catch-all mailbox (kind: "mailbox-address"), see Address and catch-all changes |
A loosening attempt in enforce mode gets a 403 shaped like this (an autonomy raise, in this example):
{
"error": "human_decision_required",
"message": "Agents may tighten controls but not loosen them. This change needs a human: share the decisionUrl with a person on your team; they can make it in the Molted portal.",
"decisionUrl": "https://molted.email/app/decide/autonomy/MAILBOX_ID",
"kind": "autonomy",
"id": "MAILBOX_ID",
"reason": "enforce",
"loosens": ["autonomyLevel 1 -> 3"]
}The same loosenings are gated on their other routes too: autonomy also through PATCH /v1/agent/mailboxes/:id/autonomy and PATCH /v1/mailboxes/:id; safety settings through PUT /v1/agent/config/safety-settings, PUT /v1/ops/safety-settings and PUT /v1/spam-filter/rules; the humanizer through PUT /v1/agent/config/humanizer, PUT /v1/ops/humanizer and, for one agent, PUT /v1/agent/agents/:agentId/config (kind: "agent-config").
loosens lists what the change would loosen. kind is mailbox-resume, account-resume, autonomy, safety-settings, humanizer, tenant-settings, agent-config, api-key, adopt-invite, suppression, domain-warmup, domain-rate-limits, contact, mailbox-address or billing; for a key or invite that doesn't exist yet, id is new. Don't retry: send the decisionUrl to a person, or drop the loosening part of the change (a tightening on its own goes through). People signed in to the portal are never gated.
If a person changes the same control while an agent's request is being checked, and the agent's change would now loosen what is stored, the answer is 409 settings_changed with the loosens list, and nothing is written. Read the settings again and retry.
Pausing the whole account (not just one mailbox) is one such tightening/loosening pair -- pausing always works, resuming is usually gated the same way:
molted pause --reason "investigating a bounce spike" # POST /v1/ops/pause -- always works
molted resume # POST /v1/ops/resume -- usually needs a human, per the table above
molted incidents list # GET /v1/ops/incidents -- always works
molted incidents ack <id> # PATCH .../incidents/:id {status: acknowledged} -- always works
molted incidents resolve <id> # PATCH .../incidents/:id {status: resolved} -- same gate as resume, if it's an open pause incidentLoosenings with no portal screen
Most loosenings have somewhere to go in the portal: a person resumes a mailbox or the account, raises autonomy, creates or manages a key, and edits the spam-filter rules and the humanizer under Settings > Safety. Five kinds do not. A decisionUrl for them opens a page that explains what the agent asked for; it has no form to make the change. This is how a person makes each one today:
kind | What the agent tried | How a person does it today |
|---|---|---|
tenant-settings | Lower the cooldown, change or clear the postal address, turn consent off | PATCH /v1/me/settings with the portal session, which is never gated. See Workspace Settings. |
domain-rate-limits | Raise a domain's rate limit, or clear one | PATCH /v1/me/domains/:domainId/rate-limits with the owner's portal session. See Sender Addresses & Domain Rate Limits. |
suppression | Remove a suppression, or un-suppress a domain | Only two cases have a route for a person: Undo on a contact's Keep agents away (see Contacts), and undoing an approved unsubscribe within 10 minutes. Any other suppression, such as a bounce, a complaint or a domain, has none. |
domain-warmup | Skip a domain's warmup | None. Only the agent route exists, and it can't be undone. |
agent-config | Change another agent's configuration in a way that loosens a control | None. Only the agent route exists (PUT /v1/agent/agents/:agentId/config). |
There is no self-serve way to make the last three changes yet: an agent key is refused on a workspace in enforce mode, the default, and a person has nothing to click. On a workspace in off mode, or in warn mode while its warning period lasts, an agent key can make them directly.
The safety settings have a smaller version of the same gap. The portal edits only the six spam-filter fields; a person changes the per-verdict actions and the guardrail flags with PUT /v1/me/safety-settings and their portal session. See Safety Settings.
Billing decisions
Starting checkout through the session route (POST /v1/billing/checkout), opening the billing portal, and ending a trial early ("start now") are human decisions, even for an agent holding the workspace owner's own session (e.g. one created by molted auth signup): money changes need a person, no matter how the change would tighten or loosen anything else. They follow the workspace's human-gate mode like the loosenings above (enforce answers 403, warn lets it through with a deprecation header, off allows it). The session routes are owner-only (403 Only the workspace owner can manage billing first), and POST /v1/billing/start-now with a key also needs admin scope on all mailboxes. This doesn't apply to the agent Bearer-token checkout route (POST /v1/billing/setup-link) -- see below.
{
"error": "human_decision_required",
"message": "Billing changes need a person. Open the link to manage the plan.",
"decisionUrl": "https://molted.email/app/decide/billing/TENANT_ID",
"kind": "billing",
"id": "TENANT_ID",
"reason": "enforce",
"loosens": ["agent-held session"]
}(loosens is on the session-route answer; the key route's start-now answer has none.)
Don't retry: send the decisionUrl (or the link from molted auth login-link) to a person, or complete it yourself if you're a human on the portal's own session.
Free plan requires a verified human
Activating the Free plan (POST /v1/billing/activate-free) is a different kind of decision: it is refused outright, not delegated to a person via a decisionUrl, and it does not consult the workspace's human-gate mode -- warn or off don't loosen it. It succeeds only when the tenant's owner is a human account (principal_kind = 'human') with a verified email on a non-disposable domain.
An agent-owned tenant (e.g. one created by molted auth signup or any non-portal signup) gets:
{
"statusCode": 403,
"error": "free_plan_requires_human",
"message": "The Free plan needs a person as the owner. This workspace is agent-owned, so activate-free is refused. Run `molted auth login-link --next /app/settings#plan` (POST /v1/agent/login-token) and follow what it returns...",
"plans": ["solo", "team"],
"setupLinkRoute": "/v1/billing/setup-link",
"loginLinkRoute": "/v1/agent/login-token",
"loginLinkCommand": "molted auth login-link --next /app/settings#plan",
"claimRoute": "/v1/me/principal/upgrade"
}To get Free, run molted auth login-link --next "/app/settings#plan" (POST /v1/agent/login-token) and follow what it returns: when the account's email is your human's own inbox, Molted emails them a link to set their password and take the account over; when it is one of your Molted mailboxes, you get a claim link to give them (see Links for your human). Once they are done, activate-free succeeds, also with your existing key. Or buy a paid plan with POST /v1/billing/setup-link (molted billing setup --plan solo, or the billing_checkout_link MCP tool): agents may always request a checkout link, since a human still has to complete the Stripe-hosted page.
A human owner whose email isn't verified yet gets:
{
"statusCode": 403,
"error": "email_verification_required",
"message": "Verify your email before activating the Free plan. Check your inbox for the verification link, or resend it from the portal (Settings > Email)."
}A verified human owner whose email domain is a known disposable provider (mailinator.com and the like) gets:
{
"statusCode": 403,
"error": "disposable_email_not_allowed",
"message": "The Free plan needs a real, reachable email address. Use one from a regular provider, or choose a paid plan instead...",
"plans": ["solo", "team"],
"setupLinkRoute": "/v1/billing/setup-link"
}The checks run in that order (agent-owned, then unverified, then disposable). Activating from a plan other than trial is a 400 (Cannot activate free plan from "<plan>" plan. Only trial accounts can activate the free tier.), as is a workspace whose plan is set by Molted support.
GET /v1/billing/status never advertises "activate_free" in actions unless the owner already qualifies, and its activateFreeBlockedReason names which of the three applies, so an agent driven purely off actions won't hit this 403 in the first place. Existing Free tenants (activated before this rule shipped, or comped by an admin) keep their plan -- this never claws one back.
Plan limits on settings
Turning the humanizer on (PUT /v1/agent/config/humanizer with "enabled": true, or an agent's own humanizer_enabled) on a plan that doesn't include it (Free) is refused before any human decision:
{
"error": "humanizer_not_in_plan",
"message": "The humanizer is not included in your plan. Upgrade to Solo (50 rewrites a month) or Team (300 a month) to turn it on.",
"plan": "free"
}Sends are never refused for this: a send whose rewrite will be skipped is accepted and carries warnings (see Humanizer).
Sending a previewed rewrite
A send with humanizePreviewId (Send exactly what you previewed) is refused, and nothing is sent, when the preview doesn't fit it. In a batch, only that recipient fails, with the code as its reason.
| Status | Error | Cause | Fix |
|---|---|---|---|
409 | humanize_preview_used | Another send already used this preview id (an id sends once). | Preview again for this send; in a batch, give each recipient its own preview. |
409 | humanize_preview_mismatch | The email's html or text differs from what was previewed (another template, variable, body or footer). | Preview the exact content you send, or send without the id. |
404 | humanize_preview_not_found | Unknown or expired id (previews last 24 hours), another workspace's, or previewed for a different mailbox. | Preview again (with this send's mailboxId if you set one). |
400 | humanize_preview_conflict | humanize: false together with a preview id, or a humanizeStyle other than the one the preview was rewritten in. | Pass one or the other; leave humanizeStyle out (the preview's style is delivered) or preview again in that style. |
400 | invalid_humanize_preview_id | The id is not a preview id (a UUID). | Pass the previewId the preview returned. |
If the humanizer was turned off after the preview, the send is not refused: the original goes out with the warning humanize_preview_ignored_humanizer_off.
Other error codes
Machine-readable codes the API returns outside the send path. Most come as error in the body; ones marked (message) come as the message of a plain error.
Auth and scope
| Status | Code | When |
|---|---|---|
401 | missing_api_key (message) | No Authorization header. |
401 | invalid_api_key (message) | The key is unknown or revoked. |
401 | missing_tenant_id (message) | A call to api.molted.email without tenantId (mailbox.molted.email, the CLI and the MCP server fill it in). |
403 | mailbox_scope_denied (message) | The key is scoped to some mailboxes and the route needs one it doesn't cover, or covers every mailbox. |
403 | insufficient_scope (message) | The key lacks the scope the route needs (for example manage). |
403 | missing_scope:operator (message) | The route needs the operator scope (alert status, quarantined content). |
403 | human_required | A human-only portal route (/v1/me/*) called by an agent, including an agent-held session. |
403 | oauth_forbidden | A route closed to hosted MCP (OAuth) apps. |
401 | invalid_token | A rejected OAuth token. |
400 | tenant_header_required / invalid_tenant_header | A phone-app request without an X-Molted-Tenant, or with one that is not a single workspace id. See Workspaces and X-Molted-Tenant. |
403 | not_a_member | X-Molted-Tenant names a workspace the signed-in person doesn't belong to. |
403 | auto_pause_web_only | The phone app tried to resume something that was paused automatically (an account auto_pause or a mailbox paused for its reputation). Resume it from the portal; see Pause and resume sending. |
404 | incident_not_found (message) | PATCH /v1/ops/incidents/:id with an unknown incident, or one from another workspace. |
Approvals and held items
| Status | Code | When |
|---|---|---|
404 | approval_not_found | No such approval. |
409 | approval_not_pending / already_decided | It was already decided. |
410 | expired | The approval expired. |
409 | superseded / batch_changed / thread_changed | What you approved changed in the meantime; reload and decide again. |
409 | thread_in_review / thread_trashed / pending_send_approval / quarantine_verdict | The thread's state doesn't allow the action. |
429 | quota_exceeded / free_sends_paused | Approved, but the send would pass a quota (or Free sending is paused); reason says which. |
422 | attachment_blocked | An attachment on the approved send isn't sendable (attachmentIds lists them). |
502 | enqueue_failed | The approved send couldn't be queued; retry. |
Plans, team addresses and limits
| Status | Code | When |
|---|---|---|
402 | subscription_expired | An attachment upload on an expired plan. |
402 | payment_action_required / payment_failed | "Start my plan now" payment needs 3D Secure, or failed. |
403 | sends_blocked | Adding a team address on a workspace that can't send (trial or expired). |
403 | team_address_cap_reached | The plan's team-address cap (with cap). |
429 | confirmation_rate_limited | Too many team-address confirmation emails (with retryAfterSeconds). |
429 | rate_limited | A rate-limited route such as an alert test send (some with retryAfterSeconds). |
400 | reserved_metadata_key | Thread metadata uses a reserved key (keys lists them). |
413 | preview_too_large / 503 humanizer_unavailable | A humanizer preview that is too large, or the humanizer is down. |
503 | suppression_check_unavailable | The suppression check couldn't run, so the request was refused rather than risk a send. |
Suppressions, labels and lists
| Status | Code | When |
|---|---|---|
400 | invalid_email / invalid_domain / invalid_params / reserved_source | A malformed suppression request. |
403 | global_suppression_not_removable / global_scope_not_allowed | Platform-wide suppressions can't be changed by a tenant. |
404 | suppression_not_found (message) | No such suppression. |
409 | label_exists / label_limit_reached | A label with that name exists, or the label cap is reached. |
404 | label_not_found / threads_not_found | Unknown label or threads. |
409 | list_name_conflict / list_archived | See Lists. |
404 | segment_not_found / contact_not_found (message) | Unknown segment or contact. |
Request IDs
Every response from the API and from the mailbox host carries an x-request-id header, errors included. When a request goes through the mailbox host to the API, both services use the same ID, so one ID follows the call all the way.
An error body repeats the ID as traceId:
{
"statusCode": 404,
"message": "Thread not found",
"error": "Not Found",
"traceId": "cor_0f3a9c1d5b7e4a2c8d6e1f0a3b5c7d9e"
}The field is traceId, never requestId: requestId in a send response means the send request, not the HTTP request. Most CLI commands print the error body under detail, so the traceId is in their output.
You can set the ID yourself. An x-request-id (or x-correlation-id) of 8 to 128 characters made of letters, digits, ., _, : and - is kept as sent; anything else is replaced with one Molted makes, so a malformed value never travels on. That lets a caller tag a request and find it again in its own logs.
Quote the ID when you report a problem. Molted keeps a short record of each failed request for 30 days, found by this ID: the route, the status, the error code, which workspace and which kind of caller. Never a request body and never an email address. Failures on the portal, agent and claim routes and on the mailbox host are kept, except 401 answers, which are not recorded.
Handling blocked sends
- Check the
reasonfield in the response to understand why the send was blocked. - Refer to the tables above for the specific reason.
- Take the appropriate action -- wait for a cooldown, verify a domain, upgrade your plan, etc.
- Retry the request once the issue is resolved. Use a new
dedupeKeyif the original was consumed.
Related reading
- Why AI Agents Need Email Guardrails -- the failure modes these policy rules prevent
- How to Send Your First Policy-Checked Email -- see policy traces in action with a step-by-step tutorial