Guide to Canny Webhooks: Features and Best Practices
Canny webhooks tell your systems when customer feedback changes: someone posts a feature request, an admin changes its status, a customer comments or votes, or a post is linked to a Jira issue. If you collect feedback on Canny, webhooks are how that activity reaches your product tools, CRM, data warehouse or Slack as it happens.
This guide covers how Canny webhooks are set up and signed, how secret rotation and the legacy headers work, the events and their payload, and the practices that keep a Canny receiver reliable.
What are Canny webhooks?
You add a webhook URL in your Canny settings, and Canny sends a request to it for each event, carrying a JSON event object. The event object names the event in type, the kind of object in objectType, and includes the post, comment or vote itself in object.
Canny signs every request with the Standard Webhooks specification. Each request carries webhook-id, webhook-timestamp and webhook-signature headers, and the signing secret starts with whsec_.
Older accounts see more headers. Teams that used Canny webhooks before signing secrets were introduced also receive canny-timestamp, canny-nonce and canny-signature. That legacy signature covers only the nonce, signed with your API key, so it protects neither the body nor the timestamp. Canny says to switch to webhook-signature and that the legacy headers will be removed.
Canny webhook features
| Feature | Details |
|---|---|
| Configuration | Your Subdomain > Settings > API |
| Subscription | No per-event selection is documented |
| Payload | Event object: created, object, objectType, type |
| Signature headers | webhook-id, webhook-timestamp, webhook-signature |
| Scheme | Standard Webhooks: HMAC-SHA256, base64, entries prefixed with v1, |
| Signed content | {webhook-id}.{webhook-timestamp}.{raw body} |
| Secret | A whsec_ signing secret, shown in API settings once a webhook exists |
| Secret rotation | Regenerating double-signs for 24 hours. Revoking takes effect immediately |
| Replay window | Canny suggests rejecting requests "older than a few minutes", without a number |
| Legacy headers | canny-timestamp, canny-nonce, canny-signature for older accounts, to be removed |
Canny doesn't document a timeout, a retry policy or source IPs for webhooks.
Common events
Canny describes its list as "all types of events we send".
| Event | Fires when |
|---|---|
post.created, post.edited, post.deleted | A post is created, edited or deleted |
post.status_changed | A post's status changes |
post.tag_added, post.tag_removed | A post is tagged, or a tag is removed |
post.jira_issue_linked, post.jira_issue_unlinked | A Jira issue is linked to or unlinked from a post |
comment.created, comment.edited, comment.deleted | A portal comment is created, edited or deleted |
vote.created, vote.deleted | A user votes on a post, or removes their vote |
Here is Canny's example event object for post.created, trimmed:
{
"created": "2017-07-15T22:11:00.000Z",
"object": {
"id": "6a2889c586d7b8843bf4cf05",
"author": {
"id": "6a2889c586d7b8843bf4cf07",
"email": "test@test.test",
"isAdmin": false,
"name": "Sally Doe",
"userID": "1234"
},
"board": {
"id": "6a2889c586d7b8843bf4cf01",
"name": "Feature Requests",
"url": "https://your-company.canny.io/admin/board/feature-requests"
},
"commentCount": 10,
"details": "Test post details",
"eta": "February 2020",
"score": 72,
"status": "in progress",
"title": "An awesome feature request",
"url": "https://your-company.canny.io/admin/board/feature-requests/p/post-title"
},
"objectType": "post",
"type": "post.created"
}
Route on type. objectType tells you which shape object has: a post, a comment or a vote. Canny publishes an example only for post.created. Comment and vote events carry those objects, whose fields are described in Canny's REST API reference for comments and votes. The body has no event ID, so use the webhook-id header to identify a message.
Setting up Canny webhooks
- In Canny, go to Your Subdomain > Settings > API and add your webhook URL.
- Once a webhook exists, the signing secret appears on the same page. Copy it and store it server-side, for example as
CANNY_WEBHOOK_SECRET. - Trigger an event, such as creating a post or voting on one in a test board, and check that your endpoint receives it.
To rotate the secret, regenerate it on the same page. For 24 hours Canny signs every request with both the old and the new secret, so you can deploy the new one inside that window without failed verifications. If the secret has leaked, revoke the old one immediately instead.
If you receive Canny webhooks through Hookdeck, create a source with the Canny source type, add its URL as the webhook URL in Canny, and paste the whsec_ secret into the source. Hookdeck verifies the webhook-* headers and ignores the legacy canny-* headers.
Securing Canny webhooks
Join webhook-id, webhook-timestamp and the raw request body with dots. Take the part of the secret after whsec_, base64-decode it, and use those bytes as the HMAC-SHA256 key. Base64-encode the result and compare it in constant time with each v1, entry in the space-separated webhook-signature header. A match on any entry is enough, which is what makes the 24-hour rotation overlap work.
Canny says you can reject requests older than a few minutes. The check is optional in the code below and off by default. If you turn it on, choose the window yourself, and remember that a retry or a replay keeps the original timestamp.
const crypto = require('crypto');
const express = require('express');
function verifyCannySignature(rawBody, headers, secret, toleranceSeconds = 0) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatureHeader = headers['webhook-signature'];
if (!id || !timestamp || !signatureHeader || !secret || !secret.startsWith('whsec_')) return false;
if (!/^\d+$/.test(timestamp)) return false;
// Optional replay check, off by default.
if (toleranceSeconds > 0 && Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
return false;
}
const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
const expected = crypto.createHmac('sha256', key).update(`${id}.${timestamp}.`).update(rawBody).digest();
return signatureHeader.split(' ').some((entry) => {
const [version, signature] = entry.split(',');
if (version !== 'v1' || !signature) return false;
const received = Buffer.from(signature, 'base64');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
const app = express();
app.post('/webhooks/canny', express.raw({ type: '*/*' }), (req, res) => {
const secret = process.env.CANNY_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500); // fail closed
if (!verifyCannySignature(req.body, req.headers, secret)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
// Deduplicate on req.headers['webhook-id'], route on event.type, then process asynchronously.
res.sendStatus(200);
});
The same check in Python:
import base64
import binascii
import hashlib
import hmac
import time
def verify_canny_signature(raw_body: bytes, headers, secret: str, tolerance_seconds: int = 0) -> bool:
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signature_header = headers.get("webhook-signature")
if not (msg_id and timestamp and signature_header and secret and secret.startswith("whsec_")):
return False
if not timestamp.isdigit():
return False
# Optional replay check, off by default.
if tolerance_seconds > 0 and abs(time.time() - int(timestamp)) > tolerance_seconds:
return False
try:
key = base64.b64decode(secret[len("whsec_"):])
except (binascii.Error, ValueError):
return False
signed = f"{msg_id}.{timestamp}.".encode("utf-8") + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())
for entry in signature_header.split(" "):
version, _, signature = entry.partition(",")
if version == "v1" and hmac.compare_digest(signature.encode("utf-8"), expected):
return True
return False
# In FastAPI: raw_body = await request.body()
# verify_canny_signature(raw_body, request.headers, os.environ["CANNY_WEBHOOK_SECRET"])
Canny's docs recommend the official Standard Webhooks libraries, which take the whsec_ secret, the raw body and the headers. Those libraries always reject a webhook-timestamp more than 5 minutes from your clock, and the window can't be turned off. A delivery that reaches your code later than that, such as a Hookdeck retry or replay, fails even though its signature is valid. The Canny skill's examples use the standardwebhooks library too, so they have the same 5-minute window. Use a manual verifier like the one above when deliveries can arrive late.
To check your verifier, use the secret whsec_, the ID msg_, the timestamp 1761400000 and this exact body:
{"created":"2026-10-25T13:46:40.000Z","objectType":"vote","type":"vote.created","object":{"id":"6a2889c586d7b8843bf4cf05"}}
The webhook-signature header should be:
v1,w6KGHR5tS283tmPpb3NIWIn18g89BXCVya8Ssx9wS9E=
Make Canny webhooks production-ready. Hookdeck Event Gateway verifies the Standard Webhooks signature on every Canny delivery, keeps verifying through a secret rotation, and queues each post, comment and vote event until your service has processed it.
Canny webhook limitations and pain points
Legacy headers that look like a signature
The Problem: Your receiver verifies canny-signature, and forged bodies pass.
Why It Happens: Accounts that used webhooks before signing secrets still receive canny-timestamp, canny-nonce and canny-signature. That signature covers only the nonce, so anyone who has captured one request can attach its headers to a different body.
Workarounds:
- Verify
webhook-signatureand ignore thecanny-*headers. - Remove any old verification code that used your API key as the key. Canny will stop sending the legacy headers.
How Hookdeck Can Help: Hookdeck's Canny source type verifies only the Standard Webhooks headers, so the legacy signature plays no part in deciding whether a request is genuine.
The library's fixed 5-minute window
The Problem: Genuine events fail verification when they are retried or replayed.
Why It Happens: The standardwebhooks libraries reject any webhook-timestamp more than 5 minutes from the current time. Retries and replays keep the original timestamp, because it is part of the signed content.
Workarounds:
- Verify manually and choose your own window, or drop the window and deduplicate on
webhook-id. - Run verification as soon as the request arrives, before it is queued.
How Hookdeck Can Help: Hookdeck verifies the signature when the request arrives and applies no timestamp window, so retrying or replaying an event from Hookdeck doesn't fail verification at the source.
No documented retries or timeout
The Problem: You can't tell what happens to an event if your receiver is down.
Why It Happens: Canny's webhook docs don't describe a success code, a timeout or a retry policy.
Workarounds:
- Return a
2xxquickly and do the work afterwards. - Reconcile periodically through the Canny REST API, for example with the List posts, List votes and List status changes endpoints, so a lost event is eventually picked up.
How Hookdeck Can Help: Hookdeck accepts each delivery as soon as it arrives and stores it, then delivers to your service with retries you configure, so an outage on your side doesn't depend on whatever Canny does.
Only one example payload
The Problem: Comment and vote handlers break on fields you assumed would be there.
Why It Happens: Canny publishes a full example only for post.created. The other events carry comment or vote objects described in the REST reference, not as webhook examples.
Workarounds:
- Branch on
objectTypefirst, and treat every field as optional. - Log the first delivery of each event type before you build on it.
How Hookdeck Can Help: Hookdeck keeps the full body of every request, so you can inspect real comment and vote payloads and replay them against your handler while you build it.
Best practices
Verify against the raw body
Canny says to pass the body "exactly as received, before parsing it as JSON". Use express.raw({ type: '*/*' }) in Express, await request.text() in a Next.js route handler and await request.body() in FastAPI.
Deduplicate on webhook-id
There is no ID in the body, so the webhook-id header is the message identifier. Record it before processing and skip repeats. See our guide to webhook idempotency.
Route on type, then read by objectType
Switch on type for the action and use objectType to decide how to read object. Log and acknowledge any type you don't recognise.
Rotate inside the 24-hour overlap
When you regenerate the secret, deploy the new value within 24 hours, while Canny still signs with both. Accept a request when any v1, entry matches. Revoke immediately only if the old secret has leaked, and expect failed verifications until the new one is deployed.
Acknowledge fast and process asynchronously
Verify, store the event, return 200, and call other systems afterwards. See why to process webhooks asynchronously.
Conclusion
Canny signs every webhook with Standard Webhooks: an HMAC-SHA256 of webhook-id, webhook-timestamp and the raw body, keyed with the decoded whsec_ secret. Verify webhook-signature and ignore the legacy canny-* headers, accept any matching v1, entry so rotation works, deduplicate on webhook-id, and choose your own timestamp window rather than the libraries' fixed 5 minutes if deliveries can arrive late.
Hookdeck Event Gateway sits in front of your receiver, checks every Canny signature, and holds each event until your service has processed it.
Get started with Hookdeck for free and handle Canny webhooks reliably in minutes.