Skip to content

02 Get your data in /Integrations

Pylon

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

  1. P PylonTickets, chats, calls, reviews
  2. ⟳ Your jobPull new · map fields · every 15 min
  3. { } Universal APIPOST /track · /trackhistorical
  4. Dimension LabsRead in full · enriched daily

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.

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

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.

Search issues by when they last changed:

Terminal window
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). 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 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).

Dimension LabsPylonNotes
textmessage message_html, HTML strippedSkip messages where is_private is true: those are internal notes.
userIdissue requester.idPylon’s internal contact id, on both their messages and your replies. Fall back to the sessionId.
incomingmessage author.contactSet when a customer contact wrote the message, so incoming is true. Your team’s messages have author.user instead.
sessionIdissue idPrefix it, for example pylon-0a1b2c.
conversationIdissue idSame value as sessionId.
dimensionlabs_timestampmessage timestampConvert with toMillis. Pylon returns times to the second, so messages with the same time get 1,000 ms added each.
platformJsonissue number, source, state, type, tags, account.idaccount.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.

// pylon.mjs. Environment: PYLON_API_TOKEN, PYLON_API_URL (EU: https://api.eu.usepylon.com), DL_API_KEY.
import { sendToDimensionLabs, toMillis } from './dimensionlabs.mjs';
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;
}

Save the code as pylon.mjs in a project set up as in Set up your project, and run it with the 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 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 of exported issues (check it with the file checker), then run a backfill.

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