# HubSpot help desk and inbox

> Send HubSpot help desk and conversations inbox threads, with their ticket ids, to Dimension Labs with the universal API.

This brings in the message threads from HubSpot's conversations inbox and help desk workspace (chat, email and other connected channels), one conversation per thread, with the associated ticket id so you can join to ticket data. You run it on your side: a small script pulls from the HubSpot Conversations API and posts each message to the [universal API](/api/tracker).

## What you need

- A HubSpot super admin, to create a private app. HubSpot requires super admin access for private apps.
- The private app's access token, with the `conversations.read` scope.
- A Dimension Labs API key for a new **Universal API** source. See [Your API key](/api/tracker#your-api-key).

## 1. Get API access in HubSpot

HubSpot calls private apps "legacy apps": still supported, without the newest app features ([private apps](https://developers.hubspot.com/docs/apps/legacy-apps/private-apps/overview)).

1. In HubSpot, go to **Development**, then **Legacy apps** in the left sidebar.
2. In the top right, click **Create legacy app** and select **Private**.
3. Name the app, then on the **Scopes** tab add `conversations.read`.
4. Click **Create app**.
5. Open the app, go to the **Auth** tab, click **Show token** and copy it.
6. Send it as `Authorization: Bearer YOUR_TOKEN` on every call.

## 2. Pull new threads

[Retrieve threads](https://developers.hubspot.com/docs/api-reference/conversations-conversations-inbox-&-messages-v3/guide) sorted by their latest message, starting from your cursor. `association=TICKET` adds the ticket id to threads that have one.

```bash
curl -H "Authorization: Bearer $HUBSPOT_TOKEN" \
  "https://api.hubspot.com/conversations/v3/conversations/threads?sort=latestMessageTimestamp&latestMessageTimestampAfter=2026-10-13T00:00:00Z&limit=500&association=TICKET"
```

`sort=latestMessageTimestamp` needs `latestMessageTimestampAfter`. Results are always in ascending order, and `limit` goes up to 500. To get the next set, repeat the call with `latestMessageTimestampAfter` set to the last thread's `latestMessageTimestamp`.

For each thread, `GET /conversations/v3/conversations/threads/{threadId}/messages` returns its message history. Page through it with the `after` parameter, using `paging.next.after` from the response. The history also holds internal comments and system entries: keep only `type` `MESSAGE`.

HubSpot's semantic-version APIs such as `/conversations/v3/` remain supported alongside its newer date-based versions ([API versioning](https://developers.hubspot.com/docs/developer-tooling/platform/versioning)).

Rate limits, per HubSpot, for a private app: 100 requests per 10 seconds on Free and Starter, 190 on Professional and Enterprise. Daily limits per account are 250,000 on Free and Starter, 625,000 on Professional, and 1,000,000 on Enterprise ([usage guidelines](https://developers.hubspot.com/docs/developer-tooling/platform/usage-guidelines)).

## 3. Map the fields

| Dimension Labs | HubSpot | Notes |
|---|---|---|
| `text` | message `text` | `richText` holds the HTML version. Email messages can be cut to the latest reply: see `truncationStatus`. |
| `userId` | thread `associatedContactId` | HubSpot's internal contact id. Fall back to the thread `id` when there's no contact. |
| `incoming` | message `direction` | `true` when `direction` is `INCOMING`, `false` for `OUTGOING`. |
| `sessionId` | thread `id` | Prefix it, for example `hubspot-176347007`. |
| `conversationId` | thread `id` | Same value as `sessionId`. |
| `dimensionlabs_timestamp` | message `createdAt` | Convert with `toMillis`. |
| `platformJson` | thread `inboxId`, `status`, `threadAssociations.associatedTicketId`; message `channelId` | The ticket id lets you join to HubSpot ticket data. Leave out `senders`: it can hold names and email addresses. |

**Personal data.** Messages can hold names, emails and phone numbers: redact them before you send. See [PII redaction](/data/pii-redaction).

## 4. Send it

```js
// hubspot.mjs. Environment: HUBSPOT_TOKEN, DL_API_KEY.


const HOUR = 60 * 60 * 1000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const base = 'https://api.hubspot.com/conversations/v3/conversations';

// Retries HubSpot's rate limits (429) and server errors (5xx), honoring Retry-After when it is sent.
async function get(url) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.HUBSPOT_TOKEN}` } });
    if (res.ok) return res.json();
    if ((res.status !== 429 && res.status < 500) || attempt === 5) throw new Error(`HubSpot returned ${res.status}: ${await res.text()}`);
    await sleep((Number(res.headers.get('retry-after')) || 2 ** attempt) * 1000);
  }
}

// Sends every message written at or after `since` (Unix ms). Returns the new `since`.
export async function syncHubSpot({ since, historical = false }) {
  let newest = since;
  let after = new Date(since).toISOString();
  for (;;) {
    const page = await get(`${base}/threads?sort=latestMessageTimestamp&latestMessageTimestampAfter=${after}&limit=500&association=TICKET`);
    for (const thread of page.results) {
      for (let next = ''; next !== null; ) {
        const res = await get(`${base}/threads/${thread.id}/messages${next ? `?after=${next}` : ''}`);
        for (const m of res.results) {
          const ts = toMillis(m.createdAt);
          if (m.type !== 'MESSAGE' || ts < since || !m.text?.trim()) continue;
          await sendToDimensionLabs({
            text: m.text,
            userId: thread.associatedContactId ? String(thread.associatedContactId) : `hubspot-${thread.id}`,
            incoming: m.direction === 'INCOMING',
            sessionId: `hubspot-${thread.id}`,
            conversationId: `hubspot-${thread.id}`,
            dimensionlabs_timestamp: ts,
            platformJson: { inbox_id: thread.inboxId, status: thread.status, ticket_id: thread.threadAssociations?.associatedTicketId, channel_id: m.channelId },
          }, { historical: historical || ts < Date.now() - HOUR });
          newest = Math.max(newest, ts);
        }
        next = res.paging?.next?.after ?? null;
      }
    }
    const last = page.results.at(-1)?.latestMessageTimestamp;
    if (page.results.length < 500 || !last || last === after) return newest;
    after = last;
  }
}
```

## 5. Run it

Save the code as `hubspot.mjs` in a project set up as in [Set up your project](/api/tracker#set-up-your-project), and run it with the [scheduled sync](/api/scheduled-sync) runner: `import { syncHubSpot as sync } from './hubspot.mjs';`.

- **History first:** `node run.mjs 2025-01-01` sends every message since that date to `/trackhistorical`. Then start a [backfill](/dimensions/backfill) from the **Backfill** tab.
- **Then every 15 minutes:** the scheduled job sends new messages to `/track`, and they are enriched automatically every day. A message that is more than an hour old when the job sees it goes to `/trackhistorical`, and needs a backfill.
- **Re-runs are safe:** duplicates are removed automatically.

You can also [upload a file](/data/upload-a-file) of exported conversations (check it with the [file checker](/data/check-a-file)), then run a backfill.

## HubSpot reference

- [Conversations inbox and messages API guide](https://developers.hubspot.com/docs/api-reference/conversations-conversations-inbox-&-messages-v3/guide)
- [Get a single message (message fields)](https://developers.hubspot.com/docs/api-reference/legacy/conversations/conversations/messages/get-conversation)
- [Private apps](https://developers.hubspot.com/docs/apps/legacy-apps/private-apps/overview)
- [API usage guidelines and limits](https://developers.hubspot.com/docs/developer-tooling/platform/usage-guidelines)
- [Developer platform and API versioning](https://developers.hubspot.com/docs/developer-tooling/platform/versioning)

Checked against HubSpot's documentation on 2026-10-13.
