# Pylon

> Send Pylon issues and their messages to Dimension Labs with the universal API.

This brings in each Pylon issue as one conversation: your customers' messages and your team's replies, from Slack, Microsoft Teams, email, the chat widget and the other channels Pylon connects. You run it on your side: a small script pulls from the Pylon API and posts each message to the [universal API](/api/tracker).

## What you need

- A Pylon Admin: only Admins can create API tokens.
- A Pylon API token, and which region your Pylon tenant is in (US or EU).
- 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 Pylon

1. Go to **Settings > Integrations & dev > API tokens** and click **Create API Token**.
2. Enter a **Name**.
3. Leave **Act as user or bot (optional)** empty and pick a **Role** that can read issues, or select a bot.
4. Optionally add an **IP allowlist** with your job's public IP addresses.
5. Click **Create** and copy the token. Pylon shows it only once ([authentication](https://docs.usepylon.com/pylon-docs/developer/api/authentication)).

Send it as `Authorization: Bearer <token>`. Tokens don't expire. Pylon deletes a token when the user who created it, or the user it acts as, is deactivated, so create it from an account that will stay active.

Pylon's API runs in two regions with the same endpoints: US at `https://api.usepylon.com` and EU at `https://api.eu.usepylon.com`. A token that starts with `pylon_api_eu_` is an EU token. Sending a token to the wrong region returns `401` with the `wrong_region_token` error code.

## 2. Pull new issues

[Search issues](https://docs.usepylon.com/pylon-docs/developer/api/api-reference/issues) by when they last changed:

```bash
curl -X POST "https://api.usepylon.com/issues/search" \
  -H "Authorization: Bearer $PYLON_API_TOKEN" -H "Content-Type: application/json" \
  -d '{"filter":{"field":"updated_at","operator":"time_range","values":["2026-10-01T00:00:00Z","2026-10-26T00:00:00Z"]},"limit":1000}'
```

`time_range` includes both ends and has no maximum window. `limit` goes up to 1,000. When the response has `pagination.has_next_page: true`, send `pagination.cursor` as `cursor` in the body of the next request, with the same filter ([conventions](https://docs.usepylon.com/pylon-docs/developer/api/conventions)). Every timestamp needs a time zone (`Z` or an offset).

`GET /issues` filters only on when an issue was created, needs both `start_time` and `end_time`, and allows at most 365 days, so it can't pick up new replies on older issues.

For each issue, [get its messages](https://docs.usepylon.com/pylon-docs/developer/api/api-reference/messages) with `GET /issues/{id}/messages?limit=1000`, oldest first. The list includes internal notes.

Rate limits, per Pylon: each endpoint has its own allowance per organization per minute, shared by all your tokens. `POST /issues/search` and `GET /issues/{id}/messages` allow 120 requests a minute each. Going over returns `429` with a `Retry-After` header in seconds ([rate limits](https://docs.usepylon.com/pylon-docs/developer/api/rate-limits)).

## 3. Map the fields

| Dimension Labs | Pylon | Notes |
|---|---|---|
| `text` | message `message_html`, HTML stripped | Skip messages where `is_private` is `true`: those are internal notes. |
| `userId` | issue `requester.id` | Pylon's internal contact id, on both their messages and your replies. Fall back to the `sessionId`. |
| `incoming` | message `author.contact` | Set when a customer contact wrote the message, so `incoming` is `true`. Your team's messages have `author.user` instead. |
| `sessionId` | issue `id` | Prefix it, for example `pylon-0a1b2c`. |
| `conversationId` | issue `id` | Same value as `sessionId`. |
| `dimensionlabs_timestamp` | message `timestamp` | Convert with `toMillis`. Pylon returns times to the second, so messages with the same time get 1,000 ms added each. |
| `platformJson` | issue `number`, `source`, `state`, `type`, `tags`, `account.id` | `account.id` joins to your account data; `account.external_ids` holds your own ids if you set them. |

**Personal data.** Don't send `author.name`, `author.contact.email`, `email_info` or `requester.email`. Messages can hold names, phone numbers and account details: redact them before you send. See [PII redaction](/data/pii-redaction).

## 4. Send it

```js
// pylon.mjs. Environment: PYLON_API_TOKEN, PYLON_API_URL (EU: https://api.eu.usepylon.com), DL_API_KEY.


const HOUR = 60 * 60 * 1000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const base = process.env.PYLON_API_URL ?? 'https://api.usepylon.com';
const headers = { Authorization: `Bearer ${process.env.PYLON_API_TOKEN}`, 'Content-Type': 'application/json' };
const stripHtml = (html) => String(html ?? '').replace(/<br\s*\/?>|<\/p>/gi, '\n').replace(/<[^>]+>/g, ' ')
  .replace(/&nbsp;/g, ' ').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, '&')
  .replace(/[ \t]+/g, ' ').replace(/\s*\n\s*/g, '\n').trim();

// Retries Pylon's rate limits (429) and server errors (5xx), honoring Retry-After.
async function call(path, body) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(`${base}${path}`, body ? { method: 'POST', headers, body: JSON.stringify(body) } : { headers });
    if (res.ok) return res.json();
    if ((res.status !== 429 && res.status < 500) || attempt === 5) throw new Error(`Pylon returned ${res.status}: ${await res.text()}`);
    await sleep((Number(res.headers.get('retry-after')) || 2 ** attempt) * 1000);
  }
}

async function changedIssues(since) {
  const filter = { field: 'updated_at', operator: 'time_range', values: [new Date(since).toISOString(), new Date().toISOString()] };
  const issues = [];
  for (let cursor; ; ) {
    const page = await call('/issues/search', { filter, limit: 1000, ...(cursor ? { cursor } : {}) });
    issues.push(...page.data);
    if (!page.pagination?.has_next_page) return issues;
    cursor = page.pagination.cursor;
  }
}

async function messages(id) {
  const all = [];
  for (let cursor; ; ) {
    const page = await call(`/issues/${id}/messages?limit=1000${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`);
    all.push(...page.data);
    if (!page.pagination?.has_next_page) return all;
    cursor = page.pagination.cursor;
  }
}

// Sends every customer-visible message written at or after `since` (Unix ms). Returns the new `since`.
export async function syncPylon({ since, historical = false }) {
  let newest = since;
  for (const issue of await changedIssues(since)) {
    let last = 0;
    for (const m of await messages(issue.id)) {
      const written = toMillis(m.timestamp);
      const ts = written <= last ? last + 1000 : written;
      last = ts;
      const text = stripHtml(m.message_html);
      if (m.is_private || written < since || !text) continue;
      await sendToDimensionLabs({
        text,
        userId: issue.requester?.id ?? `pylon-${issue.id}`,
        incoming: Boolean(m.author?.contact),
        sessionId: `pylon-${issue.id}`,
        conversationId: `pylon-${issue.id}`,
        dimensionlabs_timestamp: ts,
        platformJson: { number: issue.number, source: issue.source, message_source: m.source, state: issue.state, type: issue.type, tags: issue.tags, account_id: issue.account?.id },
      }, { historical: historical || ts < Date.now() - HOUR });
      newest = Math.max(newest, written);
    }
  }
  return newest;
}
```

## 5. Run it

Save the code as `pylon.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 { syncPylon as sync } from './pylon.mjs';`. For an EU tenant, also set `PYLON_API_URL=https://api.eu.usepylon.com`.

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

Each issue costs one messages call, and the messages endpoint allows 120 calls a minute shared across your organization, so a history load takes a while and can slow other Pylon integrations; the code waits whenever Pylon asks it to. You can also [upload a file](/data/upload-a-file) of exported issues (check it with the [file checker](/data/check-a-file)), then run a backfill.

## Pylon reference

- [Authentication](https://docs.usepylon.com/pylon-docs/developer/api/authentication)
- [Conventions: timestamps, pagination and search filters](https://docs.usepylon.com/pylon-docs/developer/api/conventions)
- [Rate limits](https://docs.usepylon.com/pylon-docs/developer/api/rate-limits)
- [Issues](https://docs.usepylon.com/pylon-docs/developer/api/api-reference/issues)
- [Messages](https://docs.usepylon.com/pylon-docs/developer/api/api-reference/messages)

Checked against Pylon's documentation on 2026-10-26.
