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:

Request body
{ "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:

Request headers
Authorization: Bearer <your source token>
Content-Type: application/json
Accept: application/json
Cache-Control: no-store

Intray does not follow redirects. A redirect counts as a failure.

What you return

200 OK
{
  "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.

version1Required

Always the number 1. Any other value is rejected.

customerIdstringRequired

The same identifier Intray sent. A different value is rejected.

titlestringRequired

The customer's display name. 1 to 2,000 characters.

subtitlestringOptional

A second line under the title. 1 to 2,000 characters.

profileUrlstringOptional

A link to this customer in your product. It must be HTTPS and on an origin you gave us when we added your source.

updatedAtstringOptional

ISO timestamp with a timezone. Send it only if your record has a real last-updated time. Intray shows its own fetch time separately.

fieldsarrayRequired

Up 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: stringOptional

Plain text, 1 to 2,000 characters.

numbervalue: numberOptional

A finite JSON number. Zero is valid.

datevalue: stringOptional

ISO calendar date such as 2026-03-12. Intray shows it without shifting timezones.

timestampvalue: stringOptional

ISO timestamp with a timezone, such as 2026-09-03T14:12:00Z.

badgevalue: stringOptional

Plain text, plus a required tone of neutral, positive, or warning.

linkvalue: stringOptional

The 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.

server/intray-profile.ts
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

LimitValue
Fields per profile20
Field key80 characters
Field label200 characters
Title, subtitle, text values2,000 characters
URLs2,000 characters
Customer ID256 characters
Response size32 KiB
Time to respond5 seconds
RetriesNone
Requests per person30 per minute, per workspace
Requests per workspace300 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 returnThe team sees
404This profile could not be found in the source product.
410This profile is no longer available in the source product.
401 or 403The source refused access. Ask the workspace owner to check this connection.
429Too many profile requests. Please try again in a minute.
Any other errorThe source is unavailable. You can continue the conversation and try again.
No answer in 5 secondsThe 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.

1

Open Customer profiles

In Intray, go to Settings, then Developers, and find Customer profiles.

2

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.

3

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.

Intray does not guess which customer an email belongs to. A workspace owner links each one.

1

Open the Details panel

Open a conversation on a connected address. The Details panel has a Profile section.

2

Press Link profile

Enter the Source customer ID, the stable identifier from your product.

3

Press Preview profile

Intray calls your endpoint and shows the result. A preview expires after five minutes. Preview again if it does.

4

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-store on 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.