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.
Send your contact form to the inbox
A contact, demo, or waitlist form creates a conversation in Todo.
Send a welcome email and receive the replies
Your app sends from a shared address, and the customer's reply reaches the team.
Link a conversation to a record in your app
Store the conversation ID and add an Open in Intray link to your admin.
Reply to or close a conversation from your app
Reply in the thread, mark it done, or assign it when something happens in your product.
Check whether an email was delivered
Read the delivery status and handle bounces and opt-outs.
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_.
| Permission | Grants |
|---|---|
conversations:create | Create a conversation from a form submission |
conversations:read | Read conversations, entries, and email content on the allowed addresses |
conversations:write | Change status, snooze, and assignee on the allowed addresses |
messages:send | Send 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
AuthorizationheaderRequiredBearer followed by the key. A missing, malformed, retired, or revoked key
returns 401.
Content-TypeheaderRequiredapplication/json on every POST and PATCH. Anything else returns 415 unsupported_media_type.
Idempotency-KeyheaderRequiredRequired on every POST and PATCH. 1 to 128 printable ASCII characters,
no spaces. Missing or invalid returns 400 idempotency_key_required. See
Retries.
expected_versionstringOptionalRequired on reply and triage. Copy version from the conversation you read.
If the conversation changed since, the write returns 409 version_conflict.
Body size256 KBOptionalA 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 fieldsrejectedOptionalAny field this page does not list returns 422 invalid_request. The message
names the field.
Request limit60 per minuteOptionalPer 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": {
"code": "version_conflict",
"message": "Conversation changed; read it again before retrying with a new idempotency key",
"request_id": "5b0c1c9e-6f0e-4a57-9d0e-2f1b7c3a9e11"
}
}| Status | Code | Meaning |
|---|---|---|
400 | idempotency_key_required | Invalid JSON, or a missing or invalid Idempotency-Key |
401 | Missing, malformed, retired, or revoked key | |
403 | forbidden | The key lacks the permission for this request |
403 | sending_paused | Sending is paused for the workspace |
404 | not_found | Unknown endpoint, or a resource the key cannot access |
409 | version_conflict | The conversation changed since the version you sent |
409 | idempotency_conflict, idempotency_expired | The Idempotency-Key was used with a different payload, or more than seven days ago |
409 | address_disabled | The address is turned off |
409 | conversation_spam | The conversation is in Spam. Move it out before you send |
413 | body_too_large | Body over 256 KB |
415 | unsupported_media_type | Content-Type is not application/json |
422 | invalid_request | Invalid or unknown field |
422 | INVALID_ARGUMENT | The recipient is suppressed, or assignee_id is not a member of the workspace |
429 | send_limit, integration_limit, recipient_limit | Sending capacity is used up for the workspace, this key, or this recipient. Retry-After: 60 |
503 | sending_not_configured | Sending 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.
| Method | Path | Permission | Result |
|---|---|---|---|
GET | /addresses | Any key | Addresses the key may use, and whether each can send |
POST | /conversations | conversations:create | 201. New conversation from a submission. Sends no email |
POST | /messages | messages:send | 202. New email, queued |
POST | /conversations/{id}/messages | messages:send | 202. Reply on that conversation, queued |
PATCH | /conversations/{id} | conversations:write | 200. Status, snooze, and assignee |
GET | /conversations/{id} | conversations:read | The conversation |
GET | /conversations/{id}/entries | conversations:read | Emails and submissions, newest first |
GET | /messages/{id} | conversations:read | One 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"{
"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"
}'{
"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_idstringRequiredFrom GET /addresses. A disabled address returns 409 address_disabled.
customerobjectRequiredemail, and optionally name. A submitted name never overwrites an
existing customer's name.
subjectstringRequiredUp to 500 characters, no line breaks.
initial_entry.type"submission"Requiredinitial_entry.textstringRequired1 to 32,000 characters.
initial_entry.fieldsobjectOptionalUp 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_idstringOptionalYour 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"OptionalDefaults 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"
}'{
"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
torecipient per email. - No Cc and no Bcc. A non-empty
ccorbccis 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.
| Status | Meaning |
|---|---|
todo | Needs the team's attention |
done | Archived. A new inbound email moves it back to Todo |
snoozed | Hidden until snooze_until, a future time in Unix milliseconds |
spam | Quarantined. 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"{
"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"{
"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:
| Status | Meaning |
|---|---|
queued | Accepted, not yet handed to the mail provider |
sent | Handed to the mail provider |
delivered | The recipient's server accepted it |
delivery_delayed | The recipient's server deferred it. The provider keeps trying |
bounced | The recipient's server rejected it |
complained | The recipient marked it as spam |
failed | It could not be sent |
unknown | Intray 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
GETfor 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.