---
title: "Reply to an inbound email"
method: POST
path: "/emails/{id}/reply"
tags: ["Sending"]
---

# Reply to an inbound email

`POST /emails/{id}/reply`

Sends an outbound reply to the inbound email identified by `id`.
Threading headers (`In-Reply-To`, `References`), recipient
derivation (Reply-To, then From, then bare sender), and the
`Re:` subject prefix are all derived server-side from the
stored inbound row. The request body carries only the message
body, optional From override, optional attachments, and optional
`wait` flag; passing any header or recipient override is
rejected by the schema (`additionalProperties: false`).

Forwards through the same gates as `/send-mail`: the response
status, error envelope, and `idempotent_replay` flag mirror
the send-mail contract verbatim.

## Headers

- `Idempotency-Key` string

## Request body

- object — Body shape for `/emails/{id}/reply`. Intentionally narrow: recipients (`to`), subject, and threading headers (`in_reply_to`, `references`) are derived server-side from the inbound row referenced by the path id and are rejected by `additionalProperties` if passed (returns 400). `from` IS allowed because of legitimate use cases (display-name addition, replying from a different verified outbound address, multi-team triage). Send-mail's per-send `canSendFrom` gate validates the from-domain regardless, so the override carries no extra privilege.
  - `body_text` string — Plain-text reply body. At least one of body_text or body_html is required. The combined UTF-8 byte length of body_text and body_html must be at most 262144 bytes (same cap as send-mail).
  - `body_html` string — HTML reply body. At least one of body_text or body_html is required.
  - `from` string — Optional override for the reply's From header. Defaults to the inbound's recipient. Use to add a display name (`"Acme Support" <agent@company.com>`) or to reply from a different verified outbound address (e.g. multi-team routing where support@ triages to billing@). The from-domain must be a verified outbound domain for your org, same as send-mail.
  - `wait` boolean — When true, wait for the first downstream SMTP delivery outcome before returning, mirroring the send-mail `wait` semantics.
  - `attachments` object[] — Inline attachments for this reply. Use https://api.primitive.dev/v1 for replies with attachments. Combined raw decoded attachment bytes must be at most 31457280.
    - `filename` string, required — Attachment filename. Control characters are rejected.
    - `content_type` string — Optional MIME content type. Control characters are rejected.
    - `content_base64` string, required — Base64-encoded attachment bytes.

## Response `200`

Outbound relay result

- object
  - `success` true, required
  - `data` object, required
    - `id` string, required — Persisted sent-email attempt ID.
    - `status` 'queued' | 'submitted_to_agent' | 'agent_failed' | 'gate_denied' | 'unknown' | 'delivered' | 'bounced' | 'deferred' | 'wait_timeout', required — Lifecycle status of a sent_emails row. Possible values: - `queued`: pre-call INSERT; the outbound agent has not yet replied. - `submitted_to_agent`: agent accepted; `queue_id` is set. - `agent_failed`: agent rejected; `error_code` and `error_message` carry the reason. - `gate_denied`: a recipient-scope gate denied the send; the agent was never called. The `gates` array carries the denial detail. /send-mail returns 403 in this case so callers see the denial synchronously; /sent-emails additionally records the row for historical lookup, which is when this status appears in a listing. - `unknown`: terminal indeterminate; the on-box log poller couldn't classify the receiver's response. - `delivered` / `bounced` / `deferred` / `wait_timeout`: terminal delivery outcomes (see DeliveryStatus).
    - `from` string, required — Bare from-address actually written on the wire. Echoed on every success branch so callers can confirm what went out, particularly useful for the /emails/{id}/reply path where `from` is server-derived from the inbound's recipient when the caller doesn't override. For sends where the caller passed a from-header that included a display name (e.g. `"Acme Support" <support@acme.test>`), this field is the parsed bare address (`support@acme.test`). The display name was sent on the wire intact; this field just makes the address easy to compare against allowlists.
    - `queue_id` string, nullable, required — Message identifier assigned by Primitive's OUTBOUND relay (the box that signs your mail and submits it to the receiving MTA). NOT the receiver's queue id. The receiver may also report its own queue id in `smtp_response_text` (e.g. `"250 2.0.0 Ok: queued as 99D111927CDA"` from a Postfix receiver). Those two ids refer to different mail systems and are NOT comparable. Treat `queue_id` as Primitive-internal and the receiver's id as remote-system-internal. Null on rows that never reached the relay (queued, gate_denied, agent_failed before signing).
    - `accepted` string[], required — Recipient addresses accepted by the relay.
    - `rejected` string[], required — Recipient addresses rejected by the relay.
    - `client_idempotency_key` string, required — Effective idempotency key used for this send.
    - `request_id` string, required — Server-issued request identifier for support and tracing.
    - `content_hash` string, required — Stable hash of the canonical send payload.
    - `delivery_status` 'delivered' | 'bounced' | 'deferred' | 'wait_timeout' — Narrower enum covering only the four terminal delivery outcomes returned to a synchronous `wait: true` send. On the SendMailResult shape, `delivery_status` is always equal to `status` whenever both are present (i.e. on terminal-state replays and live wait=true responses). The two fields exist so callers that want to type-narrow on "this is a delivery outcome" can pattern-match against the four-value enum without handling the broader SentEmailStatus value set (which also covers `queued`, `submitted_to_agent`, `agent_failed`, `gate_denied`, `unknown`). On async-mode and pre-terminal responses, `delivery_status` is absent and only `status` is populated. Use `status` if you want a single field that's always present.
    - `smtp_response_code` integer, nullable — SMTP response code from the first downstream delivery outcome when wait is true.
    - `smtp_response_text` string — SMTP response text from the first downstream delivery outcome when wait is true.
    - `idempotent_replay` boolean, required — True when the response replays a previously-recorded send keyed by `client_idempotency_key` (same key, same canonical payload). False on a fresh send and on gate-denied responses. Lets callers branch on cache state without diffing fields.

## Other responses

- `400` — Invalid request parameters
- `401` — Invalid or missing API key
- `403` — Authenticated caller lacks permission for the operation
- `404` — Resource not found
- `422` — The request was well-formed but could not be processed. For Payments this covers a missing payout address, a failed payment verification, a spend-policy decline, or an expired challenge; `error.code` distinguishes them.
- `429` — Rate limit exceeded
- `500` — Primitive encountered an internal error
- `502` — Primitive could not complete the downstream SMTP request
- `503` — Primitive is temporarily unable to process the request

---

[API](https://skmtc.dev/primitive/apis/primitive-api.md) · [All operations](https://skmtc.dev/primitive/apis/primitive-api/llms.txt) · [OpenAPI document](https://skmtc-service-production.skmtc.workers.dev/v1/apis/primitive/primitive-api/revisions/c2299d28022c/schema)
