> ## Documentation Index
> Fetch the complete documentation index at: https://docs.armin.cx/llms.txt
> Use this file to discover all available pages before exploring further.

# Send a message

> Send a reply or internal note via POST /public/v1/tickets/:ticketNumber/messages.

Add a message to an existing ticket — a customer-facing reply or an internal note. Requires the **`tickets:write`** scope. Rate limit: **30 requests / minute** (see [Rate limits](/api/rate-limits)).

## Prerequisites

1. **API key** with **`tickets:write`** — create or update under [Settings → API](https://armin.cx/app/_/settings/api).
2. **An existing ticket** — get its `ticket_number` from [Create a ticket](/api/create-ticket) or `GET /public/v1/tickets`.
3. **HTTP client** — `curl`, Postman, or any server that can send `POST` with JSON.

## Request

|             |                                                                                                             |
| ----------- | ----------------------------------------------------------------------------------------------------------- |
| **Method**  | `POST`                                                                                                      |
| **URL**     | `https://api.armin.cx/public/v1/tickets/{ticket_number}/messages`                                           |
| **Headers** | `cx-api-key: YOUR_API_KEY`, `Content-Type: application/json`, `Idempotency-Key: YOUR_UNIQUE_KEY` (optional) |

Send only the fields listed below. Extra fields in the JSON body return an error.

## Request body

| Field                | Type    | Default | Description                                                                                                                                                                                                             |
| -------------------- | ------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type`               | string  | `reply` | `reply` sends the message to the customer. `note` saves an internal note that staff can see but is never sent.                                                                                                          |
| `body`               | string  | —       | Plain-text message content (max 100,000 characters). One of `body` or `html_body` is required.                                                                                                                          |
| `html_body`          | string  | —       | HTML message content (max 100,000 characters).                                                                                                                                                                          |
| `channel`            | string  | —       | Send on a specific channel type instead of the ticket's own channel. One of: `email`, `widget`, `whatsapp`, `voice`, `instagram`, `facebook`, `tiktok`, `youtube`, `contact_form`.                                      |
| `channel_identifier` | string  | —       | Unique inbox identifier when you have multiple channels of the same `channel` type. Only used together with `channel`.                                                                                                  |
| `from_agent`         | string  | —       | Email of the teammate this message is sent from. Must match an existing agent in your organization, or the request fails. When omitted, the message is still recorded as sent by your business, without a named sender. |
| `attachment_ids`     | UUID\[] | —       | IDs of attachments confirmed via the [Attachments](#attachments) flow below (max **10**).                                                                                                                               |
| `skip_email`         | boolean | `false` | Set to `true` to record the message without sending an outbound notification — for example when backfilling messages already delivered elsewhere.                                                                       |
| `metadata`           | object  | —       | Key/value pairs for external references (max 50 keys; string values max 2,000 characters).                                                                                                                              |

<Tip>
  Leave `channel` unset for the common case — the message is sent on whichever channel the ticket already uses.
</Tip>

## Idempotency (optional)

Add an `Idempotency-Key` header with any unique value you generate per real send attempt (for example a UUID) to safely retry a request — for example after a timeout — without sending the same message twice.

* **First request with a key:** processed normally. The response is cached for **1 hour**.
* **Same key, same body, within 1 hour:** no new message is sent. You get back the exact same response as the first request.
* **Same key, different body:** rejected with a `400 IDEMPOTENCY_KEY_CONFLICT` error — reuse a key only for retries of the identical request.
* **No key:** works exactly as described above, with no duplicate protection.

```bash theme={null}
curl -sS -X POST \
  -H "cx-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 3f29f...unique-per-attempt" \
  "https://api.armin.cx/public/v1/tickets/1042/messages" \
  -d '{
    "type": "reply",
    "body": "Thanks for reaching out! Your order ships tomorrow."
  }'
```

## Attachments

Attach a file in three calls: request an upload URL, upload the file, then confirm it before referencing it here. See [Create a ticket → Attachments](/api/create-ticket#attachments) for the full flow and limits.

```bash theme={null}
curl -sS -X POST \
  -H "cx-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  "https://api.armin.cx/public/v1/tickets/1042/messages" \
  -d '{
    "type": "reply",
    "body": "Here is your invoice.",
    "attachment_ids": ["ATTACHMENT_ID"]
  }'
```

## Minimal example

Reply on the ticket's own channel:

```bash theme={null}
curl -sS -X POST \
  -H "cx-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  "https://api.armin.cx/public/v1/tickets/1042/messages" \
  -d '{
    "type": "reply",
    "body": "Thanks for reaching out! Your order ships tomorrow."
  }'
```

## Full example

Reply sent from a named agent, with metadata:

```bash theme={null}
curl -sS -X POST \
  -H "cx-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  "https://api.armin.cx/public/v1/tickets/1042/messages" \
  -d '{
    "type": "reply",
    "body": "Thanks for reaching out! Your order ships tomorrow.",
    "html_body": "<p>Thanks for reaching out! Your order ships tomorrow.</p>",
    "from_agent": "support@acme.com",
    "metadata": {
      "source": "order-automation"
    }
  }'
```

Internal note, never sent to the customer:

```bash theme={null}
curl -sS -X POST \
  -H "cx-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  "https://api.armin.cx/public/v1/tickets/1042/messages" \
  -d '{
    "type": "note",
    "body": "Escalated to logistics — awaiting carrier update."
  }'
```

## Response

**`201 Created`** — standard v1 success envelope:

```json theme={null}
{
  "success": true,
  "data": {
    "id": "019b0000-0000-7000-8000-000000000020",
    "ticket_id": "019b0000-0000-7000-8000-000000000001",
    "ticket_number": 1042,
    "role": "agent",
    "direction": "outbound",
    "text": "Thanks for reaching out! Your order ships tomorrow.",
    "channel": "email",
    "author_name": "Acme Support",
    "status": "queued",
    "created_at": "2026-08-25T10:00:00.000Z",
    "attachments": []
  }
}
```

`status` reflects delivery state (`queued`, `sent`, `delivered`, `failed`, …). A `reply` is born `queued` and picked up by the existing send pipeline; a `note` is `delivered` immediately since it is never sent out.

## Errors

| Situation                                                                                 | HTTP       | Code                                       |
| ----------------------------------------------------------------------------------------- | ---------- | ------------------------------------------ |
| Missing or invalid API key                                                                | 401        | `API_KEY_REQUIRED`, `INVALID_API_KEY`      |
| Key lacks `tickets:write`                                                                 | 403        | `FORBIDDEN_SCOPE`                          |
| Unknown `ticket_number` in this organization                                              | 404        | `TICKET_NOT_FOUND`                         |
| Invalid body, unknown field, missing `body`/`html_body`, or `from_agent` not a real agent | 400        | `VALIDATION_ERROR`                         |
| No connected channel for `channel` / `channel_identifier`                                 | 400        | `VALIDATION_ERROR`                         |
| Attachment not found, unconfirmed, or already used on another message                     | 404 or 400 | `ATTACHMENT_NOT_FOUND`, `VALIDATION_ERROR` |
| Rate limit exceeded                                                                       | 429        | `RATE_LIMIT_EXCEEDED`                      |
| Same `Idempotency-Key` reused with a different request body                               | 400        | `IDEMPOTENCY_KEY_CONFLICT`                 |
| A previous request with this `Idempotency-Key` is still processing                        | 409        | `IDEMPOTENCY_KEY_IN_PROGRESS`              |

See [Errors](/api/errors) for the full catalog and `request_id` usage.

<Note>
  Every successful request creates a **new** message. Use the optional [`Idempotency-Key` header](#idempotency-optional) to safely retry without duplicating it.
</Note>

## Try it first

1. In [Settings → API](https://armin.cx/app/_/settings/api), create a key with **`tickets:write`**.
2. Create a test ticket (see [Create a ticket](/api/create-ticket)), then send a message to it using the examples above.
3. Confirm the message appears in the ticket thread with the expected channel and sender.

Revoke the test key when you are finished.

## Related

<CardGroup cols={2}>
  <Card title="OpenAPI reference" icon="book" href="https://api.armin.cx/docs/v1">
    Interactive schema and Try it for `POST /tickets/:ticketNumber/messages`.
  </Card>

  <Card title="Create a ticket" icon="ticket" href="/api/create-ticket">
    Start a ticket, including its first message.
  </Card>

  <Card title="Authentication" icon="key" href="/api/authentication">
    Scopes, key rotation, and least privilege.
  </Card>

  <Card title="Rate limits" icon="gauge" href="/api/rate-limits">
    Write tier (30/min) and backoff on 429.
  </Card>
</CardGroup>
