# Reply to a conversation

`POST /v1/inbox/conversations/{conversation_id}/replies`

Emails a reply to the conversation's latest inbound message, from the campaign mailbox or the reply address.

**Scope:** `inbox:reply`

**Access:** The key's member must be able to see the conversation in the inbox.

DialEcho sends the reply the way your team's replies from the inbox go out: the recipient, subject and threading come from the conversation, and without `html` the HTML part is made from `text`. Recipients, sender, subject, CC, BCC and attachments cannot be set; any field not listed here is rejected. `200` means the mail provider accepted the reply and DialEcho stored it, not that it reached the inbox. It shows in the thread as a `manual_reply` with `external_reply` set.

`in_reply_to_message_id` must be the conversation's latest inbound message, from the feed or the conversation. If a newer one has arrived the reply is refused with `409 conversation_changed`: read the conversation again and answer the latest. Each inbound message gets at most one reply through the API, across all your keys; a message your team or the AI agent already answered is `409 already_replied` too. A pending AI reply or draft for the message is cancelled when yours is sent; on real-estate conversations a draft waiting for review is refused with `409 draft_requires_review` instead.

`send_as` picks the sender; check the conversation's `reply_identities` first. `campaign_mailbox` sends from the mailbox the thread came from. `reply_address` sends from the campaign's connected reply address; when it is not set up, disconnected or out of capacity the reply is refused with `409 sender_unavailable` and never sent from another address.

`Idempotency-Key` is required and belongs to your API key and this conversation. The same key with the same body returns `200` with the original message and `replayed: true` and sends nothing, even after the conversation was archived or answered. The same key with a different body is `409 idempotency_conflict`. Bodies are compared after `text` is trimmed and a blank `html` dropped.

After a timeout or `409 delivery_unknown`, retry only with the same `Idempotency-Key` and body: it reports the outcome and never sends a second email. `delivery_unknown` means the reply may have gone out, so never send it again under a new key. To change the conversation's state after replying, use `PATCH`; never resend an email to fix state.

Replies have their own limit on top of the request limit: 20 a minute per API key, in bursts of up to 5. Requests rejected as invalid (`422`) do not count; every other reply request does, replays included.

## Headers

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | Yes | Identifies this reply for your API key and this conversation. Reusing it with a different body is `409 idempotency_conflict`. 16 to 128 printable ASCII characters, no spaces (a UUID works). |

## Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `conversation_id` | `UUID` | Yes | The conversation. |

## Body

**Content type:** `application/json`

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `text` | `string` | Yes | The reply as plain text. Leading and trailing whitespace is trimmed, and it must contain more than whitespace. Up to 50,000 characters, counted before trimming. |
| `html` | `string` | No | An HTML version of the same reply. Blank is the same as leaving it out. Up to 200,000 characters. |
| `send_as` | `string` | Yes | Which sender to reply from. One of `campaign_mailbox`, `reply_address`. |
| `in_reply_to_message_id` | `UUID` | Yes | The inbound message you are answering: the conversation's latest inbound message. |

## Response

**Status:** `200 OK`

The same for a new reply and a replay; `replayed` tells them apart.

| Name | Type | Description |
| --- | --- | --- |
| `status` | `string` | Always `sent`. One of `sent`. |
| `replayed` | `boolean` | `true` when this answers an earlier request with the same `Idempotency-Key`; nothing was sent this time. |
| `message` | `object` | The reply as stored: `direction` `outbound`, `kind` `manual_reply`, `external_reply` set. |
| `message.id` | `UUID` | The message. Pass an inbound message's `id` as `in_reply_to_message_id` to answer it. |
| `message.conversation_id` | `UUID` | Its conversation. |
| `message.campaign_id` | `UUID` | The conversation's campaign. |
| `message.domain` | `string` | The conversation's inbox. One of `contact`, `realestate`. |
| `message.direction` | `string` | `inbound` from the contact, `outbound` to them. One of `inbound`, `outbound`. |
| `message.kind` | `string \| null` | `human_reply`: a person wrote back. `auto_reply`: an auto-responder, such as an out-of-office. `manual_reply`: sent by your team or through this API. `ai_reply`: sent by the AI email agent. `null` when the message was never classified, as with older messages and `origin_message`. One of `human_reply`, `auto_reply`, `manual_reply`, `ai_reply`. |
| `message.from_email` | `string \| null` | The sender, following the member's email visibility. |
| `message.to_emails` | `array of strings` | The recipients. Addresses the member's role hides are left out. |
| `message.cc_emails` | `array of strings` | The CC recipients, the same way. |
| `message.subject` | `string \| null` | The subject. |
| `message.text` | `string \| null` | The plain-text body. `Restricted` when the member's role masks messages. |
| `message.html` | `string \| null` | The HTML body exactly as stored, or `null` (also when the role masks messages). Sanitize it before you display it and never execute it. |
| `message.attachments` | `array of objects` | What was attached; there are no download links. Empty when the role masks messages. A detail that is not known is `null`. |
| `message.attachments.filename` | `string \| null` | The file name. |
| `message.attachments.content_type` | `string \| null` | The MIME type, for example `application/pdf`. |
| `message.attachments.size_bytes` | `integer \| null` | The size in bytes. |
| `message.created_at` | `timestamp \| null` | When DialEcho stored the message (RFC 3339, UTC). The inbound feed is ordered by it. |
| `message.received_at` | `timestamp \| null` | When the mail provider received an inbound message. |
| `message.sent_at` | `timestamp \| null` | When an outbound message was sent. |
| `message.is_synthetic` | `boolean` | `true` only for a conversation's `origin_message`, which is not a stored message. |
| `message.external_reply` | `object \| null` | Set on replies sent through this API; `null` on every other message. |
| `message.external_reply.api_key_id` | `UUID \| null` | The API key that sent it. |
| `message.external_reply.organization_member_id` | `UUID \| null` | The key's team member. |
| `message.external_reply.automated` | `boolean` | Always `true`: sent by an integration, not typed by a person. |
| `message.external_reply.in_reply_to_message_id` | `UUID \| null` | The inbound message it answered. |

## Example request

**curl**

```bash
curl -X POST "https://development.agencydevworks.ai/v1/inbox/conversations/44444444-0000-4000-8000-000000000001/replies" \
  -H "Authorization: Bearer dei_your_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 0f0f0f0f-0000-4000-8000-00000000abcd" \
  -d '{
  "text": "Thursday at 2 pm works. Talk then!",
  "html": "<p>Thursday at 2 pm works. Talk then!</p>",
  "send_as": "reply_address",
  "in_reply_to_message_id": "66666666-0000-4000-8000-000000000002"
}'
```

**JavaScript**

```js
const response = await fetch("https://development.agencydevworks.ai/v1/inbox/conversations/44444444-0000-4000-8000-000000000001/replies", {
  method: "POST",
  headers: {
    "Authorization": "Bearer dei_your_key",
    "Content-Type": "application/json",
    "Idempotency-Key": "0f0f0f0f-0000-4000-8000-00000000abcd",
  },
  body: JSON.stringify({
    "text": "Thursday at 2 pm works. Talk then!",
    "html": "<p>Thursday at 2 pm works. Talk then!</p>",
    "send_as": "reply_address",
    "in_reply_to_message_id": "66666666-0000-4000-8000-000000000002"
  }),
});
const data = await response.json();
```

## Example response

**Status:** `200 OK`

```json
{
  "status": "sent",
  "replayed": false,
  "message": {
    "id": "88888888-0000-4000-8000-000000000001",
    "conversation_id": "44444444-0000-4000-8000-000000000001",
    "campaign_id": "cccccccc-0000-4000-8000-000000000001",
    "domain": "contact",
    "direction": "outbound",
    "kind": "manual_reply",
    "from_email": "jordan@example.com",
    "to_emails": ["pat@example.com"],
    "cc_emails": [],
    "subject": "Re: Your solar quote",
    "text": "Thursday at 2 pm works. Talk then!",
    "html": "<p>Thursday at 2 pm works. Talk then!</p>",
    "attachments": [],
    "created_at": "2026-11-02T16:22:04Z",
    "received_at": null,
    "sent_at": "2026-11-02T16:22:03Z",
    "is_synthetic": false,
    "external_reply": {
      "api_key_id": "abababab-0000-4000-8000-000000000001",
      "organization_member_id": "ffffffff-0000-4000-8000-000000000001",
      "automated": true,
      "in_reply_to_message_id": "66666666-0000-4000-8000-000000000002"
    }
  }
}
```

## Errors

| HTTP | Code | When |
| --- | --- | --- |
| 403 | `insufficient_scope` | The key does not have `inbox:reply`. Checked before the body is validated. |
| 404 | `not_found` | The conversation does not exist, was deleted, or the key's member cannot see it; or `in_reply_to_message_id` is not a message of this conversation. |
| 409 | `idempotency_conflict` | The `Idempotency-Key` was already used on this conversation with a different body. Nothing was sent. |
| 409 | `conversation_changed` | `in_reply_to_message_id` is not the latest inbound message any more, or the conversation changed while the reply was prepared. Read it again and answer the latest. |
| 409 | `already_replied` | The message was already answered, by your team, the AI agent or another API reply. Do not resend. |
| 409 | `reply_in_progress` | Another reply to the conversation is being written or sent right now. `retryable` is `true`: retry shortly with the same `Idempotency-Key`. |
| 409 | `delivery_unknown` | The reply may have been sent but the provider did not confirm it. Retry only with the same `Idempotency-Key` and body, never as a new request. |
| 409 | `conversation_archived` | The conversation is archived. Restore it with `PATCH` and `{"archived": false}` first. |
| 409 | `recipient_suppressed` | The recipient unsubscribed or is suppressed. Do not retry. |
| 409 | `non_actionable_inbound` | The message cannot be answered automatically: it is an auto-reply or an opt-out, or the conversation has no recipient address. |
| 409 | `sender_unavailable` | The `send_as` sender is not set up, disconnected or out of capacity, or an earlier API reply to this message failed before it was sent and must be reviewed in DialEcho. Nothing was sent. |
| 409 | `draft_requires_review` | Real-estate conversations: an AI draft for this message is waiting for review in DialEcho. |
| 413 | `payload_too_large` | The body is larger than 1 MB. It was not read and nothing was sent. |
| 422 | `invalid_request` | `conversation_id` is not a UUID, the `Idempotency-Key` is missing or malformed, the body is invalid (`error.fields` lists every problem), or `in_reply_to_message_id` is not an inbound message. |
| 429 | `rate_limited` | Over the reply limit (20 a minute per key, bursts of 5) or the request limit. Wait the `Retry-After` seconds, then retry with the same `Idempotency-Key`. |
| 503 | `dependency_unavailable` | The suppression check, the inbox or the access check was unavailable before anything was sent. Retry with backoff and the same `Idempotency-Key`. |

Every endpoint can also answer `401 invalid_api_key`, `429 rate_limited` and `503 dependency_unavailable`. See [Errors](/api/#errors) in the overview.

## Related endpoints

- `GET /v1/inbox/inbound-messages` · [List inbound messages](/api/inbox/list-inbound)
- `GET /v1/inbox/conversations/{conversation_id}` · [Retrieve a conversation](/api/inbox/get-conversation)
- `PATCH /v1/inbox/conversations/{conversation_id}` · [Update a conversation](/api/inbox/update-conversation)
