# Canny

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

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

## What you need

- A Canny admin who can open the company settings and copy the secret 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 Canny

1. In Canny, open your admin **Settings** and go to the **API** page (`https://canny.io/admin/settings/api`).
2. Copy your **secret API key**.
3. Send it as the `apiKey` parameter in the body of every request ([authentication](https://developers.canny.io/api-reference)).

The key is secret: keep it on your server, never in a browser or app.

## 2. Pull new posts and comments

Every Canny API call is a `POST` with a JSON body, even the ones that only read. [List posts](https://developers.canny.io/api-reference) newest first:

```bash
curl https://canny.io/api/v1/posts/list -X POST \
  -H "Content-Type: application/json" \
  -d '{"apiKey":"'"$CANNY_API_KEY"'","sort":"newest","limit":100,"skip":0}'
```

- Posts page with `skip` and `limit`, and the response's `hasMore` says whether there are more. There is no "updated since" filter: read newest first and stop once you reach posts older than your cursor.
- [List portal comments](https://developers.canny.io/api-reference) at `POST https://canny.io/api/v2/comments/list` pages with a cursor instead: pass the response's `cursor` until `hasNextPage` is `false`. `limit` is 1 to 100. Comments come back newest first. `boardID` and `postID` are optional filters; the code leaves them out to read every board at once.

Rate limits, per Canny, are counted per API key: 20 requests per second, 600 per minute and 15,000 per hour on Business; 10, 300 and 5,000 on Core, Starter, Growth and Pro; 5, 100 and 1,000 on Free. Going over returns `429` with a `Retry-After` header ([rate limits](https://developers.canny.io/api-reference)).

## 3. Map the fields

| Dimension Labs | Canny | Notes |
|---|---|---|
| `text` | post `title` and `details`; comment `value` | Skip comments where `internal` is `true`: those are notes for your team. |
| `userId` | `author.id` | Canny's internal user id. On your team's replies, use the post author's id; fall back to the `sessionId`. |
| `incoming` | `author.isAdmin` | `isAdmin` is `true` for your team, so `incoming` is its opposite. |
| `sessionId` | post `id` (on a comment: `post.id`) | Prefix it, for example `canny-6a2889c5...`. |
| `conversationId` | post `id` | Same value as `sessionId`. |
| `dimensionlabs_timestamp` | `created` | ISO 8601. Convert with `toMillis`. |
| `platformJson` | `board.name`, `category.name`, `status`, `tags`, `score` (votes); comment `parentID`, `likeCount`, `status` | `author.userID` is your own app's id for the user, when you've set it: keep it to join to account data. |

A comment marked `private` is still a customer's comment (it's hidden from other end users, not from you), so the code keeps it.

**Personal data.** Don't send `author.email` or `author.name`. 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
// canny.mjs. Environment: CANNY_API_KEY, DL_API_KEY.


const HOUR = 60 * 60 * 1000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// Every Canny call is a POST with a JSON body. Retries 429 and 5xx, honoring Retry-After.
async function call(path, body) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(`https://canny.io/api/${path}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ apiKey: process.env.CANNY_API_KEY, ...body }),
    });
    if (res.ok) return res.json();
    if ((res.status !== 429 && res.status < 500) || attempt === 5) throw new Error(`Canny returned ${res.status}: ${await res.text()}`);
    await sleep((Number(res.headers.get('retry-after')) || 2 ** attempt) * 1000);
  }
}

// Sends every post and public comment created at or after `since` (Unix ms). Returns the new `since`.
export async function syncCanny({ since, historical = false }) {
  let newest = since;
  const send = async (msg) => {
    if (!msg.text?.trim()) return;
    const ts = msg.dimensionlabs_timestamp;
    await sendToDimensionLabs(msg, { historical: historical || ts < Date.now() - HOUR });
    newest = Math.max(newest, ts);
  };

  // New posts, newest first. Stop at the first page that reaches back past `since`.
  for (let skip = 0; ; ) {
    const page = await call('v1/posts/list', { sort: 'newest', limit: 100, skip });
    const fresh = page.posts.filter((p) => toMillis(p.created) >= since);
    for (const p of fresh) {
      const sessionId = `canny-${p.id}`;
      await send({
        text: [p.title, p.details].filter(Boolean).join('\n\n'),
        userId: p.author?.id ?? sessionId,
        incoming: !p.author?.isAdmin,
        sessionId,
        conversationId: sessionId,
        dimensionlabs_timestamp: toMillis(p.created),
        platformJson: { board: p.board?.name, category: p.category?.name, status: p.status, tags: p.tags?.map((t) => t.name), score: p.score, customer_user_id: p.author?.userID },
      });
    }
    skip += page.posts.length;
    if (!page.hasMore || !page.posts.length || fresh.length < page.posts.length) break;
  }

  // New comments on every board, newest first.
  for (let cursor; ; ) {
    const page = await call('v2/comments/list', { limit: 100, ...(cursor ? { cursor } : {}) });
    const fresh = page.items.filter((c) => toMillis(c.created) >= since);
    for (const c of fresh) {
      if (c.internal || !c.post?.id) continue; // internal comments are notes for your team
      const sessionId = `canny-${c.post.id}`;
      const incoming = !c.author?.isAdmin;
      await send({
        text: c.value,
        userId: (incoming ? c.author?.id : c.post.author?.id) ?? sessionId,
        incoming,
        sessionId,
        conversationId: sessionId,
        dimensionlabs_timestamp: toMillis(c.created),
        platformJson: { board: c.board?.name, comment_id: c.id, parent_id: c.parentID, status_change: c.status, likes: c.likeCount, customer_user_id: incoming ? c.author?.userID : undefined },
      });
    }
    if (!page.hasNextPage || fresh.length < page.items.length) break;
    cursor = page.cursor;
  }
  return newest;
}
```

## 5. Run it

Save the code as `canny.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 { syncCanny as sync } from './canny.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 two lists, so a run costs a few calls, not one per post. The code stays well inside Canny's hourly limit and waits whenever Canny 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.

## Canny reference

- [Canny API reference](https://developers.canny.io/api-reference): authentication, pagination, rate limits, the post object, list posts, the portal comment object and list portal comments

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