# Productboard

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

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](/api/tracker).

## What you need

- 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](/api/tracker#your-api-key).

## 1. Get API access in Productboard

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](https://developer.productboard.com/reference/api-token)).

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

## 2. Pull new notes

[List notes](https://developer.productboard.com/reference/listnotes) with `updatedFrom` set to your cursor:

```bash
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](https://developer.productboard.com/reference/introduction), [rate limits](https://developer.productboard.com/reference/rate-limits)).

## 3. Map the fields

| Dimension Labs | Productboard | Notes |
|---|---|---|
| `text` | text note: `fields.name` and `fields.content`; conversation note: each part's `content` | `content` is rich text that can hold HTML: the code strips it. Parts with no text are skipped. |
| `userId` | the note's `customer` relationship, when its target is a `user` | Productboard's internal user id. When the customer is a company, or there is none, fall back to the `sessionId`. |
| `incoming` | text note: always `true`; conversation part: `authorType` | `authorType` is `customer` or `agent`. Only `customer` is `incoming: true`. |
| `sessionId` | note `id` | Prefix it, for example `productboard-123e4567-...`. |
| `conversationId` | note `id` | Same value as `sessionId`. |
| `dimensionlabs_timestamp` | text note: `createdAt`; conversation part: `timestamp` | Convert with `toMillis`. Parts with the same time are spaced 1,000 ms apart so they keep their order. |
| `platformJson` | `type`, `fields.name`, `fields.tags`, the company id when the customer is a company, `metadata.source.system` | The 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](/data/pii-redaction).

## 4. Send it

```js
// productboard.mjs. Environment: PRODUCTBOARD_TOKEN, DL_API_KEY.


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;
}
```

## 5. Run it

Save the code as `productboard.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 { 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](/dimensions/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](https://support.productboard.com/hc/en-us/articles/360056354854)). 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](/data/convert-a-file), check the file with the [file checker](/data/check-a-file), then [upload it](/data/upload-a-file) and run a backfill. Leave out the email and name columns.

## Productboard reference

- [Introduction to the Productboard API](https://developer.productboard.com/reference/introduction)
- [Authentication](https://developer.productboard.com/reference/authentication)
- [API token authentication](https://developer.productboard.com/reference/api-token)
- [List notes](https://developer.productboard.com/reference/listnotes)
- [Rate limits](https://developer.productboard.com/reference/rate-limits)
- [Exporting your feedback into a CSV](https://support.productboard.com/hc/en-us/articles/360056354854)

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