MOLTED EMAIL

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) can actually return -- the same table the CLI itself is driven from (packages/cli/src/reasons.ts), so it can't drift out of sync the way this page once did.

Policy engine

ReasonDescription
duplicateA 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.
cooldownThe recipient was emailed too recently. Wait for the cooldown period to expire, or use a different template.
suppressedThe recipient is on the suppression list (e.g., from a prior bounce or complaint). See Suppression reasons below for the underlying cause.
no_consentThis 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.
disengagedThe recipient hasn't opened or clicked in the policy window, so automated sends are paused for them.
active_opportunityAn active sales opportunity exists for this contact. Automated sends are paused to avoid interference.
tenant_pausedSending is paused for this tenant, either manually or due to an incident.
mailbox_pausedThe sending mailbox is paused (e.g. reputation). Run molted mailboxes unpause <mailboxId>.
template_not_approvedThe template requires approval and has not been approved yet.
template_lint_failedThe template failed content linting checks (e.g., missing unsubscribe link, spam triggers).
negative_signal_budget_exceededToo many negative signals (bounces, complaints) received in the last 24 hours.
budget_exceeded / monthly_budget_exceededPresent 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

ReasonDescription
trial_not_activatedYour account is on the trial plan. Run molted billing activate-free for the free tier, or molted billing setup --plan solo for a paid plan.
subscription_expiredYour 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_pausedMolted 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.

ReasonDescription
hourly_limit_exceededThe hourly send quota is used up.
daily_limit_exceededThe daily send quota is used up.
monthly_limit_exceededThe monthly send quota is used up for this billing period.
overage_cap_exceededYour plan's overage cap for this billing period was reached.
rate_limitedFallback 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

ReasonDescription
warmup_limitThe sending domain is still warming up and hit its cap for now. Check molted domains warmup <domainId>.
warmup_horizon_exceededThe sending domain's warmup schedule is booked out too far to defer this send; the response carries projectedDate.
domain_throttledToo many sends to this recipient's domain this hour -- a per-tenant, per-recipient-domain cap, separate from the sending domain's own limits below.
domain_daily_limit_exceeded / domain_hourly_limit_exceeded / domain_minute_limit_exceededThe sending domain hit its own daily / hourly / per-minute rate limit. Check molted domains rate-limits <domainId>.
domain_rate_limitedFallback only, when the per-domain check fails without a specific sub-reason.

Template and content

ReasonDescription
template_not_foundThe template slug or ID does not exist for this tenant.
template_render_failedThe template failed to render (likely a missing required variable).
missing_physical_addressMarketing 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

ReasonDescription
no_verified_domainNo verified sending domain for this tenant. Add and verify one in the portal's Settings > Domains (see Domains).
mailbox_not_activeThe mailbox is not active yet. Run molted mailboxes verify <id>.
sender_domain_not_verifiedThis mailbox's domain isn't verified for your account (e.g. a mailbox on a domain the tenant doesn't own).
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)Restoring the mailbox would go over your plan's mailbox limit. Delete another mailbox or upgrade first.

Coordination and safety

ReasonDescription
lease_conflictAnother agent currently holds a coordination lease on this contact. Wait for the lease to expire or be released.
canary_violationA 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.
thread_taken_overA 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 instead of a blocked body; a batch item gets it as an ordinary blocked result.

Held for human approval (status: "pending_approval")

ReasonDescription
autonomy_levelThe mailbox's autonomy level requires approval for this recipient.
thread_risk_flaggedRecent messages on this thread were flagged as high risk (possible prompt injection).
content_checkThe content check flagged this email (possible phishing-like request, leaked personal data, deceptive claims, or spam). Only fires when the tenant has the content-check hold enabled.

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

ReasonDescription
warmup_deferDeferred 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.

ReasonDescription
duplicate_in_batchTwo entries in the same batch call used the same dedupeKey.
recipient_email_requiredThis batch entry has no (or an empty) recipient email.
dedupe_key_requiredThis batch entry has no dedupeKey.
internal_errorAn unexpected error on this recipient specifically; other recognizable reasons pass through unchanged. Use molted trace <requestId> if a requestId is present.

Suppression reasons

When a recipient is on the suppression list (suppressed block reason), the suppression was created for one of these reasons:

Reason codeDescription
complaintThe recipient previously marked an email as spam.
hard_bounceA previous email to this address hard-bounced (e.g., address does not exist).
manual_dncThe recipient was manually added to the do-not-contact list.
legal_requestA suppression was added due to a legal request (e.g., GDPR erasure).
role_accountThe address is a role account (e.g., info@, admin@) that should not receive automated email.

Validation errors

These errors are returned as 400 Bad Request before the policy engine runs. They indicate malformed input rather than a policy decision.

ErrorCauseFix
recipientEmail must be an emailThe recipientEmail field (or --to in the CLI) is not a valid email address.Use a properly formatted email address (e.g. user@example.com). Validate before sending.
templateId must be a stringThe templateId field is missing or not a string.Pass a valid template slug or ID.
dedupeKey must be a stringThe dedupeKey field is missing or not a string.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 arrayOn 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, 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 booleanOn 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.
durationMinutes must be a number of minutes / timeoutMinutes must be a number of minutesOn 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:

{
  "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). 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.

ControlAgents mayNeeds a human
PausePause a mailbox or the accountResume 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
AutonomyLower itRaise it (1 to 2, 2 to 3)
Safety settings and spam-filter rulesTurn a check on, pick a stricter action (from deliver to quarantine to reject), lower spamThreshold or maxLinksThreshold, add blockedKeywords, remove allowedSendersThe opposite of each
Humanizer (account, agent, or mailbox config -- mailbox is style-only, it can't turn the humanizer on or off)Turn it offTurn it on, or change its style or provider while it is on
Account settingsRaise cooldownMinutes; set physicalAddress when there is none; turn requireConsentForMarketing onLower cooldownMinutes; change or clear an existing physicalAddress; turn requireConsentForMarketing off
API keysMint keys within your own scope; narrow yourself or a key you minted; revoke any key you own (or, from a key, 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 a key you didn't mint
Adoption invitesInvite within your own scope; revoke any inviteAn invite wider than your own key
SuppressionsAdd a suppressionRemove one (DELETE /v1/suppressions/:id or /v1/ops/suppressions/:id), or un-suppress a whole domain (DELETE /v1/suppressed-domains/:domain)
Domain warmupCheck warmup statusSkip warmup (POST /v1/agent/domains/:id/warmup/skip) -- can't be undone
Domain rate limitsLower a limitRaise a limit, or clear one to unlimited (PATCH /v1/agent/domains/:id/rate-limits)
ContactsCreate, update, syncErase (DELETE /v1/contacts/:id) -- irreversible, see Contacts

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"]
}

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

Pausing the whole account (not just one mailbox) is one such tightening/loosening pair -- pausing always works, resuming is usually gated the same way:

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

Billing decisions

Starting checkout through the session route (POST /v1/billing/checkout), opening the billing portal, and ending a trial early ("start now") are always 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. 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"
}

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:

{
  "error": "free_plan_requires_human",
  "message": "The Free plan is for verified humans only. This tenant is agent-owned, so activate-free is refused. Buy a paid plan instead...",
  "plans": ["solo", "team"],
  "setupLinkRoute": "/v1/billing/setup-link",
  "claimRoute": "/v1/me/principal/upgrade"
}

Buy a paid plan instead 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. Alternatively, have a human claim the account (POST /v1/me/principal/upgrade) and activate Free from there.

A human owner whose email isn't verified yet gets:

{
  "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:

{
  "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"
}

GET /v1/billing/status never advertises "activate_free" in actions unless the owner already qualifies, 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:

{
  "statusCode": 403,
  "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.

StatusErrorCauseFix
409humanize_preview_usedAnother send already used this preview id (an id sends once).Preview again for this send; in a batch, give each recipient its own preview.
409humanize_preview_mismatchThe 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.
404humanize_preview_not_foundUnknown 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).
400humanize_preview_conflicthumanize: 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.
400invalid_humanize_preview_idThe 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.

Handling blocked sends

  1. Check the reason field in the response to understand why the send was blocked.
  2. Refer to the tables above for the specific reason.
  3. Take the appropriate action -- wait for a cooldown, verify a domain, upgrade your plan, etc.
  4. Retry the request once the issue is resolved. Use a new dedupeKey if the original was consumed.