Skip to content

02 Get your data in /Integrations

Canny

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

  1. C CannyTickets, 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 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.

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

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

Every Canny API call is a POST with a JSON body, even the ones that only read. List posts newest first:

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

Dimension LabsCannyNotes
textpost title and details; comment valueSkip comments where internal is true: those are notes for your team.
userIdauthor.idCanny’s internal user id. On your team’s replies, use the post author’s id; fall back to the sessionId.
incomingauthor.isAdminisAdmin is true for your team, so incoming is its opposite.
sessionIdpost id (on a comment: post.id)Prefix it, for example canny-6a2889c5....
conversationIdpost idSame value as sessionId.
dimensionlabs_timestampcreatedISO 8601. Convert with toMillis.
platformJsonboard.name, category.name, status, tags, score (votes); comment parentID, likeCount, statusauthor.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.

// canny.mjs. Environment: CANNY_API_KEY, DL_API_KEY.
import { sendToDimensionLabs, toMillis } from './dimensionlabs.mjs';
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;
}

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

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