# Featurebase

> Send Featurebase posts and comments to Dimension Labs with the universal API.

This brings in each Featurebase post as one conversation: the feedback post or feature request (title and content) and the public comments on it, from your users and from your team. You run it on your side: a small script pulls from the Featurebase API and posts each message to the [universal API](/api/tracker).

## What you need

- A Featurebase admin who can open **Settings > API** and create an API key.
- 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 Featurebase

1. In your Featurebase dashboard, open **Settings > API**.
2. Create a key and copy it. It starts with `sk_`.
3. Send it as a Bearer token on every request, with a pinned `Featurebase-Version` header ([getting started with the Featurebase API](https://help.featurebase.app/articles/6403913-getting-started-with-the-featurebase-api)).

An API key has full access to your workspace: keep it on your server. Pin the version (the code uses `2026-08-19.orbit`): without the header, your workspace's default applies, and someone can change that default in the dashboard.

## 2. Pull new posts and comments

[List posts](https://help.featurebase.app/api-reference/posts/list-posts) newest first:

```bash
curl -H "Authorization: Bearer $FEATUREBASE_API_KEY" -H "Featurebase-Version: 2026-08-19.orbit" \
  "https://do.featurebase.app/v2/posts?sortBy=createdAt&sortOrder=desc&limit=100"
```

- `limit` is 1 to 100. Pass the response's `nextCursor` as `cursor` until it is `null`.
- There is no "created since" filter: read newest first and stop once you reach posts older than your cursor.
- `kind` defaults to `issue` (requests). In workspaces where Featurebase turns customer submissions into insights, those submissions are `kind=record`. The code reads both.
- `content` is HTML.

[List comments](https://help.featurebase.app/api-reference/comments/list-comments) with `GET /v2/comments?sortBy=new&privacy=public`. Without `postId`, it returns comments across the whole organization, newest first. Each comment carries its `postId` (or a `changelogId` for comments on your changelog).

Featurebase doesn't publish its rate limits. Going over returns `429` (`rate_limit_error`); Featurebase asks you to retry only `429` and `5xx` responses, with exponential backoff.

## 3. Map the fields

| Dimension Labs | Featurebase | Notes |
|---|---|---|
| `text` | post `title` and `content`; comment `content` | Both are HTML: the code strips it. Skip comments where `isPrivate`, `isDeleted` or `isSpam` is `true`; private comments are visible only to admins. |
| `userId` | `author.id` | Featurebase's internal id. On your team's replies, and when `author.id` is `null`, fall back to the `sessionId`. |
| `incoming` | `author.type` | `customer`, `guest` and `lead` are `incoming: true`. `admin`, `integration` and `bot` are your side. |
| `sessionId` | post `id` (on a comment: `postId`) | Prefix it, for example `featurebase-abc123`. The code skips changelog comments, which have no `postId`. |
| `conversationId` | post `id` | Same value as `sessionId`. |
| `dimensionlabs_timestamp` | `createdAt` | ISO 8601. Convert with `toMillis`. |
| `platformJson` | post `kind`, `boardId`, `status.name`, `tags`, `upvotes`; comment `id`, `parentCommentId`, `upvotes`, `author.type` | `boardId` tells feedback boards apart. |

**Personal data.** Don't send `author.email`, `author.name` or `author.profilePicture`. Posts and comments can hold names, emails and account details: redact them before you send. See [PII redaction](/data/pii-redaction).

## 4. Send it

```js
// featurebase.mjs. Environment: FEATUREBASE_API_KEY, DL_API_KEY.


const HOUR = 60 * 60 * 1000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const headers = { Authorization: `Bearer ${process.env.FEATUREBASE_API_KEY}`, 'Featurebase-Version': '2026-08-19.orbit' };
const CUSTOMER = new Set(['customer', 'guest', 'lead']); // admin, integration and bot are your side
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 Featurebase's rate limits (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(`Featurebase returned ${res.status}: ${await res.text()}`);
    await sleep((Number(res.headers.get('retry-after')) || 2 ** attempt) * 1000);
  }
}

// Reads a newest-first list until it reaches items created before `since`.
async function newSince(path, since) {
  const items = [];
  for (let cursor = null, first = true; first || cursor; first = false) {
    const page = await get(`https://do.featurebase.app/v2/${path}&limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`);
    const fresh = page.data.filter((x) => toMillis(x.createdAt) >= since);
    items.push(...fresh);
    cursor = fresh.length < page.data.length ? null : page.nextCursor;
  }
  return items;
}

// Sends every post and public comment created at or after `since` (Unix ms). Returns the new `since`.
export async function syncFeaturebase({ since, historical = false }) {
  let newest = since;
  const send = async (msg) => {
    if (!msg.text) return;
    const ts = msg.dimensionlabs_timestamp;
    await sendToDimensionLabs(msg, { historical: historical || ts < Date.now() - HOUR });
    newest = Math.max(newest, ts);
  };
  // Requests (kind=issue) and customer submissions filed as insights (kind=record).
  for (const kind of ['issue', 'record']) {
    for (const p of await newSince(`posts?kind=${kind}&sortBy=createdAt&sortOrder=desc`, since)) {
      const sessionId = `featurebase-${p.id}`;
      await send({
        text: [p.title, stripHtml(p.content)].filter(Boolean).join('\n\n'),
        userId: p.author?.id ?? sessionId,
        incoming: CUSTOMER.has(p.author?.type),
        sessionId,
        conversationId: sessionId,
        dimensionlabs_timestamp: toMillis(p.createdAt),
        platformJson: { kind: p.kind, board_id: p.boardId, status: p.status?.name, tags: p.tags?.map((t) => t.name), upvotes: p.upvotes },
      });
    }
  }
  for (const c of await newSince('comments?sortBy=new&privacy=public', since)) {
    if (!c.postId || c.isPrivate || c.isDeleted || c.isSpam) continue; // changelog comments have no postId
    const sessionId = `featurebase-${c.postId}`;
    const incoming = CUSTOMER.has(c.author?.type);
    await send({
      text: stripHtml(c.content),
      userId: (incoming && c.author?.id) || sessionId,
      incoming,
      sessionId,
      conversationId: sessionId,
      dimensionlabs_timestamp: toMillis(c.createdAt),
      platformJson: { comment_id: c.id, parent_id: c.parentCommentId, upvotes: c.upvotes, author_type: c.author?.type },
    });
  }
  return newest;
}
```

## 5. Run it

Save the code as `featurebase.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 { syncFeaturebase as sync } from './featurebase.mjs';`.

- **History first:** `node run.mjs 2025-01-01` sends every post and comment since that date to `/trackhistorical`. Then start a [backfill](/dimensions/backfill) from the **Backfill** tab.
- **Then every 15 minutes:** the scheduled job sends new posts and comments 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.

Posts and comments come from organization-wide lists, so a run costs a few calls, not one per post; the code waits whenever Featurebase asks it to. You can also [upload a file](/data/upload-a-file) of exported posts (check it with the [file checker](/data/check-a-file)), then run a backfill.

## Featurebase reference

- [Getting started with the Featurebase API](https://help.featurebase.app/articles/6403913-getting-started-with-the-featurebase-api)
- [List all posts](https://help.featurebase.app/api-reference/posts/list-posts)
- [List comments](https://help.featurebase.app/api-reference/comments/list-comments)

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