Customer profiles
See user profiles next to your emails. When someone on your team opens a conversation, the Details panel shows a Profile section with facts from your own product, such as plan, orders, or signup date. The profile is read-only. Your product stays the place where the data lives and changes.
You build one POST endpoint. Intray calls it when a teammate opens the profile, and shows what you return.
Pilot setup
During the pilot, Intray staff add your endpoint as a profile source. Send us your endpoint URL and we add it. Ask your Intray contact. After that, a workspace owner connects the source to an inbox address in Settings.
What Intray sends
One JSON body, and nothing else:
{ "version": 1, "customerId": "cus_4821" }customerId is the identifier a workspace owner entered when they linked the customer. Intray treats it as an opaque string of up to 256 characters.
Intray sends no conversation text, no email history, no notes, and no attachments.
The request is a POST with these headers:
Authorization: Bearer <your source token>
Content-Type: application/json
Accept: application/json
Cache-Control: no-storeIntray does not follow redirects. A redirect counts as a failure.
What you return
{
"version": 1,
"customerId": "cus_4821",
"title": "Ada Byron",
"subtitle": "Example company",
"profileUrl": "https://admin.example.com/customers/cus_4821",
"updatedAt": "2026-09-03T14:12:00Z",
"fields": [
{
"key": "plan",
"label": "Plan",
"type": "badge",
"value": "Team",
"tone": "positive"
},
{ "key": "orders", "label": "Orders", "type": "number", "value": 12 },
{
"key": "since",
"label": "Customer since",
"type": "date",
"value": "2026-03-12"
}
]
}Return Content-Type: application/json and Cache-Control: no-store. Any other content type is rejected.
version1RequiredAlways the number 1. Any other value is rejected.
customerIdstringRequiredThe same identifier Intray sent. A different value is rejected.
titlestringRequiredThe customer's display name. 1 to 2,000 characters.
subtitlestringOptionalA second line under the title. 1 to 2,000 characters.
profileUrlstringOptionalA link to this customer in your product. It must be HTTPS and on an origin you gave us when we added your source.
updatedAtstringOptionalISO timestamp with a timezone. Send it only if your record has a real last-updated time. Intray shows its own fetch time separately.
fieldsarrayRequiredUp to 20 fields. Every key must be unique. An empty array is valid.
Field types
Every field has key (1 to 80 characters), label (1 to 200 characters), type, and value.
textvalue: stringOptionalPlain text, 1 to 2,000 characters.
numbervalue: numberOptionalA finite JSON number. Zero is valid.
datevalue: stringOptionalISO calendar date such as 2026-03-12. Intray shows it without shifting
timezones.
timestampvalue: stringOptionalISO timestamp with a timezone, such as 2026-09-03T14:12:00Z.
badgevalue: stringOptionalPlain text, plus a required tone of neutral, positive, or warning.
linkvalue: stringOptionalThe link label, plus a required url. The URL must be HTTPS and on an
origin you gave us when we added your source.
Leave out a field you do not have. Do not send null. There is no HTML, Markdown, image, nested layout, or editing control.
Intray rejects the whole profile when it finds an unknown property, an unknown field type, a duplicate key, a customerId that does not match, an invalid date, or a link on another origin. The team then sees "The source returned a profile we couldn't display."
Example handler
Verify the bearer token before you look anything up. Take the account scope from the token, never from customerId. A customer ID must not be able to reach another account's data.
import { timingSafeEqual } from "node:crypto";
const NO_STORE = { "Cache-Control": "no-store" };
function tokenMatches(header: string | null): boolean {
const expected = Buffer.from(`Bearer ${process.env.INTRAY_PROFILE_TOKEN}`);
const actual = Buffer.from(header ?? "");
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
export async function POST(request: Request): Promise<Response> {
// 1. Verify the credential first.
if (!tokenMatches(request.headers.get("authorization"))) {
return new Response(null, { status: 401, headers: NO_STORE });
}
// 2. Read the one field Intray sends.
const body = (await request.json().catch(() => null)) as {
version?: unknown;
customerId?: unknown;
} | null;
if (body?.version !== 1 || typeof body.customerId !== "string") {
return new Response(null, { status: 400, headers: NO_STORE });
}
// 3. Look the customer up inside the account this token belongs to.
const customer = await db.customers.findInAccount(
process.env.INTRAY_PROFILE_ACCOUNT_ID!,
body.customerId
);
if (!customer) return new Response(null, { status: 404, headers: NO_STORE });
if (customer.deletedAt) {
return new Response(null, { status: 410, headers: NO_STORE });
}
// 4. Return only fields every member of the Intray workspace may see.
return Response.json(
{
version: 1,
customerId: body.customerId,
title: customer.name,
subtitle: customer.companyName,
profileUrl: `https://admin.example.com/customers/${customer.id}`,
fields: [
{
key: "plan",
label: "Plan",
type: "badge",
value: customer.plan,
tone: customer.plan === "Free" ? "neutral" : "positive",
},
{
key: "orders",
label: "Orders",
type: "number",
value: customer.orderCount,
},
{
key: "since",
label: "Customer since",
type: "date",
value: customer.createdAt.toISOString().slice(0, 10),
},
],
},
{ headers: NO_STORE }
);
}db.customers.findInAccount stands for your own data access. Everything else is the contract.
Limits
| Limit | Value |
|---|---|
| Fields per profile | 20 |
| Field key | 80 characters |
| Field label | 200 characters |
| Title, subtitle, text values | 2,000 characters |
| URLs | 2,000 characters |
| Customer ID | 256 characters |
| Response size | 32 KiB |
| Time to respond | 5 seconds |
| Retries | None |
| Requests per person | 30 per minute, per workspace |
| Requests per workspace | 300 per minute |
Opening a profile, previewing one, and linking one all count toward the same two request limits.
Status codes
Intray reads the status code and ignores the error body. It does not show or log what you put in it.
| You return | The team sees |
|---|---|
404 | This profile could not be found in the source product. |
410 | This profile is no longer available in the source product. |
401 or 403 | The source refused access. Ask the workspace owner to check this connection. |
429 | Too many profile requests. Please try again in a minute. |
| Any other error | The source is unavailable. You can continue the conversation and try again. |
| No answer in 5 seconds | The same "source is unavailable" message. |
A failed profile never blocks the conversation. The team can still read and reply.
Connect the source to an address
A workspace owner does this once per inbox address.
Open Customer profiles
In Intray, go to Settings, then Developers, and find Customer profiles.
Choose the address and the source
Pick an Inbox address and a Profile source, then press Connect. The source list contains the endpoints Intray staff added for your workspace.
Turn it off when you need to
Disable stops all requests to your endpoint and keeps the links. Remove deletes the connection and every link made through it. To switch an address to another source, remove the old connection first.
A connection belongs to one inbox address.
Link a customer
Intray does not guess which customer an email belongs to. A workspace owner links each one.
Open the Details panel
Open a conversation on a connected address. The Details panel has a Profile section.
Press Link profile
Enter the Source customer ID, the stable identifier from your product.
Press Preview profile
Intray calls your endpoint and shows the result. A preview expires after five minutes. Preview again if it does.
Press Link profile to confirm
Intray calls your endpoint once more, validates the answer, and saves the identifier. Only the identifier is saved.
Owners can link, change, and remove links. Members can read linked profiles. The Intray AI agent cannot read profiles at all.
A link belongs to one Intray customer on one connection. It carries over to every conversation with that customer on that address. If the same person writes from a new email address and Intray creates a new customer, link that one too. If a person can belong to several accounts in your product, use an identifier that includes the account.
Privacy
- Intray fetches a profile only when someone opens the Profile section, and again when they press Refresh profile. It does not prefetch, poll, or refresh in the background.
- A profile stays in the open browser tab for five minutes. After that Intray hides it until someone refreshes.
- Intray stores the connection settings, the customer identifiers you link, and request counts. It does not store the profile you return. The profile does not go into search, the conversation timeline, or the AI agent's context.
- Send
Cache-Control: no-storeon every response, including errors. - Turn off request and response body logging for this route, in your app and in any proxy in front of it.
- The token gives every member of the Intray workspace the same view. Return only fields all of them may see.
- A linked identifier is personal data. Removing a link deletes the identifier from Intray. It does not change emails, notes, or attachments already in the conversation.