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
| 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 recipient was emailed too recently. Wait for the cooldown period to expire, or use a different template. |
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 recipient hasn't opened or clicked in the policy window, so automated sends are paused for them. |
active_opportunity | An active sales opportunity exists for this contact. Automated sends are paused to avoid interference. |
tenant_paused | Sending is paused for this tenant, either manually or due to an incident. |
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 | Too many negative signals (bounces, complaints) received in the last 24 hours. |
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 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_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 | 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 its cap for now. 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, 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 verified sending domain for this tenant. Add and verify one in the portal's Settings > Domains (see Domains). |
mailbox_not_active | The mailbox is not active yet. Run molted mailboxes verify <id>. |
sender_domain_not_verified | This 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
| Reason | Description |
|---|---|
lease_conflict | Another agent currently holds a coordination lease on this contact. Wait for the lease to expire or be released. |
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. |
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 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 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")
| 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. |
internal_error | An 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 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). |
manual_dnc | The recipient was manually added to the do-not-contact list. |
legal_request | A suppression was added due to a legal request (e.g., GDPR erasure). |
role_account | The 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.
| Error | Cause | Fix |
|---|---|---|
recipientEmail must be an email | The 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 string | The templateId field is missing or not a string. | Pass a valid template slug or ID. |
dedupeKey must be a string | The 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 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, 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. |
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:
{
"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.
| 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 -- mailbox is style-only, it can't turn the humanizer on or off) | Turn it off | Turn it on, or change its style or provider while it is on |
| Account settings | Raise cooldownMinutes; 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 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 invites | Invite within your own scope; revoke any invite | An invite wider than your own key |
| Suppressions | Add a suppression | Remove one (DELETE /v1/suppressions/:id or /v1/ops/suppressions/:id), or un-suppress a whole domain (DELETE /v1/suppressed-domains/:domain) |
| 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 | Erase (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:
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 incidentBilling 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.
| 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.
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