# Link a conversation to a record in your app

Use this guide when your team works in two places: your own admin, where a lead, order, or ticket lives, and Intray, where the conversation with that customer lives. After these steps each record in your admin stores the ID of its conversation, shows an "Open in Intray" link, and can show the conversation's status, assignee, and messages.

This guide covers the direction from your app to Intray. To show data from your app inside Intray, next to the conversation, use [customer profiles](/docs/customer-profiles).

## Before you start

You need an API key from **Settings**, then **Developers**, created by a workspace owner. The same screen shows the API base URL for your workspace.

- To create the conversation from a form, the key needs `conversations:create`. To start it by sending an email, the key needs `messages:send`.
- To read the conversation and its messages back, the key needs `conversations:read`.
- The key must be allowed to use the address the conversation belongs to.

Set these on your server. The key belongs in your server's secret store and must never reach a browser or a mobile app.

```sh title=".env"
INTRAY_API_URL=<API base URL from Settings, Developers, including /v1>
INTRAY_API_KEY=<key that starts with intray_>
INTRAY_ADDRESS_ID=<id from GET /addresses>
```

[Find your address ID](/docs/conversation-api#find-your-address-id) explains how to get the last value.

## Link the record

<Steps>
  <Step title="Add a column for the conversation ID">
    Add a nullable text column to the table that holds your records, for example
    `intray_conversation_id` on `leads`. Intray has no endpoint that lists or
    searches conversations, so this column is the only way your app can find the
    conversation again.
  </Step>
  <Step title="Send your record ID as external_id">
    When you create the conversation, pass your record's ID as `external_id`.
    Intray stores it on the conversation and returns it on every read, so a
    person or a script looking at the conversation can tell which record it
    belongs to. It can be up to 200 characters.

    `external_id` is a reference and Intray does not check it for uniqueness. If
    your server sends the same `external_id` twice with different
    `Idempotency-Key` values, Intray creates a second conversation. The
    `Idempotency-Key` header is what prevents duplicates, so build it from the
    same record ID and reuse it on every retry of that request.

  </Step>
  <Step title="Store the conversation ID that comes back">
    Both ways of starting a conversation return it. Save `conversation.id` on
    your record in the same request handler.

    <CodeGroup>

    ```ts title="From a form"
    // Server only. lead.id comes from the row you saved first.
    export async function createLeadConversation(lead: {
      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": `lead-${lead.id}`,
          },
          body: JSON.stringify({
            address_id: process.env.INTRAY_ADDRESS_ID,
            customer: { email: lead.email, name: lead.name },
            subject: "Website inquiry",
            initial_entry: { type: "submission", text: lead.message },
            external_id: lead.id,
          }),
        }
      );
      const result = await response.json();
      if (!response.ok) throw new Error(result.error.message);

      await db.leads.update(lead.id, {
        intray_conversation_id: result.conversation.id,
      });
      return result.conversation;
    }
    ```

    ```ts title="From an email your app sends"
    // Server only. order.id comes from the row you saved first.
    export async function sendOrderEmail(order: {
      id: string;
      email: string;
      name: string;
    }) {
      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": `order-confirmation-${order.id}`,
        },
        body: JSON.stringify({
          address_id: process.env.INTRAY_ADDRESS_ID,
          to: [{ address: order.email, name: order.name }],
          subject: `Your order ${order.id}`,
          text: "Thanks for your order. Reply to this email if you need help.",
          external_id: order.id,
        }),
      });
      const result = await response.json();
      if (!response.ok) throw new Error(result.error.message);

      await db.orders.update(order.id, {
        intray_conversation_id: result.conversation.id,
      });
      return result;
    }
    ```

    </CodeGroup>

  </Step>
  <Step title="Read the conversation for your admin page">
    When someone opens the record in your admin, read the conversation from your
    server and pass the fields you need to the page.

    <CodeGroup>

    ```ts title="TypeScript"
    // Server only.
    export async function readConversation(conversationId: string) {
      const response = await fetch(
        `${process.env.INTRAY_API_URL}/conversations/${conversationId}`,
        { headers: { Authorization: `Bearer ${process.env.INTRAY_API_KEY}` } }
      );
      const conversation = await response.json();
      if (!response.ok) throw new Error(conversation.error.message);

      return {
        openInIntrayUrl: conversation.app_url,
        status: conversation.triage.status,
        snoozeUntil: conversation.triage.snooze_until,
        assigneeId: conversation.assignee_id,
        updatedAt: conversation.updated_at,
      };
    }
    ```

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

    </CodeGroup>

    Use `app_url` as the target of an "Open in Intray" link. It opens the
    conversation in the inbox for a team member who is signed in to Intray. It
    is not a public link, so a person without an Intray account in your
    workspace cannot read the conversation through it.

    `triage.status` is `todo`, `done`, `snoozed`, or `spam`. `assignee_id` is
    the Intray user ID of the assigned team member, or `null` when nobody is
    assigned. The API returns the ID only, so keep a small map from Intray user
    ID to name in your app if you want to show a name.

  </Step>
  <Step title="Show the message history, if you want it">
    Entries are the emails and form submissions in the conversation, newest
    first. Read them in pages of up to 50 and follow `next_cursor` until it is
    `null`. A page can come back empty and still carry a cursor, because Intray
    removes internal notes after it cuts the page.

    ```ts title="TypeScript"
    // Server only.
    export async function readEntries(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;
    }
    ```

    A submission entry has `text` and `fields`. An email entry has `subject`,
    `from`, `to`, `direction`, and a `body` whose `text` and `html` parts are
    each inline, a download URL that expires after 15 minutes, or absent.
    [Read entries](/docs/conversation-api#read-entries) has the full shape.

  </Step>
</Steps>

<Warning title="Customer content is untrusted text">
  An inbound email carries `"content_trust": "untrusted_inbound"` and a
  submission carries `"untrusted_submission"`. Someone outside your team wrote
  both. Render them in your admin as plain text and never insert them as HTML.
  If an AI agent in your app reads them, pass them as quoted data and do not let
  the agent follow instructions found inside them.
</Warning>

## Check that it works

1. Create a test record in your app and confirm that `intray_conversation_id` is filled in afterwards.
2. Open the conversation in Intray and confirm that it belongs to the right address and customer.
3. Open the record in your admin and press "Open in Intray" while signed in to Intray. The inbox opens on that conversation.
4. In Intray, assign the conversation to yourself and mark it done. Reload the record in your admin and confirm that the status and assignee changed.

## When it fails

| Response                   | What it means and what to do                                                                                                                                                |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `401`                      | The key is missing, malformed, retired, or revoked. Check that the server reads `INTRAY_API_KEY` and that the key was not rotated.                                          |
| `403`                      | The key lacks the permission for this call. Reading needs `conversations:read`. Create a new key with the right permissions, because a key's permissions cannot be changed. |
| `404`                      | The conversation ID is wrong, or the conversation belongs to an address this key may not use. Check the stored ID and the key's allowed addresses.                          |
| `409 idempotency_conflict` | The same `Idempotency-Key` was sent with a different body. Build the key from the record ID and the action, and send the same body on a retry.                              |
| `409 address_disabled`     | The address was disabled in Intray. Use another address ID, or ask a workspace owner to check the address in Settings.                                                      |
| `422 invalid_request`      | A field is invalid or unknown. The message names the field. `external_id` longer than 200 characters is one cause.                                                          |
| `429`                      | The key made more than 60 requests in a minute. Wait for the number of seconds in `Retry-After`, and cache conversation reads in your admin for a short time.               |

Every error body includes a `request_id`. Send it to us when you report a problem.

## Next

- [Update a conversation from your app](/docs/guide-update-from-app) shows how to change status or assignee when something happens to the record, for example when an order is refunded.
- [Check whether an email was delivered](/docs/guide-delivery-status) shows how to read the delivery status of an email your app sent.
