# Errors & Policy Blocks

Source: https://molted.email/docs/errors

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](https://molted.email/docs/journeys.md#how-journeys-execute)).

For errors outside the send path (auth, scopes, approvals, team addresses and so on), see [Other error codes](#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](#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](https://molted.email/docs/suppressions.md#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](https://molted.email/docs/account-pause.md). |
| `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](https://molted.email/docs/rate-limits.md) 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](#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](https://molted.email/docs/domains.md)). |
| `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](https://molted.email/docs/mailboxes.md#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](https://molted.email/docs/autonomy-levels.md) 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](https://molted.email/docs/storage.md#attachment-api)). |
| `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:

```json
{
  "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/*`.

```json
{
  "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](https://molted.email/docs/approval-queues.md#decisions-need-a-human).

## 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](https://molted.email/docs/approval-queues.md#decisions-need-a-human): `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](https://molted.email/docs/workspace-settings.md)) | 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](https://molted.email/docs/contacts.md#delete-a-contact) |
| 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](https://molted.email/docs/mailboxes.md#address-and-catch-all-changes) |

A loosening attempt in `enforce` mode gets a `403` shaped like this (an autonomy raise, in this example):

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

```bash title="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
```

### Loosenings 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](https://molted.email/docs/workspace-settings.md#how-a-person-changes-them). |
| `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](https://molted.email/docs/sender-addresses.md#portal-endpoints). |
| `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](https://molted.email/docs/contacts.md#looking-someone-up-in-the-portal)), 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](https://molted.email/docs/safety-settings.md#where-to-change-them).

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

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

```json
{
  "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](https://molted.email/docs/authentication.md#links-for-your-human-take-over-claim-or-sign-in)). 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:

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

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

```json
{
  "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](https://molted.email/docs/humanizer.md#plans-and-limits)).

## Sending a previewed rewrite

A send with `humanizePreviewId` ([Send exactly what you previewed](https://molted.email/docs/humanizer.md#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`](https://molted.email/docs/authentication.md#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](https://molted.email/docs/account-pause.md#resuming-is-a-human-decision). |
| `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](https://molted.email/docs/lists.md). |
| `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`:

```json
{
  "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

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.

## Related reading

- [Why AI Agents Need Email Guardrails](https://molted.email/blog/why-ai-agents-need-email-guardrails) -- the failure modes these policy rules prevent
- [How to Send Your First Policy-Checked Email](https://molted.email/blog/send-your-first-policy-checked-email) -- see policy traces in action with a step-by-step tutorial
