Skip to content

02 Get your data in /Integrations

Productboard

Send Productboard feedback notes and conversations to Dimension Labs with the universal API.

  1. P ProductboardTickets, 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 Productboard note as one conversation. A text note (feedback captured from a portal, email, the Chrome extension or by your team) arrives as one customer message. A conversation note keeps every part: the customer’s turns and your agents’ replies. You run it on your side: a small script pulls from the Productboard API and posts each message to the universal API.

  • A Productboard workspace on the Pro plan or higher. Productboard’s API tokens aren’t available on the Essentials plan.
  • A Productboard user who can open the workspace’s integration settings and create an API token.
  • A Dimension Labs API key for a new Universal API source. See Your API key.
  1. Sign in to your Productboard workspace.
  2. Go to Settings > Integrations > Public APIs > Access Token. If you don’t see it, the workspace is probably on the Essentials plan.
  3. Click the plus (+) icon to create a token, and copy it. Productboard shows it only once.
  4. Send it as a Bearer token, with Accept: application/json, on every request (API token authentication).

Treat the token like a password, and rotate it from the same settings page.

List notes with updatedFrom set to your cursor:

Terminal window
curl -g -H "Authorization: Bearer $PRODUCTBOARD_TOKEN" -H "Accept: application/json" \
"https://api.productboard.com/v2/notes?updatedFrom=2026-10-01T00:00:00Z&type[]=textNote&type[]=conversationNote"
  • updatedFrom and updatedTo take ISO 8601 date-times and are inclusive. createdFrom and createdTo work the same way.
  • type[] limits the list to textNote, conversationNote or opportunityNote. The code reads text and conversation notes.
  • Results come newest first. Follow links.next (a full URL) until it is null.
  • Owner and creator email addresses come back redacted unless the token has the members:pii:read scope. You don’t need them.

Rate limits, per Productboard: 50 requests per second per token. Going over returns 429 with a Retry-After header (introduction, rate limits).

Dimension LabsProductboardNotes
texttext note: fields.name and fields.content; conversation note: each part’s contentcontent is rich text that can hold HTML: the code strips it. Parts with no text are skipped.
userIdthe note’s customer relationship, when its target is a userProductboard’s internal user id. When the customer is a company, or there is none, fall back to the sessionId.
incomingtext note: always true; conversation part: authorTypeauthorType is customer or agent. Only customer is incoming: true.
sessionIdnote idPrefix it, for example productboard-123e4567-....
conversationIdnote idSame value as sessionId.
dimensionlabs_timestamptext note: createdAt; conversation part: timestampConvert with toMillis. Parts with the same time are spaced 1,000 ms apart so they keep their order.
platformJsontype, fields.name, fields.tags, the company id when the customer is a company, metadata.source.systemThe source system tells you which integration captured the note.

Personal data. Don’t send owner, creator or customer email addresses, or authorName from conversation parts. Notes often quote the customer’s name, email signature or account details: redact them before you send. See PII redaction.

// productboard.mjs. Environment: PRODUCTBOARD_TOKEN, DL_API_KEY.
import { sendToDimensionLabs, toMillis } from './dimensionlabs.mjs';
const HOUR = 60 * 60 * 1000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const headers = { Authorization: `Bearer ${process.env.PRODUCTBOARD_TOKEN}`, Accept: 'application/json' };
const stripHtml = (html) => String(html ?? '').replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, '&').replace(/\s+/g, ' ').trim();
// Retries Productboard's rate limit (429) and server errors (5xx), honoring Retry-After.
async function get(url) {
for (let attempt = 0; ; attempt++) {
const res = await fetch(url, { headers });
if (res.ok) return res.json();
if ((res.status !== 429 && res.status < 500) || attempt === 5) throw new Error(`Productboard returned ${res.status}: ${await res.text()}`);
await sleep((Number(res.headers.get('retry-after')) || 2 ** attempt) * 1000);
}
}
// Turns one note into messages: a text note is one incoming message, a conversation note one message per part.
function toMessages(note) {
const f = note.fields ?? {};
if (note.type === 'conversationNote') {
let last = 0;
return (Array.isArray(f.content) ? f.content : []).map((part) => {
let ts = toMillis(part.timestamp);
if (ts <= last) ts = last + 1000; // parts with the same time keep their order
last = ts;
return { text: stripHtml(part.content), incoming: part.authorType === 'customer', ts, part_id: part.externalId };
});
}
const text = [f.name, stripHtml(f.content)].filter(Boolean).join('\n\n');
return [{ text, incoming: true, ts: toMillis(note.createdAt ?? f.createdAt) }];
}
// Sends every text and conversation note updated at or after `since` (Unix ms). Returns the new `since`.
export async function syncProductboard({ since, historical = false }) {
let newest = since;
const params = new URLSearchParams({ updatedFrom: new Date(since).toISOString() });
params.append('type[]', 'textNote');
params.append('type[]', 'conversationNote');
for (let url = `https://api.productboard.com/v2/notes?${params}`; url; ) {
const page = await get(url);
for (const note of page.data) {
const rels = Array.isArray(note.relationships) ? note.relationships : note.relationships?.data ?? [];
const customer = rels.find((r) => r.type === 'customer')?.target;
const sessionId = `productboard-${note.id}`;
for (const m of toMessages(note)) {
if (!m.text) continue;
await sendToDimensionLabs({
text: m.text,
userId: customer?.type === 'user' ? customer.id : sessionId,
incoming: m.incoming,
sessionId,
conversationId: sessionId,
dimensionlabs_timestamp: m.ts,
platformJson: {
note_type: note.type,
title: note.fields?.name,
tags: note.fields?.tags?.map((t) => t.name),
company_id: customer?.type === 'company' ? customer.id : undefined,
source_system: note.metadata?.source?.system,
part_id: m.part_id,
},
}, { historical: historical || m.ts < Date.now() - HOUR });
}
newest = Math.max(newest, toMillis(note.updatedAt ?? note.fields?.updatedAt));
}
url = page.links?.next ?? null;
}
return newest;
}

Save the code as productboard.mjs in a project set up as in Set up your project, and run it with the scheduled sync runner: import { syncProductboard as sync } from './productboard.mjs';.

  • History first: node run.mjs 2025-01-01 sends every note updated since that date to /trackhistorical. Then start a backfill from the Backfill tab.
  • Then every 15 minutes: the scheduled job sends new and updated notes 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: a note that changes (a new tag, a link to a feature) is sent again, and the duplicates are removed automatically.

One call returns a page of notes, so even a long history takes few requests; the code waits whenever Productboard asks it to.

No API access? If your workspace isn’t on the Pro plan, export your notes instead. Signed in to Productboard as a maker, open https://YOUR_WORKSPACE.productboard.com/api/notes/export in your browser. Productboard emails you a link to a CSV with id, created_at, note_title and note_text, among other columns (exporting your feedback into a CSV). Notes that came in through Zendesk, Slack, Intercom and other third-party integrations arrive with an empty note_text. Map the columns with the converter, check the file with the file checker, then upload it and run a backfill. Leave out the email and name columns.

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