# Reply to or close a conversation from your app

Use this guide when something happens in your product that belongs in a conversation your team already has with a customer. A refund is issued, an order ships, or a demo is booked, and your server replies in the same email thread, marks the conversation done, or assigns it to the person who owns the account.

The result is the same as if a teammate had done it in the inbox. The reply is a real email sent from the shared address, and the status and assignee show up for everyone on the team.

<Warning title="A person may be working the same thread">
  Your team reads and answers these conversations by hand. Keep automated
  replies rare, and word them so the customer can tell the message comes from
  your system, for example "This is an automatic update about your refund."
  Every write in this guide checks that the conversation has not changed since
  you read it, so your app does not reply over a teammate who answered a moment
  ago.
</Warning>

## Before you start

You need the ID of the conversation. Intray returns it when your server creates the conversation or sends the first email, so store it on your own record at that point. [Link a conversation to a record in your app](/docs/guide-link-records) shows how.

Create the key in Intray under **Settings**, then **Developers**. Choose the addresses the key may use and these permissions.

| Permission            | Needed for                                                      |
| --------------------- | --------------------------------------------------------------- |
| `conversations:read`  | Reading the conversation to get its `version`                   |
| `messages:send`       | Sending the reply                                               |
| `conversations:write` | Changing status or assignee, and `triage_after_send` on a reply |

Store two values in your server secrets. Keys live in server secrets only and never in browser code.

| Variable         | Value                                                            |
| ---------------- | ---------------------------------------------------------------- |
| `INTRAY_API_URL` | The API base URL shown in Settings, Developers. It ends in `/v1` |
| `INTRAY_API_KEY` | The key. It starts with `intray_` and Intray shows it once       |

Replies are email, so they follow the same pilot rule as other API email. Sending is turned on for each workspace during the pilot. Until it is on for yours, the reply call fails and the status and assignee calls still work. [Conversation API](/docs/conversation-api) has the details.

## Steps

<Steps>
  <Step title="Read the conversation to get its version">
    Every write needs `expected_version`. The version changes when an email or
    a form submission arrives, and when status, snooze, or assignee change, from
    the API or from the inbox. Reading the conversation right before you write
    gives you the current value.

    ```ts title="server/intray.ts"
    export const base = process.env.INTRAY_API_URL!;
    export const auth = { Authorization: `Bearer ${process.env.INTRAY_API_KEY}` };

    export async function readConversation(conversationId: string) {
      const response = await fetch(`${base}/conversations/${conversationId}`, {
        headers: auth,
      });
      const result = await response.json();
      if (!response.ok) throw new IntrayError(response.status, result.error);
      return result.conversation as {
        id: string;
        version: string;
        assignee_id: string | null;
        triage: { status: string; snooze_until: number | null };
      };
    }

    export class IntrayError extends Error {
      constructor(
        public status: number,
        public detail: { code: string; message: string; request_id: string }
      ) {
        super(`${detail.code}: ${detail.message}`);
      }
    }
    ```

  </Step>
  <Step title="Reply in the thread">
    Post the reply with the version you read. Build the `Idempotency-Key` from
    the event in your own system, such as the refund ID, so that a retry of the
    same event sends one email. Use the customer email address your app already
    has on file for `to`. The API accepts one recipient.

    `triage_after_send` is `preserve` by default, which leaves the status as it
    is. Set it to `done` when the reply closes the matter, or to `todo` when
    someone on the team should follow up.

    <CodeGroup>

    ```ts title="TypeScript"
    import { auth, base, IntrayError, readConversation } from "./intray";

    export async function replyAboutRefund(refund: {
      id: string;
      conversationId: string;
      customerEmail: string;
      amount: string;
    }) {
      const conversation = await readConversation(refund.conversationId);

      const response = await fetch(
        `${base}/conversations/${refund.conversationId}/messages`,
        {
          method: "POST",
          headers: {
            ...auth,
            "Content-Type": "application/json",
            "Idempotency-Key": `refund-reply-${refund.id}`,
          },
          body: JSON.stringify({
            expected_version: conversation.version,
            to: [{ address: refund.customerEmail }],
            text: `This is an automatic update. Your refund of ${refund.amount} was issued today. Reply to this email if anything looks wrong.`,
            triage_after_send: "done",
          }),
        }
      );
      const result = await response.json();
      if (!response.ok) throw new IntrayError(response.status, result.error);
      return result; // { message_id, conversation, delivery, suppressed }
    }
    ```

    ```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: refund-reply-8841" \
      -d '{
        "expected_version": "<version from GET>",
        "to": [{ "address": "ada@example.com" }],
        "text": "This is an automatic update. Your refund was issued today.",
        "triage_after_send": "done"
      }'
    ```

    </CodeGroup>

    A `202` response means Intray queued the email. It does not mean the email
    was delivered. [Check whether an email was delivered](/docs/guide-delivery-status)
    covers the delivery states.

  </Step>
  <Step title="Change status or assignee without sending email">
    Use `PATCH` when the event needs no message to the customer. Send `triage`,
    `assignee_id`, or both. A request with neither returns `422`.

    ```ts title="TypeScript"
    import { auth, base, IntrayError, readConversation } from "./intray";

    export async function closeAfterShipment(order: {
      id: string;
      conversationId: string;
    }) {
      const conversation = await readConversation(order.conversationId);

      const response = await fetch(
        `${base}/conversations/${order.conversationId}`,
        {
          method: "PATCH",
          headers: {
            ...auth,
            "Content-Type": "application/json",
            "Idempotency-Key": `order-shipped-${order.id}`,
          },
          body: JSON.stringify({
            expected_version: conversation.version,
            triage: { status: "done" },
          }),
        }
      );
      const result = await response.json();
      if (!response.ok) throw new IntrayError(response.status, result.error);
      return result.conversation;
    }
    ```

    `triage.status` accepts `todo`, `done`, `snoozed`, and `spam`. With
    `snoozed`, also send `snooze_until` as a future time in Unix milliseconds. A
    conversation marked `done` moves back to Todo when the customer writes again.
    A status change made through the API does not run the rules that fire when a
    person changes status in the inbox.

  </Step>
  <Step title="Assign the conversation to a teammate">
    `assignee_id` is the Intray user ID of a current workspace member, or `null`
    to unassign. The API has no endpoint that lists members. To get an ID,
    assign any conversation to that person in the inbox, read the conversation
    with `GET`, and copy the `assignee_id` from the response. Store the ID next
    to the account owner in your own system.

    ```ts title="TypeScript"
    body: JSON.stringify({
      expected_version: conversation.version,
      assignee_id: account.intrayUserId,
    }),
    ```

  </Step>
  <Step title="Handle a version conflict">
    `409 version_conflict` means the conversation changed after you read it.
    Usually the customer wrote again or a teammate replied, changed the status,
    or took the thread. Read the conversation again and decide whether your
    change still makes sense. If a teammate has replied in the meantime, the
    automated reply is often no longer needed. When you do send it again, use
    the new version and a new `Idempotency-Key`, because the payload is
    different from the first attempt.

    ```ts title="TypeScript"
    try {
      await replyAboutRefund(refund);
    } catch (error) {
      if (error instanceof IntrayError && error.detail.code === "version_conflict") {
        // Someone acted on the thread. Hand the decision to a person.
        await flagForReview(refund.id, "Conversation changed before the refund reply was sent");
        return;
      }
      throw error;
    }
    ```

  </Step>
</Steps>

## Check that it works

Run the reply once against a test conversation that uses your own email address as the customer. The email arrives from the shared address, in the same thread as the earlier messages. In the Intray inbox the conversation shows the reply in its timeline, and the status matches the `triage_after_send` you sent.

Then run the same event a second time with the same `Idempotency-Key` and the same payload. Intray returns the original response and sends no second email.

## When it fails

Every error has the shape `{ "error": { "code", "message", "request_id" } }`. Include the `request_id` when you ask Intray about a failed call.

| Status and code                | What it means                                                               | What to do                                                                                       |
| ------------------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `400 idempotency_key_required` | The write has no `Idempotency-Key` header                                   | Add the header, built from your event ID                                                         |
| `403 forbidden`                | The key lacks a permission the call needs                                   | Check the key's permissions. `todo` and `done` in `triage_after_send` need `conversations:write` |
| `403 sending_paused`           | Sending is paused for the workspace or the address                          | Ask your Intray contact. Status and assignee calls are not affected                              |
| `404 not_found`                | The conversation does not exist, or it is on an address the key may not use | Check the stored ID and the key's addresses                                                      |
| `409 version_conflict`         | The conversation changed after you read it                                  | Read it again and decide again, as in the last step                                              |
| `409 conversation_spam`        | The conversation is in Spam, so Intray refuses to send                      | Someone on the team moves it out of Spam first                                                   |
| `409 idempotency_conflict`     | The same key was used with a different payload                              | Use one key per event and keep the payload stable across retries                                 |
| `422 invalid_request`          | A field is missing or unknown. The message names the field                  | Fix the request. Unknown fields are rejected                                                     |
| `422 INVALID_ARGUMENT`         | `assignee_id` is not a current member of the workspace                      | Read the ID again from a conversation assigned to that person                                    |
| `429`                          | Request or sending capacity is used up. The response has `Retry-After: 60`  | Retry later with the same key and the same payload                                               |
| `503 sending_not_configured`   | Sending is not turned on for the workspace                                  | Ask your Intray contact to enable sending. Status and assignee calls are not affected            |

When a call times out or returns `429` or a `5xx` status, save a retry job and send the same key and payload again after a delay. [Retries](/docs/conversation-api#retries) explains how Intray matches a retry to the first attempt.

## Next

- [Check whether an email was delivered](/docs/guide-delivery-status)
- [Link a conversation to a record in your app](/docs/guide-link-records)
