# Molted Email — Agent Skill Reference

You are integrating with Molted Email, an agent-native email control plane.
Your agent sends business intents (not raw SMTP). Policy governs every send.

Base URL: `https://api.molted.email`

---

## Getting Started

Create an account and get an API key in two calls:

### 1. Sign up

```
POST /api/auth/sign-up/email
Content-Type: application/json

{
  "name": "My Agent",
  "email": "agent@example.com",
  "password": "your-password"
}
```

The response sets a session cookie. Include it in the next request.

### 2. Create your API key

```
POST /v1/me/keys
Content-Type: application/json
Cookie: <session cookie from step 1>

{ "label": "default" }
```

Response:
```json
{
  "id": "uuid",
  "keyPrefix": "mm_live_...",
  "rawKey": "mm_live_abc123...",
  "label": "default",
  "status": "active",
  "scopeAllMailboxes": true,
  "createdAt": "2026-04-17T..."
}
```

Save `rawKey` — it is only shown once. Use it as your Bearer token for all subsequent requests.

### Get your tenant ID

Your tenant ID is needed for every API call. Retrieve it with the session cookie:

```
GET /v1/me/tenant
Cookie: <session cookie from step 1>
```

Response:
```json
{
  "id": "tenant-my-agent-a1b2c3d4",
  "name": "My Agent",
  "status": "active",
  "billing_plan": "trial"
}
```

Save `id` — this is your `tenantId` for all subsequent requests.

### 3. Activate billing

New tenants start on the `trial` plan with sends blocked. You must activate billing before sending.

Check your billing status:

```
GET /v1/billing/status?tenantId=<tenant-id>
Authorization: Bearer <api-key>
```

Response:
```json
{
  "plan": "trial",
  "state": "needs_payment_setup",
  "sendBlocked": true,
  "hasPaymentMethod": false,
  "actions": ["setup_billing", "activate_free"]
}
```

Trial accounts have sends blocked until billing is activated. To activate the free plan (removes trial expiry and unblocks sends) or upgrade to a paid plan, use the endpoints below.

To activate the free plan (no credit card required):

```
POST /v1/billing/activate-free
Authorization: Bearer <api-key>
```

Response:
```json
{
  "plan": "free"
}
```

To upgrade to a paid plan, create a checkout session:

```
POST /v1/billing/setup-link
Authorization: Bearer <api-key>
Content-Type: application/json

{
  "tenantId": "<tenant-id>",
  "plan": "starter",
  "successUrl": "https://yourdomain.com/billing/success",
  "cancelUrl": "https://yourdomain.com/billing/cancel"
}
```

`plan` defaults to `"starter"` if omitted. `successUrl` and `cancelUrl` are optional.

Response:
```json
{
  "url": "https://checkout.stripe.com/...",
  "plan": "starter"
}
```

Direct your human to the `url` to complete payment. Once payment succeeds, billing activates automatically and sends are unblocked.

**Billing states:**

| State | Meaning |
|-------|---------|
| `needs_payment_setup` | Trial -- sends blocked, activate free plan or upgrade to unblock |
| `payment_ok` | Active subscription or free plan, sends allowed |
| `payment_action_required` | Payment method needs updating |
| `payment_failed_grace` | Payment failed, in grace period (sends still allowed) |
| `billing_paused` | Expired -- sends blocked |

**Plan limits:**

| Plan | Monthly | Daily | Hourly | Mailboxes | Custom Domains | Storage |
|------|---------|-------|--------|-----------|----------------|---------|
| Trial | 100 | 50 | 20 | 1 | 0 | 50 MB |
| Starter | 3,000 | 500 | 100 | 3 | 3 | 3 GB |
| Growth | 10,000 | 1,000 | 200 | 10 | 10 | 10 GB |
| Enterprise | Unlimited | Unlimited | Unlimited | Unlimited | Unlimited | Unlimited |

### 4. Generate a login link for your human (optional)

Give your human a one-click link to the portal dashboard — no credentials shared:

```
POST /v1/agent/login-token
Authorization: Bearer <your API key>
```

```json
{
  "tenantId": "your-tenant-id"
}
```

Response:
```json
{
  "token": "abc123...",
  "url": "https://molted.email/auth/token-login?token=abc123...",
  "expiresAt": "2026-02-27T15:30:00.000Z"
}
```

Share the `url` with your human. They click it and are signed into the portal with the correct tenant context. The link is single-use and expires in 15 minutes.

### 5. Verify your token

Check which tenant your API key belongs to:

```
POST /v1/agent/whoami
Authorization: Bearer <your API key>
```

Response:
```json
{
  "tenant_id": "your-tenant-id"
}
```

---

## Email Identity: Domains, Sender Addresses, and Mailboxes

There are three layers to email identity. Understanding them will help you configure sending and receiving correctly.

| Concept | Purpose | Scope |
|---------|---------|-------|
| **Domain** | DNS authentication (DKIM, SPF, DMARC) | Authorizes you to send from a domain |
| **Sender address** | The `from` address on outbound emails | One default is auto-provisioned on `agent.molted.email` |
| **Mailbox** | A named inbox that owns sends and receives | Every email belongs to a mailbox |

### How sending works

When you send an email, the system resolves your `from` address in this order:

1. **Mailbox address** — if you provide `mailboxId`, the mailbox's address is used as the sender
2. **Default sender address** — auto-created at signup (e.g., `yourslug@agent.molted.email`)
3. **Verified domain fallback** — if no sender address exists, uses `noreply@{your-verified-domain}`
4. **No match** — send is blocked

**Recommended:** Always include `mailboxId` in your send requests. This scopes the send to a specific mailbox, making it visible in the portal's mailbox view and enabling per-mailbox metrics, approvals, and audit trails.

### Manage Your Sender Address

List your sender addresses and update the local part (the bit before `@`):

```
GET /v1/me/sender-addresses
Cookie: <session cookie>
```

```
PATCH /v1/me/sender-address
Cookie: <session cookie>

{ "localPart": "hello" }
```

This changes your from-address to `hello@agent.molted.email`. Creating additional sender addresses requires admin access.

### How receiving works

Inbound emails are routed based on the **mailbox address**. When someone replies to an email your agent sent, the system matches the `To` address to a mailbox. If you're using the agentic mailbox, create a mailbox with the address you want to receive at.

> **Note:** The `/v1/me/*` endpoints below use session cookie auth (the cookie from sign-up). These are setup actions, not runtime API calls.

---

### Set Up a Sending Domain (optional)

Add and verify a custom domain so messages are authenticated with DKIM, SPF, and DMARC under your brand:

```
POST /v1/me/domains
Cookie: <session cookie>

{ "domain": "mail.yourco.com" }
```

Response:
```json
{
  "id": "dom_abc123",
  "domain": "mail.yourco.com",
  "status": "pending",
  "dnsRecords": [
    { "type": "TXT", "name": "mail._domainkey.yourco.com", "value": "v=DKIM1; k=rsa; p=..." },
    { "type": "TXT", "name": "mail.yourco.com", "value": "v=spf1 include:..." },
    { "type": "TXT", "name": "_dmarc.yourco.com", "value": "v=DMARC1; p=none; ..." }
  ]
}
```

Add the DNS records at your registrar, then verify:

```
POST /v1/me/domains/dom_abc123/verify
Cookie: <session cookie>
```

Other domain endpoints:

```
GET /v1/me/domains                      — list domains
GET /v1/me/domains/:domainId            — get domain details
DELETE /v1/me/domains/:domainId         — remove domain
```

---

### Create a Mailbox

To receive and manage threaded conversations, create a mailbox with a specific email address.

Mailbox endpoints use the same base URL (`https://api.molted.email`):

```
POST /v1/agent/mailboxes
Authorization: Bearer mm_live_...

{
  "address": "support@yourco.com",
  "displayName": "Support Inbox"
}
```

Response:
```json
{
  "id": "mbx_abc123",
  "tenantId": "your-tenant-id",
  "address": "support@yourco.com",
  "displayName": "Support Inbox",
  "status": "active",
  "config": {}
}
```

Mailboxes on verified or shared domains are **auto-activated** on creation (`status: "active"`). Mailboxes on unverified domains start as `"provisioning"` — verify the domain first, then activate the mailbox via `PATCH /v1/agent/mailboxes/:id` with `{ "status": "active" }`.

Sending from a provisioning mailbox is blocked with reason `mailbox_not_active` (the send does **not** silently fall back to another sender).

```
GET /v1/agent/mailboxes                       — list mailboxes
GET /v1/agent/mailboxes/:id                   — get a single mailbox
PATCH /v1/agent/mailboxes/:id                 — update address, display name, status, or config
POST /v1/agent/mailboxes/:id/clone            — clone a mailbox with its settings (requires admin scope)
DELETE /v1/agent/mailboxes/:id                — soft-delete a mailbox (requires admin scope)
GET /v1/agent/mailboxes/:id/stats?period=7d   — per-mailbox usage stats (sends, deliverability, inbound)
```

Mailbox limits by plan:

| Plan | Max Mailboxes |
|------|---------------|
| Trial | 1 |
| Starter | 3 |
| Growth | 10 |
| Enterprise | Unlimited |

---

## Authentication

Every request requires a Bearer token and your tenant ID:

```
Authorization: Bearer mm_live_...
```

Include `tenantId` in the request body (POST) or query string (GET/DELETE) for every call.

Keys can be scoped to specific mailboxes with granular permissions:

| Permission | Capabilities |
|------------|-------------|
| `read` | View emails, threads, metrics for the mailbox |
| `send` | Send and propose emails from the mailbox |
| `manage` | Update settings, approve/reject approval queue, archive emails |

A key with no mailbox scopes has access to all mailboxes (wildcard). New keys created in the portal default to explicit mailbox scoping.

Example: An onboarding agent's key might have `read` + `send` on the support mailbox only, while a monitoring key has `read` on all mailboxes.

### List API keys

```
GET /v1/agent/keys?tenantId=your-tenant-id
Authorization: Bearer mm_live_...
```

### Create an API key

```
POST /v1/agent/keys
Authorization: Bearer mm_live_...
Content-Type: application/json

{ "tenantId": "your-tenant-id", "label": "worker-2" }
```

Mint a **scoped** key by adding `scopeAllMailboxes` or `mailboxScopes`. You may
only scope a key to mailboxes your own tenant owns — the API returns `403
mailbox_not_owned` otherwise. The raw key is returned once in the `rawKey`
field; store it then.

```
{ "tenantId": "your-tenant-id", "label": "sender",
  "mailboxScopes": [{ "mailboxId": "mbx_...", "permissions": ["read", "send"] }] }
```

CLI (routes through the mailbox automatically — no tenantId needed):

```
molted auth keys create --label sender --mailbox mbx_... --permissions read,send
molted auth keys create --label ops --scope-all
molted auth keys create --label multi --scope mbx_a:read,send --scope mbx_b:read
molted auth keys update <id> --mailbox mbx_... --permissions manage
molted auth keys list
molted auth keys revoke <id>
```

### Update API key scopes

```
PATCH /v1/agent/keys/:id
Authorization: Bearer mm_live_...
Content-Type: application/json

{ "tenantId": "your-tenant-id", "scopeAllMailboxes": false, "mailboxScopes": [{ "mailboxId": "mbx_...", "permissions": ["read", "send"] }] }
```

### Revoke an API key

```
DELETE /v1/agent/keys/:id?tenantId=your-tenant-id
Authorization: Bearer mm_live_...
```

---

## Quick Reference

| Goal | Method | Endpoint |
|------|--------|----------|
| Send an email | POST | `/v1/agent/request-send` (include `mailboxId`) |
| Batch send | POST | `/v1/agent/batch/request-send` (include `mailboxId`) |
| Dry-run policy check | POST | `/v1/agent/simulate-send` |
| Batch dry-run | POST | `/v1/agent/simulate-batch` |
| Get template candidates | POST | `/v1/agent/propose-email` |
| Classify inbound intent | POST | `/v1/agent/classify-intent` |
| Batch classify | POST | `/v1/agent/batch/classify-intent` |
| Get next-best-action | POST | `/v1/agent/next-best-action` |
| Batch next-best-action | POST | `/v1/agent/batch/next-best-action` |
| Get contact timeline | GET | `/v1/agent/thread-context` |
| Schedule followup | POST | `/v1/agent/schedule-followup` |
| Cancel followup | DELETE | `/v1/agent/followups/:id` |
| Cancel scheduled send | DELETE | `/v1/agent/sends/:requestId?tenantId=` |
| Check send budget | GET | `/v1/agent/budget` |
| Record inbound email | POST | `/v1/agent/record-inbound` (include `mailboxId` or auto-resolved from `toEmail`) |
| Get raw MIME for message | GET | `/v1/agent/messages/:id/raw?tenantId=` |
| Generate portal login link | POST | `/v1/agent/login-token` |
| Verify your token | POST | `/v1/agent/whoami` |
| Subscribe to events | GET | `/v1/agent/events/stream` (SSE) — *coming soon* |

### Mailboxes

| Goal | Method | Endpoint |
|------|--------|----------|
| Create mailbox | POST | `/v1/agent/mailboxes` |
| List mailboxes | GET | `/v1/agent/mailboxes` |
| Get mailbox | GET | `/v1/agent/mailboxes/:id` |
| Update mailbox | PATCH | `/v1/agent/mailboxes/:id` |
| Clone mailbox | POST | `/v1/agent/mailboxes/:id/clone` |
| Delete mailbox | DELETE | `/v1/agent/mailboxes/:id` |
| Mailbox stats | GET | `/v1/agent/mailboxes/:id/stats?period=7d` |

### Outbound (Mailbox)

| Goal | Method | Endpoint |
|------|--------|----------|
| Send from mailbox | POST | `/v1/agent/outbound/send` |
| Reply from mailbox | POST | `/v1/agent/outbound/reply` |
| Schedule followup | POST | `/v1/agent/outbound/schedule-followup` |

### Attachments

| Goal | Method | Endpoint |
|------|--------|----------|
| Upload attachment | POST | `/v1/attachments` |
| List by message | GET | `/v1/attachments?messageId=&messageType=` (`messageType` must be `inbound` or `thread`) |
| Get download URL | GET | `/v1/attachments/:id/download` |

### Agent Domain Management

| Goal | Method | Endpoint |
|------|--------|----------|
| Add a sending domain | POST | `/v1/agent/domains` |
| List domains | GET | `/v1/agent/domains` |
| Get domain details | GET | `/v1/agent/domains/:domainId` |
| Check one-click DNS setup | GET | `/v1/agent/domains/:domainId/domain-connect` |
| Verify domain DNS | POST | `/v1/agent/domains/:domainId/verify` |
| Remove domain | DELETE | `/v1/agent/domains/:domainId` |
| Get warmup status | GET | `/v1/agent/domains/:domainId/warmup` |
| Skip warmup | POST | `/v1/agent/domains/:domainId/warmup/skip` |
| Update open/click tracking | PATCH | `/v1/agent/domains/:domainId/tracking` |

### Agent Analytics

| Goal | Method | Endpoint |
|------|--------|----------|
| Contact fatigue score | GET | `/v1/agent/analytics/contact-fatigue` |
| Send velocity | GET | `/v1/agent/analytics/send-velocity` |
| Deliverability stats | GET | `/v1/agent/analytics/deliverability` |
| Segment membership check | GET | `/v1/agent/analytics/segment-check` |

### Delivery Tracking

| Goal | Method | Endpoint |
|------|--------|----------|
| Full decision trace | GET | `/v1/ops/trace/:requestId` |
| Delivery event timeline | GET | `/v1/dashboard/request/:id/timeline` |
| Message lifecycle events | GET | `/v1/agent/messages/:id/events?tenantId=` |
| Delivery summary counts | GET | `/v1/dashboard/delivery-summary` |

### Templates

Templates have a `type` field: `marketing` (default) or `transactional`. Transactional templates bypass certain policy checks (e.g., suppression rules may differ). A default `_default` transactional template is auto-created at signup.

| Goal | Method | Endpoint |
|------|--------|----------|
| Create template | POST | `/v1/templates` |
| List templates | GET | `/v1/templates` |
| Get template | GET | `/v1/templates/:id` |
| Add version | POST | `/v1/templates/:id/versions` |
| Publish version | POST | `/v1/templates/:id/publish` |
| Approve/reject | PATCH | `/v1/templates/approvals/:id` |
| Test render | POST | `/v1/templates/:id/render` |

### Journeys

| Goal | Method | Endpoint |
|------|--------|----------|
| Create journey | POST | `/v1/journeys` |
| List journeys | GET | `/v1/journeys` |
| Get journey | GET | `/v1/journeys/:id` |
| Update journey | PATCH | `/v1/journeys/:id` |
| Add step | POST | `/v1/journeys/:id/steps` |
| List runs | GET | `/v1/journeys/:id/runs` |
| Ingest trigger event | POST | `/v1/agent/events/ingest` |

### Segments

| Goal | Method | Endpoint |
|------|--------|----------|
| Create segment | POST | `/v1/segments` |
| List segments | GET | `/v1/segments` |
| Get segment | GET | `/v1/segments/:id` |
| Update segment | PATCH | `/v1/segments/:id` |
| Archive segment | DELETE | `/v1/segments/:id` |
| Trigger compute | POST | `/v1/segments/:id/compute` |
| List members | GET | `/v1/segments/:id/members` |
| Get contact segments | GET | `/v1/segments/contact/:contactId/segments` |

### Email Lists

| Goal | Method | Endpoint |
|------|--------|----------|
| Create list | POST | `/v1/agent/lists` |
| List all lists | GET | `/v1/agent/lists` |
| Get list | GET | `/v1/agent/lists/:id` |
| Update list | PATCH | `/v1/agent/lists/:id` |
| Archive list | DELETE | `/v1/agent/lists/:id` |
| Subscribe contact | POST | `/v1/agent/lists/:id/subscribers` |
| Unsubscribe contact | POST | `/v1/agent/lists/:id/unsubscribe` |
| List subscribers | GET | `/v1/agent/lists/:id/subscribers` |
| Bulk subscribe | POST | `/v1/agent/lists/:id/subscribers/bulk` |
| Send broadcast | POST | `/v1/agent/lists/:id/send` |
| List sends | GET | `/v1/agent/lists/:id/sends` |
| List stats | GET | `/v1/agent/lists/:id/stats` |
| List growth | GET | `/v1/agent/lists/:id/stats/growth` |
| List send performance | GET | `/v1/agent/lists/:id/stats/sends` |

### Experiments

> **Note:** Experiments endpoints use session cookie auth (not Bearer token).

| Goal | Method | Endpoint |
|------|--------|----------|
| Create experiment | POST | `/v1/experiments` |
| List experiments | GET | `/v1/experiments` |
| Get experiment | GET | `/v1/experiments/:id` |
| Start experiment | POST | `/v1/experiments/:id/start` |
| Stop experiment | POST | `/v1/experiments/:id/stop` |
| Get results | GET | `/v1/experiments/:id/results` |

### Outcomes & Attribution

| Goal | Method | Endpoint |
|------|--------|----------|
| Ingest outcome | POST | `/v1/outcomes/ingest` |
| List outcomes | GET | `/v1/outcomes` |
| Get outcome | GET | `/v1/outcomes/:id` |
| Outcomes dashboard | GET | `/v1/outcomes/dashboard` |
| Journey impact report | GET | `/v1/outcomes/journey-impact` |

### Suppressions & Consent

| Goal | Method | Endpoint |
|------|--------|----------|
| Add suppression | POST | `/v1/suppressions` |
| Remove suppression | DELETE | `/v1/suppressions/:id` |
| List suppressions | GET | `/v1/suppressions` |
| Add domain suppression | POST | `/v1/suppressed-domains` |
| Remove domain suppression | DELETE | `/v1/suppressed-domains/:domain` |
| List domain suppressions | GET | `/v1/suppressed-domains` |
| Record consent | POST | `/v1/consent` |
| Get consent status | GET | `/v1/consent` |

### Safety & Configuration

Safety and humanizer settings can be configured at the tenant level or per-mailbox. Per-mailbox settings override tenant defaults.

| Goal | Method | Endpoint |
|------|--------|----------|
| Get tenant safety settings | GET | `/v1/me/safety-settings` |
| Update tenant safety settings | PUT | `/v1/me/safety-settings` |
| Get spam filter rules | GET | `/v1/spam-filter/rules` |
| Update spam filter rules | PUT | `/v1/spam-filter/rules` |
| Get tenant humanizer config | GET | `/v1/me/humanizer` |
| Update tenant humanizer config | PUT | `/v1/me/humanizer` |

Per-mailbox safety and humanizer config is managed via `PATCH /v1/agent/mailboxes/:id` in the mailbox `config` JSON field.

### Mailboxes

Every email (sent and received) belongs to a mailbox. Mailboxes are the primary organizational unit in the portal — each has its own Sent, Inbox, Approvals, and Flagged views.

| Goal | Method | Endpoint |
|------|--------|----------|
| Create mailbox | POST | `/v1/agent/mailboxes` |
| List mailboxes | GET | `/v1/agent/mailboxes` |
| Get mailbox | GET | `/v1/agent/mailboxes/:id` |
| Update mailbox | PATCH | `/v1/agent/mailboxes/:id` |
| Delete mailbox | DELETE | `/v1/agent/mailboxes/:id` |
| Mailbox stats | GET | `/v1/agent/mailboxes/:id/stats?period=7d` |
| List mailbox threads | GET | `/v1/agent/threads?mailboxId=:id` |
| List mailbox approvals | GET | `/v1/send/approvals?mailboxId=:id` |

#### Mailbox Reputation

Reputation is recalculated automatically after bounces/complaints. When thresholds are breached, the mailbox is auto-paused.

```
GET /v1/me/mailboxes/:id/reputation              — get reputation stats
POST /v1/me/mailboxes/:id/reputation/recalculate  — force recalculation (5-min cooldown)
POST /v1/me/mailboxes/:id/unpause                 — unpause and reset counters
```

#### Mailbox Autonomy Level

Control how much human oversight a mailbox requires. Requires `manage` scope on the mailbox.

| Level | Name | Behavior |
|-------|------|----------|
| 1 | Full approval | Every send requires explicit human approval |
| 2 | First-contact | Only the first message to a new recipient requires approval |
| 3 | Full auto | Sends execute without approval (default) |

```
GET /v1/agent/mailboxes/:id/autonomy?tenantId=T   — get current autonomy level
PATCH /v1/agent/mailboxes/:id/autonomy?tenantId=T  — update autonomy level
  Body: { "autonomyLevel": 1 | 2 | 3 }
```

### Mailbox Reputation

```
GET /v1/agent/mailboxes/:id/reputation?tenantId=T      — get reputation stats
POST /v1/agent/mailboxes/:id/reputation/recalculate     — force recalculation (5-min cooldown)
  Body: { "tenantId": "your-tenant-id" }
POST /v1/agent/mailboxes/:id/unpause                    — unpause a paused mailbox
  Body: { "tenantId": "your-tenant-id" }
```

### Multi-Agent Coordination

| Goal | Method | Endpoint |
|------|--------|----------|
| Register agent | POST | `/v1/agent/register` |
| Heartbeat | POST | `/v1/agent/coordination/heartbeat` |
| Acquire contact lease | POST | `/v1/agent/coordination/lease` |
| Release lease | DELETE | `/v1/agent/coordination/lease/:id` |
| List active leases | GET | `/v1/agent/coordination/leases` |
| Request consensus vote | POST | `/v1/agent/coordination/consensus` |
| Get consensus status | GET | `/v1/agent/coordination/consensus/:id` |
| Cast vote | POST | `/v1/agent/coordination/consensus/:id/vote` |
| Get agent config | GET | `/v1/agent/agents/:agentId/config` |
| Update agent config | PUT | `/v1/agent/agents/:agentId/config` |
| Get tenant settings | GET | `/v1/agent/config/settings` |
| Update tenant settings | PATCH | `/v1/agent/config/settings` |
| Get humanizer config | GET | `/v1/agent/config/humanizer` |
| Update humanizer config | PUT | `/v1/agent/config/humanizer` |
| Get safety settings | GET | `/v1/agent/config/safety-settings` |
| Update safety settings | PUT | `/v1/agent/config/safety-settings` |

### Agent Adoption

| Goal | Method | Endpoint |
|------|--------|----------|
| Create invite token | POST | `/v1/agent/adopt/invite` |
| List pending tokens | GET | `/v1/agent/adopt/pending` |
| Revoke token | DELETE | `/v1/agent/adopt/:id` |

### Agentic Mailbox

| Goal | Method | Endpoint |
|------|--------|----------|
| Send from mailbox | POST | `/v1/agent/outbound/send` |
| Reply to thread | POST | `/v1/agent/outbound/reply` |
| Schedule thread followup | POST | `/v1/agent/outbound/schedule-followup` |
| List threads | GET | `/v1/agent/threads` |
| Get thread with messages | GET | `/v1/agent/threads/:id` |
| Update thread | PATCH | `/v1/agent/threads/:id` |
| Archive threads | POST | `/v1/agent/threads/archive` |
| Unarchive threads | POST | `/v1/agent/threads/unarchive` |
| Trash threads | POST | `/v1/agent/threads/trash` |
| Restore threads | POST | `/v1/agent/threads/restore` |
| Permanently delete thread | DELETE | `/v1/agent/threads/:id/permanent` |
| Override queue counts | GET | `/v1/agent/override/queues/counts` |
| List queue items | GET | `/v1/agent/override/queues/:queue` |
| List held messages | GET | `/v1/agent/override/held-messages` |
| Release held message | POST | `/v1/agent/override/held-messages/:messageId/release` |
| Reject held message | POST | `/v1/agent/override/held-messages/:messageId/reject` |
| Approve send | POST | `/v1/agent/override/:threadId/approve` |
| Reject send | POST | `/v1/agent/override/:threadId/reject` |
| Edit and approve | POST | `/v1/agent/override/:threadId/edit` |
| Escalate thread | POST | `/v1/agent/override/:threadId/escalate` |
| Mark not spam | POST | `/v1/agent/override/:threadId/not-spam` |
| Delete spam | POST | `/v1/agent/override/:threadId/delete-spam` |
| Report spam | POST | `/v1/agent/override/:threadId/report-spam` |
| Spam feedback stats | GET | `/v1/agent/override/spam-feedback/stats` |
| Sender reputation | GET | `/v1/agent/override/sender-reputation?domain=X` |
| Alert status | GET | `/v1/agent/alerts/status` |

---

## Core Workflow: Send an Email

### 1. Request Send

```
POST /v1/agent/request-send
```

```json
{
  "tenantId": "your-tenant-id",
  "recipientEmail": "alice@example.com",
  "templateId": "onboarding-welcome",
  "dedupeKey": "onboarding-alice-step1",
  "payload": { "firstName": "Alice", "trialDays": 14 },
  "sendReason": "onboarding sequence step 1",
  "mailboxId": "mbx_abc123"
}
```

**Success** (200):
```json
{
  "requestId": "req_abc123",
  "status": "queued",
  "policyTrace": {
    "decision": { "allow": true },
    "auditEvents": [...]
  }
}
```

**Blocked** (200 — not a 4xx):
```json
{
  "requestId": "req_abc123",
  "status": "blocked",
  "reason": "cooldown",
  "policyTrace": {
    "decision": { "allow": false, "reason": "cooldown" },
    "auditEvents": [...]
  }
}
```

**Scheduled** (200):
```json
{
  "requestId": "req_abc123",
  "status": "scheduled",
  "scheduledAt": "2026-03-24T09:00:00Z",
  "policyTrace": {
    "decision": { "allow": true },
    "auditEvents": [...]
  }
}
```

**Important:** Blocked sends return HTTP 200 with `status: "blocked"`.
Always check `status` in the response body, not just the HTTP code.

### Fields

| Field | Required | Description |
|-------|----------|-------------|
| `tenantId` | yes | Your tenant identifier |
| `recipientEmail` | yes | Recipient email address |
| `templateId` | yes | Template slug (e.g. `welcome`) or template ID |
| `dedupeKey` | yes | Idempotency key — same key = same send, never duplicated. Must be a non-empty, non-whitespace string (e.g. `"workflow-contactId-step"`). Empty strings are rejected with `400 dedupe_key_required`. |
| `payload` | yes | Template variables (object) |
| `sendReason` | no | Human-readable context for audit trail |
| `idempotencyKey` | no | Alias for `dedupeKey` |
| `mailboxId` | no | Mailbox to send from. When provided, the send is automatically projected into a thread for that contact — replies from the recipient are linked to the same thread. Use `GET /v1/agent/threads` to track the conversation. Recommended. |
| `attachments` | no | Array of attachment refs: `[{ "id": "uuid", "filename": "report.pdf", "contentType": "application/pdf" }]`. Upload first via `POST /v1/attachments`. |
| `scheduledAt` | no | ISO 8601 timestamp to defer the send to a specific future time (e.g. `"2026-03-24T09:00:00Z"`). Must be in the future. When set, the send is enqueued with a delay and returns `status: "scheduled"`. |

### Policy Rules Evaluated

Every send is checked against these rules (in order). If any fails, the send is blocked:

| Rule | Block Reason | Description |
|------|-------------|-------------|
| Tenant paused | `tenant_paused` | Emergency pause is active |
| Trial not activated | `trial_not_activated` | Trial plan not activated for sending |
| Subscription expired | `subscription_expired` | Subscription has expired |
| Template not approved | `template_not_approved` | Template requires approval and hasn't been approved |
| Template lint failed | `template_lint_failed` | Template has validation errors |
| Template not found | `template_not_found` | Specified `templateId` does not exist |
| Template render failed | `template_render_failed` | Template exists but rendering failed (e.g. missing required variable) |
| Suppression list | `suppressed` | Recipient is on a suppression list |
| Disengaged | `disengaged` | Recipient is marked as disengaged |
| Global DNC | `dnc` | Hard bounce, complaint, legal request, or role account |
| Active opportunity | `active_opportunity` | Contact has an active sales deal (demo, proposal, negotiation, contract) |
| Duplicate | `duplicate` | Same `dedupeKey` was already used |
| Cooldown | `cooldown` | Same template sent to same recipient within 10 minutes |
| Hourly rate limit | `rate_limited` | Hourly send quota exceeded |
| Hourly limit | `hourly_limit_exceeded` | Exceeded hourly send quota |
| Daily limit | `daily_limit_exceeded` | Exceeded daily send quota |
| Daily budget | `budget_exceeded` | Daily send quota exceeded |
| Monthly limit | `monthly_limit_exceeded` | Exceeded monthly send quota |
| Monthly budget | `monthly_budget_exceeded` | Monthly send quota exceeded |
| Overage cap | `overage_cap_exceeded` | Exceeded overage hard cap |
| Missing recipient email | `recipient_email_required` | Batch send entry had no `email`/`recipientEmail` — returned per-recipient in `results[]` so the rest of the batch still processes |
| Missing dedupe key | `dedupe_key_required` | Batch send entry had no `dedupeKey` (or it was empty/whitespace-only). Returned per-recipient in `results[]`. The CLI auto-generates a dedupeKey for batch entries that omit one; direct API callers must provide one explicitly. |
| Duplicate dedupeKey in batch | `duplicate_in_batch` | Two or more entries in the same batch shared a `dedupeKey`. The first occurrence is processed normally; subsequent ones are returned as `status: "error"` with this reason (and an empty `requestId`) so caller-side de-dup is unambiguous. |
| Risk score | `risk_budget_exceeded` | Daily risk score budget exceeded |
| Negative signals | `negative_signal_budget_exceeded` | Too many bounces + complaints in 24h |
| Warmup limit | `warmup_limit` | Exceeded daily warmup limit (transactional sends / `deferOnWarmup: false`) |
| Warmup horizon exceeded | `warmup_horizon_exceeded` | Warmup defer horizon (30 days) cannot accommodate the send; response includes `projectedDate` |
| Domain throttled | `domain_throttled` | Exceeded per-recipient-domain hourly throttle |
| Lease conflict | `lease_conflict` | Another agent holds a lease on this contact |
| Canary violation | `canary_violation` | Canary token leaked in outbound payload |
| Mailbox not active | `mailbox_not_active` | Explicit mailbox is in provisioning/paused state |
| Mailbox paused | `mailbox_paused` | Mailbox auto-paused due to reputation threshold breach |
| Autonomy level | `autonomy_level` | Mailbox autonomy level requires human approval for this send |
| No verified domain | `no_verified_domain` | No verified domain available for sending |

---

## Simulate Before Sending

Dry-run a send without persisting or sending anything:

```
POST /v1/agent/simulate-send
```

```json
{
  "tenantId": "your-tenant-id",
  "recipientEmail": "alice@example.com",
  "templateId": "onboarding-welcome",
  "dedupeKey": "test-dry-run",
  "mailboxId": "mbx_abc123",
  "payload": { "name": "Alice" }
}
```

| Field | Required | Description |
|-------|----------|-------------|
| `tenantId` | yes | Tenant identifier |
| `recipientEmail` | yes | Recipient email |
| `templateId` | yes | Template slug or ID to check against |
| `dedupeKey` | no | Deduplication key (checks duplicate policy) |
| `mailboxId` | no | Mailbox to resolve sender from |
| `payload` | no | Template variables (passed to policy engine for validation) |

Response (includes full policy debug info):
```json
{
  "wouldAllow": true,
  "simulation": true,
  "resolvedFromAddress": "support@yourco.com",
  "rateLimited": false,
  "policyContext": {
    "hasDuplicate": false,
    "isCooldownHit": false,
    "isSuppressed": false,
    "isDisengaged": false,
    "hasActiveOpportunity": false,
    "isTenantPaused": false,
    "templateApproved": true,
    "templateLintPassed": true,
    "isBillingBlocked": false,
    "billingPlan": "starter"
  }
}
```

Or if blocked:
```json
{
  "wouldAllow": false,
  "reason": "suppressed",
  "suppressionInfo": {
    "scope": "global",
    "reasonCode": "hard_bounce"
  },
  "simulation": true,
  "resolvedFromAddress": "support@yourco.com",
  "rateLimited": false,
  "policyContext": {
    "hasDuplicate": false,
    "isCooldownHit": false,
    "isSuppressed": true,
    "isTenantPaused": false,
    "templateApproved": true,
    "templateLintPassed": true
  }
}
```

> Note: Rate limit checks in simulation use non-blocking peek. There is a
> small TOCTOU window between simulation and actual send.

### Batch Simulate

```
POST /v1/agent/simulate-batch
```

```json
{
  "tenantId": "your-tenant-id",
  "templateId": "onboarding-welcome",
  "recipientEmails": ["alice@example.com", "bob@example.com"]
}
```

Response:
```json
{
  "total": 2,
  "wouldAllow": 1,
  "wouldBlock": 1,
  "results": [
    { "recipientEmail": "alice@example.com", "wouldAllow": true },
    { "recipientEmail": "bob@example.com", "wouldAllow": false, "reason": "dnc" }
  ]
}
```

---

## Batch Send

Send to up to 500 recipients with per-recipient policy evaluation:

```
POST /v1/agent/batch/request-send
```

```json
{
  "tenantId": "your-tenant-id",
  "templateId": "product-update",
  "mailboxId": "mbx_abc123",
  "sends": [
    {
      "recipientEmail": "alice@example.com",
      "dedupeKey": "update-v2-alice",
      "payload": { "firstName": "Alice" }
    },
    {
      "recipientEmail": "bob@example.com",
      "dedupeKey": "update-v2-bob",
      "payload": { "firstName": "Bob" }
    }
  ]
}
```

Response:
```json
{
  "batchId": "batch_xyz",
  "total": 2,
  "queued": 1,
  "blocked": 1,
  "results": [
    { "recipientEmail": "alice@example.com", "requestId": "req_1", "status": "queued" },
    { "recipientEmail": "bob@example.com", "requestId": "req_2", "status": "blocked", "reason": "duplicate" }
  ]
}
```

---

## Email Capture

One-call contact collection: upsert contact, subscribe to list, and optionally send a confirmation email.

```
POST /v1/agent/capture
```

```json
{
  "tenantId": "your-tenant-id",
  "email": "user@example.com",
  "list": "waitlist",
  "subject": "You're on the waitlist!",
  "html": "<p>Thanks for signing up. We'll notify you when we launch.</p>",
  "text": "Thanks for signing up.",
  "name": "Jane Smith",
  "tags": ["early-access", "landing-page"],
  "doubleOptIn": true,
  "source": "form",
  "metadata": { "referrer": "producthunt" }
}
```

| Field | Required | Description |
|-------|----------|-------------|
| `email` | Yes | Recipient email address |
| `list` | Yes | List slug - subscribes contact to this list. Creates the list if it doesn't exist |
| `subject` | No | Confirmation email subject. If omitted, no email is sent (silent capture) |
| `html` | No | Email HTML body (required if subject is set) |
| `text` | No | Email plaintext body (fallback) |
| `name` | No | Contact display name |
| `tags` | No | Array of string tags stored in contact metadata |
| `doubleOptIn` | No | If true and list is new, subscription starts as `pending`. Existing lists use their own setting. Default: false |
| `source` | No | Attribution source: `form`, `api`, `import`, `manual`. Default: `api` |
| `metadata` | No | Arbitrary JSON merged into contact metadata |

**Success** (200):
```json
{
  "contactId": "uuid",
  "listId": "uuid",
  "subscriptionStatus": "active",
  "emailStatus": "queued",
  "requestId": "uuid"
}
```

**Silent capture** (no subject):
```json
{
  "contactId": "uuid",
  "listId": "uuid",
  "subscriptionStatus": "active",
  "emailStatus": null,
  "requestId": null
}
```

**Double opt-in**:
```json
{
  "contactId": "uuid",
  "listId": "uuid",
  "subscriptionStatus": "pending",
  "emailStatus": "queued",
  "requestId": "uuid"
}
```

### Behavior

1. **Upserts contact** by `(tenant_id, email)`. Merges name, tags, and metadata.
2. **Resolves list** by slug. Auto-creates if not found (`type: newsletter`).
3. **Subscribes** contact (idempotent - skips if already subscribed).
4. **Sends email** via `_default` template if subject provided. Dedupe key: `capture:{list}:{email}`.
5. Returns combined result.

### CLI

```bash
# Silent capture (no email)
molted capture --email user@example.com --list waitlist

# With confirmation email
molted capture --email user@example.com --list waitlist \
  --subject "Welcome!" --body "<p>Thanks!</p>"

# With tags and metadata
molted capture --email user@example.com --list waitlist \
  --tags early-access,landing-page \
  --metadata '{"referrer": "producthunt"}'
```

---

## Propose Email

Get template candidates and a policy pre-check before deciding what to send:

```
POST /v1/agent/propose-email
```

```json
{
  "tenantId": "your-tenant-id",
  "recipientEmail": "alice@example.com"
}
```

Response:
```json
{
  "draftCandidates": [
    { "templateId": "tmpl_1", "templateSlug": "onboarding-welcome", "templateName": "Welcome Email" },
    { "templateId": "tmpl_2", "templateSlug": "trial-nudge", "templateName": "Trial Ending Nudge" }
  ],
  "policyPreCheck": { "allow": true },
  "contactContext": {
    "id": "contact_abc",
    "email": "alice@example.com",
    "name": "Alice",
    "lifecycleStage": "trial",
    "dealStage": null,
    "recentSends": 2,
    "recentInbound": 1,
    "activeJourneys": 1
  }
}
```

`policyPreCheck` mirrors what `simulate-send` and a real `request-send` would decide. Reasons that can appear when `allow: false` include suppression / disengagement / `active_opportunity` / `tenant_paused`, plus billing blocks: `trial_not_activated` (trial plan that hasn't been upgraded) and `subscription_expired` (paid plan past grace). Treat any `allow: false` as a hard stop -- don't proceed to `request-send`.

---

## Classify Inbound Intent

Classify an email's intent:

```
POST /v1/agent/classify-intent
```

```json
{
  "tenantId": "your-tenant-id",
  "subject": "Re: your proposal",
  "bodyText": "Thanks, I'd love to schedule a demo next week."
}
```

Response:
```json
{
  "intent": "interested",
  "confidence": 0.92,
  "suggestedAction": "notify_owner"
}
```

### Intent Values

| Intent | Description |
|--------|-------------|
| `interested` | Positive engagement, wants to proceed |
| `not_now` | Timing isn't right, may revisit |
| `objection` | Concern or pushback on offering ("not interested", "no thanks") |
| `unsubscribe` | Opt-out / removal request -- compliance-critical, never auto-archive |
| `support` | Needs help with product/service |
| `billing` | Billing or payment related |
| `legal` | Legal matter (compliance, GDPR, etc.) |
| `security` | Security concern or report |
| `out_of_office` | Auto-reply / OOO |
| `unclassified` | Could not determine intent |

### Suggested Actions

| Action | Description |
|--------|-------------|
| `notify_owner` | Alert the contact owner |
| `require_approval` | Queue for human review before responding |
| `auto_archive` | Safe to archive without action |
| `trigger_unsubscribe` | Add the contact to the suppression list and acknowledge -- legally required for opt-out requests under CAN-SPAM, GDPR, CASL. Never auto-archive these. |
| `escalate` | Needs immediate human attention |

### Batch Classify

```
POST /v1/agent/batch/classify-intent
```

```json
{
  "tenantId": "your-tenant-id",
  "messages": [
    { "subject": "Re: demo", "bodyText": "Yes, interested" },
    { "subject": "OOO", "bodyText": "I am out of office until Monday" }
  ]
}
```

---

## Next Best Action

Get a recommendation for what to do next with a contact:

```
POST /v1/agent/next-best-action
```

```json
{
  "tenantId": "your-tenant-id",
  "contactEmail": "alice@example.com"
}
```

Response:
```json
{
  "recommendation": "nudge",
  "reasoning": "Last send was 5 days ago with no response. Contact is in trial stage. A gentle followup is appropriate.",
  "contactSummary": {
    "email": "alice@example.com",
    "name": "Alice",
    "lastSendAt": "2026-02-20T10:00:00Z",
    "lastInboundAt": null,
    "isSuppressed": false,
    "activeIncidents": 0
  }
}
```

### Batch Next Best Action

Get recommendations for multiple contacts at once:

```
POST /v1/agent/batch/next-best-action
```

```json
{
  "tenantId": "your-tenant-id",
  "contactEmails": ["alice@example.com", "bob@example.com"]
}
```

### Recommendation Values

| Value | Meaning |
|-------|---------|
| `reply` | Contact sent something — respond to it |
| `wait` | Too soon to reach out again, let them breathe |
| `nudge` | Time for a gentle followup |
| `stop` | Contact is suppressed, disengaged, or at risk — do not send |
| `escalate` | Situation needs human judgment |

---

## Thread Context

Get the full operational context for a contact — sends, inbound, journeys, suppression:

```
GET /v1/agent/thread-context?tenantId=your-tenant-id&contactEmail=alice@example.com
```

Response:
```json
{
  "timeline": {
    "sends": [
      { "requestId": "req_1", "templateId": "welcome", "status": "delivered", "createdAt": "2026-02-18T10:00:00Z" }
    ],
    "journeyRuns": [
      { "journeyId": "j_1", "runId": "run_1", "status": "active", "createdAt": "2026-02-18T10:00:00Z" }
    ],
    "inboundMessages": [
      { "id": "msg_1", "subject": "Re: welcome", "fromEmail": "alice@example.com", "createdAt": "2026-02-19T14:00:00Z" }
    ]
  },
  "suppressionStatus": { "suppressed": false },
  "activeIncidents": [],
  "lastClassification": { "intent": "interested", "confidence": 0.92 }
}
```

---

## Schedule Followup

Schedule a delayed send that auto-cancels if the recipient replies. Use either `delayMinutes` (relative) or `scheduledAt` (absolute ISO 8601 timestamp) — exactly one is required:

```
POST /v1/agent/schedule-followup
```

**Relative delay:**
```json
{
  "tenantId": "your-tenant-id",
  "contactEmail": "alice@example.com",
  "threadRequestId": "req_abc123",
  "delayMinutes": 1440,
  "cancelOnReply": true
}
```

**Absolute time:**
```json
{
  "tenantId": "your-tenant-id",
  "contactEmail": "alice@example.com",
  "threadRequestId": "req_abc123",
  "scheduledAt": "2026-03-27T09:00:00Z",
  "cancelOnReply": true
}
```

Response:
```json
{
  "followupId": "fu_xyz",
  "status": "pending",
  "scheduledAt": "2026-02-26T10:00:00Z"
}
```

`delayMinutes` range: 1–43200 (1 minute to 30 days).

### Cancel Followup

```
DELETE /v1/agent/followups/fu_xyz
```

### Cancel Scheduled Send

Cancel a send that is currently in the `scheduled` delivery state (either explicitly scheduled via `scheduledAt`, or auto-deferred by the warmup ramp with `reason: 'warmup_defer'`). Immediate (`queued`) or already-sent requests cannot be cancelled.

```
DELETE /v1/agent/sends/:requestId?tenantId=your-tenant-id
Authorization: Bearer <api-key>
```

Response:
```json
{ "cancelled": true }
```

If the send has already been picked up, is not scheduled, or does not exist, the API returns `{ "cancelled": false, "reason": "not_cancellable" | "not_found" }` (reason is `not_cancellable` when the request exists but is not in the scheduled state; `not_found` when the id doesn't match any request for this tenant). Cancelling a warmup-deferred send releases its reserved slot back to that day's pool so other sends can use the capacity.

---

## Check Budget

See remaining send quota before committing to a batch:

```
GET /v1/agent/budget?tenantId=your-tenant-id
```

Response:
```json
{
  "tenantId": "your-tenant-id",
  "monthly": { "used": 450, "limit": 1000, "remaining": 550 },
  "daily": { "used": 42, "limit": 1000, "remaining": 958 },
  "hourly": { "used": 8, "limit": 100, "remaining": 92 },
  "negativeSignals": { "count": 1, "budget": 50, "remaining": 49 },
  "timestamp": "2026-02-25T15:30:00Z"
}
```

---

## Agent Analytics

### Contact Fatigue Score

Check how fatigued a contact is before deciding to send:

```
GET /v1/agent/analytics/contact-fatigue?tenantId=your-tenant-id&contactEmail=alice@example.com
```

Response:
```json
{
  "contactEmail": "alice@example.com",
  "fatigueScore": 45,
  "factors": {
    "sendFrequency": 15,
    "bounceCount": 0,
    "complaintCount": 0,
    "replyRate": 0.3,
    "daysSinceLastEngagement": 12
  },
  "recommendation": "reduce_frequency"
}
```

| Score | Recommendation | Meaning |
|-------|---------------|---------|
| 0–39 | `safe_to_send` | Contact is healthy, send freely |
| 40–69 | `reduce_frequency` | Reduce cadence, space out sends |
| 70–100 | `stop_sending` | Contact is over-contacted or disengaged — stop |

### Send Velocity

```
GET /v1/agent/analytics/send-velocity?tenantId=your-tenant-id
```

Returns send volume counts across time windows and an hourly trend for the last 24 hours.

```json
{
  "tenantId": "your-tenant-id",
  "lastHour": 12,
  "last24Hours": 156,
  "last7Days": 892,
  "hourlyTrend": [
    { "hour": "2026-04-10 08:00:00+00", "count": 5 },
    { "hour": "2026-04-10 09:00:00+00", "count": 8 }
  ]
}
```

`hourlyTrend` is an empty array when there are no sends in the last 24 hours.

### Deliverability Stats

```
GET /v1/agent/analytics/deliverability?tenantId=your-tenant-id&period=7d
```

Periods: `24h`, `7d`, `30d`. Returns bounce rate, complaint rate, and delivery success rate.

### Segment Membership Check

```
GET /v1/agent/analytics/segment-check?tenantId=your-tenant-id&segmentId=seg_123&contactEmail=alice@example.com
```

Returns whether the contact is a member of the segment.

### Policy Decisions

```
GET /v1/agent/analytics/policy-decisions?tenantId=your-tenant-id&period=7d
```

Periods: `24h`, `7d`, `30d`. Returns a breakdown of policy decisions (blocked reasons with counts and percentages).

### Suppression Analytics

```
GET /v1/agent/analytics/suppressions?tenantId=your-tenant-id&period=7d
```

Periods: `24h`, `7d`, `30d`. Returns suppression counts grouped by reason code.

---

## Record Inbound Email

Manually record an inbound email for classification and routing:

```
POST /v1/agent/record-inbound
```

```json
{
  "tenantId": "your-tenant-id",
  "fromEmail": "alice@example.com",
  "toEmail": "support@yourco.com",
  "subject": "Re: Welcome to Acme",
  "bodyText": "Thanks, I would love a demo!",
  "bodyHtml": "<p>Thanks, I would love a demo!</p>",
  "inReplyTo": "<msg-id@provider.com>",
  "providerMessageId": "provider-msg-123",
  "mailboxId": "mbx_abc123"
}
```

Response:
```json
{
  "messageId": "msg_abc123",
  "threadId": "thd_abc123",
  "isNewThread": true,
  "linkedRequestId": "req_xyz789",
  "classificationJobId": "job_456"
}
```

The response includes the `threadId` for the created or matched thread. Use this ID with `threads reply` to respond to the inbound email.

If `mailboxId` is omitted, the system auto-resolves it by matching `toEmail` against active mailbox addresses.

| Field | Required | Description |
|-------|----------|-------------|
| `tenantId` | yes | Your tenant identifier |
| `fromEmail` | yes | Sender's email address |
| `toEmail` | yes | Recipient email (your domain) |
| `subject` | no | Email subject line |
| `bodyText` | no | Plain-text body |
| `bodyHtml` | no | HTML body |
| `inReplyTo` | no | Message-ID header for thread linking |
| `referencesHeader` | no | References header for thread linking |
| `providerMessageId` | no | Provider's message identifier |
| `mailboxId` | no | Mailbox that received this email. Auto-resolved from `toEmail` if not provided. |
| `threadId` | no | Explicit thread ID to link to an existing thread |

After recording, the email is automatically queued for intent classification and routing.

---

## Delivery Tracking

### Decision Trace

Get the full send lifecycle for a request — policy decision, delivery events, and audit trail:

```
GET /v1/ops/trace/req_abc123?tenantId=your-tenant-id
```

Response:
```json
{
  "request": {
    "id": "req_abc123",
    "recipientEmail": "alice@example.com",
    "templateId": "onboarding-welcome",
    "status": "sent",
    "deliveryStatus": "delivered",
    "createdAt": "2026-02-25T10:00:00Z"
  },
  "policyDecision": {
    "allow": true,
    "auditEvents": [...]
  },
  "deliveryEvents": [
    { "eventType": "queued", "occurredAt": "2026-02-25T10:00:01Z" },
    { "eventType": "accepted", "occurredAt": "2026-02-25T10:00:02Z" },
    { "eventType": "delivered", "occurredAt": "2026-02-25T10:00:05Z" }
  ]
}
```

### Delivery Event Timeline

```
GET /v1/dashboard/request/req_abc123/timeline?tenantId=your-tenant-id
```

Returns delivery events in chronological order for a single request.

### Message Lifecycle Events

```
GET /v1/agent/messages/req_abc123/events?tenantId=your-tenant-id
```

Returns all lifecycle events for a message (send request) in chronological order. Events include sent, delivered, opened, clicked, bounced, complained, and failed.

Response:
```json
{
  "events": [
    {
      "id": "evt_1",
      "message_id": "req_abc123",
      "tenant_id": "your-tenant-id",
      "event_type": "sent",
      "payload": { "provider_name": "resend", "occurred_at": "2026-03-23T10:00:00Z" },
      "created_at": "2026-03-23T10:00:00Z"
    },
    {
      "id": "evt_2",
      "message_id": "req_abc123",
      "tenant_id": "your-tenant-id",
      "event_type": "delivered",
      "payload": { "provider_name": "resend", "occurred_at": "2026-03-23T10:00:05Z" },
      "created_at": "2026-03-23T10:00:05Z"
    }
  ]
}
```

### Delivery Status Values

| Status | Terminal? | Description |
|--------|-----------|-------------|
| `queued` | no | Job enqueued for delivery |
| `accepted` | no | Provider accepted the message |
| `sent` | no | Handed to provider |
| `delivered` | yes | Delivery confirmed |
| `deferred` | no | Provider soft-deferral, will retry |
| `bounced` | yes | Hard or soft bounce |
| `complained` | yes | Spam complaint received |
| `failed` | yes | Terminal failure |

---

## Webhooks

Register webhook endpoints to receive push notifications when events occur. Webhooks are available via cookie auth (`/v1/me/webhooks`) for portal access, or via Bearer token auth (`/v1/agent/webhooks?tenantId=T`) for CLI and agent access.

### Register Webhook

```
POST /v1/me/webhooks
POST /v1/agent/webhooks?tenantId=T
{ "url": "https://example.com/hooks/molted", "events": ["inbound.received", "delivery.bounced"], "description": "Production webhook" }
```

Response includes a `secret` for HMAC signature verification. Store it securely.

### List Webhooks

```
GET /v1/me/webhooks
GET /v1/agent/webhooks?tenantId=T
```

### Get Webhook

```
GET /v1/me/webhooks/:id
GET /v1/agent/webhooks/:id?tenantId=T
```

### Update Webhook

```
PATCH /v1/me/webhooks/:id
PATCH /v1/agent/webhooks/:id?tenantId=T
{ "url": "https://new-url.com/hook", "events": ["inbound.received"], "enabled": false }
```

### Delete Webhook

```
DELETE /v1/me/webhooks/:id
DELETE /v1/agent/webhooks/:id?tenantId=T
```

### List Deliveries

```
GET /v1/me/webhooks/:id/deliveries
GET /v1/agent/webhooks/:id/deliveries?tenantId=T
```

Returns recent delivery attempts with status, HTTP response code, and timestamps.

### Test Webhook

```
POST /v1/me/webhooks/:id/test
POST /v1/agent/webhooks/:id/test?tenantId=T
```

Sends a test event to verify the endpoint is reachable and responding. Test deliveries are recorded in the delivery history.

### Webhook Event Types

| Event | Fired When |
|-------|------------|
| `inbound.received` | New inbound email stored |
| `inbound.classified` | Inbound email classified |
| `inbound.routed` | Inbound email routed to thread |
| `delivery.sent` | Email sent via provider |
| `delivery.delivered` | Email delivered |
| `delivery.bounced` | Email bounced |
| `delivery.complained` | Spam complaint received |
| `suppression.created` | A hard bounce, complaint, or repeated soft bounce auto-suppressed an address. Payload: `{ recipientEmail, reasonCode, requestId, source: "webhook", triggerEventType, sourceEventId, softBounceCount?, expiresAt? }`. Subscribe to react to addresses going un-sendable without polling `suppressions list`. |

Set `events` to an empty array (or omit) to receive all event types.

### Webhook Payload Format

```json
POST https://your-url.com/hook
Headers:
  X-Molted-Signature: sha256=<HMAC-SHA256 of body using endpoint secret>
  X-Molted-Event: inbound.received
  X-Molted-Delivery: <delivery UUID>
  Content-Type: application/json

{
  "event": "inbound.received",
  "timestamp": "2026-03-22T10:00:00Z",
  "data": { "messageId": "...", "from": "sender@example.com", "subject": "Hello" }
}
```

### Signature Verification

Verify webhook authenticity by computing HMAC-SHA256:

```javascript
const crypto = require('crypto');
const signature = crypto.createHmac('sha256', webhookSecret).update(rawBody).digest('hex');
const expected = req.headers['x-molted-signature'].replace('sha256=', '');
if (signature !== expected) throw new Error('Invalid signature');
```

### Delivery & Retries

- Failed deliveries are retried up to 5 times with exponential backoff (10s, 30s, 2m, 10m, 30m).
- A delivery is successful on any 2xx response.
- After max attempts, the delivery is marked as `failed`.

---

## Real-Time Events (SSE) — *coming soon*

Subscribe to a live event stream for your tenant:

```
GET /v1/agent/events/stream?tenantId=your-tenant-id&eventTypes=send.*,delivery.*
```

Query parameters:

| Param | Required | Description |
|-------|----------|-------------|
| `tenantId` | yes | Your tenant ID |
| `eventTypes` | no | Comma-separated filter with wildcards (e.g., `send.*`, `delivery.delivered`) |
| `contactEmail` | no | Filter events for a single contact |
| `since` | no | Replay events from this event ID |

### Event Types

| Event | Fired When |
|-------|------------|
| `send.queued` | Send accepted and queued for delivery |
| `send.failed` | Send processing failed |
| `policy.blocked` | Policy rejected a send request |
| `policy.allowed` | Policy approved a send request |
| `delivery.sent` | Email handed to provider |
| `delivery.delivered` | Delivery confirmed |
| `delivery.bounced` | Hard or soft bounce |
| `delivery.complained` | Spam complaint received |
| `suppression.created` | Address auto-suppressed (hard bounce, complaint, or 3+ soft bounces). Send response shows `status: queued` at the time of the call; this event fires when the address becomes un-sendable for future sends. |
| `inbound.classified` | Inbound email classified |
| `inbound.routed` | Inbound email routed to handler |
| `journey.entered` | Contact entered a journey |
| `journey.exited` | Contact exited a journey |
| `coordination.lease_acquired` | Agent acquired a contact lease |
| `coordination.lease_released` | Contact lease released |
| `followup.scheduled` | Followup scheduled |
| `followup.executed` | Followup fired |

---

## Multi-Agent Coordination

When multiple agents operate on the same tenant, use coordination primitives to prevent conflicts.

### Register Agent

```
POST /v1/agent/register
```

```json
{
  "tenantId": "your-tenant-id",
  "agentName": "outbound-sdr",
  "agentRole": "outbound"
}
```

Response includes your `id` (format: `agent_<uuid>`). Use this as `agentId` in subsequent calls.

### Heartbeat

Keep your registration alive:

```
POST /v1/agent/coordination/heartbeat
```

```json
{
  "tenantId": "your-tenant-id",
  "agentId": "agent_abc123"
}
```

### Contact Lease

Acquire exclusive access to a contact for a time window. Prevents other agents from sending to the same person simultaneously:

```
POST /v1/agent/coordination/lease
```

```json
{
  "tenantId": "your-tenant-id",
  "agentId": "agent_abc123",
  "contactEmail": "alice@example.com",
  "intent": "outbound_sales",
  "durationMinutes": 30
}
```

**Success:**
```json
{
  "id": "lease_xyz",
  "agentId": "agent_abc123",
  "contactEmail": "alice@example.com",
  "expiresAt": "2026-02-25T16:00:00Z"
}
```

**Conflict** (another agent holds the lease):
```json
{
  "conflict": true,
  "leaseHolder": {
    "agentId": "agent_other",
    "agentName": "retention-agent",
    "expiresAt": "2026-02-25T15:45:00Z"
  }
}
```

If you get a conflict: wait until `expiresAt` or try a different contact.

Sends to a leased contact by a non-holder agent are blocked with reason `lease_conflict`.

### List Active Leases

```
GET /v1/agent/coordination/leases?tenantId=your-tenant-id
```

Returns all active contact leases for your tenant.

### Release Lease

```
DELETE /v1/agent/coordination/lease/lease_xyz?tenantId=your-tenant-id
```

### Consensus Voting

For high-stakes decisions, request approval from peer agents:

```
POST /v1/agent/coordination/consensus
```

```json
{
  "tenantId": "your-tenant-id",
  "requestingAgentId": "agent_abc123",
  "action": "send_pricing_override",
  "contactEmail": "cto@bigcorp.com",
  "reason": "Contact requested custom pricing, sending override proposal",
  "timeoutMinutes": 60
}
```

Check consensus status:

```
GET /v1/agent/coordination/consensus/:id?tenantId=your-tenant-id
```

Response:
```json
{
  "id": "consensus-uuid",
  "tenantId": "your-tenant-id",
  "requestingAgentId": "agent_sales",
  "action": "send_proposal",
  "contactEmail": "lead@example.com",
  "reason": "Lead scored 85+",
  "status": "approved",
  "timeoutAt": "2025-01-01T00:05:00.000Z",
  "createdAt": "2025-01-01T00:00:00.000Z"
}
```

Other agents vote:

```
POST /v1/agent/coordination/consensus/:id/vote
```

```json
{
  "agentId": "agent_reviewer",
  "vote": "approve",
  "reason": "Contact is qualified, pricing is within bounds"
}
```

The vote response includes the current consensus status:
```json
{
  "id": "vote-uuid",
  "consensusRequestId": "consensus-uuid",
  "agentId": "agent_reviewer",
  "vote": "approve",
  "consensusStatus": "approved",
  "createdAt": "2025-01-01T00:00:01.000Z"
}
```

Status progresses: `pending` → `approved` | `rejected` | `expired`.

### Agent Config

Get or update per-agent configuration (e.g., humanizer settings):

```
GET /v1/agent/agents/:agentId/config?tenantId=your-tenant-id
```

Response:
```json
{
  "humanizer_enabled": true,
  "humanizer_style": "professional",
  "humanizer_provider": "anthropic"
}
```

```
PUT /v1/agent/agents/:agentId/config?tenantId=your-tenant-id
```

```json
{
  "humanizer_enabled": true,
  "humanizer_style": "friendly"
}
```

Allowed config keys: `humanizer_enabled` (boolean), `humanizer_style` (string), `humanizer_provider` (string). Unknown keys are silently ignored.

### Tenant Settings

Get or update tenant-level settings like the cooldown period between duplicate sends and the physical mailing address for CAN-SPAM compliance.

```
GET /v1/agent/config/settings?tenantId=your-tenant-id
```

Response:
```json
{
  "cooldownMinutes": 10,
  "physicalAddress": "123 Main St, Suite 100, San Francisco, CA 94105"
}
```

```
PATCH /v1/agent/config/settings?tenantId=your-tenant-id
```

```json
{
  "cooldownMinutes": 5,
  "physicalAddress": "123 Main St, Suite 100, San Francisco, CA 94105"
}
```

Fields:
- `cooldownMinutes` (number, 0-1440) - minutes before the same template can be sent to the same recipient again. Default: 10.
- `physicalAddress` (string | null, max 500 chars) - physical mailing address for CAN-SPAM compliance. Required for marketing emails. Set to `null` to clear. Marketing sends are blocked if this is not set.

---

## Humanizer Config (Agent API)

Get and update the email humanizer configuration. When enabled, outbound emails pass through AI processing to improve tone and readability.

### Get Humanizer Config

```
GET /v1/agent/config/humanizer?tenantId=your-tenant-id
```

Response:
```json
{
  "enabled": true,
  "style": "professional",
  "provider": "anthropic"
}
```

### Update Humanizer Config

```
PUT /v1/agent/config/humanizer?tenantId=your-tenant-id
```

```json
{
  "enabled": true,
  "style": "friendly",
  "provider": "anthropic"
}
```

Fields:
- `enabled` (boolean, required) - whether outbound emails are humanized before delivery
- `style` (string, optional) - humanization tone/style
- `provider` (string, optional) - AI provider: "anthropic" or "openai"

---

## Safety Settings (Agent API)

Get and update tenant-wide safety settings that control inbound classification and sending guardrails.

### Get Safety Settings

```
GET /v1/agent/config/safety-settings?tenantId=your-tenant-id
```

Response:
```json
{
  "tenantId": "tenant-xxx",
  "quarantineHighInjection": true,
  "holdCriticalAnomalies": true,
  "blockCanaryViolations": true,
  "spamAction": "quarantine",
  "phishingAction": "quarantine",
  "malwareAction": "reject",
  "abuseAction": "quarantine",
  "impersonationAction": "quarantine",
  "spamThreshold": 0.5,
  "maxLinksThreshold": 5,
  "blockNoAuth": false,
  "blockedKeywords": [],
  "allowedSenders": [],
  "spamActionLowConfidence": "deliver"
}
```

### Update Safety Settings

PUT is a partial update - only fields you include are changed.

```
PUT /v1/agent/config/safety-settings?tenantId=your-tenant-id
```

```json
{
  "spamAction": "reject",
  "spamThreshold": 0.7,
  "blockedKeywords": ["lottery", "urgent"]
}
```

Fields:

Threat response actions (valid values: `"deliver"`, `"quarantine"`, `"reject"`):
- `spamAction` (string) - action for detected spam (default: quarantine)
- `phishingAction` (string) - action for detected phishing (default: quarantine)
- `malwareAction` (string) - action for detected malware (default: reject)
- `abuseAction` (string) - action for abuse/harassment (default: quarantine)
- `impersonationAction` (string) - action for impersonation attempts (default: quarantine)
- `spamActionLowConfidence` (string) - action when spam score is below threshold (default: deliver)

Classification thresholds:
- `spamThreshold` (number, 0.1-1.0) - spam score cutoff, default 0.5
- `maxLinksThreshold` (number, 1-100) - max links per message before flagging, default 5

Guardrail flags (boolean):
- `quarantineHighInjection` (boolean) - quarantine messages with high prompt-injection risk (default: true)
- `holdCriticalAnomalies` (boolean) - hold messages flagged as critical anomalies (default: true)
- `blockCanaryViolations` (boolean) - block messages that trip canary tokens (default: true)
- `blockNoAuth` (boolean) - block messages with no SPF/DKIM authentication (default: false)

Allow/deny lists:
- `blockedKeywords` (string[], max 100) - keywords that trigger quarantine
- `allowedSenders` (string[], max 100) - sender addresses that bypass safety checks

---

## Agent Adoption (Agent API)

Manage agent adoption tokens. Owner-side operations for creating, listing, and revoking invite tokens.

### Create Invite Token

```
POST /v1/agent/adopt/invite?tenantId=your-tenant-id
```

```json
{
  "label": "outreach-agent",
  "scopeAllMailboxes": false,
  "mailboxScopes": [
    { "mailboxId": "mbx_abc", "permissions": ["read", "send"] }
  ],
  "expiresMinutes": 1440
}
```

Response:
```json
{
  "id": "tok_abc123",
  "token": "ma_inv_...",
  "label": "outreach-agent",
  "expiresAt": "2026-04-02T12:00:00Z"
}
```

### List Pending Tokens

```
GET /v1/agent/adopt/pending?tenantId=your-tenant-id
```

Response: array of pending adoption tokens.

### Revoke Token

```
DELETE /v1/agent/adopt/:id?tenantId=your-tenant-id
```

Response:
```json
{
  "revoked": true
}
```

---

## Domain Management (Agent API)

Manage sending domains and set up DNS records via the agent API. All endpoints use Bearer token auth.

### Add a Domain

```
POST /v1/agent/domains
```

```json
{
  "tenantId": "your-tenant-id",
  "domain": "notifications.example.com"
}
```

Response:
```json
{
  "id": "dom_abc123",
  "domain": "notifications.example.com",
  "status": "pending",
  "dnsRecords": [
    { "type": "TXT", "name": "notifications.example.com", "value": "v=spf1 include:..." },
    { "type": "CNAME", "name": "resend._domainkey.notifications.example.com", "value": "..." },
    { "type": "TXT", "name": "_dmarc.notifications.example.com", "value": "v=DMARC1; p=none; ..." }
  ]
}
```

### List Domains

```
GET /v1/agent/domains?tenantId=your-tenant-id
```

### Get Domain Details

```
GET /v1/agent/domains/:domainId?tenantId=your-tenant-id
```

When the parent `status` is terminal (`failed` or `verified`), per-record `dnsRecords[*].status` values stuck at `pending` / `not_started` are reconciled to match the parent status, and the response includes `"dnsRecordsStale": true` so callers know the per-record statuses were inferred from the cached snapshot rather than read live from the provider. To force a live re-check, call `POST /v1/agent/domains/:id/verify`.

### Check One-Click DNS Setup (Domain Connect)

Check if the user's DNS provider supports one-click setup via the [Domain Connect](https://www.domainconnect.org/) protocol. If supported, returns a redirect URL the user can click to automatically configure all DNS records — no manual copy-pasting required.

Supported providers include Cloudflare, Vercel, and other Domain Connect-compatible providers.

```
GET /v1/agent/domains/:domainId/domain-connect?tenantId=your-tenant-id
```

If supported:
```json
{
  "supported": true,
  "redirectUrl": "https://dash.cloudflare.com/cdn-cgi/access/domain-connect/v2/domainTemplates/..."
}
```

If not supported:
```json
{
  "supported": false,
  "reason": "DNS provider does not support Domain Connect"
}
```

**Recommended flow:** If `supported` is `true`, send the `redirectUrl` to the user so they can approve DNS changes with a single click. After they approve, call the verify endpoint. If `supported` is `false`, share the DNS records from the domain details and guide the user through manual setup.

### Verify Domain

After the user has configured DNS records (via Domain Connect or manually), trigger verification:

```
POST /v1/agent/domains/:domainId/verify
```

```json
{
  "tenantId": "your-tenant-id"
}
```

Once the response shows `"status": "verified"`, the domain is ready to send from.

### Remove Domain

```
DELETE /v1/agent/domains/:domainId?tenantId=your-tenant-id
```

### Get Domain Warmup Status

Check the warmup progress for a domain. New domains have daily send limits that gradually increase over ~28 days to build sender reputation.

```
GET /v1/agent/domains/:domainId/warmup?tenantId=your-tenant-id
Authorization: Bearer <api-key>
```

Response:
```json
{
  "domainId": "dom_abc123",
  "domain": "notifications.example.com",
  "warmupActive": true,
  "skipped": false,
  "firstSendAt": "2026-03-10T12:00:00.000Z",
  "currentDay": 13,
  "dailyLimit": 500,
  "sendsToday": 142
}
```

Warmup schedule: day 0-6 → 100/day, day 7-13 → 500/day, day 14-27 → 2,000/day, day 28+ → 10,000/day. If `skipped` is `true`, warmup limits are bypassed.

#### Deferring over-limit sends (`deferOnWarmup`)

When a marketing send would exceed today's warmup cap, the API schedules it to the earliest future day that still has capacity instead of blocking, returning `status: 'scheduled'` with `reason: 'warmup_defer'` (`warmup_defer`). Transactional sends (template id starts with `_`, or `templates.type = 'transactional'`) are still hard-rejected with `warmup_limit` so they don't get silently delayed.

- Pass `deferOnWarmup: true` on `POST /v1/agent/request-send` to force deferral for any over-limit send.
- Pass `deferOnWarmup: false` to force hard-reject.
- Omit the flag to use the default: marketing defers, transactional blocks. `POST /v1/agent/batch/request-send` defaults every item to deferral.
- If the caller also passes `scheduledAt`, the explicit schedule wins and the send is still hard-rejected on over-limit (we don't auto-shift explicit schedules).

Deferred responses look like:
```json
{
  "requestId": "req_...",
  "status": "scheduled",
  "scheduledAt": "2026-04-18T09:00:00.000Z",
  "reason": "warmup_defer"
}
```

If the reservation would land more than 30 days out, the API returns a block instead:
```json
{
  "requestId": "req_...",
  "status": "blocked",
  "reason": "warmup_horizon_exceeded",
  "projectedDate": "2026-05-16"
}
```

Use `/v1/agent/simulate-send` or `/v1/agent/simulate-batch` to preview the projected slot before committing; they return `wouldDefer: true` and `scheduledFor: "YYYY-MM-DD"` for over-cap recipients.

### Skip Domain Warmup

Skip the warmup schedule for an established domain. This immediately removes daily send limits imposed by the warmup system.

```
POST /v1/agent/domains/:domainId/warmup/skip
Authorization: Bearer <api-key>
Content-Type: application/json

{
  "tenantId": "your-tenant-id"
}
```

Response:
```json
{
  "domainId": "dom_abc123",
  "domain": "notifications.example.com",
  "warmupSkipped": true
}
```

### Get Domain Rate Limits

View the current rate limit configuration and usage for a specific domain. All fields are `null` if no custom limits have been set (the global tenant limits apply instead).

```
GET /v1/agent/domains/:domainId/rate-limits?tenantId=your-tenant-id
Authorization: Bearer mm_live_...
```

Response:
```json
{
  "maxPerMinute": 100,
  "maxPerHour": 1000,
  "maxPerDay": 10000,
  "perMinute": { "used": 12, "limit": 100, "remaining": 88 },
  "perHour": { "used": 340, "limit": 1000, "remaining": 660 },
  "perDay": { "used": 1200, "limit": 10000, "remaining": 8800 }
}
```

The top-level `maxPerMinute|maxPerHour|maxPerDay` fields mirror the configuration returned by `PATCH /rate-limits` so the same field name works whether you're configuring a limit or observing usage. The per-window object is `null` when no custom limit is configured for that window.

### Update Domain Rate Limits

Set per-domain sending rate limits. Pass `null` to remove a custom limit and fall back to the global tenant default. All fields are optional — only provided fields are updated.

```
PATCH /v1/agent/domains/:domainId/rate-limits
Authorization: Bearer mm_live_...
Content-Type: application/json

{
  "tenantId": "your-tenant-id",
  "maxPerMinute": 100,
  "maxPerHour": 1000,
  "maxPerDay": null
}
```

Response:
```json
{
  "id": "dom_abc123",
  "domain": "notifications.example.com",
  "maxPerMinute": 100,
  "maxPerHour": 1000,
  "maxPerDay": null
}
```

### Update Domain Tracking

Enable or disable open and click tracking for a domain. Both are **off by default**. All fields are optional — only provided fields are updated.

Tracking only becomes active once a tracking subdomain CNAME (e.g. `links.example.com`) is added to DNS and verified. When you enable tracking without specifying `trackingSubdomain`, it defaults to `links`. The refreshed `dnsRecords` in the response will include the new `Tracking` CNAME you need to add — verify it with `POST /v1/agent/domains/:domainId/verify`.

```
PATCH /v1/agent/domains/:domainId/tracking
Authorization: Bearer mm_live_...
Content-Type: application/json

{
  "tenantId": "your-tenant-id",
  "openTracking": true,
  "clickTracking": true,
  "trackingSubdomain": "links"
}
```

Response:
```json
{
  "id": "dom_abc123",
  "domain": "notifications.example.com",
  "status": "pending",
  "openTracking": true,
  "clickTracking": true,
  "trackingSubdomain": "links",
  "dnsRecords": [
    { "type": "CNAME", "name": "links.notifications.example.com", "value": "links1.resend-dns.com", "status": "not_started" }
  ]
}
```

Tracking can also be enabled at creation time by passing `openTracking`, `clickTracking`, and/or `trackingSubdomain` to `POST /v1/agent/domains`.

> **Note:** Click tracking rewrites every link in your emails to redirect through your tracking subdomain, which can affect deliverability — leave it off for purely transactional mail unless you need click metrics. A tracking subdomain can be changed but never removed once set, and Resend limits changes to once per 24 hours.

---

## Agentic Mailbox — *coming soon*

The mailbox API manages threaded conversations with human-in-the-loop override capabilities. Use this when your agent operates a shared inbox.

### Thread Lifecycle

Threads are created automatically — you don't need to create them manually.

- **Outbound:** When you send via `POST /v1/agent/request-send` with a `mailboxId`, a thread is created (or matched to an existing one) for `(mailboxId, recipientEmail)`. The thread status transitions to `waiting`.
- **Inbound:** When the recipient replies, the inbound email is matched to the same thread. The thread status transitions back to `open`.
- **Conversation tracking:** Use `GET /v1/agent/threads?mailboxId=:id` to list threads and `GET /v1/agent/threads/:id` to get the full conversation (all outbound and inbound messages in chronological order).

Thread statuses: `open` (has unread inbound) → `waiting` (awaiting reply after outbound) → `resolved` / `escalated`.

### Send from Mailbox

```
POST /v1/agent/outbound/send
```

```json
{
  "recipientEmail": "alice@example.com",
  "templateId": "intro-outreach",
  "dedupeKey": "intro-alice-001",
  "payload": { "firstName": "Alice" },
  "sendReason": "initial outreach",
  "mailboxId": "mbx_abc123",
  "threadId": "optional-thread-uuid"
}
```

| Field | Required | Description |
|-------|----------|-------------|
| `recipientEmail` | yes | Recipient email address |
| `templateId` | yes | Template slug (e.g. `welcome`) or template ID |
| `dedupeKey` | yes | Idempotency key |
| `payload` | yes | Template variables (object) |
| `sendReason` | no | Human-readable context for audit trail |
| `idempotencyKey` | no | Alias for `dedupeKey` |
| `mailboxId` | no | Mailbox to send from |
| `threadId` | no | Existing thread to project into |
| `subject` | no | Override the template subject line |
| `scheduledAt` | no | ISO 8601 timestamp to defer the send to a specific future time (e.g. `"2026-03-24T09:00:00Z"`). Must be in the future. |

Requires `send` permission on the target mailbox. The `mailboxId` determines the from-address and scopes the send to that mailbox's view in the portal. If `threadId` is provided, the send is projected into that thread.

### Reply to Thread

```
POST /v1/agent/outbound/reply
```

```json
{
  "threadId": "thread-uuid",
  "templateId": "followup-reply",
  "dedupeKey": "reply-alice-002",
  "payload": { "context": "responding to their pricing question" }
}
```

| Field | Required | Description |
|-------|----------|-------------|
| `threadId` | yes | Thread to reply in |
| `templateId` | yes | Template slug (e.g. `welcome`) or template ID |
| `dedupeKey` | yes | Idempotency key |
| `payload` | yes | Template variables (object) |
| `sendReason` | no | Human-readable context for audit trail |
| `idempotencyKey` | no | Alias for `dedupeKey` |

### Schedule Thread Followup

```
POST /v1/agent/outbound/schedule-followup
```

Use either `delayMinutes` (relative) or `scheduledAt` (absolute ISO 8601 timestamp):

```json
{
  "contactEmail": "alice@example.com",
  "threadRequestId": "req_abc123",
  "delayMinutes": 1440,
  "cancelOnReply": true,
  "triggerConditions": { "noReplyOnly": true }
}
```

Response:
```json
{
  "followupId": "fu_xyz",
  "status": "pending",
  "scheduledAt": "2026-02-26T10:00:00Z"
}
```

| Field | Required | Description |
|-------|----------|-------------|
| `contactEmail` | yes | Contact email address |
| `threadRequestId` | yes | UUID of the original send request to follow up on |
| `delayMinutes` | yes | Delay before followup fires (1–43200, i.e. 1 min to 30 days) |
| `triggerConditions` | no | Conditions that must be met for the followup to fire (object) |
| `cancelOnReply` | no | Auto-cancel if the contact replies before the delay expires (default: `true`) |

**Errors:**
- `404 send_request_not_found` — no send request matches `threadRequestId` for this tenant. Check the `requestId` from your original `send` response.

### List Threads

```
GET /v1/agent/threads?mailboxId=mbx_abc123&status=open&contactEmail=alice@example.com
```

The `mailboxId` param filters threads to a specific mailbox. All query params are optional. Returns threads sorted by `lastMessageAt`.

Response (paginated wrapper, **not** a bare array):
```json
{
  "items": [
    {
      "id": "thread-uuid",
      "contactEmail": "alice@example.com",
      "mailboxId": "mbx-uuid",
      "status": "open",
      "subject": "Re: your proposal",
      "lastMessageAt": "2026-02-25T14:00:00Z",
      "messageCount": 4
    }
  ],
  "totalCount": 42
}
```

Iterate over `items` and use `totalCount` with `limit`/`offset` for pagination. Most other list endpoints return a bare array — `threads` is an exception.

### Get Thread with Messages

```
GET /v1/agent/threads/:id
```

Returns the thread plus all messages (inbound and outbound) in chronological order:

```json
{
  "id": "thread-uuid",
  "contactEmail": "alice@example.com",
  "status": "open",
  "messages": [
    {
      "id": "msg-1",
      "direction": "outbound",
      "subject": "Intro",
      "bodyText": "Hi Alice...",
      "fromEmail": "team@yourco.com",
      "toEmail": "alice@example.com",
      "createdAt": "2026-02-24T10:00:00Z"
    },
    {
      "id": "msg-2",
      "direction": "inbound",
      "subject": "Re: Intro",
      "bodyText": "Thanks, interested...",
      "fromEmail": "alice@example.com",
      "toEmail": "team@yourco.com",
      "createdAt": "2026-02-25T14:00:00Z"
    }
  ]
}
```

### Get Raw MIME

Retrieve the raw provider response for an inbound message as stored at ingest time (e.g. the full Resend API response JSON, not RFC 2822 MIME). Useful for compliance, forensics, or custom parsing.

```
GET /v1/agent/messages/:id/raw?tenantId=your-tenant-id
```

Returns:

```json
{
  "messageId": "msg-uuid",
  "rawMime": "{\"id\":\"...\",\"from\":\"alice@example.com\",\"to\":[\"team@yourco.com\"],\"subject\":\"Re: Intro\",\"text\":\"...\",\"html\":\"...\",\"headers\":{...}}"
}
```

The `rawMime` field contains the full provider response as stored at ingest time. Returns 404 if the message doesn't exist or raw MIME was not captured.

### Update Thread

```
PATCH /v1/agent/threads/:id
```

```json
{
  "status": "waiting",
  "assignedAgentId": "agent_abc123",
  "metadata": { "priority": "high" }
}
```

Thread statuses: `open`, `waiting`, `resolved`, `escalated`.

### Bulk Thread Actions

Archive, unarchive, trash, or restore threads in bulk:

```
POST /v1/agent/threads/archive
POST /v1/agent/threads/unarchive
POST /v1/agent/threads/trash
POST /v1/agent/threads/restore
```

```json
{
  "threadIds": ["thread-uuid-1", "thread-uuid-2"]
}
```

Response (example for archive):
```json
{ "archived": 2 }
```

`threadIds` accepts 1–100 UUIDs per request.

### Permanently Delete Thread

Permanently removes a thread and all its messages. This cannot be undone.

```
DELETE /v1/agent/threads/:id/permanent
```

Response:
```json
{ "deleted": true }
```

### Human Override Queues

Check what needs human attention:

```
GET /v1/agent/override/queues/counts
```

```json
{
  "needs_approval_outbound": 3,
  "needs_approval_inbound":  1,
  "blocked_by_policy":       0,
  "high_risk":               0,
  "spam":                    0
}
```

Field meanings:

- `needs_approval_outbound` — agent wants to send, awaiting human OK.
- `needs_approval_inbound` — inbound email waiting for triage.
- `blocked_by_policy` — policy engine refused the send.
- `high_risk` — flagged by content risk rules.
- `spam` — flagged as spam.

List items in a queue. Valid queue names are `needs_approval`, `blocked_by_policy`, `high_risk`, `spam` — the two `needs_approval_*` counts both roll up under `needs_approval`; filter with `&direction=inbound|outbound`.

```
GET /v1/agent/override/queues/needs_approval?direction=outbound&mailboxId=optional-uuid
```

### Override Actions

**Approve** a queued send (optionally override template/payload):
```
POST /v1/agent/override/:threadId/approve
{ "reason": "Reviewed and approved" }
```

**Reject** a queued send:
```
POST /v1/agent/override/:threadId/reject
{ "reason": "Contact is in active deal, sales handling directly" }
```

**Edit** then approve:
```
POST /v1/agent/override/:threadId/edit
{ "reason": "Adjusted tone", "metadata": { "edited": true } }
```

**Escalate** to human operator:
```
POST /v1/agent/override/:threadId/escalate
{ "reason": "Legal concern detected", "assignTo": "ops-team" }
```

### Held Messages

Messages held by safety or policy checks before delivery:

```
GET /v1/agent/override/held-messages?tenantId=your-tenant-id
```

Release a held message for delivery:
```
POST /v1/agent/override/held-messages/:messageId/release
{ "tenantId": "your-tenant-id", "reason": "Reviewed and safe to send" }
```

Reject a held message (prevents delivery):
```
POST /v1/agent/override/held-messages/:messageId/reject
{ "tenantId": "your-tenant-id", "reason": "Content violates policy" }
```

### Alerts

Check the alert status for your tenant:

```
GET /v1/agent/alerts/status?tenantId=your-tenant-id
```

Returns active incidents and alert conditions (e.g., high bounce rate, quota warnings).

---

## Contacts

Manage your contact database. Contacts are created automatically when you send emails, or can be synced from your CRM.

### List Contacts

```
GET /v1/contacts?tenantId=your-tenant-id&email=alice@example.com
```

The `email` query param is optional — omit it to list all contacts.

### Get a Contact

```
GET /v1/contacts/:id?tenantId=your-tenant-id
```

### Create a Contact

Create a single contact. The email must be unique within the tenant — a duplicate returns `409`. (Use Sync for bulk upserts that tolerate duplicates.)

```
POST /v1/contacts
```

```json
{
  "tenantId": "your-tenant-id",
  "email": "alice@example.com",
  "name": "Alice Smith",
  "lifecycleStage": "lead",
  "metadata": { "plan": "pro" }
}
```

Returns the created contact (`201`).

### Update a Contact

Update fields on a contact by ID. `metadata` is merged at the top level (supplied keys overwrite, others are preserved).

```
PATCH /v1/contacts/:id
```

```json
{
  "tenantId": "your-tenant-id",
  "name": "Alice S.",
  "metadata": { "plan": "enterprise" }
}
```

Returns the updated contact, or `404` if it does not exist.

### Delete a Contact

Permanently delete a contact (GDPR erasure). Also removes the contact from any lists (decrementing subscriber counts) and segments. Irreversible.

```
DELETE /v1/contacts/:id?tenantId=your-tenant-id
```

Returns `{ "deleted": true }`, or `404` if it does not exist.

### Sync Contacts

Upsert contacts from an external system (CRM, data warehouse, etc.):

```
POST /v1/contacts/sync
```

```json
{
  "tenantId": "your-tenant-id",
  "contacts": [
    {
      "externalId": "crm-12345",
      "email": "alice@example.com",
      "name": "Alice Smith",
      "lifecycleStage": "customer",
      "dealStage": "closed_won",
      "metadata": { "plan": "enterprise" }
    }
  ]
}
```

Response:
```json
{ "syncId": "...", "processed": 1, "segmentsRecomputeQueued": 3 }
```

`segmentsRecomputeQueued` is the number of active segments whose snapshot was enqueued for recompute by the worker. Segment `contactCount` / `snapshotVersion` (visible via `GET /v1/segments`) catch up shortly after the sync without you needing to call `POST /v1/segments/:id/compute`. The recompute is best-effort -- if the queue is unavailable, the field will be `0` and you can run `compute` manually.

---

## Mailbox Rules

Automate thread routing with pattern-based rules. Rules are evaluated in priority order on incoming messages.

### List Rules

```
GET /v1/agent/rules?tenantId=your-tenant-id
```

### Create a Rule

```
POST /v1/agent/rules
```

```json
{
  "tenantId": "your-tenant-id",
  "mailboxId": "mbx_abc123",
  "name": "Route billing inquiries",
  "field": "subject",
  "pattern": "invoice|billing|payment",
  "action": "move_to_folder",
  "actionConfig": { "folder": "billing" },
  "enabled": true
}
```

### Get a Rule

```
GET /v1/agent/rules/:id?tenantId=your-tenant-id
```

### Update a Rule

```
PATCH /v1/agent/rules/:id?tenantId=your-tenant-id
```

### Delete a Rule

```
DELETE /v1/agent/rules/:id?tenantId=your-tenant-id
```

### Reorder Rules

Change the evaluation priority of rules:

```
POST /v1/agent/rules/reorder
```

```json
{
  "tenantId": "your-tenant-id",
  "ruleIds": ["rule_1", "rule_2", "rule_3"]
}
```

Rules are evaluated top-to-bottom; first match wins.

---

## Template Management

Templates define the content your agent sends. Each template has versioned content, variable declarations, and optional approval gates.

### Create a Template

```
POST /v1/templates
```

```json
{
  "tenantId": "your-tenant-id",
  "slug": "onboarding-welcome",
  "name": "Welcome Email",
  "approvalRequired": true
}
```

### List Templates

```
GET /v1/templates?tenantId=your-tenant-id
```

### Get a Template

```
GET /v1/templates/:idOrSlug?tenantId=your-tenant-id
```

Accepts either the template UUID or its slug (slug lookups are scoped to your tenant). Returns the template with its current version details, or `null` if no match.

### Add a Version

```
POST /v1/templates/:id/versions
```

```json
{
  "tenantId": "your-tenant-id",
  "subjectTemplate": "Welcome to Acme, {{firstName}}!",
  "htmlTemplate": "<h1>Hi {{firstName}}</h1><p>Your trial starts today.</p><p><a href=\"https://acme.com/unsubscribe\">Unsubscribe</a></p>",
  "textTemplate": "Hi {{firstName}}, your trial starts today.",
  "variables": [
    { "name": "firstName", "type": "string", "required": true, "description": "Recipient first name" }
  ]
}
```

### Publish a Version

Submits the latest version for approval (if `approvalRequired` is true):

```
POST /v1/templates/:id/publish
```

```json
{ "requestedBy": "agent-outbound-sdr" }
```

### Approve or Reject

```
PATCH /v1/templates/approvals/:approvalId
```

```json
{
  "reviewedBy": "human-reviewer",
  "decision": "approved",
  "comment": "Looks good"
}
```

### Test Render

Preview a template with sample data without sending. Accepts either the template UUID or its slug:

```
POST /v1/templates/:idOrSlug/render
```

```json
{ "payload": { "firstName": "Alice" } }
```

### Linting Rules

Every template version is automatically linted. Lint failures block sends:

| Rule | Severity | Description |
|------|----------|-------------|
| `spam_phrase` | warning | Detects trigger phrases ("act now", "buy now", "click here", etc.) |
| `undeclared_variable` | error | `{{var}}` used in template but not declared. **System variables** (see below) are exempt -- you can use them without declaring. |
| `unused_variable` | warning | Variable declared but never referenced |
| `system_variable_redeclared` | warning | A system variable was passed in `variables[]`. Remove it -- Molted auto-injects it at send time. |
| `insecure_url` | error | `href="http://..."` found — must use HTTPS |
| `missing_unsubscribe` | error | HTML body must contain the word "unsubscribe" |

### System Variables

Variables auto-injected by Molted at send time. Use them in your templates **without declaring them in `variables[]`** -- the linter recognizes them and the renderer fills them in for you. Declaring one as a user variable triggers `system_variable_redeclared` and is ignored at send time.

| Variable | Description |
|----------|-------------|
| `{{unsubscribe_url}}` | Per-recipient unsubscribe link. Marketing templates should use this in the unsubscribe `<a href>` to satisfy `missing_unsubscribe` without breaking `undeclared_variable`. |
| `{{physical_address}}` | The tenant's configured physical mailing address (CAN-SPAM). The send pipeline also auto-appends a footer when this is missing from marketing templates. |

---

## Email Humanizer

LLM-powered content rewriting that makes templated emails sound more natural. The humanizer runs in the send pipeline between template rendering and provider delivery.

### Per-Send Override

Add these optional fields to any `POST /v1/agent/request-send` call:

| Field | Type | Description |
|-------|------|-------------|
| `humanize` | boolean | Force humanizer on/off for this send |
| `humanizeStyle` | string | `casual`, `professional`, or `friendly` |
| `humanizeProvider` | string | `anthropic` or `openai` |

```json
{
  "tenantId": "your-tenant-id",
  "recipientEmail": "alice@example.com",
  "templateId": "onboarding-welcome",
  "dedupeKey": "onboarding-alice-step1",
  "payload": { "firstName": "Alice" },
  "humanize": true,
  "humanizeStyle": "friendly"
}
```

### Configuration Hierarchy

The humanizer resolves settings in order:

1. **Per-send** fields (`humanize`, `humanizeStyle`, `humanizeProvider`)
2. **Agent-level** config (set via agent registration)
3. **Tenant-level** config (set via settings below)
4. **Default:** disabled

### Get / Update Tenant Config

```
GET /v1/me/humanizer
Cookie: <session cookie>
```

```
PUT /v1/me/humanizer
Cookie: <session cookie>

{ "enabled": true, "style": "professional", "provider": "anthropic" }
```

| Style | Behavior |
|-------|----------|
| `casual` | Conversational, contractions, warm tone |
| `professional` | Clear, direct, polished (default) |
| `friendly` | Warm, personable, upbeat |

> The humanizer preserves all HTML structure, links, compliance text, and unsubscribe links. It only rewrites prose.

---

## Journey Orchestration

Journeys are multi-step sequences triggered by product events. Each journey has ordered steps that execute automatically.

### Create a Journey

```
POST /v1/journeys
```

```json
{
  "tenantId": "your-tenant-id",
  "name": "Onboarding Sequence",
  "triggerEvent": "user.signed_up"
}
```

### List Journeys

```
GET /v1/journeys?tenantId=your-tenant-id
```

### Get a Journey

```
GET /v1/journeys/:id?tenantId=your-tenant-id
```

### Update a Journey

```
PATCH /v1/journeys/:id
```

```json
{ "tenantId": "your-tenant-id", "status": "active" }
```

Statuses: `active`, `paused`, `archived`.

### Add Steps

```
POST /v1/journeys/:id/steps
```

```json
{
  "tenantId": "your-tenant-id",
  "stepOrder": 1,
  "stepType": "send",
  "config": {
    "templateId": "onboarding-welcome",
    "dedupeKeyPrefix": "onboarding",
    "payload": { "trialDays": 14 }
  }
}
```

#### Step Types

| Type | Config Fields | Description |
|------|--------------|-------------|
| `send` | `templateId`, `dedupeKeyPrefix`, `payload` | Send an email (policy-evaluated) |
| `delay` | `delayMinutes` | Wait before the next step (default: 60) |
| `branch` | `conditions[]` | Evaluate conditions and route to a step |
| `end` | — | Complete the journey run |

Branch condition example:
```json
{
  "stepType": "branch",
  "config": {
    "conditions": [
      { "field": "lifecycleStage", "operator": "eq", "value": "active", "nextStepOrder": 3 },
      { "field": "lifecycleStage", "operator": "eq", "value": "churned", "nextStepOrder": 5 }
    ]
  }
}
```

### Update a Step

```
PATCH /v1/journeys/:id/steps/:stepId
```

```json
{
  "tenantId": "your-tenant-id",
  "stepOrder": 2,
  "config": {
    "templateId": "new-template",
    "dedupeKeyPrefix": "onboarding",
    "payload": { "trialDays": 30 }
  }
}
```

Only allowed on journeys in `draft` status. All fields are optional — only provided fields are updated.

### Delete a Step

```
DELETE /v1/journeys/:id/steps/:stepId?tenantId=your-tenant-id
```

Removes a step and reorders remaining steps. Only allowed on journeys in `draft` status.

### Delete a Journey

```
DELETE /v1/journeys/:id?tenantId=your-tenant-id
```

Deletes a journey and its steps. Only allowed on journeys in `draft` or `completed` status with no active runs.

### List Journey Runs

```
GET /v1/journeys/:id/runs?tenantId=your-tenant-id
```

### Trigger a Journey via Event Ingestion

```
POST /v1/events/ingest
```

```json
{
  "tenantId": "your-tenant-id",
  "eventName": "user.signed_up",
  "contactEmail": "alice@example.com",
  "payload": { "plan": "starter" }
}
```

When `eventName` matches a journey's `triggerEvent`, a run is created for that contact and the first step is executed.

> Duplicate runs for the same journey + contact are automatically prevented.

---

## Audience Segmentation

Build filtered audiences for targeting and analytics.

### Create a Segment

```
POST /v1/segments
```

```json
{
  "tenantId": "your-tenant-id",
  "name": "Active Trial Users",
  "filterGroup": {
    "logic": "and",
    "filters": [
      { "type": "contact_field", "field": "lifecycleStage", "operator": "eq", "value": "trial" },
      { "type": "behavioral", "field": "event_count", "operator": "gte", "value": 3,
        "behavioralWindow": { "eventName": "page.viewed", "windowDays": 7, "countOperator": "gte", "countValue": 3 }
      }
    ]
  }
}
```

### Filter Types

| Type | Description |
|------|-------------|
| `contact_field` | Filter on contact fields (email, name, lifecycleStage, dealStage) |
| `account_field` | Filter on account/company fields |
| `metadata` | Filter on custom contact metadata |
| `firmographic` | Filter on firmographic data (industry, size) |
| `behavioral` | Filter on event counts within a time window |

### Filter Operators

`eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains`, `not_contains`, `in`, `not_in`, `exists`, `not_exists`, `between`

### Accepted filterGroup shapes

The canonical shape is `{ logic: 'and' | 'or', filters: [...] }`. The evaluator also tolerates two legacy / shorthand variants so old data and ad-hoc CLI calls keep working:

- CLI shorthand inside a group: `operator` (alias for `logic`), `conditions` (alias for `filters`), and `op` (alias for `operator` on individual filters).
- Bare filter at the top level: `{ field, op|operator, value }` is treated as a single-filter `and` group. Useful when you stored a single condition directly as the `filterGroup`. New writes (\`POST /v1/segments\`) normalize all of these to the canonical shape.

### List / Get / Update Segments

```
GET /v1/segments?tenantId=your-tenant-id
GET /v1/segments/:id
PATCH /v1/segments/:id   — update name or filters
DELETE /v1/segments/:id  — archive
```

### Compute Membership

Trigger an async membership computation:

```
POST /v1/segments/:id/compute?tenantId=your-tenant-id
```

### List Members

```
GET /v1/segments/:id/members?tenantId=your-tenant-id&limit=50&offset=0
```

### Get Contact Segments

List all active segments a contact belongs to:

```
GET /v1/segments/contact/:contactId/segments
```

Response:
```json
[
  { "id": "seg_123", "name": "Active Trial Users", "status": "active" },
  { "id": "seg_456", "name": "High Intent Leads", "status": "active" }
]
```

---

## Email Lists

Manage subscription-based email lists for newsletters, announcements, and marketing campaigns.

### Create a List

```
POST /v1/agent/lists
```

```json
{
  "tenantId": "your-tenant-id",
  "name": "Weekly Newsletter",
  "description": "Product updates and tips",
  "type": "newsletter",
  "doubleOptIn": false,
  "metadata": {}
}
```

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `name` | string | yes | - | List display name |
| `description` | string | no | null | List description |
| `type` | string | no | `newsletter` | One of: `newsletter`, `announcement`, `marketing` |
| `doubleOptIn` | boolean | no | `false` | Require email confirmation before activating subscriptions |
| `defaultTemplateId` | string | no | null | Default template for sends |
| `metadata` | object | no | `{}` | Custom key-value metadata |

**Errors:**

| Status | Code | Meaning |
|---|---|---|
| 409 | `list_name_conflict` | A non-archived list with this `name` already exists for the tenant. The response body includes the conflicting `name`. |

### List / Get / Update / Archive Lists

```
GET /v1/agent/lists?tenantId=your-tenant-id&limit=50&offset=0
GET /v1/agent/lists/:id?tenantId=your-tenant-id
PATCH /v1/agent/lists/:id      -- update name, description, type, doubleOptIn, status
DELETE /v1/agent/lists/:id     -- archive (soft delete)
```

Response includes `subscriberCount` (denormalized count of active subscribers).

### List Statuses

| Status | Description |
|--------|-------------|
| `active` | Accepting subscriptions and sends |
| `paused` | Temporarily disabled |
| `archived` | Soft-deleted, hidden from list queries |

### List Analytics

#### Stats snapshot

```
GET /v1/agent/lists/:id/stats
```

Returns current subscriber counts and send summary:

```json
{
  "subscriberCount": 1250,
  "pendingCount": 23,
  "unsubscribedCount": 87,
  "totalSends": 14,
  "lastSentAt": "2026-03-20T10:00:00Z"
}
```

#### Subscriber growth over time

```
GET /v1/agent/lists/:id/stats/growth?period=30d
```

Returns daily subscribe/unsubscribe counts. Supported periods: `7d`, `30d`, `90d`, `all`.

```json
[
  { "date": "2026-03-01", "subscribed": 12, "unsubscribed": 2, "net": 10 },
  { "date": "2026-03-02", "subscribed": 8, "unsubscribed": 1, "net": 7 }
]
```

#### Send performance

```
GET /v1/agent/lists/:id/stats/sends
```

Returns per-send delivery metrics (most recent 50 sends):

```json
[
  {
    "sendId": "send-uuid",
    "sentAt": "2026-03-20T10:00:00Z",
    "subscriberCount": 1250,
    "status": "completed",
    "templateId": "tpl-uuid",
    "delivered": 1240,
    "bounced": 3,
    "opened": 820,
    "clicked": 145
  }
]
```

### Subscriber Management

#### Subscribe a contact

```
POST /v1/agent/lists/:id/subscribers
```

```json
{
  "tenantId": "your-tenant-id",
  "email": "subscriber@example.com",
  "source": "api"
}
```

- If the list has `doubleOptIn: true`, subscription status starts as `pending`.
- If `doubleOptIn: false`, status goes straight to `active`.
- `source` is optional, defaults to `"api"`. Valid values: `api`, `import`, `form`, `manual`.
- If the contact doesn't exist, it is auto-created.

#### Unsubscribe a contact

```
POST /v1/agent/lists/:id/unsubscribe
```

```json
{
  "tenantId": "your-tenant-id",
  "email": "subscriber@example.com"
}
```

#### List subscribers

```
GET /v1/agent/lists/:id/subscribers?tenantId=your-tenant-id&limit=50&offset=0
```

Returns subscribers with contact details (email, name, subscription status, dates).

#### Bulk subscribe

```
POST /v1/agent/lists/:id/subscribers/bulk
```

```json
{
  "tenantId": "your-tenant-id",
  "emails": ["a@example.com", "b@example.com"],
  "source": "import"
}
```

Returns `{ "subscribed": 2, "pending": 0, "failed": 0 }`.

#### Send broadcast

```
POST /v1/agent/lists/:id/send
```

```json
{
  "tenantId": "your-tenant-id",
  "templateId": "template-uuid"
}
```

Queues a broadcast to all active subscribers. Returns the send record.

#### List sends

```
GET /v1/agent/lists/:id/sends?tenantId=your-tenant-id
```

Returns send history with delivery metrics (same format as stats/sends).

---

## Experiments (A/B Testing)

Run experiments on journey steps to compare template variants.

> **Auth:** Experiments endpoints use session cookie auth (same as templates), not Bearer token auth.

### Create an Experiment

```
POST /v1/experiments
Cookie: <session cookie>
```

```json
{
  "tenantId": "your-tenant-id",
  "journeyId": "j_abc",
  "journeyStepId": "step_1",
  "name": "Welcome Email Subject Test",
  "type": "ab",
  "segmentId": "seg_123",
  "variants": [
    { "id": "v1", "name": "Control", "weight": 50, "templateVersionId": "tv_1", "isControl": true, "isHoldout": false },
    { "id": "v2", "name": "Casual Subject", "weight": 50, "templateVersionId": "tv_2", "isControl": false, "isHoldout": false }
  ]
}
```

### Start / Stop

```
POST /v1/experiments/:id/start
POST /v1/experiments/:id/stop
```

### Get Results

```
GET /v1/experiments/:id/results
```

Response:
```json
[
  {
    "variantId": "v1",
    "variantName": "Control",
    "totalAssigned": 500,
    "totalSent": 498,
    "totalDelivered": 490,
    "totalConverted": 45,
    "conversionRate": 0.09,
    "isSignificant": false,
    "pValue": 0.12,
    "confidenceInterval": [0.065, 0.115]
  },
  {
    "variantId": "v2",
    "variantName": "Casual Subject",
    "totalAssigned": 500,
    "totalSent": 497,
    "totalDelivered": 489,
    "totalConverted": 62,
    "conversionRate": 0.124,
    "isSignificant": true,
    "pValue": 0.03,
    "confidenceInterval": [0.095, 0.153]
  }
]
```

---

## Outcome Tracking & Attribution

Record business outcomes and attribute them to email touchpoints.

### Ingest an Outcome

```
POST /v1/outcomes/ingest
```

```json
{
  "tenantId": "your-tenant-id",
  "contactEmail": "alice@example.com",
  "eventType": "deal_closed",
  "eventName": "Enterprise Deal Won",
  "revenue": 15000,
  "metadata": { "dealId": "deal_xyz" }
}
```

| Field | Required | Description |
|-------|----------|-------------|
| `tenantId` | yes | Your tenant identifier |
| `contactEmail` | yes | Contact email to attribute |
| `eventType` | yes | One of: `activation`, `trial_conversion`, `meeting_booked`, `deal_closed`, `upsell`, `custom` |
| `eventName` | yes | Human-readable event label |
| `revenue` | no | Revenue amount attributed to this outcome |
| `metadata` | no | Arbitrary metadata (object) |
| `occurredAt` | no | ISO 8601 timestamp. Defaults to now if omitted. |

### List Outcomes

```
GET /v1/outcomes?tenantId=your-tenant-id&eventType=deal_closed&startDate=2026-01-01&endDate=2026-02-28
```

### Outcomes Dashboard

Get a summary of outcomes over the last 30 days:

```
GET /v1/outcomes/dashboard?tenantId=your-tenant-id
```

Response:
```json
{
  "totalOutcomes": 120,
  "totalRevenue": 48000,
  "byType": [
    { "event_type": "deal_closed", "count": 15, "revenue": 35000 },
    { "event_type": "meeting_booked", "count": 45, "revenue": 0 },
    { "event_type": "trial_conversion", "count": 60, "revenue": 13000 }
  ]
}
```

### Journey Impact Report

See how journeys contribute to outcomes:

```
GET /v1/outcomes/journey-impact?tenantId=your-tenant-id
```

Response:
```json
[
  {
    "journeyId": "j_abc",
    "journeyName": "Onboarding Sequence",
    "totalOutcomes": 120,
    "totalRevenue": 48000,
    "attributedOutcomes": 85,
    "attributedRevenue": 34000,
    "segmentBreakdown": { "seg_123": { "outcomes": 50, "revenue": 20000 } },
    "experimentBreakdown": { "exp_456": { "outcomes": 30, "revenue": 12000 } }
  }
]
```

---

## Suppression Management

Manage suppression lists and consent records. Suppressions prevent sends at the policy layer.

### Add a Suppression

```
POST /v1/suppressions
```

```json
{
  "tenantId": "your-tenant-id",
  "recipientEmail": "bob@example.com",
  "scope": "tenant",
  "reasonCode": "manual_dnc",
  "source": "agent-retention"
}
```

| Field | Required | Description |
|-------|----------|-------------|
| `tenantId` | yes | Your tenant identifier |
| `recipientEmail` | yes | Email address to suppress |
| `scope` | yes | `tenant` or `campaign` |
| `reasonCode` | yes | Reason code (see table below) |
| `source` | no | Source identifier (e.g., agent name) |
| `campaignId` | no | Campaign ID (required when scope is `campaign`) |
| `sourceEventId` | no | ID of the event that triggered this suppression |
| `expiresAt` | no | ISO 8601 expiry timestamp. Permanent if omitted. |

| Scope | Description |
|-------|-------------|
| `tenant` | Suppressed for your tenant only |
| `campaign` | Suppressed for a specific campaign |

Platform-wide (`global`) suppressions are reserved for automated
complaint/hard-bounce handling by Molted's webhook pipeline and cannot be
created via tenant API keys. Sending `scope: "global"` returns `403
global_scope_not_allowed`.

| Reason Code | Description |
|-------------|-------------|
| `complaint` | Spam complaint received |
| `hard_bounce` | Hard bounce on delivery |
| `manual_dnc` | Manually added do-not-contact |
| `legal_request` | GDPR/legal erasure request |
| `role_account` | Role-based address (info@, support@, etc.) |

### List Suppressions

```
GET /v1/suppressions?tenantId=your-tenant-id&recipientEmail=bob@example.com
```

### Remove a Suppression

```
DELETE /v1/suppressions/:id?tenantId=your-tenant-id
```

### Domain Suppressions

Suppress all addresses at a domain (e.g., block sends to `@competitor.com`):

```
POST /v1/suppressed-domains
```

```json
{
  "tenantId": "your-tenant-id",
  "domain": "competitor.com",
  "reasonCode": "manual_dnc",
  "source": "agent-cleanup"
}
```

| Field | Required | Description |
|-------|----------|-------------|
| `tenantId` | yes | Your tenant identifier |
| `domain` | yes | Domain to suppress (e.g., `competitor.com`) |
| `reasonCode` | yes | Same reason codes as email suppressions |
| `source` | no | Source identifier |

```
GET /v1/suppressed-domains?tenantId=your-tenant-id
```

```
DELETE /v1/suppressed-domains/competitor.com?tenantId=your-tenant-id
```

### Record Consent

```
POST /v1/consent
```

```json
{
  "tenantId": "your-tenant-id",
  "recipientEmail": "alice@example.com",
  "basis": "explicit_opt_in",
  "source": "signup-form",
  "jurisdiction": "EU"
}
```

| Field | Required | Description |
|-------|----------|-------------|
| `tenantId` | yes | Your tenant identifier |
| `recipientEmail` | yes | Contact email address |
| `basis` | yes | `explicit_opt_in`, `legitimate_interest`, `contractual`, or `legal_obligation` |
| `source` | no | Where consent was collected (e.g., `signup-form`) |
| `jurisdiction` | no | Legal jurisdiction (e.g., `EU`, `US-CA`) |
| `grantedAt` | no | ISO 8601 timestamp of when consent was granted. Defaults to now (server stamps the request time). |
| `revokedAt` | no | ISO 8601 timestamp if consent has been revoked. |

Response includes the full inserted record so callers can confirm the stored timestamps without an extra `consent check` round-trip:

```json
{
  "id": "consent-uuid",
  "tenantId": "your-tenant-id",
  "recipientEmail": "alice@example.com",
  "basis": "explicit_opt_in",
  "source": "signup-form",
  "jurisdiction": "EU",
  "grantedAt": "2026-04-20T17:30:00.000Z",
  "revokedAt": null,
  "createdAt": "2026-04-20T17:30:00.000Z"
}
```

### Check Consent

```
GET /v1/consent?tenantId=your-tenant-id&recipientEmail=alice@example.com
```

---

## Safety Settings & Canary Tokens

### Safety Settings

Configure automated safety mitigations:

```
GET /v1/me/safety-settings
Cookie: <session cookie>
```

```
PUT /v1/me/safety-settings
Cookie: <session cookie>

{
  "quarantineHighInjection": true,
  "holdCriticalAnomalies": true,
  "blockCanaryViolations": true
}
```

| Setting | Default | Description |
|---------|---------|-------------|
| `quarantineHighInjection` | true | Quarantine inbound emails with high prompt injection risk |
| `holdCriticalAnomalies` | true | Hold emails with thread anomalies (forged injection, intent flips) for review |
| `blockCanaryViolations` | true | Block outbound sends that leak canary tokens |

### Spam Filter Rules

Configure per-tenant spam filter rules. Available via the agentic-mailbox API (Bearer token):

```
GET /v1/spam-filter/rules
Authorization: Bearer <api-key>
```

```
PUT /v1/spam-filter/rules
Authorization: Bearer <api-key>

{
  "spamThreshold": 0.3,
  "maxLinksThreshold": 3,
  "blockNoAuth": true,
  "blockedKeywords": ["crypto", "wire transfer"],
  "allowedSenders": ["trusted.com"],
  "spamActionLowConfidence": "quarantine"
}
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `spamThreshold` | number | 0.5 | Spam verdict threshold (0.1-1.0). Lower = more aggressive. |
| `maxLinksThreshold` | integer | 5 | Max links before excessive_links signal fires. |
| `blockNoAuth` | boolean | false | Heavy spam penalty when all auth (SPF+DKIM+DMARC) fails. |
| `blockedKeywords` | string[] | [] | Custom keywords that add +0.4 to spam score per match. |
| `allowedSenders` | string[] | [] | Emails/domains that bypass classification. |
| `spamActionLowConfidence` | string | deliver | Action for low-confidence spam. One of: deliver, quarantine, reject. |

Also available via cookie auth at `GET/PUT /v1/me/safety-settings` with the same fields alongside existing safety settings.

### Canary Tokens

Canary tokens are an integrity check for AI agents. When your agent reads a thread via `GET /v1/agent/threads/:id`, the response includes an `_agentContext.canaryGuidance` field containing a secret token and instructions.

**How it works:**
1. Each thread gets a deterministic canary token (format: `MLTED-<hex>`)
2. The token is included in the agent context with guidance: *"Do not include this token in any outbound message"*
3. Before every outbound send, the system scans the payload for leaked canary tokens
4. If a token is found, the send is blocked with reason `canary_violation`

**Why it matters:** If an attacker embeds a prompt injection in an inbound email that manipulates your agent into echoing the canary token in a reply, the system catches it before the email is sent. This protects against data exfiltration and context manipulation attacks.

**Agent guidance:** Never include any `MLTED-` prefixed string from `_agentContext` in outbound email content.

---

## Common Patterns

### Pattern 1: Safe Send (Check → Send)

```
1. POST /v1/agent/simulate-send       → check if policy allows
2. POST /v1/agent/request-send         → send if simulation passed
```

### Pattern 2: Informed Send (Propose → Decide → Send)

```
1. POST /v1/agent/propose-email        → get templates + contact context
2. POST /v1/agent/simulate-send        → dry-run with chosen template
3. POST /v1/agent/request-send         → commit the send
```

### Pattern 3: Send with Followup

```
1. POST /v1/agent/request-send         → initial email
2. POST /v1/agent/schedule-followup    → auto followup in 3 days, cancel on reply
```

### Pattern 4: Multi-Agent Safe Send

```
1. POST /v1/agent/coordination/lease   → acquire exclusive access
2. GET  /v1/agent/thread-context       → understand contact history
3. POST /v1/agent/next-best-action     → get recommendation
4. POST /v1/agent/request-send         → send if recommended
5. DELETE /v1/agent/coordination/lease  → release when done
```

### Pattern 5: Inbound Triage

```
1. POST /v1/agent/classify-intent      → classify the reply
2. GET  /v1/agent/thread-context       → get full contact history
3. POST /v1/agent/next-best-action     → decide what to do
4. Branch on recommendation:
   - "reply"    → POST /v1/agent/outbound/reply
   - "wait"     → POST /v1/agent/schedule-followup
   - "escalate" → POST /v1/agent/override/:threadId/escalate
   - "stop"     → no action
```

### Pattern 6: Batch Outreach

```
1. GET  /v1/agent/budget               → check remaining quota
2. POST /v1/agent/simulate-batch       → pre-check all recipients
3. POST /v1/agent/batch/request-send   → send to allowed recipients
```

### Pattern 7: Fatigue-Aware Send

```
1. GET  /v1/agent/analytics/contact-fatigue  → check fatigue score
2. Branch on recommendation:
   - "safe_to_send"      → proceed to send
   - "reduce_frequency"  → delay or skip
   - "stop_sending"      → do not send
3. POST /v1/agent/request-send               → send if safe
```

### Pattern 8: Journey-Driven Onboarding

```
1. POST /v1/templates                  → create email templates
2. POST /v1/templates/:id/versions     → add content versions
3. POST /v1/journeys                   → create the journey
4. POST /v1/journeys/:id/steps         → add send/delay/branch steps
5. PATCH /v1/journeys/:id              → activate the journey
6. POST /v1/agent/events/ingest        → fire trigger event for a contact
```

### Pattern 9: Full Setup (New Agent)

```
1.  POST /api/auth/sign-up/email        → create account
2.  POST /v1/me/keys                    → generate API key
3.  GET  /v1/me/tenant                  → get tenant ID
4.  POST /v1/templates                  → create first template
5.  POST /v1/templates/:id/versions     → add template content
6.  POST /v1/agent/simulate-send        → dry-run to confirm setup
7.  POST /v1/agent/request-send         → send first email (uses default @agent.molted.email address)
--- optional: custom domain + mailbox ---
8.  POST /v1/agent/domains              → add custom sending domain
9.  GET  /v1/agent/domains/:id/domain-connect → check for one-click DNS setup
10. POST /v1/agent/domains/:id/verify   → verify DNS records
11. POST /v1/agent/mailboxes            → create mailbox for receiving
```

---

## Idempotency

The `dedupeKey` field guarantees exactly-once semantics:

- First call with a given `dedupeKey` → processed normally
- Subsequent calls with the same `dedupeKey` → blocked with reason `duplicate`

Use deterministic keys like `{workflow}-{contactId}-{step}` to make retries safe. Empty or whitespace-only `dedupeKey` values are rejected with `400 dedupe_key_required` -- they would cause all such sends to collide into one "duplicate" and silently block subsequent sends.

---

## Error Handling

| HTTP Code | Meaning |
|-----------|---------|
| 200 | Success (check `status` field — may be `blocked`) |
| 400 | Validation error (missing/invalid fields) |
| 401 | Invalid or missing API key |
| 403 | Insufficient scope or tenant mismatch |
| 404 | Resource not found |
| 500 | Server error |

**Key distinction:** A policy-blocked send returns `200` with `status: "blocked"`, not a 4xx. This is intentional — the request was valid, policy just didn't allow it. Always inspect the response body.

---

## Rate Limits

Per-tenant quotas (configurable by plan):

| Window | Default |
|--------|---------|
| Monthly | Plan-based (Trial: 100, Starter: 3,000, Growth: 10,000) |
| Daily | 10,000 |
| Hourly | 1,000 |
| Negative signal budget | 50 bounces+complaints per 24h |

Check current usage with `GET /v1/agent/budget` before large batches.

---

## Attachments

Upload files to object storage and attach them to outbound emails. Attachments are stored in R2/S3 and linked by ID — they are never embedded in the API request payload.

### Upload an Attachment

```
POST /v1/attachments
Authorization: Bearer <api-key>
Content-Type: application/json

{
  "tenantId": "your-tenant-id",
  "filename": "report.pdf",
  "contentType": "application/pdf",
  "contentBase64": "<base64-encoded file content>"
}
```

Response:
```json
{
  "id": "att_uuid",
  "filename": "report.pdf",
  "contentType": "application/pdf",
  "sizeBytes": 12345,
  "checksumSha256": "abc123..."
}
```

Limits:
- Max 10 MB per file (decoded)
- Total storage is quota-enforced per tenant (see Storage Limits below)

### Send with Attachments

Upload first, then reference by ID in the send request:

```json
{
  "tenantId": "your-tenant-id",
  "recipientEmail": "alice@example.com",
  "templateId": "contract-signed",
  "dedupeKey": "contract-alice-v2",
  "payload": { "firstName": "Alice" },
  "attachments": [
    { "id": "att_uuid", "filename": "contract.pdf", "contentType": "application/pdf" }
  ]
}
```

Attachments must belong to the same tenant and be in a usable state (`uploaded` or `scan_passed`). Deleted or failed attachments will return a 400 error.

### List Attachments by Message

```
GET /v1/attachments?tenantId=<id>&messageId=<id>&messageType=inbound|thread
```

### Get Download URL

```
GET /v1/attachments/:id/download?tenantId=<id>
```

Returns a time-limited presigned URL (1 hour) for downloading the file.

---

## Storage Limits

Per-tenant storage quotas by plan:

| Plan | Storage Limit | Retention | Max Mailboxes | Custom Domains |
|------|--------------|-----------|---------------|----------------|
| Trial | 50 MB | 7 days | 1 | 0 |
| Starter | 3 GB | 30 days | 3 | 3 |
| Growth | 10 GB | 90 days | 10 | 10 |
| Enterprise | Unlimited | 365 days | Unlimited | Unlimited |

Storage usage (body + attachments) is tracked separately and included in the billing status response:

```
GET /v1/billing/my-status
```

Response includes:
```json
{
  "storage": {
    "messageBodyBytes": 4096,
    "attachmentBytes": 102400,
    "totalBytes": 106496,
    "limitBytes": 1073741824,
    "retentionDays": 30
  },
  "mailboxes": {
    "used": 2,
    "limit": 3
  }
}
```

When the storage limit is reached, attachment uploads return a 403 error. Expired attachments are automatically cleaned up based on the retention window.

---

## Alert Destinations

Configure where delivery alerts (bounces, complaints, anomalies) are sent. Supports webhook URLs and Slack incoming webhooks. All endpoints use Bearer token auth.

### Create an Alert Destination

```
POST /v1/agent/alerts/destinations
Authorization: Bearer mm_live_...
Content-Type: application/json

{
  "tenantId": "your-tenant-id",
  "type": "webhook",
  "url": "https://example.com/alerts"
}
```

`type` must be `"webhook"` or `"slack"`. The `url` must use `http` or `https`.

Response:
```json
{
  "id": "uuid",
  "tenant_id": "your-tenant-id",
  "type": "webhook",
  "url": "https://example.com/alerts",
  "is_active": true,
  "created_at": "2026-03-22T10:00:00Z"
}
```

### List Alert Destinations

```
GET /v1/agent/alerts/destinations?tenantId=your-tenant-id
Authorization: Bearer mm_live_...
```

Returns an array of all configured alert destinations for your tenant.

### Delete an Alert Destination

```
DELETE /v1/agent/alerts/destinations/:id?tenantId=your-tenant-id
Authorization: Bearer mm_live_...
```

Response:
```json
{ "deleted": true }
```

Returns 404 if the destination does not exist or does not belong to your tenant.

---

## Webhooks

Webhooks deliver real-time event notifications to your URL via HTTP POST. Webhook management is available via cookie auth (`/v1/me/webhooks`) or Bearer token auth (`/v1/agent/webhooks?tenantId=T`).

### Subscribable Event Types

- `inbound.received` — new inbound email stored
- `inbound.classified` — inbound email classified
- `inbound.routed` — inbound email routed to thread
- `delivery.sent` — email sent
- `delivery.delivered` — email delivered
- `delivery.bounced` — email bounced
- `delivery.complained` — spam complaint received
- `suppression.created` — recipient auto-suppressed (hard bounce / complaint / 3+ soft bounces); subscribe to react to addresses going un-sendable without polling `suppressions list`

### Register a Webhook Endpoint

```
POST /v1/me/webhooks
```

Body:
```json
{
  "url": "https://example.com/hooks/molted",
  "description": "Production webhook",
  "events": ["inbound.received", "delivery.bounced"]
}
```

Response (create only -- `secret` is shown once):
```json
{
  "id": "...",
  "url": "https://example.com/hooks/molted",
  "secret": "whsec_2a47fe351a7206ecef3d1ff18979f209f0f4c72bef4fc79d",
  "secretPrefix": "whsec_2a47fe35...",
  "events": ["inbound.received", "delivery.bounced"],
  "enabled": true
}
```

**Save the `secret` immediately** -- it is only returned in the create response. Subsequent get/list/update calls return only `secretPrefix` (first 16 characters). The secret is used to verify webhook signatures.

### List Webhook Endpoints

```
GET /v1/me/webhooks
```

### Get Webhook Details

```
GET /v1/me/webhooks/:id
```

### Update a Webhook Endpoint

```
PATCH /v1/me/webhooks/:id
```

Body (all fields optional):
```json
{
  "url": "https://new-url.com/hook",
  "events": ["delivery.bounced"],
  "enabled": false,
  "description": "Updated description"
}
```

### Delete a Webhook Endpoint

```
DELETE /v1/me/webhooks/:id
```

### List Recent Deliveries

```
GET /v1/me/webhooks/:id/deliveries?limit=50
```

Returns recent delivery attempts with status (`pending`, `delivered`, `failed`), HTTP status, and attempt count.

### Send a Test Event

```
POST /v1/me/webhooks/:id/test
```

Sends a test event to verify the endpoint is reachable. Returns the delivery status and HTTP response code.

### Webhook Payload Format

Events are delivered as HTTP POST requests:

```
POST https://your-url.com/hook
Headers:
  X-Molted-Signature: sha256=<HMAC-SHA256 of body using endpoint secret>
  X-Molted-Event: inbound.received
  X-Molted-Delivery: <delivery UUID>
  Content-Type: application/json

{
  "event": "inbound.received",
  "timestamp": "2026-03-22T10:00:00Z",
  "data": { ... }
}
```

### Signature Verification

Verify webhook authenticity by computing HMAC-SHA256 of the raw request body:

```javascript
const crypto = require('crypto');
const signature = crypto
  .createHmac('sha256', webhookSecret)
  .update(rawBody)
  .digest('hex');
const expected = req.headers['x-molted-signature'].replace('sha256=', '');
if (signature !== expected) throw new Error('Invalid signature');
```

### Delivery & Retries

- Deliveries timeout after 5 seconds
- Failed deliveries retry up to 5 times with exponential backoff (10s, 30s, 2min, 10min, 30min)
- After all retries are exhausted, the delivery is marked as `failed`
- Use the deliveries endpoint to debug failed webhooks

## Inbound Webhook Mappings

Inbound webhook mappings let you receive webhooks from external services (Clerk, Stripe, Supabase Auth, etc.) and automatically route the data into Molted features -- sync contacts, subscribe to lists, or trigger journeys. No code required.

### Create an Inbound Webhook Mapping

```
POST /v1/agent/inbound-hooks?tenantId=T
```

Body:
```json
{
  "name": "Clerk Signups",
  "source": "clerk",
  "emailPath": "data.email_addresses[0].email_address",
  "fieldMappings": {
    "name": "data.first_name",
    "clerkId": "data.id"
  },
  "actions": [
    { "type": "sync_contact" },
    { "type": "subscribe_to_list", "listId": "lst_abc" }
  ]
}
```

Response includes a `urlToken` field. The webhook receiver URL is:
```
POST /v1/hooks/{urlToken}
```

Paste this URL into your external provider's webhook settings.

**Security model:** the receiver endpoint is authenticated by the URL token alone -- there is no provider-side signature verification in this MVP. Treat tokens as secrets and rotate them (delete + recreate) if a URL leaks. Per-token rate limit: 120 requests per 60 seconds; excess deliveries return `429`.

### List Inbound Webhook Mappings

```
GET /v1/agent/inbound-hooks?tenantId=T
```

### Get Inbound Webhook Mapping

```
GET /v1/agent/inbound-hooks/:id?tenantId=T
```

### Update Inbound Webhook Mapping

```
PATCH /v1/agent/inbound-hooks/:id?tenantId=T
```

Body (all fields optional):
```json
{
  "name": "Updated name",
  "emailPath": "data.email",
  "fieldMappings": { "name": "data.name" },
  "actions": [{ "type": "sync_contact" }],
  "enabled": false
}
```

### Delete Inbound Webhook Mapping

```
DELETE /v1/agent/inbound-hooks/:id?tenantId=T
```

### View Delivery Logs

```
GET /v1/agent/inbound-hooks/:id/logs?tenantId=T&limit=50
```

Returns recent delivery logs with status (`processed`, `partial`, `failed`, `rejected`), per-action execution results, and error messages.

### Receive a Webhook (External Providers)

```
POST /v1/hooks/:token
```

This is the public receiver endpoint -- no auth required. External services (Clerk, Stripe, etc.) POST their webhook payloads here. Molted extracts the email, maps fields, and executes configured actions.

### Email Path Syntax

Use dot notation with array index support:

| Provider | emailPath |
|----------|----------|
| Clerk | `data.email_addresses[0].email_address` |
| Stripe | `data.object.email` |
| Supabase Auth | `record.email` |

### Action Types

| Action | Description | Parameter |
|--------|------------|-----------|
| `sync_contact` | Upsert contact with mapped fields | None |
| `subscribe_to_list` | Add contact to a list | `listId` (required) |
| `trigger_journey` | Fire an event to start a journey | `eventName` (required) |

Actions execute sequentially. Failed actions are logged but don't block subsequent actions.
