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.
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 needsmessages: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.
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 explains how to get the last value.
Link the record
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.
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.
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.
// 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;
}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.
// 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,
};
}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.
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.
// 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 has the full shape.
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.
Check that it works
- Create a test record in your app and confirm that
intray_conversation_idis filled in afterwards. - Open the conversation in Intray and confirm that it belongs to the right address and customer.
- Open the record in your admin and press "Open in Intray" while signed in to Intray. The inbox opens on that conversation.
- 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 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 shows how to read the delivery status of an email your app sent.