Send your contact form to the inbox
Use this guide when your website has a contact, demo, or waitlist form and the team should answer those submissions from Intray. Each submission becomes a conversation in Todo on the address you choose, for example info. The conversation shows the person's message and any extra fields you send, such as company and team size, and the team replies to the person by email from the inbox.
Your server makes the request. Intray has no browser CORS and no anonymous endpoint, so a request from a web page fails.
Before you start
- A key with the
conversations:createpermission, limited to the address that should receive the submissions. A workspace owner creates it under Settings, then Developers. Intray shows the key once. - The API base URL from the same screen. It already ends in
/v1. - The ID of the address. You read it from
GET /addressesin the first step below.
Store all three as server secrets. The key must never appear in browser JavaScript, HTML, or a mobile app bundle.
INTRAY_API_URL=https://<your Intray API base>/v1
INTRAY_API_KEY=intray_...
INTRAY_ADDRESS_ID=<address ID>Set it up
Find the address ID
Every write names its address by ID. List the addresses your key may use and copy the id of the one that should receive form submissions into INTRAY_ADDRESS_ID.
curl "$INTRAY_API_URL/addresses" \
-H "Authorization: Bearer $INTRAY_API_KEY"The response also has can_send and sending_reason. They describe sending email through the API, which this guide does not use, so can_send: false does not block a form.
Save the submission in your own database first
Validate the form on your server and write it to your own database before you call Intray. The saved record gives you a stable ID, and that ID becomes the Idempotency-Key. When the same key and the same payload arrive twice, Intray returns the first response and creates nothing new, so a retry after a timeout cannot produce a second conversation.
Saving first also means a submission is never lost while Intray is unreachable. Your retry job sends it later.
Send the saved record to Intray
Build the payload only from the saved record, so every retry sends the same body. If a retry sends a different body under the same key, Intray answers 409 idempotency_conflict.
// Server only.
export type SavedSubmission = {
id: string; // Primary key of the record in your own database.
email: string;
name?: string;
message: string;
company?: string;
teamSize?: string;
};
export type ForwardResult =
| { ok: true; conversationId: string; appUrl: string }
| { ok: false; retry: boolean; code: string; requestId?: string };
export async function forwardSubmission(
submission: SavedSubmission
): Promise<ForwardResult> {
// Leave out empty values. Intray rejects a field that is not a string.
const fields: Record<string, string> = {};
if (submission.company) fields["company"] = submission.company;
if (submission.teamSize) fields["team size"] = submission.teamSize;
let response: Response;
try {
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": `form-${submission.id}`,
},
body: JSON.stringify({
address_id: process.env.INTRAY_ADDRESS_ID,
customer: { email: submission.email, name: submission.name },
subject: "Website inquiry",
initial_entry: {
type: "submission",
text: submission.message,
fields,
},
external_id: submission.id,
}),
signal: AbortSignal.timeout(10_000),
});
} catch {
// Timeout or network failure. Intray may or may not have the request.
return { ok: false, retry: true, code: "network_error" };
}
const body = await response.json().catch(() => null);
if (response.ok) {
return {
ok: true,
conversationId: body.conversation.id,
appUrl: body.conversation.app_url,
};
}
return {
ok: false,
retry: response.status === 429 || response.status >= 500,
code: body?.error?.code ?? "unknown",
requestId: body?.error?.request_id,
};
}A successful call returns 201 with the new conversation and the ID of its submission entry. The Conversation API reference lists every request field and the full response.
Store the conversation ID on your record
Save conversation.id next to the submission in your database. The API has no endpoint that lists conversations, so this stored ID is how your app finds the conversation again, for example to read it or to change its status later. conversation.app_url opens the conversation for a signed-in team member. It is not a public link, so show it only in your own admin screens.
Retry when the call did not get through
Retry when the request timed out, when the network failed, and when Intray answered 429 or a 5xx status. A 429 response carries Retry-After: 60. Every other 4xx status means the request itself is wrong, and sending it again returns the same error.
Keep a status column on your submission record and run a scheduled job over the records that still need sending. The job calls the same function, which rebuilds the same key and the same payload from the saved record.
import { forwardSubmission, type SavedSubmission } from "./intray-forms";
// These stand for your own database code.
declare function listUnsentSubmissions(): Promise<SavedSubmission[]>;
declare function markSent(
id: string,
conversationId: string,
appUrl: string
): Promise<void>;
declare function markFailed(
id: string,
code: string,
requestId?: string
): Promise<void>;
// Run this every few minutes from your scheduler.
export async function retryUnsentSubmissions() {
for (const submission of await listUnsentSubmissions()) {
const result = await forwardSubmission(submission);
if (result.ok) {
await markSent(submission.id, result.conversationId, result.appUrl);
} else if (!result.retry) {
await markFailed(submission.id, result.code, result.requestId);
}
// A retryable failure stays unsent, and the next run picks it up.
}
}Intray keeps the stored response for a key for seven days. After that the same key returns 409 idempotency_expired, so stop retrying a record that is older than a week and look at it by hand.
Protect your form endpoint
Intray accepts whatever your server sends with a valid key, so spam control belongs on your own endpoint. Validate the email address and the message length, rate limit by IP address, add a hidden field that a person leaves empty, and verify a captcha token on the server if the form attracts bots. Intray also limits each key to 60 requests per minute, and a flood of spam would use that up for real submissions.
The field rules
The fields object is where extra form inputs go. Intray shows them under the message in the conversation.
- A submission can have up to 30 fields.
- A field name starts with a letter and has up to 64 letters, digits, spaces, or hyphens.
team sizeis valid.team_sizeis rejected because of the underscore. - A value is a string of up to 2,000 characters. Convert numbers and checkboxes to strings before you send them.
initial_entry.textis required and holds 1 to 32,000 characters. If your form has no free text input, write a short sentence there, such as "Joined the waitlist".subjectholds up to 500 characters with no line breaks.- Any field the API does not know returns
422 invalid_request, and the message names the field.
external_id is your own reference, up to 200 characters, and the saved record's ID is a good value for it. It does not deduplicate. Only the Idempotency-Key prevents a second conversation.
Where the code runs
In Next.js, put the form endpoint in a Route Handler, for example app/api/contact/route.ts with an exported POST(request: Request) function. Save the record there, call forwardSubmission, and return your own response to the browser. The environment variables must not have the NEXT_PUBLIC_ prefix, because that prefix ships a value to the browser.
In Convex, a mutation cannot call fetch, so split the work. The mutation saves the submission and schedules an action with ctx.scheduler.runAfter(0, ...). The action calls Intray and then runs a mutation that stores the conversation ID. Set the three variables as environment variables on the Convex deployment and read them with process.env inside the action. A Convex cron job can run the retry function.
What the customer sees
Creating the conversation sends no email, so the person who filled in the form hears nothing from Intray at that point. If you want an immediate confirmation, send it from your own app. When someone on the team replies from the inbox, the customer receives a normal email from the shared address with the subject you set. Their answer comes back into the same conversation, and from then on it behaves like any other email thread.
A name sent with a submission never overwrites the name of a customer Intray already knows. Submissions also do not run the automation rules that fire when an email arrives, so a rule that labels incoming email does not label a form submission.
Check that it works
- Submit the form once with your own email address.
- Open the inbox in Intray. The conversation is in Todo on the address you chose, with your subject, the message, and the fields under it.
- Reply from the inbox and confirm that the email reaches you with the same subject.
- Answer that email and confirm that your answer appears in the same conversation.
- Send the same saved record again, for example by running the retry job by hand. Intray returns the first response and no second conversation appears.
When it fails
| Response | Cause | What to do |
|---|---|---|
| Request blocked in the browser | The call runs in a web page. The API has no browser CORS. | Move the call to your server. |
400 idempotency_key_required | The header is missing, longer than 128 characters, or contains a space. | Build the key from the saved record's ID and keep it short. |
401 | The key is missing, malformed, rotated, or revoked. | Check the secret on the server. Create a new key if the old one was revoked. |
403 forbidden | The key lacks conversations:create. | Create a key with that permission. Permissions on a key cannot be changed. |
404 not_found | address_id is not one of the addresses the key may use. | Read GET /addresses again with this key and copy the id. |
409 address_disabled | The address is turned off in Intray. | Turn the address on, or send to another address. |
409 idempotency_conflict | The key was already used with a different payload. | Build the payload only from the saved record, so retries stay identical. |
409 idempotency_expired | The key is older than seven days. | Stop the retry and check by hand whether the conversation exists. |
413 body_too_large | The request is over 256 KB. | Shorten the message on your side before you send it. |
415 unsupported_media_type | Content-Type is not application/json. | Set the header. |
422 invalid_request | A field is invalid or unknown. The message names it. | Fix the named field. Check field names against the rules above. |
429 | The key or the workspace used its requests for this minute. | Wait for the Retry-After time, then send the same key and payload. |
500, 503 | A failure on Intray's side. | Retry later with the same key and payload. |
Every error body has a request_id, and the same value is in the X-Request-Id header. Store it with the failed record and include it when you report a problem.