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.

PermissionNeeded for
conversations:readReading the conversation to get its version
messages:sendSending the reply
conversations:writeChanging 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.

VariableValue
INTRAY_API_URLThe API base URL shown in Settings, Developers. It ends in /v1
INTRAY_API_KEYThe 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

1

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.

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}`);
  }
}
2

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.

3

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.

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.

4

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.

TypeScript
body: JSON.stringify({
  expected_version: conversation.version,
  assignee_id: account.intrayUserId,
}),
5

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.

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;
}

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 codeWhat it meansWhat to do
400 idempotency_key_requiredThe write has no Idempotency-Key headerAdd the header, built from your event ID
403 forbiddenThe key lacks a permission the call needsCheck the key's permissions. todo and done in triage_after_send need conversations:write
403 sending_pausedSending is paused for the workspace or the addressAsk your Intray contact. Status and assignee calls are not affected
404 not_foundThe conversation does not exist, or it is on an address the key may not useCheck the stored ID and the key's addresses
409 version_conflictThe conversation changed after you read itRead it again and decide again, as in the last step
409 conversation_spamThe conversation is in Spam, so Intray refuses to sendSomeone on the team moves it out of Spam first
409 idempotency_conflictThe same key was used with a different payloadUse one key per event and keep the payload stable across retries
422 invalid_requestA field is missing or unknown. The message names the fieldFix the request. Unknown fields are rejected
422 INVALID_ARGUMENTassignee_id is not a current member of the workspaceRead the ID again from a conversation assigned to that person
429Request or sending capacity is used up. The response has Retry-After: 60Retry later with the same key and the same payload
503 sending_not_configuredSending is not turned on for the workspaceAsk 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.

Next