# Mailrun Agent Identity API — workflow guide

This is the how-to companion to `/docs/openapi-v1.yaml` (the Swagger UI at
`/docs` renders that spec). It is written for both a human integrating this
API and an LLM agent driving it directly — it names the state machines, the
exact error codes, and what to do next after each one. Every endpoint below
is referenced as a plain method + path (`POST /v1/domains`); it links
nothing.

All requests except `GET /v1/health` require a bearer API key
(`Authorization: Bearer mr_live_...`) with the scope named on each endpoint,
or `agent:*`. Every response carries an `X-Request-Id` header — include it
when asking for support.

## 1. What this API is

An **identity** is a named container an agent sends and receives through.
Email and phone are each **optional, plan-gated attachments** to that
container — at most one of each. An identity may hold email only, phone
only, both, or (transiently) neither.

- Create the container first: `POST /v1/identities`. This is free and
  instant.
- Attach email either inline at create, or later via `POST
  /v1/identities/{id}/email`.
- Attach phone only via the explicit `POST /v1/identities/{id}/phone` — phone
  is never bundled into identity creation.
- Email always requires a domain: before any email attachment, you need an
  `active` row from `POST /v1/domains`. Phone-only identities never touch a
  domain.
- Deleting an identity releases everything it holds: phone back to the
  carrier (subject to a 7-day hold), mailbox torn down, and the domain kept
  — still yours, free to rebind to a different identity later.

## 2. Domain onboarding lifecycle

### Claiming a domain

`POST /v1/domains` claims a domain you own and starts onboarding. `domain`
is required; `sender_first_name`, `sender_last_name`, and `email_address`
are optional **as a group** — send all three or none. A partial set is
`validation_failed`.

**Subdomains.** A subdomain (for example `agent.acme.com`) is enrolled into its
parent's zone, so the parent must already be one of your domains here and
`active`. Otherwise the claim is refused with `parent_domain_required`. That
includes a parent held by another account, a parent that is only in Mailrun's
email product, and a parent still onboarding. Claim and activate the root
domain first. A subdomain never needs nameserver changes.

**With sender fields (fast path).** The address you give becomes the
domain's mailbox. It is the address an identity's email attach must exactly
match to bind in seconds, with no waiting on anything beyond the domain
reaching `active`.

**Without sender fields (address-less path).** The domain onboards with no
mailbox at all: enrollment is sent no sender name and no address. The
response's `address_source` field reads `none`, and — this matters for
anything parsing the response — `sender_first_name`, `sender_last_name`, and
`email_address` are **omitted from the response entirely**, not returned as
null. You decide the first address later, when you attach email to an
identity (`POST /v1/identities/{id}/email`): any address on the domain is
minted on first attach, exactly as on a domain onboarded with its own sender
fields.

Domains onboarded without sender fields by an earlier release read
`address_source: placeholder`: they carry a hidden system address that is
never returned and can never be attached.

Either way, onboarding is asynchronous. `POST /v1/domains` responds `202`
(or `200` on an idempotent replay of an identical prior claim) and dispatches
setup in the background.

### Polling to `active`

Poll `GET /v1/domains/{domain}` and watch `status`:

```
pending → awaiting_nameservers | enrolling → active
                                            ↘ failed
```

- `pending`: just claimed, setup not yet run.
- `awaiting_nameservers`: setup ran; nameserver delegation has not been
  observed yet. Call `GET /v1/domains/{domain}/nameservers` for the records
  to delegate at your registrar — a `202` there means they are not known yet
  either; retry after a short wait. Root domains only: a subdomain (for
  example `agent.acme.com`) is enrolled into its parent zone, never delegated,
  so it goes straight from `pending` to `enrolling` and its nameservers call
  answers `nameservers_required: false`.
- `enrolling`: delegation observed, upstream enrollment running. The
  fifteen-minute sweep resolves the domain's public NS records and moves
  the row here once every assigned nameserver is present (`domain.enrolling`
  fires once per claim).
- `active`: ready. An identity can now attach this domain's email.
- `failed`: check `failure_stage` and `failure_note` (both null on every
  other status — a live row never carries a note). Resubmit `POST
  /v1/domains` with the same domain (and, if you want to change them, new
  sender fields) to retry from the failed stage — this only works before
  `begin_requested_at` is set; after that the onboarded address is committed
  to the enrollment already in flight, so a retry after that point keeps the
  original address regardless of what you resend.

`verification` on `GET /v1/domains/{domain}` shows which DNS checks have
passed upstream — DKIM, SPF, and the domain record — as of the last look
at them (the sweep while onboarding, the daily enrollment re-check once
active), timestamped `checked_at`. It is null until that first look. Treat it as diagnostics for
a delegation that is not landing, nothing more: it can lag reality by a
poll, and promotion to `active` still requires the two signals onboarding
looks for, never this field.

**Once active, a domain is re-verified on a schedule** (since 2026-09-04):
hourly, the authoritative delegation must still carry every assigned
nameserver and the domain must still publish MX; daily, Mailrun's own
verification verdict must still be verified (SPF, DKIM). The domain row
carries `reverified_at` (last pass) and, while a check fails,
`verification_failed_at` + `verification_failure` (`nameservers_changed`,
`mx_missing`, `enrollment_unverified`). A failure emits
`domain.verification_failed`, emails the account owner once, and every
send from an identity on that domain is refused `409 domain_unverified`
with the reason in `details` — nothing is metered. Restore the records
and, once every failing check passes again (the DNS check and the
enrollment verdict are tracked independently, so a DNS pass does not
clear an enrollment failure or vice versa), the failure clears and
`domain.verification_restored` is emitted; no action is needed on our
side. A message accepted before the failure is not sent while the domain
is unverified: the worker holds it in `queued` (reservation kept) and
looks again every few minutes; past the backlog horizon it fails with
`failure_code` `domain_unverified` and its allowance is released. A check
that cannot be made (no DNS answer at all, upstream down) changes nothing.

### The 7-day claim expiry ladder

An unresolved claim (never reaching `active`) is swept on this schedule,
measured from `created_at`:

- 48 hours: first warning notice.
- 96 hours (day 4): second warning.
- 144 hours (day 6): final warning.
- 168 hours (day 7): the claim expires — moved to `removal_pending` and torn
  down upstream once confirmed.

Reaching `active` before day 7 stops the ladder for good; there is no
expiry once a domain is active.

### Removing a domain

`DELETE /v1/domains/{domain}` requires header `X-Confirm-Destroy` set to the
exact canonical domain string. It refuses with `domain_in_use`
(`details.reason: identity_bound`) while any non-released identity still
holds an address on the domain, including one whose provisioning failed —
release each one first (`DELETE /v1/identities/{id}/email`) — and with
`details.reason: mailbox_in_flight` while any of the domain's mailboxes is
still being created, deleted or awaiting operator reconciliation (retry in
a few minutes). Mailboxes left `teardown_pending` do not block: removal
settles them. The response is always `202` with `status:
removal_pending`: the claim stays until the background sweep confirms
upstream deletion, after which `GET /v1/domains/{domain}` is `not_found`.
Repeating the `DELETE` is another `202`; re-claiming a `removal_pending`
domain with `POST /v1/domains` is `domain_in_use` (`details.reason:
removal_pending`) until it settles.

### Redirect and TXT records

Once a **root** domain is `active` you can manage two things in its DNS
yourself. A subdomain (`agent.example.com`) has neither: it is refused with
`validation_failed` (`details.reason: subdomain`). A domain still onboarding
is `domain_not_provisioned` (`details.status`, retryable); a `failed` or
`removal_pending` one is `domain_unavailable` (`details.reason: not_active`).

- **Redirect.** `PUT /v1/domains/{domain}/redirect` with `{"redirect_to":
  "https://www.example.org/welcome"}` changes where visitors to the bare
  domain and its `www.` are sent (`agent:domains.write`). The URL must be
  https, at most 255 characters, a hostname with an alphabetic top-level
  domain (no IP address) and an optional path of letters, digits and
  `. _ ~ % / -` with no empty, `.` or `..` segments; no credentials, port,
  query or fragment, and never this domain or a name under it. A bare host
  (`mailrun.ai`) means `https://mailrun.ai`. A blank value is refused. Every
  visitor lands on exactly this URL; the path they asked for is not
  appended. The stored value is
  `https://` + lowercase host + path, trailing slash dropped. The answer is
  the domain row, sent only after Mailrun has written and verified the
  redirect. `domain_redirect_unavailable` means Mailrun no longer manages
  the domain's website.
- **TXT records.** `GET /v1/domains/{domain}/txt-records` lists the records
  you added with `limits` (record cap and the apex byte budget) and
  `reconciled` (false when the DNS provider could not be read just now).
  `POST` with `{"name": "@", "content": "..."}` adds one: `name` is `@` for
  the apex, a relative name (`_github-challenge-org`) or a full name under
  the domain (a name ending in `.` is absolute and must already be under the
  domain). It answers `201` when created and `200` with the existing
  record when the same name and content is already yours. `DELETE
  /v1/domains/{domain}/txt-records/{recordId}` removes a record you added
  and answers it with `state: removed`; any other id is `not_found`.
  Mailrun's mail records are protected: names with `_dmarc`, `_domainkey`,
  `_mta-sts`, `_bimi` or `_acme-challenge` as any label, or `_smtp._tls`,
  are `validation_failed` (`details.reason: reserved_name`), and so is
  content starting `v=spf1`, `v=dmarc1`, `v=dkim1`, `v=stsv1`, `v=tlsrptv1`
  or `v=bimi1` (`reserved_content`). `txt_record_exists` means an identical
  record you did not add is already in the zone; `txt_name_conflict` means
  the DNS provider refused a TXT record at that name because it holds a CNAME; `txt_record_limit_reached`
  means the cap is reached, or (`details.reason: apex_budget`) the apex
  byte budget; `txt_record_changed` on delete means the record was edited
  outside Mailrun and nothing was deleted.

None of these calls needs an idempotency key, and none is blocked while the
account is past due. Repeating the same redirect or add is safe: on
`service_unavailable` (`details.reason` `zone_busy` or `outcome_unknown`)
wait for `Retry-After` and send the same request again.

### What's blocked on the mailbox-provider integration

Mailrun's **create-mailbox** and **delete-mailbox** endpoints went live on
2026-09-03, and since 2026-09-06 a sending domain carries **several
mailboxes**, each bound to its own identity (the domain row's `mailboxes`
lists them; `mailbox_count` and `mailboxes_per_domain` say how many of the
domain's slots are used). Releasing an email marks **that mailbox**
`teardown_pending` and keeps it alive for a **rebind grace window** (24 h by
default; its `teardown_due_at` says when it closes); sibling mailboxes on
the domain are untouched. The **same identity** re-attaching the **same
address** inside the window is a fast rebind: the pending teardown is
cancelled and the live mailbox, with its mail, is reused — no delete, no
mint, no export wait, typically seconds. Any **other** identity (or a new
identity) asking for that address inside the window is refused
`email_address_held` (409, not retryable; `details.available_after` says
when the window ends): the old mailbox and its mail are never handed
to a different identity; after the teardown the address is minted as a new,
empty mailbox.
Once the window closes with no identity holding the address, the sweep
tears the mailbox down (within the next fifteen minutes) and the address is
free to be minted again. Asking for a **different** address on the domain
is simply another mailbox while the domain is under its limit; at the limit
it is `mailbox_limit_reached` (`details.limit`, `details.teardown_due_at`),
and if a released mailbox is still inside its window that teardown is
brought forward at once, so a retry within the next sweep finds a slot
free. The same address asked for after its window closed, while its
teardown is still running, is `domain_rebind_unavailable`
(`details.reason: teardown_in_progress`, retryable). Mail that arrives in a
kept mailbox while it is detached is not ingested by the first poll after
a re-attach (first-poll semantics, §4). A domain onboarded by an earlier
release with `address_source: placeholder` accepts customer addresses like
any other; only its hidden system address answers `service_unavailable`.

## 3. Identity lifecycle

### Creating a container

`POST /v1/identities` — `name` is required. `email_local_part`,
`email_domain`, and `domain_mode` are optional **as a group**:

- **Omit all three**: a bare container is created, `status: active`
  immediately. Nothing is provisioned; there is nothing to poll.
- **Supply all three**: creation also attaches email in the same request,
  equivalent to create-then-attach. The identity comes back `status:
  provisioning`; poll `GET /v1/identities/{id}/status` or wait for
  `identity.provisioned`. `email_domain` must be an `active` domain of
  yours; `email_local_part` may be any local part no other identity holds,
  up to the domain's `mailboxes_per_domain` (`email_local_part` "sarah" +
  `email_domain` "acmeagents.com" → `sarah@acmeagents.com`). The address
  the domain was onboarded with binds at once; any other is minted on
  first attach (§3, "Attaching email"). Local parts use letters, digits,
  `.`, `_` and `-`; `+` is refused because the mail server treats
  `name+tag` as sub-addressing of `name`.

`voice`, `retain_content`, and `daily_send_cap` are container-level settings
that can be set regardless of what is attached:

- `voice`: the text-to-speech voice for outbound calls. It takes effect only
  once a phone is attached — safe to set ahead of time on a bare or
  email-only container, but has no effect until a phone lands.
- `retain_content`: when true, email subject/body copies and call turn
  transcripts are stored for 30 days; the default (false) keeps metadata
  only, no content. Each retained turn's `at` is when the line was spoken
  (hosted-agent calls: from the voice relay's timing; webhook-answered
  calls: approximate, the time the turn was received during the call) and
  is null when unknown; `received_at` is when the turn was stored.
- `daily_send_cap`: a per-identity daily email limit, tighter than the
  plan-wide cap. Reached sends refuse with `send_cap_reached`.

### Attaching email (the mirror-image endpoint)

`POST /v1/identities/{id}/email` — attaches email to an existing container.
Body: `email_local_part`, `email_domain`, `domain_mode` (all required).
Refusals, in the order checked:

1. `email_already_attached` — this identity already holds an email address.
   Release it first with `DELETE /v1/identities/{id}/email`.
2. `identity_already_released` / `identity_suspended` — terminal or
   kill-switched identity.
3. `feature_not_available` — `domain_mode: dedicated_subdomain` is accepted
   by validation but not yet built.
4. `email_not_included` (or `plan_required` if no plan is assigned at all)
   — your plan does not include the email channel.
5. `domain_unavailable` — no domain claim of yours matches `email_domain`.
   Onboard it first with `POST /v1/domains`.
6. `email_address_taken` — that exact address is already attached to
   another identity (checked before the domain's mailboxes are looked at).
7. `domain_not_provisioned` — the domain exists but has not reached
   `active` yet. Poll `GET /v1/domains/{domain}`.
8. `domain_rebind_unavailable` — this exact address's previous mailbox is
   still being torn down (`details.reason: teardown_in_progress`: the
   rebind window has closed and the delete is running; or
   `reconciliation_required`: an operator has to settle it). Retry within
   the next sweep (up to fifteen minutes). The identity that detached the
   address does not hit this inside its window — it reuses the mailbox. Any
   other identity gets `email_address_held` until `details.available_after`
   (up to 24 h).
9. `service_unavailable` — the address you gave is the hidden system
   address of a domain onboarded by an earlier release (`address_source:
   placeholder`), which can never be attached. Any other address on the
   domain is fine.
10. `mailbox_limit_reached` — the domain already uses every one of its
    `mailboxes_per_domain` slots (`details.limit`; `details.teardown_due_at`
    is set when a released mailbox is still inside its grace window and has
    now been brought forward, so a retry within the next sweep finds a slot).

A successful attach returns `202` with the identity now `status:
provisioning`; poll `GET /v1/identities/{id}/status` or watch for
`identity.provisioned`. Several identities may each attach their own
address on the same active domain, up to the domain's
`mailboxes_per_domain` limit; we recommend up to three so each agent has
room to work. They share the domain's hourly sending budget, so adding
mailboxes never increases throughput, and releasing one never affects
the others.

### Releasing email

`DELETE /v1/identities/{id}/email` clears the identity's mailbox
credentials and marks **its** mailbox `teardown_pending` with a rebind
grace window (`teardown_due_at` on that mailbox); the mailbox is torn down
after the window and its address freed, unless the same address is
re-attached first — see §2. Other mailboxes on the domain are untouched. The identity survives as a container, possibly now bare or
phone-only; the response's `status` is the identity's real status after the
release (`active`). Refusals, in order: `identity_already_released` (a
released identity keeps its address reserved), `identity_suspended`, then
`email_not_provisioned` when there is no address to release.

### Attaching and releasing phone

`POST /v1/identities/{id}/phone` — requires a plan with `includes_phone`
and a card on file (a number is a recurring carrier charge, so this is
required even during a trial). Country defaults to `US`; only `US`/`CA` are
accepted. Guards run in this order: plan channel (`channel_not_in_plan`) →
card (`card_required`) → hourly rate limit, 3 provisions per customer per
hour (`phone_provision_rate_limited`, with `Retry-After`) → one-number-per-
identity (`phone_already_provisioned`). A successful call returns `202`
with `phone_number: null` — the number is bought asynchronously; poll `GET
/v1/identities/{id}/phone` or watch for `identity.provisioned`. While the
purchase is in flight the poll answers `200 {status: "provisioning"}`; when
it lands, the number. If the purchase is definitely refused (no inventory,
a carrier error, or the purchase never started within 15 minutes) the poll
answers `200 {status: "failed", failure_code}` and the identity is
re-provisionable — the original `Idempotency-Key` is spent (a replay still
returns the first `202`), so `POST` again with a new key.

`DELETE /v1/identities/{id}/phone` releases the number back to the carrier,
**unless** it is inside its minimum hold period, in which case it is
refused outright with `phone_hold_period` — there is no partial release and
no way to force it early. The hold is **7 days**, measured from when the
number actually landed (not from the provision request), which makes it
burner-proof: releasing and re-provisioning restarts the clock. Call history
under `GET /v1/identities/{id}/calls` survives release.

### Releasing the whole identity

`DELETE /v1/identities/{id}` releases phone first (subject to the same
7-day hold — if the phone is held, the **entire release is refused**, not
just the phone half), then email (as above), then marks the identity
`released` and deletes its knowledge files (documents and extracted text)
from voice storage. History rows are preserved; `released` is terminal.

### What's mutable after creation

`PATCH /v1/identities/{id}` can change `name`, `daily_send_cap`, `voice`,
and `retain_content`. Email, domain mode, and phone fields
(`email_address`, `email_local_part`, `email_domain`, `domain_mode`,
`phone_number`) are immutable from creation — they change only through
their dedicated attach/release endpoints; sending any of them to `PATCH`
fails with `validation_failed`.

## 4. Sending and reading mail

Every mailbox-dependent route (`messages`, `drafts`, `threads`, `bounces`,
send) requires the identity to hold a live email address. A bare or
phone-only identity refuses with `email_not_provisioned` — attach email
first.

### Inbox discovery and explicit sync

`GET /v1/identities/{id}/status` reports the current inbox state in
`inbox_sync`: `uninitialized`, `importing`, `catching_up`, `ready`, `hold`,
or `failed`. `inbox_sync_pending` says whether durable work remains, and
`inbox_sync_error_code` carries the latest initialization or sync error.
Its `push` object reports the current mailbox lifetime's push-binding
`state`, `verified_at`, `expires_at`, `pending_hint`, `last_hint_at`, and
`error_code`. These are status fields; they do not change discovery mode.

`POST /v1/identities/{id}/messages/sync` requires `agent:email.read`, takes
no request body, and explicitly asks the current mailbox lifetime to sync.
A `202` means the request was durably recorded and its background drain was
queued, not that the inbox is already caught up. The response uses
`inbox_sync`, `pending`, and `error_code`; follow progress through the status
endpoint above. A mailbox on reconciliation hold refuses with `409
mailbox_reconciliation_required`. A mailbox without an initialized current
lifetime refuses with retryable `503 mailbox_unavailable`. The endpoint is
limited to 10 requests per minute per API key and per identity.

Inbox discovery mode is deployment-wide and operator-controlled; there is
no public API to change it. `push` and `manual` do not schedule automatic
polls. `push` accepts verified provider hints; `manual` still allows the
explicit sync endpoint above. The retained `poll` mode schedules discovery
only when an operator selects it. No mode silently falls back to polling.

- **Sending** (`POST /v1/identities/{id}/messages`): `subject` and at least
  one of `text`/`html` are required. Each unique recipient across `to`,
  `cc`, and `bcc` consumes one email unit against both the plan allowance
  and the identity's `daily_send_cap`. Accepted (`202`) means queued, not
  delivered — poll `GET .../messages/{messageId}` or watch for `email.sent`
  / `email.delivery_failed`.
- **Sent copies**: once a message is `sent`, the exact bytes transmitted
  (Message-ID and Date included) are filed in the identity's Sent mailbox
  and the row gains a `jmap_id` (plus `message_id` and `thread_id` when the
  mailbox reports them), so
  `GET .../messages/{messageId}` reads an outbound message's body the same
  way as an inbound one and a reply lands in the same thread. Until that copy
  lands (seconds after `email.sent`, or never if the mailbox refused it —
  the send is still final) the body read is `not_found`.
- **Reading**: `GET .../messages` returns metadata only, never bodies;
  filter with `direction`, `status`, `counterparty`, `subject_contains`,
  and the inclusive ISO 8601 window `since` / `until` on `occurred_at`
  (`direction=out&since=…&until=…` lists what the agent sent in a period).
  `GET .../messages/{messageId}` fetches the body live from the mailbox on
  every call — it is never stored by this application. Attachments stream
  the same way via `GET .../messages/{messageId}/attachments/{attachmentId}`.
  `text` and `html` concatenate every body part of that type. When the
  mailbox cut a body at the proxy byte cap, `text_truncated` /
  `html_truncated` are `true` and the field holds a prefix, not the whole
  body — fetch the attachments for the rest. `encoding_problem: true` means
  at least one part could not be decoded cleanly. Each attachment carries
  `content_id` (what a `cid:` reference in `html` points at) and
  `disposition` (`inline` or `attachment`).
- **Drafts**: `POST .../drafts` creates one, `PATCH .../drafts/{draftId}`
  updates it, `POST .../drafts/{draftId}/send` queues it for sending (the
  draft stays in the mailbox unless the provider actually accepts the
  send). POST and PATCH responses carry `jmap_state`, the mailbox's state
  token after that write; pass it back to make the next write conditional:

  ```
  POST  .../drafts                 → 201 { "id": "…", "jmap_state": "svajq", … }
  PATCH .../drafts/{id}  { "subject": "v2", "jmap_state": "svajq" }
                                   → 200 { "jmap_state": "svajr", … }   (or 409 draft_conflict if stale)
  DELETE .../drafts/{id}?jmap_state=svajr
  ```

  Omit `jmap_state` to overwrite unconditionally.
- **Threads**: `GET .../threads` groups messages; `GET .../threads/{id}`
  returns one thread's messages, chronological, metadata only.
- **Bounces**: `GET .../bounces` is the one unpaginated list in this API —
  it queries a live upstream source with no stable sort key, so there is
  no `next_cursor`. It currently answers a retryable `503` honestly, until
  that transport is wired up.
- **Domain health**: a send from an identity whose domain failed
  re-verification is `409 domain_unverified` (see §2).
- **Caps**: recipients per message, message/attachment byte size, and
  attachment file types are all policy-enforced (`too_many_recipients`,
  `message_too_large`, `attachment_too_large`, `attachment_type_denied`).
  Executable file types are refused outright regardless of the declared
  content type: the Outlook/Gmail executable set (`.exe .bat .cmd .com
  .scr .pif .ps1 .psm1 .vbs .vbe .js .jse .wsf .wsh .msi .msp .jar .dll
  .apk .app .cpl .hta .inf .reg .lnk .iso .img`) on the last AND every
  inner extension (`report.pdf.exe`), plus a magic-byte check that catches
  a renamed Windows, Linux or macOS binary or a `#!` script whatever it is
  called. ZIP archives are opened one level and every entry is held to the
  same rules; an encrypted or unreadable ZIP is refused because it cannot
  be inspected. `7z`, `gz` and `tar` pass without inspection. The error
  details carry `filename`, `reason` and, for an archive, `entry`. The
  same policy applies to a direct send and to a draft send.
- **Draft attachments**: `POST .../drafts/{id}/send` sends whatever the
  draft holds, attachments included. The bytes are streamed from the
  mailbox at accept time for the policy check and again at send time for
  transmission; they are never stored by this API. Each attachment is
  subject to `max_attachment_bytes` and all of them to `max_message_bytes`.
  A blob that disappears between accept and send fails the message with
  `failure_code` `draft_attachment_missing`; the draft itself is kept.
  A message whose domain fails re-verification while it waits fails with
  `failure_code` `domain_unverified` once the backlog horizon passes (it is
  held, not sent, until then). A message still queued when its identity is
  suspended or released, or its account is suspended, fails with
  `identity_suspended`, `identity_released` or `account_suspended`; one
  whose email attachment was detached or replaced by another address
  before it was submitted fails with `mailbox_detached`. In every one of
  those cases the allowance is returned. Shutdown wins right up to the
  moment the message is handed to the provider; mail already submitted
  cannot be recalled.
  Other send-time attachment failure codes: `attachment_too_large`,
  `attachment_type_denied` (policy tightened after accept) and
  `draft_attachment_unavailable` (any other non-retryable mailbox answer).
  When attachment malware scanning is enabled, an attachment-bearing
  message is scanned before it reaches the provider and may fail with
  `attachment_malware_detected`, `attachment_unscannable` or
  `attachment_scan_unavailable`; messages without attachments are never
  held by the scanner.

## 5. Webhooks and events

`GET /v1/events` lists the same versioned envelope your webhooks receive.
Use `since` (an ISO 8601 timestamp) as the **gap-recovery path**: if your
webhook receiver was down, replay everything from the last event you saw.
Events are retained for 90 days.

Register a receiver with `POST /v1/webhooks`. The signing secret
(`whsec_...`) is returned **exactly once**, in that creation response — it
cannot be retrieved again, only rotated by deleting and recreating the
endpoint. Verify inbound deliveries:

1. Compute HMAC-SHA256 over `{X-Mailrun-Timestamp}.{raw request body}` using
   the secret.
2. Compare it to the lowercase hex digest after `sha256=` in
   `X-Mailrun-Signature`.
3. Reject anything more than 300 seconds from your clock.

Non-2xx responses are recorded as failures and retried, except
`voice.turn`, which is synchronous and answered inline (the response body's
`text` field is the agent's next spoken line — there is no retry for it).
An endpoint that racks up too many consecutive failures is auto-disabled:
`disabled_at` is set and deliveries stop, while `active` is left unchanged
— it is your switch, not the breaker's. `PATCH` the endpoint (even with no
changes) to clear `disabled_at`, reset the failure count and resume
deliveries; a `PATCH` that does not include `url` is not re-validated, so a
receiver whose host has gone away can still be switched off with
`{"active": false}`.

**Inbound voice conversations need a receiver you host.** To supply your
agent's replies, expose a **customer-hosted public HTTPS endpoint** registered
with `POST /v1/webhooks` and subscribed to `voice.turn`. A basic phone
connectivity check does not require a conversational agent: with no
subscriber an inbound call goes to voicemail when it is on (the default), or
hears "We are unable to take your call right now." and hangs up; it never
waits in a loop. With a subscriber, a turn with no usable reply in time gets
"One moment please." and listens again; after
`voice_max_consecutive_fillers` (2) such turns in a row the call ends (an
inbound call that nobody has answered live goes to voicemail on the first
miss). An outbound call with no subscriber speaks its `opening_text` and
hangs up (unless the identity is a hosted phone agent, below, in which case
`opening_text` is optional and `purpose` describes the call). Delivery
failures are recorded when an endpoint was actually attempted.
If the Mailrun voice relay itself is unavailable, Comms uses the account's
static unavailable-message-and-hangup fallback. An agent running only through
MCP on a desktop can use email, voicemail and one-way **outbound** calls with
a webhook identity; conversational replies then require that public receiver.
Hosted identities use Mailrun's phone agent when the realtime relay is enabled.

**Hosted phone agent (realtime relay).** Any plan that includes phone may use
the hosted agent; it is priced, not gated. Hosted calls bill at the hosted
per-minute rate, 22¢/min versus 13¢/min with your own agent, per second with a
1¢ minimum per call (see `tariff` on `GET /v1/usage`), and the switch takes
effect on the next call. A plan without phone is refused with
`409 channel_not_in_plan`. Where the realtime relay is enabled,
an identity with `voice_mode: hosted` needs no receiver: Mailrun's AI carries
the conversation from the identity's `hosted_system_prompt` (with `{name}` and
`{number}` substituted), speaks `hosted_greeting` first on inbound calls, says
that it is an AI assistant (`ai_disclosure`, always on for outbound), and can
take a message for you (`relay.messages` on the call). The prompt and greeting
are screened when saved: content that would have the agent run a scam,
impersonate a bank, government body or utility, abuse debt collection or
deceive callers is refused `hosted_content_rejected` with the reason;
borderline content is accepted, reviewed by Mailrun and reported as
`hosted_screening` on the identity. `place_call` then needs
only `to`; add `purpose` for what this call is about and `greeting` to override
the default outbound opener ("Hi, this is {name}.", spoken the instant the call
connects) for the call, for example "Hi, this is {name} from Acme. Is this Mike?".
The agent is also told it placed the call, so it never thanks the callee for
calling. If a machine answers an outbound call the
agent leaves `outbound_voicemail_message` and hangs up. Callers can interrupt
the agent mid-sentence. A relayed call's `relay` block also carries the
session `state`, the relay's `end_reason`, the hosted agent's `summary` (null
when the call was too thin to summarize, fewer than two spoken turns or no
caller line, or the summarizer produced no usable summary) and
whether its `fallback_used` responder spoke. A `webhook` identity with a
`voice.turn` receiver has its inbound calls carried by the relay too, with
streamed replies and the same interruption handling (its outbound calls keep
the `opening_text` path); the receiver's per-call signing key is
`HMAC-SHA256(endpoint_secret, "voice-turn:" + call_id)`.

### Blocked callers and per-caller limits

PATCH an identity (or MCP `update_identity`) with `blocked_callers`, a list of
up to 50 E.164 numbers such as `+15125550123`, to refuse their inbound calls.
Spaces, dashes, dots and parentheses are ignored and duplicates are removed;
each PATCH replaces the whole list, and `null` or `[]` clears it. Numbers stay
listed until you remove them. Inbound calls are also limited per caller number:
by default 5 calls in 10 minutes per identity, with callers that withhold their
number sharing one bucket. A blocked or rate-limited caller hears a short "usage
limit" message and the call ends: no call record, no `call.incoming` event, no
voicemail, nothing charged. Caller ID can be spoofed, so treat both as cost and
abuse controls, not authentication.

### Knowledge files for the hosted agent

Give a hosted identity documents to answer callers from: `POST /v1/identities/{id}/knowledge` with a multipart
`file` part or JSON `content_base64` plus `filename` (MCP `upload_knowledge`).
PDF, DOCX, plain text, Markdown, CSV and HTML are accepted, at most 20 MB
each; the kind is detected from the bytes. Mailrun extracts the text and
stores it, with the original, in the voice service's storage; the Agent API
keeps metadata only (name, type, size, SHA-256, token count). Limits per
identity: 5 files and 25,000 tokens of extracted text, about 40 pages
(`knowledge_limit_reached`, `knowledge_text_limit`). A scanned or encrypted
PDF with no extractable text is `knowledge_no_text`. Uploading the same
document again returns the stored file. The extracted text is screened on
save the same way as `hosted_system_prompt` and `hosted_greeting`: content
that sets the agent up for scam, impersonation (banks, government,
utilities), collections abuse or deception is refused `hosted_content_rejected`
with the reason and not stored; borderline content is accepted, flagged for
Mailrun review and reported as `screening` on the file (`hosted_screening` on
the identity). Screening never blocks a save when it is unavailable
(`skipped`). `GET .../knowledge` (`list_knowledge`)
lists the files and limits; `DELETE .../knowledge/{fileId}` (`delete_knowledge`)
removes one, document and text included (a repeat is `not_found`). Changes
take effect on the next call; a call in progress keeps what it loaded.
Releasing the identity deletes all of its files. Knowledge is information the
owner would tell any caller: never put confidential data in it, since a reply
once spoken cannot be retracted. Reads need `agent:identities.read`; writes
need `agent:identities.write` on the `agent-knowledge-write` rate group (10 per
minute per identity per API key, 20 per minute across the key); the list shares
the baseline read group. Knowledge is stored with the account's voice service, so
the account needs a provisioned phone number first (`knowledge_requires_phone`).

### Uploading files without base64

Greeting recordings and knowledge documents can be sent straight from the
agent's own shell instead of as `content_base64`, so the bytes never pass
through the model's context. `POST /v1/uploads` (MCP `prepare_upload`) with
`purpose` (`voicemail_greeting` or `knowledge`), `filename` and the exact
`size_bytes` returns an `upload_id`, a signed `upload_url` on this API's host,
and a ready-to-run `next_step` command. Run it: one `PUT` of the raw file, with
the returned `Content-Type`, within 15 minutes, no API key (the URL's `exp`
and `sig` are the credential). The PUT answers with `size_bytes` and `sha256`
to compare with the local file. Then call the greeting or knowledge upload
with `upload_id` in place of `content_base64`; the file is used once and the
bytes are dropped. `GET /v1/uploads/{id}` (`get_upload`) shows the state
(`awaiting`, `received`, `consumed`, `expired`). Tickets need
`agent:identities.write`; the host to allowlist is this API's domain.

Every event carries one of these `type` values (the same list
`POST /v1/webhooks` accepts in `event_types`):

- `email.received`
- `email.sent`
- `email.delivery_failed`
- `call.incoming`
- `call.initiated`
- `call.completed`
- `voice.turn`
- `voicemail.received`
- `identity.provisioned`
- `identity.suspended`
- `identity.reactivated`
- `domain.nameservers_ready`
- `domain.enrolling`
- `domain.expiry_warning`
- `domain.expired`
- `domain.active`
- `domain.failed`
- `domain.removed`
- `domain.verification_failed`
- `domain.verification_restored`
- `usage.threshold.reached`
- `usage.allowance_exhausted`
- `usage.spend_limit_reached`
- `usage.hourly_cap_reached`
- `voice.cap_reached`
- `trial.ending`
- `trial.ended`
- `plan.channel_removed`
- `webhook.endpoint_disabled`

`plan.channel_removed` is emitted once per held phone number when a plan change removes
the phone channel (for example a downgrade to Free). Data: `channel` (`phone`), `reason`
(`channel_not_in_plan`), `phone_number`, `plan_code`, `episode`. From that moment calls and
phone provisioning on that number answer `channel_not_in_plan` (409) until the plan includes
phone again; the number is not released automatically. Likewise a plan without email answers
`email_not_included` (409) on every send, including mail already queued.

`call.completed` carries `provider_error_code` when the carrier reported one
for a failed call, `failure_reason` (`busy`, `no_answer`, `canceled` or `failed`; busy and
no answer are worth retrying later) and `sip_response_code` when known (all also on the call
row), and `ended_reason`: `normal` (the call ran to its end),
`cap` (this API ended it at the per-call limit), `failed` (busy, no answer,
canceled or a carrier failure) or `stale` (no terminal status ever arrived
from the carrier and periodic reconciliation closed the call; its
`duration_seconds` is the provider's figure when one exists, otherwise 0).

## 6. Status-machine reference

**Domain (`status`):** `pending` → `awaiting_nameservers` | `enrolling` →
`active`; or `failed` at any stage; `removal_pending` during/after
`DELETE`. See §2.

**Domain (`binding`):** a summary of the domain's mailboxes: `unbound` (no
mailbox held) → `bound` (at least one identity holds a mailbox, including
one still being created) → `teardown_pending` (none held, at least one
released mailbox inside its grace window or awaiting deletion) → back to
`unbound`. A domain accepts attaches in every state up to
`mailboxes_per_domain`.

**Mailbox (`mailboxes[].state`):** `creating` → `awaiting_export` → `ready`
(bound while `identity_id` is set) → `teardown_pending` (after a release,
through the grace window and until deletion confirms) → gone from the list;
or `failed` when provisioning gave up (release the identity's email to
free the address); or straight back to `ready` when the same address is
re-attached inside the window.

**Identity (`status`):** `pending` (briefly, right before provisioning) →
`provisioning` (an attach is in flight) → `active` (usable, including a
bare container, which is `active` immediately) → `suspended` (kill-switched;
send/voice routes refuse) → `released` (terminal, via `DELETE
/v1/identities/{id}`).

**Identity provisioning stage (`GET /v1/identities/{id}/status`):** an
opaque, customer-facing view distinct from `status` — `queued` right after
create, `preparing_mailbox` while an attach runs, `ready` once usable
(a suspended or released identity also reports `ready` here; those states
surface instead at send/voice time), `failed` when the most recent attach
could not complete.

**Message (`status`):** `queued` → `sending` → `sent` | `failed`;
`reconciliation_required` when delivery status could not be confirmed and
is being retried; `received` for inbound; `draft` for an unsent draft.

**Call (`status`):** `placing` → `ringing` → `in_progress` → `completed`;
`failed` at any stage.

## 7. Error catalog

Every error response is `{ "error": { "code", "message", "details",
"retryable", "request_id" } }`. `code` is the stable field to branch on.

| Code | HTTP | Retryable | Next action |
|---|---|---|---|
| `unauthorized` | 401 | no | No key, malformed header, or unknown key — check the `Authorization` header. |
| `key_revoked` | 401 | no | This key was revoked. Issue a new one. |
| `key_expired` | 401 | no | This key expired. Issue a new one. |
| `https_required` | 400 | no | Use HTTPS (outside local development). |
| `forbidden_scope` | 403 | no | This key lacks the scope this route needs. Issue a key with the right scope. |
| `account_suspended` | 403 | no | The account is suspended. Contact Mailrun. |
| `customer_deleted` | 409 | no | The customer was torn down; no key can be minted and no plan status written for it again. Internal surface only in practice. |
| `account_past_due` | 402 | no | The account is past due. Resolve billing before retrying. |
| `card_required` | 402 | no | A card on file is required (phone provisioning). Add one. |
| `not_found` | 404 | no | No such resource, or it belongs to another customer — the two look identical on purpose. Check the id. |
| `validation_failed` | 422 | no | Fix the field(s) named in `details` and resubmit. |
| `too_many_recipients` | 422 | no | Reduce the combined `to`+`cc`+`bcc` count. |
| `destination_not_allowed` | 422 | no | That call destination is outside the allowed numbering plan. |
| `destination_busy` | 409 | yes | A call between this identity's number and that destination is already in progress; nothing was dialled. Retry after `Retry-After` seconds, once that call ends. |
| `identity_suspended` | 409 | no | This identity is kill-switched. Contact Mailrun. |
| `identity_already_released` | 409 | no | This identity is already released — terminal, nothing to retry. |
| `identity_limit_reached` | 409 | no | At the plan's `max_identities`. Release an identity or upgrade. |
| `domain_limit_reached` | 409 | no | At the effective `max_domains` (see `GET /v1/account` → `domains`). Remove a domain or upgrade. |
| `email_address_taken` | 409 | no | That exact address is attached to another identity already. |
| `email_not_included` | 409 | no | The plan does not include email. Upgrade, or use phone-only. |
| `email_already_attached` | 409 | no | This identity already holds an email. Release it first. |
| `email_not_provisioned` | 409 | no | This identity holds no email. Attach one before using mail routes. |
| `domain_not_verified` | 409 | no | The domain is not verified. |
| `domain_not_owned` | 404 | no | Rendered identically to `not_found` on purpose. |
| `domain_unavailable` | 409 | no | No domain claim matches. Onboard it via `POST /v1/domains`. On redirect/TXT routes with `details.reason: not_active`: the domain is `failed` or `removal_pending` (`details.status`) and cannot take DNS changes. |
| `domain_in_use` | 409 | no | On domain removal: an identity still holds an address on the domain (`details.reason: identity_bound`), a mailbox is still in flight (`mailbox_in_flight`), or subdomain claims sit under this root (`subdomains_present`); on re-claim: the domain is `removal_pending`. |
| `parent_domain_required` | 409 | no | A subdomain's parent is not an `active` domain on this account. Claim and activate the root first, then retry (the refusal is not stored under your idempotency key). |
| `domain_not_provisioned` | 409 | yes | The domain has not reached `active` yet. Poll and retry. |
| `domain_purchase_unavailable` | 402 | no | `mode: purchase` is not supported. Use `byod`. |
| `domain_unverified` | 409 | no | The identity's domain failed post-active re-verification (`details.reason`, `details.domain`, `details.failed_at`). Restore the DNS records (or the enrollment verdict); the domain heals once every failing check passes. Already-accepted messages are held, not lost, until then. |
| `domain_redirect_unavailable` | 409 | no | Mailrun no longer manages this domain's website, so its redirect cannot be changed here. Contact Mailrun. |
| `txt_record_exists` | 409 | no | An identical TXT record that you did not add is already in the zone. Nothing to add. |
| `txt_record_limit_reached` | 409 | no | At the per-domain TXT record cap, or (`details.reason: apex_budget`) the apex byte budget. Delete one of your records first. |
| `txt_name_conflict` | 409 | no | The DNS provider refused a TXT record at that name because it holds a CNAME. Use a different name. |
| `txt_record_changed` | 409 | no | On delete: the record was changed outside Mailrun, so nothing was deleted. Contact Mailrun. |
| `domain_rebind_unavailable` | 503 | yes | This exact address's previous mailbox is being torn down (`details.reason`: `teardown_in_progress`, or `reconciliation_required` when an operator must settle it; retry within the next sweep). |
| `email_address_held` | 409 | no | The address was detached by another identity and is held for it through its rebind window. Attach it after `details.available_after` (up to 24 h), when it is minted as a new, empty mailbox. |
| `mailbox_limit_reached` | 409 | no | The domain uses every one of its `mailboxes_per_domain` slots (`details.limit`, `details.limit_source`, `details.teardown_due_at`). Release a mailbox, or ask for a higher limit. |
| `plan_required` | 402 | no | No plan is assigned to this account. Assign one. |
| `feature_not_available` | 409 | no | `dedicated_subdomain` (and similar) is accepted but not yet built. |
| `phone_already_provisioned` | 409 | no | This identity already holds (or is buying) a number. |
| `phone_not_provisioned` | 409 | no | This identity holds no number and none was ever requested. Provision one first. (A provision in flight or refused answers `200` with `status: provisioning` / `failed` instead.) |
| `phone_provision_rate_limited` | 429 | yes | Over 3 provisions this hour. Wait for `Retry-After`. |
| `phone_hold_period` | 409 | no | Inside the 7-day minimum hold. Wait it out — or contact your system administrator with questions. |
| `phone_unavailable` | 503 | yes | Voice service is down. Retry later. |
| `draft_conflict` | 409 | no | The draft changed or vanished in the mailbox. Re-fetch and retry. |
| `webhook_limit_reached` | 409 | no | At the max webhook endpoint count. Delete one first. |
| `idempotency_conflict` | 409 | no | That `Idempotency-Key` was already used with a different payload. Use a new key. |
| `no_numbers_available` | 503 | yes | No numbers available for that area code/country right now. Retry, or drop the area-code preference. |
| `mailbox_unavailable` | 503 | yes | The mailbox is down. Retry later. |
| `mailbox_reconciliation_required` | 409 | no | Inbox discovery is held until an operator approves the mailbox baseline reconciliation. Do not keep requesting manual sync; contact Mailrun. |
| `send_failed` | 502 | yes | The provider rejected the send. Retry later. |
| `message_too_large` | 413 | no | Reduce the message size below the cap. |
| `attachment_too_large` | 413 | no | Reduce the attachment size below the cap. |
| `attachment_type_denied` | 422 | no | That file is not permitted outbound (executable extension, executable magic bytes, or a ZIP entry that fails either; details carry `filename`, `reason`, `entry`). |
| `attachment_malware_detected` | 422 | no | Send-time `failure_code` only: the malware scan detected a threat in an attachment, so the message was refused before it reached the provider and the allowance returned. No signature or file details are disclosed. |
| `attachment_unscannable` | 422 | no | Send-time `failure_code` only: an attachment could not be fully scanned (encrypted archive, unsupported container, or a scan limit was hit), so the message was refused before the provider. Send the content unencrypted, or as a plain file. |
| `attachment_scan_unavailable` | 503 | yes | Send-time `failure_code` only: the scanner stayed unavailable past the retry horizon, so the message was refused unsent and the allowance returned. Re-send later. |
| `send_cap_reached` | 429 | yes | The identity's `daily_send_cap` is reached. Wait for the next day, or raise the cap. |
| `hourly_send_cap_reached` | 429 | yes | The account's hourly ceiling (an anti-abuse bound, well above normal throughput) is reached. Wait for `Retry-After`, or contact Mailrun to raise it. Normal bursts are never refused here — they queue and drip out. |
| `send_backlog_full` | 429 | yes | The queued backlog on this identity's sending domain is beyond its drain horizon (`send_max_backlog_hours` at 90/hour per domain). Nothing was consumed. Wait for `Retry-After` (at most an hour) and retry. |
| `allowance_exhausted` | 429 | no | The plan's email allowance for this period is used up. Upgrade or wait for the next period. |
| `voice_cap_reached` | 429 | yes | A voice cap (concurrent calls, etc.) was reached. Retry shortly. |
| `voice_allowance_exhausted` | 429 | no | The plan's voice allowance for this period is used up. |
| `spend_limit_reached` | 429 | no | Metered voice and phone spend for this billing month has reached the customer's spend limit; raise the limit or wait for the next month (`Retry-After` is the month end). |
| `channel_not_in_plan` | 409 | no | The plan does not include this channel (e.g. phone). Upgrade. |
| `service_unavailable` | 503 | yes | A required backend is down, or the address you tried to attach is the hidden system address of a domain onboarded by an earlier release (`address_source: placeholder`; never attachable, any other address on that domain is fine). The request was refused rather than run unmetered — retry later. |
| `rate_limited` | 429 | yes | General rate limit. Back off and retry. |
| `not_implemented` | 501 | no | Not implemented. |
| `method_not_allowed` | 405 | no | Wrong HTTP method for this path. |
| `integration_error` | 500 | no | Mailrun's internal DNS integration refused this service's own credentials or contract (`details.reason: contract_fault`). Retrying will not help; contact Mailrun with `request_id`. |
| `server_error` | 500 | yes | Unexpected error. Retry; contact support with `request_id` if it persists. |

## Usage and metered voice billing

`GET /v1/usage` (MCP `get_usage`, scope `agent:usage.read`) reports the
current email allowance period: `included_emails`, `emails_out`, `emails_in`,
`percent_used.email` and `hard_stop_at_percent`, the percent of the email
allowance at which sends are refused with `allowance_exhausted`.

Voice and phone numbers are metered per UTC calendar month rather than
bucketed. Once metered billing is active the same response also carries:

- `billing_month` (`YYYY-MM`, UTC);
- `voice` — `hosted_seconds` and `byo_seconds` settled this month (calls
  answered by Mailrun's hosted agent versus your own agent), `settled_cents`
  and `reserved_cents` (calls still in progress hold a reservation until
  they settle);
- `numbers` — `active` numbers, `days_to_date` and `accrued_cents` (each
  held number is charged per UTC calendar day it was held, pro-rated from
  the monthly rate);
- `spend` — `limit_cents`, `used_cents` (settled + reserved + accrued rent),
  `remaining_cents` and `percent_used` (two decimals). A new call is refused
  with `spend_limit_reached` (429, `Retry-After` to the end of the month)
  when it would exceed the limit; numbers are kept. `percent_used.voice`
  equals `spend.percent_used` from then on;
- `tariff` — the version in effect and its cents per minute (hosted and
  BYO, billed per second with a 1 cent minimum per connected call) and per
  number per month. Every call and rental is priced at the rate stored with
  it when it was admitted, so a tariff change never re-prices past usage.

The presence of `spend` is the switch: before activation none of these
blocks exist, `percent_used.voice` is minute-based and `hard_stop_at_percent`
also bounds voice. After it, `hard_stop_at_percent` applies to email only and
`included_voice_minutes` / `voice_minutes_used` are deprecated mirrors (any
included minutes are honoured as free seconds first). Each closed month is
invoiced once on your Mailrun subscription; a provider correction after the
close restates it.

## Voicemail alpha

An unanswered inbound call plays the identity's voicemail greeting (the default,
or a custom one, below) and records up
to two minutes. A conversation that has already been answered stays on the
live-call path. Voicemail and its email notice are on by default for every
identity and are the fallback when no `voice.turn` receiver answers, so leave
them on alongside a receiver. PATCH an identity with `voicemail_enabled`,
`voicemail_email_enabled` and `voicemail_email_destination` (`account` or
`identity`) to change them. Phone-only identities use `account`, your main account email;
detaching an identity's email moves `identity` back to `account`.
Deployment and Comms configuration must also be enabled by the operator.

### Custom greetings

Each identity can have its own greeting, as text or a recording, set on its
number. Callers hear, in order of precedence: the identity's recording, the
identity's text, the account-wide recording, the account-wide text, then the
built-in default. `GET /v1/identities/{id}/voicemail/greeting` reports the
`source` in effect (`audio`, `text`, `account_audio`, `account_text` or
`default`) with the identity's text and recording metadata (type, size,
duration, file name, SHA-256); it never returns the audio or a link.

- `PUT .../voicemail/greeting` with `{"text": "..."}`: at most 500 characters,
  no control characters except tab and line breaks.
- `POST .../voicemail/greeting/audio`: an MP3 or 16-bit PCM WAV of at most 5 MB
  and 60 seconds, as a multipart `file` part or JSON `content_base64`. The
  format is detected from the bytes; neither MP3 nor WAV, or a WAV that is not
  16-bit PCM, is `greeting_audio_unsupported` (415). Oversize is
  `greeting_audio_too_large` (413); longer than 60 seconds, truncated or
  corrupt is `greeting_audio_invalid` (422).
- `DELETE .../voicemail/greeting` removes the identity's text and recording.

One greeting per identity: the most recent write wins. Setting text deletes
the identity's recording; uploading a recording clears its text. Each change
stores the new greeting before removing the old one, so callers never hear a
gap. Reads need `agent:phone.read`; writes need `agent:phone.voice` and share
the `agent-greeting-write` rate group (10 per minute per identity). An identity
without a number is `phone_not_provisioned`. MCP tools: `get_voicemail_greeting`,
`set_voicemail_greeting`, `upload_voicemail_greeting_audio` (`content_base64`)
and `clear_voicemail_greeting`.

Subscribe a webhook to `voicemail.received` to receive the recording and available
transcript. Its transient `data` includes `audio_base64`, `content_type`,
`audio_filename`, `transcript` and `transcription_status`. Stored events contain
only voicemail/call identifiers and status; they do not provide an audio archive.
Verify webhook signatures and deduplicate retries by event ID. Email and webhook
delivery retry independently. Failed transcription does not discard the recording.
