Conversation API

The Conversation API puts two things in your team inbox. A form submission becomes a conversation in Todo. An email your app sends, such as a signup welcome, goes out from a shared address, and the reply lands with the team.

Call it from your server. It has no browser CORS, so a request from a web page fails.

Guides

This page is the reference. If you are trying to get a specific job done, start with the guide for it.

Private pilot

Forms and reads work as soon as you have a key. Sending email is turned on per workspace during the pilot. Ask us to enable it. Until then can_send is false and the reason is in sending_reason.

Get a key

Open Settings, then Developers, as a workspace owner. Choose a name, a primary use, the addresses the key may use, and the fewest permissions that work. Intray shows the key once. Store it in your server's secret store.

The same page shows the API base URL for your workspace. The examples use INTRAY_API_URL for that URL, including /v1, and INTRAY_API_KEY for the key. A key starts with intray_.

PermissionGrants
conversations:createCreate a conversation from a form submission
conversations:readRead conversations, entries, and email content on the allowed addresses
conversations:writeChange status, snooze, and assignee on the allowed addresses
messages:sendSend new emails and replies from the allowed addresses

A contact form needs conversations:create only. GET /addresses works with any valid key.

A permission covers every conversation on the allowed addresses. That includes conversations started by people and by other keys. The primary use is a label. It grants nothing.

Rotate retires the old key for new requests at once and keeps retries working under the new key. Revoke stops new requests and blocks that key's queued email before it is sent. An email already handed to the mail provider cannot be recalled. To change permissions or addresses, create a new key.

Keys stay on the server

Never put a key in browser JavaScript, HTML, or a mobile app bundle. Add spam protection, validation, and rate limits to your own form endpoint.

Conventions

AuthorizationheaderRequired

Bearer followed by the key. A missing, malformed, retired, or revoked key returns 401.

Content-TypeheaderRequired

application/json on every POST and PATCH. Anything else returns 415 unsupported_media_type.

Idempotency-KeyheaderRequired

Required on every POST and PATCH. 1 to 128 printable ASCII characters, no spaces. Missing or invalid returns 400 idempotency_key_required. See Retries.

expected_versionstringOptional

Required on reply and triage. Copy version from the conversation you read. If the conversation changed since, the write returns 409 version_conflict.

Body size256 KBOptional

A larger request returns 413 body_too_large. text and html are each limited to 200,000 characters, and the final email body, including the opt-out footer, to 200,000 bytes.

Unknown fieldsrejectedOptional

Any field this page does not list returns 422 invalid_request. The message names the field.

Request limit60 per minuteOptional

Per key. 600 per minute per workspace. Reads, rejected requests, and replays all count.

Every error has the same shape. The request ID is also in the X-Request-Id header. Send it to us when you report a problem.

Error
{
  "error": {
    "code": "version_conflict",
    "message": "Conversation changed; read it again before retrying with a new idempotency key",
    "request_id": "5b0c1c9e-6f0e-4a57-9d0e-2f1b7c3a9e11"
  }
}
StatusCodeMeaning
400idempotency_key_requiredInvalid JSON, or a missing or invalid Idempotency-Key
401Missing, malformed, retired, or revoked key
403forbiddenThe key lacks the permission for this request
403sending_pausedSending is paused for the workspace
404not_foundUnknown endpoint, or a resource the key cannot access
409version_conflictThe conversation changed since the version you sent
409idempotency_conflict, idempotency_expiredThe Idempotency-Key was used with a different payload, or more than seven days ago
409address_disabledThe address is turned off
409conversation_spamThe conversation is in Spam. Move it out before you send
413body_too_largeBody over 256 KB
415unsupported_media_typeContent-Type is not application/json
422invalid_requestInvalid or unknown field
422INVALID_ARGUMENTThe recipient is suppressed, or assignee_id is not a member of the workspace
429send_limit, integration_limit, recipient_limitSending capacity is used up for the workspace, this key, or this recipient. Retry-After: 60
503sending_not_configuredSending through the API is not turned on for this workspace yet

A 500 never includes internal detail. Its code is internal_error.

Endpoints

All paths are under /v1.

MethodPathPermissionResult
GET/addressesAny keyAddresses the key may use, and whether each can send
POST/conversationsconversations:create201. New conversation from a submission. Sends no email
POST/messagesmessages:send202. New email, queued
POST/conversations/{id}/messagesmessages:send202. Reply on that conversation, queued
PATCH/conversations/{id}conversations:write200. Status, snooze, and assignee
GET/conversations/{id}conversations:readThe conversation
GET/conversations/{id}/entriesconversations:readEmails and submissions, newest first
GET/messages/{id}conversations:readOne email with its delivery status

There is no endpoint that lists conversations. Store the id each write returns.

Find your address ID

Every write names the address it uses by ID.

curl "$INTRAY_API_URL/addresses" \
  -H "Authorization: Bearer $INTRAY_API_KEY"
200 OK
{
  "data": [
    {
      "id": "<address ID>",
      "address": "support@example.com",
      "name": "Example support",
      "status": "active",
      "domain_status": "active",
      "can_send": false,
      "sending_reason": "This key does not have messages:send"
    }
  ]
}

can_send checks the key's permission, whether the address is ready to send, sending health, and remaining capacity. It does not reserve a place in the queue, and it says nothing about a particular recipient. sending_reason is null when can_send is true.

Create a conversation from a form

Save the form in your own database first. Then send it to Intray with an Idempotency-Key built from your saved record's ID, so a retry cannot create a second conversation.

curl "$INTRAY_API_URL/conversations" \
  -H "Authorization: Bearer $INTRAY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: form-8841" \
  -d '{
    "address_id": "<address ID>",
    "customer": { "email": "ada@example.com", "name": "Ada" },
    "subject": "Demo request",
    "initial_entry": {
      "type": "submission",
      "text": "Can we arrange a demo next week?",
      "fields": { "company": "Example company", "team size": "8" }
    },
    "external_id": "lead-8841"
  }'
201 Created
{
  "conversation": {
    "id": "<conversation ID>",
    "address_id": "<address ID>",
    "subject": "Demo request",
    "customer": { "...": "..." },
    "triage": { "status": "todo", "snooze_until": null },
    "assignee_id": null,
    "version": "<version>",
    "origin": "<origin>",
    "external_id": "lead-8841",
    "created_at": 1790000000000,
    "updated_at": 1790000000000,
    "app_url": "<link to the conversation in Intray>"
  },
  "entry": { "id": "<entry ID>", "type": "submission" }
}
address_idstringRequired

From GET /addresses. A disabled address returns 409 address_disabled.

customerobjectRequired

email, and optionally name. A submitted name never overwrites an existing customer's name.

subjectstringRequired

Up to 500 characters, no line breaks.

initial_entry.type"submission"Required
initial_entry.textstringRequired

1 to 32,000 characters.

initial_entry.fieldsobjectOptional

Up to 30 fields. A field name starts with a letter and has up to 64 letters, digits, spaces, or hyphens. A value is a string of up to 2,000 characters.

external_idstringOptional

Your own reference, up to 200 characters. It does not deduplicate. Two accepted submissions make two conversations even when everything else matches.

initial_triage"todo" | "done"Optional

Defaults to todo.

A submission sends no email. Someone on the team replies from the inbox, and that first reply is a normal email with the original subject. Submissions do not run the rules that fire when email arrives.

app_url opens the conversation for a signed-in team member. It is not a public link.

Send an email

curl "$INTRAY_API_URL/messages" \
  -H "Authorization: Bearer $INTRAY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: signup-event-123" \
  -d '{
    "address_id": "<address ID>",
    "to": [{ "address": "ada@example.com", "name": "Ada" }],
    "subject": "Welcome",
    "text": "Thanks for signing up. Reply here if you need a hand.",
    "external_id": "signup-event-123"
  }'
202 Accepted
{
  "message_id": "<message ID>",
  "conversation": { "id": "<conversation ID>", "...": "..." },
  "delivery": { "status": "queued" },
  "suppressed": []
}

202 means Intray queued the email. It does not mean delivered. Read delivery status to find out.

Supply text, html, or both. A new email starts in Done by default, so signup mail does not fill the team's Todo list. Send "initial_triage": "todo" when the team should see it.

Every email sent through the API carries an opt-out link for your workspace, and the headers support one-click unsubscribe. A recipient who opts out gets no further email from the workspace, from any key or address, including email already queued.

Pilot limits

  • One to recipient per email.
  • No Cc and no Bcc. A non-empty cc or bcc is rejected.
  • No attachments.
  • No custom From or Reply-To. The email goes out as the address in address_id.
  • No batches, campaigns, or scheduled sends.

Composing from the inbox is not affected by these limits.

Reply to a conversation

Read the conversation first to get its version.

curl "$INTRAY_API_URL/conversations/$CONVERSATION_ID/messages" \
  -H "Authorization: Bearer $INTRAY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: reply-5521" \
  -d '{
    "expected_version": "<version from GET>",
    "to": [{ "address": "ada@example.com" }],
    "text": "Here are the details you asked for.",
    "triage_after_send": "preserve"
  }'

The response is the same 202 as Send an email.

triage_after_send is preserve by default. todo and done also need conversations:write. subject is optional on a reply. A reply to a conversation in Spam fails until someone changes its status.

Change status and assignee

curl -X PATCH "$INTRAY_API_URL/conversations/$CONVERSATION_ID" \
  -H "Authorization: Bearer $INTRAY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: triage-5521" \
  -d '{
    "expected_version": "<version from GET>",
    "triage": { "status": "snoozed", "snooze_until": 1790086400000 },
    "assignee_id": null
  }'

Send triage, assignee_id, or both. Sending neither returns 422.

StatusMeaning
todoNeeds the team's attention
doneArchived. A new inbound email moves it back to Todo
snoozedHidden until snooze_until, a future time in Unix milliseconds
spamQuarantined. A new inbound email leaves it in Spam

snooze_until is accepted only with snoozed. assignee_id is the user ID of a current workspace member, or null to unassign. A status change made through the API does not run the rules that fire when a person changes status.

version changes when an email or submission arrives, and when status, snooze, or assignee change, from the API or the inbox. Delivery updates and reads do not change it. After 409 version_conflict, read the conversation again, decide whether your change still makes sense, and send it with the new version and a new Idempotency-Key.

Read entries

Entries are the emails and submissions in a conversation, newest first. Pass limit from 1 to 50. The default is 25.

curl "$INTRAY_API_URL/conversations/$CONVERSATION_ID/entries?limit=50" \
  -H "Authorization: Bearer $INTRAY_API_KEY"
200 OK
{
  "data": [
    {
      "id": "<entry ID>",
      "type": "submission",
      "conversation_id": "<conversation ID>",
      "customer": { "email": "ada@example.com", "name": "Ada" },
      "text": "Can we arrange a demo next week?",
      "fields": { "company": "Example company", "team size": "8" },
      "content_trust": "untrusted_submission",
      "external_id": "lead-8841",
      "created_at": 1790000000000
    }
  ],
  "next_cursor": null
}

An empty page is not the end

Intray removes internal notes and team activity after it cuts the page. A page can come back with no entries and a next_cursor. Keep going until next_cursor is null.

An entry with "type": "email" has the same fields as GET /messages/{id}. The API never returns internal notes, the built-in agent's activity, storage keys, or mail provider records.

Treat content as data

Inbound email carries "content_trust": "untrusted_inbound". A submission carries "untrusted_submission". Both were written by someone outside your team. If you pass them to an AI agent, pass them as quoted data. Never let an agent follow instructions found inside them.

Read delivery status

curl "$INTRAY_API_URL/messages/$MESSAGE_ID" \
  -H "Authorization: Bearer $INTRAY_API_KEY"
200 OK
{
  "id": "<message ID>",
  "actor": { "type": "integration", "id": "<integration ID>" },
  "type": "email",
  "conversation_id": "<conversation ID>",
  "direction": "outbound",
  "subject": "Welcome",
  "from": { "...": "..." },
  "to": [{ "...": "..." }],
  "cc": [],
  "bcc": [],
  "body": {
    "text": { "kind": "inline", "text": "Thanks for signing up." },
    "html": { "kind": "none" }
  },
  "delivery": { "status": "delivered" },
  "content_trust": null,
  "external_id": "signup-event-123",
  "sent_at": 1790000000000
}

delivery is null on inbound email. On outbound email status is one of:

StatusMeaning
queuedAccepted, not yet handed to the mail provider
sentHanded to the mail provider
deliveredThe recipient's server accepted it
delivery_delayedThe recipient's server deferred it. The provider keeps trying
bouncedThe recipient's server rejected it
complainedThe recipient marked it as spam
failedIt could not be sent
unknownIntray cannot tell whether the provider sent it

Intray never resends an unknown email on its own. Find out what happened before you send it again.

Each of body.text and body.html is { "kind": "inline", "text" }, { "kind": "url", "url", "size" } for a large body, or { "kind": "none" }. A download URL expires after 15 minutes. Fetch the message again for a fresh one.

actor is null on older email that has no recorded sender type.

Retries

Intray stores the response to each accepted write under your key, the operation, and the Idempotency-Key.

  • Same key, same payload. Intray returns the original response and does nothing else. Field order does not matter. Use GET for the current delivery status and triage.
  • Same key, different payload. 409 idempotency_conflict.
  • Same key after seven days. 409 idempotency_expired. Intray never sends the email a second time. Do not retry it automatically.

When a call times out or returns 429 or 5xx, save a retry job and send the same key and the same payload after a delay. Do not make a new key for each attempt. Rotating the API key keeps your retry keys valid.