02 Get your data in /Integrations
Canny
Send Canny posts and comments to Dimension Labs with the universal API.
- C CannyTickets, chats, calls, reviews
- ⟳ Your jobPull new · map fields · every 15 min
- { } Universal APIPOST /track · /trackhistorical
- 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.
What you need
Section titled “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.
1. Get API access in Canny
Section titled “1. Get API access in Canny”- In Canny, open your admin Settings and go to the API page (
https://canny.io/admin/settings/api). - Copy your secret API key.
- Send it as the
apiKeyparameter in the body of every request (authentication).
The key is secret: keep it on your server, never in a browser or app.
2. Pull new posts and comments
Section titled “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 newest first:
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
skipandlimit, and the response’shasMoresays 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/listpages with a cursor instead: pass the response’scursoruntilhasNextPageisfalse.limitis 1 to 100. Comments come back newest first.boardIDandpostIDare 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).
3. Map the fields
Section titled “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.
4. Send it
Section titled “4. Send it”// 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;}5. Run it
Section titled “5. Run it”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-01sends 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 reference
Section titled “Canny reference”- 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.