# Universal API

> Send messages to Dimension Labs from any system, one POST per message, to the real-time or the historical tracker.

The universal API takes messages from any system: a help desk, a chat platform, a contact center, a survey tool, a warehouse or your own app. You send one `POST` per message. Every universal API [integration guide](/integrations) uses it. (Intercom has its own one-click connector and doesn't need it.)


## Before you start

You need three things:

1. **Your data, out of the source system.** Read your source's own API docs and find the endpoints that return messages, conversations, reviews or responses. Each [integration guide](/integrations) does this for one vendor.
2. **An API key** for a Dimension Labs source. See [Your API key](#your-api-key).
3. **A choice of endpoint:** the real-time tracker for new messages, the historical tracker for older ones. See [Endpoints](#endpoints).

Then convert each record to the [conversation format](#the-message) and post it.

## Your API key

Each source has its own key:

1. In the app, open **Data Sources** and add a source under **Unstructured data**.
2. Choose the **Universal API** tile, for every guide on this site, and give the source a name, for example "Zendesk".
3. Open the new source's integration instructions and copy its API key.

Keep the key in an environment variable or a secrets manager, never in code you commit. The examples read it from `DL_API_KEY`.

**One source per system** keeps your data easy to filter later: one for Zendesk, one for Qualtrics. Several surveys, locations or apps from the same vendor can share one source; the guides put the survey, location or app id in `platformJson` so you can tell them apart.

The [Export API](/api/export) uses a different key, from **Settings**.

## Endpoints

| Use it for | Endpoint |
|---|---|
| New messages, as they happen | `https://tracker.dimensionlabs.io/track` |
| History: anything older than an hour | `https://tracker.dimensionlabs.io/trackhistorical` |

Both take the same query parameters and the same body.

| Parameter | Value |
|---|---|
| `platform` | `universal` |
| `v` | `11.1.0-rest` |
| `type` | `incoming` for a message from the customer or end user, `outgoing` for one from your bot, agent or company |
| `apiKey` | Your source's API key. See [Your API key](#your-api-key). |

Set the header `Content-Type: application/json`.

### Which endpoint, and when Dimensions arrive

- **Send new messages to `/track` as they happen.** A live job sending to `/track` is enriched automatically every day.
- **Send anything older than an hour to `/trackhistorical`.** That rule is safe whatever the exact real-time limit is. Then start a [backfill](/dimensions/backfill) from the **Backfill** tab. A backfill typically shows up within the hour, depending on how much data is being enriched.
- A [file upload](/data/upload-a-file) is treated like history: it needs a backfill too.

The [helper](#set-up-your-project) takes `{ historical: true }` to send to `/trackhistorical`. The integration guides decide per message: anything more than an hour old when the job sees it goes to `/trackhistorical`.

### Rate limits and errors

Rate limits and error bodies are not published. Send messages one at a time, or with low concurrency, and retry `429` and `5xx` responses with exponential backoff, honoring `Retry-After`. The helper does both for you. Any other `4xx` means the message itself was rejected: fix it rather than resend it unchanged.

### Incoming or outgoing

The tracker was built for conversations, so every record has a direction. The rule is simple: **anything written by a customer, user or other source outside your company is incoming.** Anything your company, bot or agent wrote is outgoing. This holds even when the data doesn't feel like a conversation:

| Source | Incoming | Outgoing |
|---|---|---|
| Chat, bot or ticket | What the customer wrote | What the bot or agent wrote |
| Online review | The review | Your reply to it, if you have one |
| Survey | Each answer | Each question |
| News article or forum post | The article or post | |

Set `type` in the URL and `incoming` in the body to match. If you use the helper, set only `incoming`: it sets `type` for you.

## The message

A minimal message:

```json
{
  "text": "Where is my order?",
  "userId": "cust-17",
  "incoming": true,
  "sessionId": "conv-1001",
  "conversationId": "conv-1001",
  "dimensionlabs_timestamp": 1703240320000,
  "platformJson": { "channel": "chat", "store_id": "SF-04" }
}
```

| Field | Required | Type | Notes |
|---|---|---|---|
| `text` | Yes | string | The words to enrich. Never empty. |
| `userId` | Yes | string | Who sent it. For a bot conversation, the end user's id on both their messages and the bot's. If you don't have one, copy the `sessionId` here. Never an email address, phone number or name. See [Participants](#participants). |
| `incoming` | Yes | boolean | `true` from the customer or end user, `false` from your side. Match it to `type`. |
| `sessionId` | Yes | string | Groups the messages of one conversation. Enrichment reads a whole session at once. No colons. |
| `dimensionlabs_timestamp` | Yes | number | Unix time in **milliseconds** (13 digits). Seconds read as a date in 1970. See [Timestamps](#timestamps). |
| `conversationId` | Recommended | string | Keeps a thread together when the sender changes, for example when a human agent takes over from a bot. No colons. |
| `platformJson` | Optional | object | Any other fields about the message or conversation: channel, rating, store, plan, account id. Kept as metadata the agent can filter and join on. See [Metadata](#metadata-platformjson). |
| `platformUserJson` | Optional | object | Attributes of the customer, such as tier or locale. See [Customer attributes](#customer-attributes-platformuserjson). |
| `intent` | Optional | object | Only for an intent-based chatbot: `{ "name": "TRACK_ORDER", "confidence": 0.94 }`. See [Intent](#intent). |
| `images` | Optional | array of objects | Images in the message. See [Rich content](#rich-content-images-buttons-and-postbacks). |
| `buttons` | Optional | array of objects | Buttons offered in the message. |
| `postback` | Optional | object | A record of a button click. |

Field names are case-sensitive: `userId` and `sessionId`, not `USERID`, `SESSIONID`, `userid` or `sessionid`. In `dimensionlabs_timestamp`, `dimensionlabs` counts as one word, so it stays lowercase. Field values can use any case, for example `TRACK_ORDER`.

Reviews and surveys use the same shape. A review is one incoming message with its own `sessionId`. For a survey, each question is an outgoing message and each answer an incoming one, under the response's `sessionId`.

A line of a JSONL [upload](/data/upload-a-file) has exactly this shape too, nested `platformJson` included.

### Participants

`userId` says who sent the message:

- **Bot conversations.** Use the end user's id on both incoming and outgoing messages, not the bot's id.
- **Tickets and email.** Use the customer's id on every message of the ticket, the agents' replies included. The integration guides do this.
- **Live chat with a human agent.** You can give each participant their own id, as long as `conversationId` ties the conversation together.
- **Omni-channel sources.** When one source carries records from several channels, keep each person's id consistent across channels if you can, and put the channel in `platformJson`.
- **No personal data.** Use the tool's internal customer id. If the only key you have is an email address, send a consistent hash of it (the same hash on both sides of any join), never the address itself. See [PII redaction](/data/pii-redaction).

`incoming` marks direction. Whatever the source, `incoming: true` is always the part of a record or transcript that came from the customer or end user. `incoming: false` covers your bot, system and human agents.

### Sessions and conversations

`sessionId` and `conversationId` often hold the same value, and in most data they cover the same span of messages. They still do different jobs:

| | `conversationId` | `sessionId` |
|---|---|---|
| What it groups | Every message in a conversation, across all participants | One session or interaction |
| What it controls | Conversation identity. Keeps a thread with two or more `userId`s intact. Messages with the same `conversationId` always appear in the same transcript. | Session boundaries: what gets enriched together |

Always send `conversationId`. Without it, a session can split each time the `userId` changes, for example when a human agent joins a live chat.

Neither id can contain a colon (`:`). If you build an id from several values, join them with `-` or `_`.

Always set `sessionId` yourself, to the vendor's own ticket, chat or response id, rather than relying on how sessions are cut without one. More on [Sessions and conversations](/data/sessions-and-conversations).

### Timestamps

`dimensionlabs_timestamp` goes at the root of the JSON, as Unix time in milliseconds:

```json
{
  "dimensionlabs_timestamp": 1627996516303,
  "text": "..."
}
```

- Send the time the message was written in your source system, not the time your script runs, so conversations are ordered the way they happened.
- The field is required: always send it.
- Keep messages in the same session at least one second (1,000 ms) apart, so their order is unambiguous. When a source gives several messages the same time (survey answers, for example), add 1,000 ms per message.
- To check a value by hand, paste it into an epoch converter such as [epochconverter.com](https://www.epochconverter.com/).

### Duplicates and re-sends

Duplicates you send are removed automatically when your data is ingested, so re-sending is safe. A retried run, a re-sent review, or a history load that overlaps your live job doesn't create extra copies. Use the vendor's own ids for `sessionId` and `conversationId`, so a re-sent message lands in the same conversation.

## Send one message

```bash
curl -X POST -H "Content-Type: application/json" \
  -d '{"text":"Where is my order?","userId":"cust-17","incoming":true,"sessionId":"conv-1001","conversationId":"conv-1001","dimensionlabs_timestamp":1703240320000}' \
  "https://tracker.dimensionlabs.io/trackhistorical?platform=universal&v=11.1.0-rest&type=incoming&apiKey=$DL_API_KEY"
```

This example's timestamp is in 2023, so it goes to `/trackhistorical`. For a message with a current timestamp, use `/track`. An outgoing message is the same call with `"incoming":false` in the body and `type=outgoing` in the URL.

## Test before you build

Send a few messages by hand before you write the integration. Any tool that can make an HTTP `POST` works: `curl` in a terminal on macOS, Windows or Linux, or an API client such as Postman or Insomnia. To send data at scale you will still write code (see [Set up your project](#set-up-your-project)).

### With Postman

1. Download, install and open [Postman](https://www.postman.com).
2. Create a new **Collection**.
3. In the collection, create a new request and choose **HTTP**.
4. Set the request type to **POST**.
5. In the URL box, enter `https://tracker.dimensionlabs.io/track` (or `/trackhistorical` for a timestamp older than an hour).
6. Under **Params**, add `platform` = `universal`, `v` = `11.1.0-rest`, `type` = `incoming`, and `apiKey` = your source's API key.
7. Under **Headers**, add `Content-Type` = `application/json`.
8. Under **Body**, choose **raw** and **JSON**, and paste a message such as the minimal one above, with a current timestamp.
9. Select **Send**. A `2xx` status means the message was received.

### With curl

Use the command in [Send one message](#send-one-message). For `/track`, replace the timestamp with a current one: `date +%s000` in a terminal prints the time in milliseconds (to the whole second, which is fine). Set `DL_API_KEY` first.

### Check it arrived

Use a test source for this, so test messages don't mix with real data. To start over, create a new source: you can't delete individual messages yourself. For a targeted deletion on a source, email support@dimensionlabs.io.

After a sync, ask the agent *"How many messages arrived in my new source today?"* Dimensions follow the timing in [Which endpoint](#which-endpoint-and-when-dimensions-arrive).

## Set up your project

<span id="the-same-thing-in-code"></span>

Every universal API guide imports two helpers from one file. Download it and put it next to your scripts:

- JavaScript: [dimensionlabs.mjs](/templates/dimensionlabs.mjs) (Node 18 or later)
- Python: [dimensionlabs.py](/templates/dimensionlabs.py) (needs `requests`)

`sendToDimensionLabs(msg, { historical })` posts one message, to `/track` or `/trackhistorical`, and retries `429` and `5xx` responses with backoff, honoring `Retry-After`. `toMillis(date)` turns any date into Unix milliseconds: seconds become milliseconds, numeric text is read as a number, and text without a time zone is read as UTC, not your computer's local time.

Set up a Node project once:

```bash
mkdir dl-sync && cd dl-sync
curl -O https://docs.dimensionlabs.io/templates/dimensionlabs.mjs
npm init -y
npm pkg set type=module
npm install google-auth-library   # only the vendor packages your guide lists
export DL_API_KEY=your-source-api-key
```

`npm pkg set type=module` lets Node run the guides' `import` lines. Each guide lists the packages it needs, if any. Save the guide's code as its own file, for example `zendesk.mjs`, and every guide starts with the same import:

```js

```

To run it once, and then on a schedule, see [Scheduled sync](/api/scheduled-sync).

In Python the import is `from dimensionlabs import send_to_dimension_labs, to_millis`. The integration guides are written in JavaScript; to port one, follow its mapping table. Run Python 3.11 or later if your vendor's dates use offsets such as `+0000`.

The JavaScript file, for reference:

```js
// Dimension Labs helpers for the universal API. Save this file next to your script as dimensionlabs.mjs.
// Node 18 or later (uses the built-in fetch). Set DL_API_KEY in the environment.
// Docs: https://docs.dimensionlabs.io/api/tracker

const DL = 'https://tracker.dimensionlabs.io';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

/**
 * Send one message. New messages go to /track; pass { historical: true } for anything older than an hour,
 * then run a backfill. Retries rate limits (429) and server errors (5xx) with exponential backoff and
 * honors Retry-After. Duplicates are removed by Dimension Labs, so a retry is safe.
 */
export async function sendToDimensionLabs(msg, { historical = false, apiKey = process.env.DL_API_KEY, retries = 5 } = {}) {
  if (!apiKey) throw new Error('Set DL_API_KEY to your source API key (Data Sources > your source > integration instructions).');
  const type = msg.incoming ? 'incoming' : 'outgoing';
  const url = `${DL}/${historical ? 'trackhistorical' : 'track'}?platform=universal&v=11.1.0-rest&type=${type}&apiKey=${encodeURIComponent(apiKey)}`;
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(msg) });
    if (res.ok) return;
    const retryable = res.status === 429 || res.status >= 500;
    if (!retryable || attempt >= retries) throw new Error(`Dimension Labs returned ${res.status}: ${await res.text()}`);
    const after = Number(res.headers.get('retry-after'));
    await sleep(Number.isFinite(after) && after > 0 ? after * 1000 : 500 * 2 ** attempt);
  }
}

/**
 * Any date to Unix milliseconds. Numbers below 1e12 are read as seconds. Text without a time zone
 * (for example "2026-01-02 09:30:00") is read as UTC, not your computer's local time.
 */
export function toMillis(d) {
  if (typeof d === 'number') return d < 1e12 ? Math.round(d * 1000) : Math.round(d);
  if (d instanceof Date) return d.getTime();
  const s = String(d).trim();
  if (/^\d+(\.\d+)?$/.test(s)) return toMillis(Number(s));
  const zoned = /([zZ]|[+-]\d\d:?\d\d)$/.test(s);
  const t = new Date(zoned ? s : s.replace(' ', 'T') + 'Z').getTime();
  if (Number.isNaN(t)) throw new Error(`Can't read the date "${s}"`);
  return t;
}
```

### Fetch, convert, send

The shape of every integration: fetch records from the source's API, convert each to the conversation format, send each one. Every guide exports one function, `syncX({ since, historical })`, that sends everything from `since` (Unix ms) and returns the new `since` for the next run. This sketch uses a chatbot vendor's messages (fields as in the [Ada guide](/integrations/ada)); swap in your vendor's API and field names.

```js


const HOUR = 60 * 60 * 1000;

export async function syncMessages({ since, historical = false }) {
  const vendorMessages = await fetchMessagesSince(since); // your vendor's API, oldest first
  let newest = since;
  for (const m of vendorMessages) {
    if (!m.message_data?.body?.trim()) continue;          // skip events, images, quick replies
    const ts = toMillis(m.date_created);
    await sendToDimensionLabs({
      text: m.message_data.body,
      userId: m.chatter_id,                                // the end user, on both sides of the conversation
      incoming: m.sender.toLowerCase() !== 'ada',          // true only for what the customer wrote
      sessionId: m.conversation_id,
      conversationId: m.conversation_id,
      dimensionlabs_timestamp: ts,
      platformJson: { messageId: m._id },                  // any extra fields you want to keep
    }, { historical: historical || ts < Date.now() - HOUR }); // older than an hour: /trackhistorical
    newest = Math.max(newest, ts);
  }
  return newest;                                           // the next run starts here
}
```

Instead of posting, you can also write the converted messages to a JSONL file (one JSON object per line) and [upload the file](/data/upload-a-file).

## History

Send history to `/trackhistorical`, or upload it as a [file](/data/upload-a-file). Then run a [backfill](/dimensions/backfill) from the **Backfill** tab once it has arrived: history is not picked up by the daily enrichment of live data. The [scheduled sync runner](/api/scheduled-sync#load-history-first) does a history load with one command.

A large history doesn't need splitting: Dimension Labs splits a large file for you, with no size limit. Posting one message at a time takes a while for millions of messages, so for a very large history a file upload is usually quicker.

## Rules that trip up integrations

- **Direction.** Many vendor APIs call the agent or bot the "author" or "sender". `incoming` is about the customer: `true` only for what the customer wrote.
- **Milliseconds.** JavaScript's `Date.getTime()` gives milliseconds. Python's `datetime.timestamp()` gives seconds: multiply by 1,000. `toMillis` handles both.
- **One second apart.** Space messages in the same session at least 1,000 ms apart.
- **Stable ids.** Use the vendor's own ticket, conversation or response id as `sessionId`, so a retried run doesn't create new conversations.
- **No colons** in `sessionId` or `conversationId`. Use `-` or `_`.
- **Strip HTML.** Ticket and email bodies often arrive as HTML. Send the plain text.
- **Skip empty and system messages.** An empty `text`, or an automatic "ticket closed" note, adds nothing.
- **No personal data.** Never use email addresses, phone numbers or names as `userId`, `sessionId` or a join key, and don't put them in `platformJson`. Remove them from message text before you send. See [PII redaction](/data/pii-redaction).

Stuck, or the tracker rejects your messages? Email support@dimensionlabs.io with the source name, the status code and one sample payload (with the API key removed).

## Optional fields

You are probably already collecting metadata such as intents, events, scores and context. None of it is required, but sending it gives the agent more to filter, group and join on, and gives enrichment more context.

### Metadata: platformJson

`platformJson` is the catch-all for anything about the record: your platform's own message JSON, CSAT or NPS scores, experience ratings, the weather, the game or event, the store. It is the preferred place for every field beyond the required ones.

```json
{
  "platformJson": {
    "channel": "webchat",
    "csatScore": 4,
    "weatherConditions": "rain",
    "gameSeries": "home-opener"
  }
}
```

- Metadata you want the agent to filter or join on must be sent in `platformJson`.
- It is visible in transcripts.
- Pick one encoding per field and keep it. `"yes"`, `"Yes"` and `1` become three different values.

### Customer attributes: platformUserJson

`platformUserJson` holds attributes of the person, kept apart from the message metadata. Use it only when keeping them separate is meaningful; otherwise use `platformJson`.

| Field | Type |
|---|---|
| `locale` | string |
| `timezone` | string |
| `customerTier` | string |
| `abTestGroup` | string |

```json
{
  "platformUserJson": {
    "locale": "en-CA",
    "timezone": "America/Toronto",
    "customerTier": "silver",
    "abTestGroup": "B"
  }
}
```

Some connectors, Intercom for example, fill `platformUserJson` for you.

:::caution
Older pages listed `firstName`, `lastName` and `gender` as fields with special handling. Dimension Labs has a policy of not ingesting or storing personal information, so leave names out. See [PII redaction](/data/pii-redaction).
:::

### Intent

`intent` carries the intent your bot or NLP engine matched for the message. You need it only for an intent-based chatbot. The full guide, including the `NotHandled` intent and event tracking, is [Intents and events](/api/intents-and-events).

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | Yes | Name of the intent, for example `TRACK_ORDER` |
| `inputs` | array of objects | No | Inputs (slots or entities) captured with the intent, each `{ "name": "...", "value": "..." }` |
| `confidence` | number | No | From 0.0 (completely uncertain) to 1.0 (completely certain) |

```json
{
  "intent": {
    "name": "TRACK_ORDER",
    "confidence": 0.948014021,
    "inputs": [
      { "name": "orderNumber", "value": "DM34137KMX" }
    ]
  }
}
```

### Rich content: images, buttons and postbacks

If your messages include images or interactive elements, send them in these shapes.

**Images.** `images` is an array of objects with one field, `url`.

```json
{
  "images": [
    { "url": "https://example.com/product-photo.jpg" }
  ]
}
```

**Buttons.** `buttons` is an array of the options offered in the message.

| Field | Type | Description |
|---|---|---|
| `id` | string | Id of the button |
| `label` | string | Label shown on the button |
| `value` | string | Value of the button |

```json
{
  "buttons": [
    { "id": "btn-track", "label": "Track my order", "value": "TRACK_ORDER" }
  ]
}
```

**Postbacks.** `postback` records a button click. It holds a `buttonClick` object with one field, `buttonId`, the id of the button that was clicked.

```json
{
  "postback": {
    "buttonClick": { "buttonId": "btn-track" }
  }
}
```

## Complete examples

The `confidence` values below are examples. Leave `confidence` out if your platform doesn't produce one.

### A bot conversation

Four messages between one user and a bot. `userId`, `conversationId` and `sessionId` stay the same across the session, and each timestamp is at least a second after the one before.

```json
[
  {
    "text": "hey",
    "userId": "7675836189527716",
    "incoming": true,
    "conversationId": "conv-88213407",
    "sessionId": "1434868331",
    "dimensionlabs_timestamp": 1703240310000,
    "intent": { "name": "HELLO", "confidence": 0.963456013 }
  },
  {
    "text": "Hi! How can I help you?",
    "userId": "7675836189527716",
    "incoming": false,
    "conversationId": "conv-88213407",
    "sessionId": "1434868331",
    "dimensionlabs_timestamp": 1703240315000,
    "intent": { "name": "HELLO_RESPONSE" }
  },
  {
    "text": "I want to know where to track my deliveries",
    "userId": "7675836189527716",
    "incoming": true,
    "conversationId": "conv-88213407",
    "sessionId": "1434868331",
    "dimensionlabs_timestamp": 1703240320000,
    "intent": { "name": "TRACK_ORDER", "confidence": 0.948014021 }
  },
  {
    "text": "You can track your order anytime at example.com/orders.",
    "userId": "7675836189527716",
    "incoming": false,
    "conversationId": "conv-88213407",
    "sessionId": "1434868331",
    "dimensionlabs_timestamp": 1703240325000,
    "intent": { "name": "TRACK_ORDER_RESPONSE" }
  }
]
```

Each object is one `POST`: the incoming ones with `type=incoming`, the outgoing ones with `type=outgoing`.

### A live chat with a human agent

Two participants, each with their own `userId`. The shared `conversationId` keeps the conversation together; without it, the session could split at each change of sender.

```json
[
  {
    "text": "My internet has been down since this morning.",
    "userId": "cust-4471920",
    "incoming": true,
    "conversationId": "conv-90551216",
    "sessionId": "9950934871",
    "dimensionlabs_timestamp": 1703278090000
  },
  {
    "text": "Sorry to hear that. Let me look into your account.",
    "userId": "agent-302",
    "incoming": false,
    "conversationId": "conv-90551216",
    "sessionId": "9950934871",
    "dimensionlabs_timestamp": 1703278095000
  },
  {
    "text": "Thanks. The router lights are all red.",
    "userId": "cust-4471920",
    "incoming": true,
    "conversationId": "conv-90551216",
    "sessionId": "9950934871",
    "dimensionlabs_timestamp": 1703278100000
  },
  {
    "text": "I see an outage in your area. Crews are on it, and service should be back within two hours.",
    "userId": "agent-302",
    "incoming": false,
    "conversationId": "conv-90551216",
    "sessionId": "9950934871",
    "dimensionlabs_timestamp": 1703278105000
  }
]
```

### One message with every optional field

```json
{
  "text": "I'd like to order a pizza and a drink",
  "userId": "user01",
  "incoming": true,
  "sessionId": "order-5521",
  "conversationId": "order-5521",
  "dimensionlabs_timestamp": 1703240320000,
  "intent": {
    "name": "ORDER",
    "inputs": [
      { "name": "dish", "value": "pizza" },
      { "name": "extra", "value": "drink" }
    ],
    "confidence": 1.0
  },
  "images": [{ "url": "https://example.com/orders/pizza.png" }],
  "buttons": [
    { "id": "btn-pizza", "label": "Pizza", "value": "pizza" },
    { "id": "btn-drink", "label": "Drink", "value": "drink" }
  ],
  "postback": { "buttonClick": { "buttonId": "btn-pizza" } },
  "platformJson": { "channel": "webchat", "store_id": "SF-04" },
  "platformUserJson": { "locale": "en-US", "customerTier": "gold" }
}
```

Last reviewed on 2026-10-14.
