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

<Note title="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.
</Note>

## What Intray sends

One JSON body, and nothing else:

```json title="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:

```http title="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

```json title="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.

<Properties>
  <Property name="version" type="1" required>
    Always the number 1. Any other value is rejected.
  </Property>
  <Property name="customerId" type="string" required>
    The same identifier Intray sent. A different value is rejected.
  </Property>
  <Property name="title" type="string" required>
    The customer's display name. 1 to 2,000 characters.
  </Property>
  <Property name="subtitle" type="string">
    A second line under the title. 1 to 2,000 characters.
  </Property>
  <Property name="profileUrl" type="string">
    A link to this customer in your product. It must be HTTPS and on an origin
    you gave us when we added your source.
  </Property>
  <Property name="updatedAt" type="string">
    ISO timestamp with a timezone. Send it only if your record has a real
    last-updated time. Intray shows its own fetch time separately.
  </Property>
  <Property name="fields" type="array" required>
    Up to 20 fields. Every `key` must be unique. An empty array is valid.
  </Property>
</Properties>

### Field types

Every field has `key` (1 to 80 characters), `label` (1 to 200 characters), `type`, and `value`.

<Properties>
  <Property name="text" type="value: string">
    Plain text, 1 to 2,000 characters.
  </Property>
  <Property name="number" type="value: number">
    A finite JSON number. Zero is valid.
  </Property>
  <Property name="date" type="value: string">
    ISO calendar date such as `2026-03-12`. Intray shows it without shifting
    timezones.
  </Property>
  <Property name="timestamp" type="value: string">
    ISO timestamp with a timezone, such as `2026-09-03T14:12:00Z`.
  </Property>
  <Property name="badge" type="value: string">
    Plain text, plus a required `tone` of `neutral`, `positive`, or `warning`.
  </Property>
  <Property name="link" type="value: string">
    The link label, plus a required `url`. The URL must be HTTPS and on an
    origin you gave us when we added your source.
  </Property>
</Properties>

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.

```ts title="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

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

<Steps>
  <Step title="Open Customer profiles">
    In Intray, go to Settings, then Developers, and find **Customer profiles**.
  </Step>
  <Step title="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.
  </Step>
  <Step title="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.
  </Step>
</Steps>

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.

<Steps>
  <Step title="Open the Details panel">
    Open a conversation on a connected address. The Details panel has a
    **Profile** section.
  </Step>
  <Step title="Press Link profile">
    Enter the **Source customer ID**, the stable identifier from your product.
  </Step>
  <Step title="Press Preview profile">
    Intray calls your endpoint and shows the result. A preview expires after
    five minutes. Preview again if it does.
  </Step>
  <Step title="Press Link profile to confirm">
    Intray calls your endpoint once more, validates the answer, and saves the
    identifier. Only the identifier is saved.
  </Step>
</Steps>

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.
