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.
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.
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 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 has the details.
Steps
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.
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}`);
}
}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.
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 }
}A 202 response means Intray queued the email. It does not mean the email
was delivered. Check whether an email was delivered
covers the delivery states.
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.
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.
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.
body: JSON.stringify({
expected_version: conversation.version,
assignee_id: account.intrayUserId,
}),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.
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;
}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 explains how Intray matches a retry to the first attempt.