# Conversation API

The Conversation API puts two things in your team inbox. A form submission becomes a conversation in Todo. An email your app sends, such as a signup welcome, goes out from a shared address, and the reply lands with the team.

Call it from your server. It has no browser CORS, so a request from a web page fails.

## Guides

This page is the reference. If you are trying to get a specific job done, start with the guide for it.

<CardGroup cols={2}>
  <Card
    title="Send your contact form to the inbox"
    href="/docs/guide-contact-form"
  >
    A contact, demo, or waitlist form creates a conversation in Todo.
  </Card>
  <Card
    title="Send a welcome email and receive the replies"
    href="/docs/guide-welcome-email"
  >
    Your app sends from a shared address, and the customer's reply reaches the
    team.
  </Card>
  <Card
    title="Link a conversation to a record in your app"
    href="/docs/guide-link-records"
  >
    Store the conversation ID and add an Open in Intray link to your admin.
  </Card>
  <Card
    title="Reply to or close a conversation from your app"
    href="/docs/guide-update-from-app"
  >
    Reply in the thread, mark it done, or assign it when something happens in
    your product.
  </Card>
  <Card
    title="Check whether an email was delivered"
    href="/docs/guide-delivery-status"
  >
    Read the delivery status and handle bounces and opt-outs.
  </Card>
</CardGroup>

<Note title="Private pilot">
  Forms and reads work as soon as you have a key. Sending email is turned on per
  workspace during the pilot. Ask us to enable it. Until then `can_send` is
  false and the reason is in `sending_reason`.
</Note>

## Get a key

Open **Settings**, then **Developers**, as a workspace owner. Choose a name, a primary use, the addresses the key may use, and the fewest permissions that work. Intray shows the key once. Store it in your server's secret store.

The same page shows the API base URL for your workspace. The examples use `INTRAY_API_URL` for that URL, including `/v1`, and `INTRAY_API_KEY` for the key. A key starts with `intray_`.

| Permission             | Grants                                                                  |
| ---------------------- | ----------------------------------------------------------------------- |
| `conversations:create` | Create a conversation from a form submission                            |
| `conversations:read`   | Read conversations, entries, and email content on the allowed addresses |
| `conversations:write`  | Change status, snooze, and assignee on the allowed addresses            |
| `messages:send`        | Send new emails and replies from the allowed addresses                  |

A contact form needs `conversations:create` only. `GET /addresses` works with any valid key.

A permission covers every conversation on the allowed addresses. That includes conversations started by people and by other keys. The primary use is a label. It grants nothing.

**Rotate** retires the old key for new requests at once and keeps retries working under the new key. **Revoke** stops new requests and blocks that key's queued email before it is sent. An email already handed to the mail provider cannot be recalled. To change permissions or addresses, create a new key.

<Warning title="Keys stay on the server">
  Never put a key in browser JavaScript, HTML, or a mobile app bundle. Add spam
  protection, validation, and rate limits to your own form endpoint.
</Warning>

## Conventions

<Properties>
  <Property name="Authorization" type="header" required>
    `Bearer` followed by the key. A missing, malformed, retired, or revoked key
    returns `401`.
  </Property>
  <Property name="Content-Type" type="header" required>
    `application/json` on every `POST` and `PATCH`. Anything else returns `415
    unsupported_media_type`.
  </Property>
  <Property name="Idempotency-Key" type="header" required>
    Required on every `POST` and `PATCH`. 1 to 128 printable ASCII characters,
    no spaces. Missing or invalid returns `400 idempotency_key_required`. See
    [Retries](#retries).
  </Property>
  <Property name="expected_version" type="string">
    Required on reply and triage. Copy `version` from the conversation you read.
    If the conversation changed since, the write returns `409 version_conflict`.
  </Property>
  <Property name="Body size" type="256 KB">
    A larger request returns `413 body_too_large`. `text` and `html` are each
    limited to 200,000 characters, and the final email body, including the
    opt-out footer, to 200,000 bytes.
  </Property>
  <Property name="Unknown fields" type="rejected">
    Any field this page does not list returns `422 invalid_request`. The message
    names the field.
  </Property>
  <Property name="Request limit" type="60 per minute">
    Per key. 600 per minute per workspace. Reads, rejected requests, and replays
    all count.
  </Property>
</Properties>

Every error has the same shape. The request ID is also in the `X-Request-Id` header. Send it to us when you report a problem.

```json title="Error"
{
  "error": {
    "code": "version_conflict",
    "message": "Conversation changed; read it again before retrying with a new idempotency key",
    "request_id": "5b0c1c9e-6f0e-4a57-9d0e-2f1b7c3a9e11"
  }
}
```

| Status | Code                                                 | Meaning                                                                                       |
| ------ | ---------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| `400`  | `idempotency_key_required`                           | Invalid JSON, or a missing or invalid `Idempotency-Key`                                       |
| `401`  |                                                      | Missing, malformed, retired, or revoked key                                                   |
| `403`  | `forbidden`                                          | The key lacks the permission for this request                                                 |
| `403`  | `sending_paused`                                     | Sending is paused for the workspace                                                           |
| `404`  | `not_found`                                          | Unknown endpoint, or a resource the key cannot access                                         |
| `409`  | `version_conflict`                                   | The conversation changed since the `version` you sent                                         |
| `409`  | `idempotency_conflict`, `idempotency_expired`        | The `Idempotency-Key` was used with a different payload, or more than seven days ago          |
| `409`  | `address_disabled`                                   | The address is turned off                                                                     |
| `409`  | `conversation_spam`                                  | The conversation is in Spam. Move it out before you send                                      |
| `413`  | `body_too_large`                                     | Body over 256 KB                                                                              |
| `415`  | `unsupported_media_type`                             | `Content-Type` is not `application/json`                                                      |
| `422`  | `invalid_request`                                    | Invalid or unknown field                                                                      |
| `422`  | `INVALID_ARGUMENT`                                   | The recipient is suppressed, or `assignee_id` is not a member of the workspace                |
| `429`  | `send_limit`, `integration_limit`, `recipient_limit` | Sending capacity is used up for the workspace, this key, or this recipient. `Retry-After: 60` |
| `503`  | `sending_not_configured`                             | Sending through the API is not turned on for this workspace yet                               |

A `500` never includes internal detail. Its code is `internal_error`.

## Endpoints

All paths are under `/v1`.

| Method  | Path                           | Permission             | Result                                                    |
| ------- | ------------------------------ | ---------------------- | --------------------------------------------------------- |
| `GET`   | `/addresses`                   | Any key                | Addresses the key may use, and whether each can send      |
| `POST`  | `/conversations`               | `conversations:create` | `201`. New conversation from a submission. Sends no email |
| `POST`  | `/messages`                    | `messages:send`        | `202`. New email, queued                                  |
| `POST`  | `/conversations/{id}/messages` | `messages:send`        | `202`. Reply on that conversation, queued                 |
| `PATCH` | `/conversations/{id}`          | `conversations:write`  | `200`. Status, snooze, and assignee                       |
| `GET`   | `/conversations/{id}`          | `conversations:read`   | The conversation                                          |
| `GET`   | `/conversations/{id}/entries`  | `conversations:read`   | Emails and submissions, newest first                      |
| `GET`   | `/messages/{id}`               | `conversations:read`   | One email with its delivery status                        |

There is no endpoint that lists conversations. Store the `id` each write returns.

## Find your address ID

Every write names the address it uses by ID.

<CodeGroup>

```sh title="curl"
curl "$INTRAY_API_URL/addresses" \
  -H "Authorization: Bearer $INTRAY_API_KEY"
```

```ts title="TypeScript"
const response = await fetch(`${process.env.INTRAY_API_URL}/addresses`, {
  headers: { Authorization: `Bearer ${process.env.INTRAY_API_KEY}` },
});
const { data } = await response.json();
```

</CodeGroup>

```json title="200 OK"
{
  "data": [
    {
      "id": "<address ID>",
      "address": "support@example.com",
      "name": "Example support",
      "status": "active",
      "domain_status": "active",
      "can_send": false,
      "sending_reason": "This key does not have messages:send"
    }
  ]
}
```

`can_send` checks the key's permission, whether the address is ready to send, sending health, and remaining capacity. It does not reserve a place in the queue, and it says nothing about a particular recipient. `sending_reason` is `null` when `can_send` is true.

## Create a conversation from a form

Save the form in your own database first. Then send it to Intray with an `Idempotency-Key` built from your saved record's ID, so a retry cannot create a second conversation.

<CodeGroup>

```sh title="curl"
curl "$INTRAY_API_URL/conversations" \
  -H "Authorization: Bearer $INTRAY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: form-8841" \
  -d '{
    "address_id": "<address ID>",
    "customer": { "email": "ada@example.com", "name": "Ada" },
    "subject": "Demo request",
    "initial_entry": {
      "type": "submission",
      "text": "Can we arrange a demo next week?",
      "fields": { "company": "Example company", "team size": "8" }
    },
    "external_id": "lead-8841"
  }'
```

```ts title="TypeScript"
// Server only. submission.id comes from your saved form record.
export async function forwardSubmission(submission: {
  id: string;
  email: string;
  name: string;
  message: string;
}) {
  const response = await fetch(`${process.env.INTRAY_API_URL}/conversations`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.INTRAY_API_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": `form-${submission.id}`,
    },
    body: JSON.stringify({
      address_id: process.env.INTRAY_ADDRESS_ID,
      customer: { email: submission.email, name: submission.name },
      subject: "Website inquiry",
      initial_entry: { type: "submission", text: submission.message },
      external_id: submission.id,
    }),
  });
  const result = await response.json();
  if (!response.ok) throw new Error(result.error.message);
  // Save result.conversation.id on your form record.
  return result;
}
```

</CodeGroup>

```json title="201 Created"
{
  "conversation": {
    "id": "<conversation ID>",
    "address_id": "<address ID>",
    "subject": "Demo request",
    "customer": { "...": "..." },
    "triage": { "status": "todo", "snooze_until": null },
    "assignee_id": null,
    "version": "<version>",
    "origin": "<origin>",
    "external_id": "lead-8841",
    "created_at": 1790000000000,
    "updated_at": 1790000000000,
    "app_url": "<link to the conversation in Intray>"
  },
  "entry": { "id": "<entry ID>", "type": "submission" }
}
```

<Properties>
  <Property name="address_id" type="string" required>
    From `GET /addresses`. A disabled address returns `409 address_disabled`.
  </Property>
  <Property name="customer" type="object" required>
    `email`, and optionally `name`. A submitted name never overwrites an
    existing customer's name.
  </Property>
  <Property name="subject" type="string" required>
    Up to 500 characters, no line breaks.
  </Property>
  <Property name="initial_entry.type" type='"submission"' required />
  <Property name="initial_entry.text" type="string" required>
    1 to 32,000 characters.
  </Property>
  <Property name="initial_entry.fields" type="object">
    Up to 30 fields. A field name starts with a letter and has up to 64 letters,
    digits, spaces, or hyphens. A value is a string of up to 2,000 characters.
  </Property>
  <Property name="external_id" type="string">
    Your own reference, up to 200 characters. It does not deduplicate. Two
    accepted submissions make two conversations even when everything else
    matches.
  </Property>
  <Property name="initial_triage" type='"todo" | "done"'>
    Defaults to `todo`.
  </Property>
</Properties>

A submission sends no email. Someone on the team replies from the inbox, and that first reply is a normal email with the original subject. Submissions do not run the rules that fire when email arrives.

`app_url` opens the conversation for a signed-in team member. It is not a public link.

## Send an email

<CodeGroup>

```sh title="curl"
curl "$INTRAY_API_URL/messages" \
  -H "Authorization: Bearer $INTRAY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: signup-event-123" \
  -d '{
    "address_id": "<address ID>",
    "to": [{ "address": "ada@example.com", "name": "Ada" }],
    "subject": "Welcome",
    "text": "Thanks for signing up. Reply here if you need a hand.",
    "external_id": "signup-event-123"
  }'
```

```ts title="TypeScript"
const response = await fetch(`${process.env.INTRAY_API_URL}/messages`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.INTRAY_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": `signup-${event.id}`,
  },
  body: JSON.stringify({
    address_id: process.env.INTRAY_ADDRESS_ID,
    to: [{ address: user.email, name: user.name }],
    subject: "Welcome",
    text: "Thanks for signing up. Reply here if you need a hand.",
    external_id: event.id,
  }),
});
const result = await response.json();
if (!response.ok) throw new Error(result.error.message);
// Save result.message_id to check delivery later.
```

</CodeGroup>

```json title="202 Accepted"
{
  "message_id": "<message ID>",
  "conversation": { "id": "<conversation ID>", "...": "..." },
  "delivery": { "status": "queued" },
  "suppressed": []
}
```

`202` means Intray queued the email. It does not mean delivered. Read [delivery status](#read-delivery-status) to find out.

Supply `text`, `html`, or both. A new email starts in Done by default, so signup mail does not fill the team's Todo list. Send `"initial_triage": "todo"` when the team should see it.

Every email sent through the API carries an opt-out link for your workspace, and the headers support one-click unsubscribe. A recipient who opts out gets no further email from the workspace, from any key or address, including email already queued.

### Pilot limits

- One `to` recipient per email.
- No Cc and no Bcc. A non-empty `cc` or `bcc` is rejected.
- No attachments.
- No custom From or Reply-To. The email goes out as the address in `address_id`.
- No batches, campaigns, or scheduled sends.

Composing from the inbox is not affected by these limits.

## Reply to a conversation

Read the conversation first to get its `version`.

<CodeGroup>

```sh title="curl"
curl "$INTRAY_API_URL/conversations/$CONVERSATION_ID/messages" \
  -H "Authorization: Bearer $INTRAY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: reply-5521" \
  -d '{
    "expected_version": "<version from GET>",
    "to": [{ "address": "ada@example.com" }],
    "text": "Here are the details you asked for.",
    "triage_after_send": "preserve"
  }'
```

```ts title="TypeScript"
const base = process.env.INTRAY_API_URL;
const headers = { Authorization: `Bearer ${process.env.INTRAY_API_KEY}` };

const current = await fetch(`${base}/conversations/${conversationId}`, {
  headers,
}).then((r) => r.json());

const response = await fetch(
  `${base}/conversations/${conversationId}/messages`,
  {
    method: "POST",
    headers: {
      ...headers,
      "Content-Type": "application/json",
      "Idempotency-Key": `reply-${job.id}`,
    },
    body: JSON.stringify({
      expected_version: current.conversation.version,
      to: [{ address: "ada@example.com" }],
      text: "Here are the details you asked for.",
    }),
  }
);
```

</CodeGroup>

The response is the same `202` as [Send an email](#send-an-email).

`triage_after_send` is `preserve` by default. `todo` and `done` also need `conversations:write`. `subject` is optional on a reply. A reply to a conversation in Spam fails until someone changes its status.

## Change status and assignee

<CodeGroup>

```sh title="curl"
curl -X PATCH "$INTRAY_API_URL/conversations/$CONVERSATION_ID" \
  -H "Authorization: Bearer $INTRAY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: triage-5521" \
  -d '{
    "expected_version": "<version from GET>",
    "triage": { "status": "snoozed", "snooze_until": 1790086400000 },
    "assignee_id": null
  }'
```

```ts title="TypeScript"
const response = await fetch(
  `${process.env.INTRAY_API_URL}/conversations/${conversationId}`,
  {
    method: "PATCH",
    headers: {
      Authorization: `Bearer ${process.env.INTRAY_API_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": `triage-${job.id}`,
    },
    body: JSON.stringify({
      expected_version: version,
      triage: { status: "done" },
    }),
  }
);
const { conversation } = await response.json();
```

</CodeGroup>

Send `triage`, `assignee_id`, or both. Sending neither returns `422`.

| Status    | Meaning                                                         |
| --------- | --------------------------------------------------------------- |
| `todo`    | Needs the team's attention                                      |
| `done`    | Archived. A new inbound email moves it back to Todo             |
| `snoozed` | Hidden until `snooze_until`, a future time in Unix milliseconds |
| `spam`    | Quarantined. A new inbound email leaves it in Spam              |

`snooze_until` is accepted only with `snoozed`. `assignee_id` is the user ID of a current workspace member, or `null` to unassign. A status change made through the API does not run the rules that fire when a person changes status.

`version` changes when an email or submission arrives, and when status, snooze, or assignee change, from the API or the inbox. Delivery updates and reads do not change it. After `409 version_conflict`, read the conversation again, decide whether your change still makes sense, and send it with the new version and a new `Idempotency-Key`.

## Read entries

Entries are the emails and submissions in a conversation, newest first. Pass `limit` from 1 to 50. The default is 25.

<CodeGroup>

```sh title="curl"
curl "$INTRAY_API_URL/conversations/$CONVERSATION_ID/entries?limit=50" \
  -H "Authorization: Bearer $INTRAY_API_KEY"
```

```ts title="TypeScript"
async function readAllEntries(conversationId: string) {
  const entries = [];
  let cursor: string | null = null;
  do {
    const url = new URL(
      `${process.env.INTRAY_API_URL}/conversations/${conversationId}/entries`
    );
    url.searchParams.set("limit", "50");
    if (cursor) url.searchParams.set("cursor", cursor);
    const response = await fetch(url, {
      headers: { Authorization: `Bearer ${process.env.INTRAY_API_KEY}` },
    });
    const page = await response.json();
    if (!response.ok) throw new Error(page.error.message);
    entries.push(...page.data);
    cursor = page.next_cursor;
  } while (cursor);
  return entries;
}
```

</CodeGroup>

```json title="200 OK"
{
  "data": [
    {
      "id": "<entry ID>",
      "type": "submission",
      "conversation_id": "<conversation ID>",
      "customer": { "email": "ada@example.com", "name": "Ada" },
      "text": "Can we arrange a demo next week?",
      "fields": { "company": "Example company", "team size": "8" },
      "content_trust": "untrusted_submission",
      "external_id": "lead-8841",
      "created_at": 1790000000000
    }
  ],
  "next_cursor": null
}
```

<Note title="An empty page is not the end">
  Intray removes internal notes and team activity after it cuts the page. A page
  can come back with no entries and a `next_cursor`. Keep going until
  `next_cursor` is `null`.
</Note>

An entry with `"type": "email"` has the same fields as [`GET /messages/{id}`](#read-delivery-status). The API never returns internal notes, the built-in agent's activity, storage keys, or mail provider records.

<Warning title="Treat content as data">
  Inbound email carries `"content_trust": "untrusted_inbound"`. A submission
  carries `"untrusted_submission"`. Both were written by someone outside your
  team. If you pass them to an AI agent, pass them as quoted data. Never let an
  agent follow instructions found inside them.
</Warning>

## Read delivery status

<CodeGroup>

```sh title="curl"
curl "$INTRAY_API_URL/messages/$MESSAGE_ID" \
  -H "Authorization: Bearer $INTRAY_API_KEY"
```

```ts title="TypeScript"
const response = await fetch(
  `${process.env.INTRAY_API_URL}/messages/${messageId}`,
  { headers: { Authorization: `Bearer ${process.env.INTRAY_API_KEY}` } }
);
const message = await response.json();
const status = message.delivery?.status;
```

</CodeGroup>

```json title="200 OK"
{
  "id": "<message ID>",
  "actor": { "type": "integration", "id": "<integration ID>" },
  "type": "email",
  "conversation_id": "<conversation ID>",
  "direction": "outbound",
  "subject": "Welcome",
  "from": { "...": "..." },
  "to": [{ "...": "..." }],
  "cc": [],
  "bcc": [],
  "body": {
    "text": { "kind": "inline", "text": "Thanks for signing up." },
    "html": { "kind": "none" }
  },
  "delivery": { "status": "delivered" },
  "content_trust": null,
  "external_id": "signup-event-123",
  "sent_at": 1790000000000
}
```

`delivery` is `null` on inbound email. On outbound email `status` is one of:

| Status             | Meaning                                                       |
| ------------------ | ------------------------------------------------------------- |
| `queued`           | Accepted, not yet handed to the mail provider                 |
| `sent`             | Handed to the mail provider                                   |
| `delivered`        | The recipient's server accepted it                            |
| `delivery_delayed` | The recipient's server deferred it. The provider keeps trying |
| `bounced`          | The recipient's server rejected it                            |
| `complained`       | The recipient marked it as spam                               |
| `failed`           | It could not be sent                                          |
| `unknown`          | Intray cannot tell whether the provider sent it               |

Intray never resends an `unknown` email on its own. Find out what happened before you send it again.

Each of `body.text` and `body.html` is `{ "kind": "inline", "text" }`, `{ "kind": "url", "url", "size" }` for a large body, or `{ "kind": "none" }`. A download URL expires after 15 minutes. Fetch the message again for a fresh one.

`actor` is `null` on older email that has no recorded sender type.

## Retries

Intray stores the response to each accepted write under your key, the operation, and the `Idempotency-Key`.

- **Same key, same payload.** Intray returns the original response and does nothing else. Field order does not matter. Use `GET` for the current delivery status and triage.
- **Same key, different payload.** `409 idempotency_conflict`.
- **Same key after seven days.** `409 idempotency_expired`. Intray never sends the email a second time. Do not retry it automatically.

When a call times out or returns `429` or `5xx`, save a retry job and send the same key and the same payload after a delay. Do not make a new key for each attempt. Rotating the API key keeps your retry keys valid.
