Outbound
Send and reply to email from a specific mailbox using the mailbox-scoped outbound API.
The outbound API is the mailbox-scoped send surface used by agentic mailbox clients. It handles thread projection, canary token detection, deduplication, and autonomy level gating per mailbox.
Outbound vs. agent send
Molted provides two sending surfaces:
| Endpoint | Best for |
|---|---|
POST /v1/agent/request-send | Tenant-level sends from any agent. Full policy trace and scheduled send support. |
POST /v1/agent/outbound/send | Mailbox-scoped sends. Thread projection, canary detection, per-mailbox autonomy enforcement. |
Use /v1/agent/outbound/send when your agent acts on behalf of a specific mailbox and you want thread continuity and per-mailbox autonomy controls. Use /v1/agent/request-send for general-purpose tenant-level sends.
See Sending Email for full documentation on the agent send endpoint.
Endpoints
| Method | Path | Description |
|---|---|---|
| POST | /v1/agent/outbound/send | Send an email from a mailbox |
| POST | /v1/agent/outbound/reply | Reply to an existing thread |
| POST | /v1/agent/outbound/schedule-followup | Schedule a followup if no reply is received |
All endpoints require Bearer authentication and the automation API key scope.
Send
Send a new email from a mailbox. Creates a new thread if no matching thread exists.
POST https://api.molted.email/v1/agent/outbound/sendcurl -X POST "https://api.molted.email/v1/agent/outbound/send?tenantId=TENANT_ID" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"recipientEmail": "alice@example.com",
"templateId": "welcome",
"dedupeKey": "welcome-alice-1",
"payload": { "name": "Alice" },
"mailboxId": "mbx_abc123"
}'Request body
| Field | Type | Required | Description |
|---|---|---|---|
recipientEmail | string | Yes | Recipient email address |
templateId | string | Yes | Template ID to use for the send |
dedupeKey | string | Yes | Unique key to prevent duplicate sends. Same key on a second call returns the original result. |
payload | object | Yes | Template variables |
mailboxId | string | No | Send from a specific mailbox. Enables per-mailbox autonomy level enforcement. |
threadId | string | No | Associate with an existing thread |
subject | string | No | Subject line for the email. Used by the _default template (or as a fallback if payload.subject is set); ignored by a named template, which renders its own subject. |
sendReason | string | No | Reason for sending, recorded in the decision trace |
idempotencyKey | string | No | Alternative deduplication key |
replyTo | string | No | Where the recipient's replies go: one plain address. See Reply-To. |
Response
{
"status": "queued",
"requestId": "req_abc123",
"threadId": "thr_abc123"
}Response status values:
| Status | Meaning |
|---|---|
queued | Accepted, headed for delivery |
scheduled | Accepted but deferred (e.g. domain warmup); check scheduledAt |
blocked | Blocked by the policy engine; check reason (a same-dedupeKey retry comes back blocked with reason: "duplicate", and the original requestId) |
pending_approval | Held by the mailbox's autonomy level |
A blocked or pending_approval status is not an error - it means the policy engine or autonomy level is working correctly. Always check the status field. See Errors & Policy Blocks for every reason value.
CLI
# The send CLI uses /v1/agent/request-send by default
# To use the outbound endpoint, see: molted threads reply
molted send --to alice@example.com --template welcome \
--dedupe-key "welcome-alice-1" --payload '{"name": "Alice"}' \
--mailbox mbx_abc123Reply
Reply to an existing thread. The reply is sent from the mailbox that owns the thread.
POST https://api.molted.email/v1/agent/outbound/replycurl -X POST "https://api.molted.email/v1/agent/outbound/reply?tenantId=TENANT_ID" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"threadId": "thr_abc123",
"templateId": "_default",
"dedupeKey": "reply-alice-1",
"payload": { "body": "Thanks for reaching out - we will get back to you shortly." }
}'Request body
| Field | Type | Required | Description |
|---|---|---|---|
threadId | string | Yes | Thread to reply to |
templateId | string | Yes | Template ID. Use _default for plain-text replies. |
dedupeKey | string | Yes | Unique key to prevent duplicate replies |
payload | object | Yes | Template variables. Set payload.subject to override the reply subject; if omitted, it defaults to Re: <thread subject>. |
sendReason | string | No | Reason for replying |
idempotencyKey | string | No | Alternative deduplication key |
replyTo | string | No | Where the contact's answer goes, e.g. a person you hand the conversation to: one plain address. See Reply-To. |
A reply goes to the Reply-To of the message it answers (inReplyToMessageId, else the latest inbound message) only when that is an address you already wrote to in the thread, or the same company's (non-shared) domain when the message was sent from that company and passed DMARC for it; otherwise to the thread's contact. The response's replyRecipient says which and why. See who a reply goes to.
A reply with no subject in payload defaults to Re: <thread subject> (never doubled if the thread's subject already starts with "Re:"). The reply also carries In-Reply-To/References headers, built from the contact's latest inbound message and your own earlier messages in the thread (when a Message-ID is on file for them), so mail clients (Gmail, Outlook, Apple Mail, ...) thread it into the same conversation instead of starting a new one.
CLI
# Shorthand with plain text body -- subject defaults to "Re: <thread subject>"
molted threads reply thr_abc123 --body "Thanks for reaching out!"
# Override the default subject
molted threads reply thr_abc123 --body "Thanks for reaching out!" --subject "Re: Question"
# With a specific template and payload
molted threads reply thr_abc123 \
--template-id follow-up \
--payload '{"name": "Alice"}' \
--dedupe-key "reply-alice-2"
# Introduce a colleague: their answer goes to Sam, not the mailbox
molted threads reply thr_abc123 --body "Sam will take it from here." --reply-to sam@acme.devSchedule followup
Schedule a followup email that sends automatically if the contact does not reply within a specified delay.
POST https://api.molted.email/v1/agent/outbound/schedule-followupcurl -X POST "https://api.molted.email/v1/agent/outbound/schedule-followup?tenantId=TENANT_ID" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"contactEmail": "alice@example.com",
"threadRequestId": "req_abc123",
"delayMinutes": 4320,
"cancelOnReply": true
}'Request body
| Field | Type | Required | Description |
|---|---|---|---|
contactEmail | string | Yes | Contact email address |
threadRequestId | string | Yes | The requestId from the initial send response |
delayMinutes | integer | Yes | Delay in minutes before the followup sends (1-43200, max 30 days) |
cancelOnReply | boolean | No | Cancel the followup automatically if the contact replies first |
triggerConditions | object | No | templateId and payload choose the followup's content -- see below |
The threadRequestId is the requestId returned from the send or reply response. Save this value when sending if you plan to schedule a follow-up later.
Choosing the content
A followup has no content of its own at schedule time -- it's rendered when it fires, from triggerConditions:
triggerConditions.templateIdnames a template slug (or ID) to render. If you don't set one, it defaults tofollowup-default-- create a template with that slug in your tenant to give every followup that names no template its content.triggerConditions.payloadsupplies that template's variables.- Set
templateIdto_defaultto skip template lookup and pass literalsubject/html/textdirectly inpayloadinstead.
curl -X POST "https://api.molted.email/v1/agent/outbound/schedule-followup?tenantId=TENANT_ID" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"contactEmail": "alice@example.com",
"threadRequestId": "req_abc123",
"delayMinutes": 4320,
"cancelOnReply": true,
"triggerConditions": {
"templateId": "followup-nudge",
"payload": { "firstName": "Alice" }
}
}'If no template resolves (no templateId given and no followup-default template exists, or an explicit templateId that doesn't exist) or the template fails to render, the followup is cancelled (reason template_not_found or render_failed) rather than sent with no content.
Approval hold
A followup goes through the same autonomy level gate as any other agent send. At level 3 it's queued and sent automatically; at level 1 (every send) or level 2 (first contact with this recipient) it's held for approval and shows up in the portal's approval queue like any other held send -- approving it sends the same content that was rendered when it fired.
At the scheduled time, the follow-up is also checked against suppressions and your billing plan before it sends. If the contact has since been suppressed, or your plan is trial (not yet activated) or expired, the follow-up is cancelled (reason suppressed, trial_not_activated, or subscription_expired, recorded in the audit trail) -- it will not silently disappear.
A follow-up sent at autonomy level 3 also counts against your send quota -- the same monthly/daily/hourly budget an API send draws from. Over the daily or hourly limit, the follow-up is deferred and retried automatically once that window resets, the same as a paused mailbox, rather than cancelled. Over the monthly limit (or its overage cap, on a plan with overage), the follow-up is cancelled with reason monthly_limit_exceeded or overage_cap_exceeded, recorded the same way as the other cancellation reasons above.
Errors
| Status | Code | Meaning |
|---|---|---|
| 404 | send_request_not_found | No send request matches threadRequestId for this tenant. Use the requestId returned from the original send or reply call. |
CLI
# Schedule followup 72 hours after initial send
molted threads followup req_abc123 \
--contact-email alice@example.com \
--delay-hours 72 \
--cancel-on-replyDeduplication
Every outbound send and reply requires a dedupeKey. The same key on a second call returns the original result without sending again. Use deterministic keys based on your workflow, contact, and step:
{workflow}-{contact-id}-{step}
# e.g. "onboard-alice-day1", "renewal-alice-2026q1"If you use an auto-generated key (like the CLI does with --dedupe-key auto), each call is treated as a new send.
Empty or whitespace-only values (e.g. --dedupe-key "") are rejected up front with 400 dedupe_key_required. Silently accepting them would collapse every such send into a single "duplicate" and block all subsequent sends after the first.
Autonomy levels
Outbound sends through a mailbox are subject to that mailbox's autonomy level:
- L1 (Assisted) - Every send requires human approval. Response status:
pending_approval. - L2 (Guarded Auto) - First message to a new contact requires approval. Subsequent messages send automatically.
- L3 (Fully Autonomous) - All policy-approved sends execute immediately.
When a send is held for approval, use the approval queues API to review and release it.