# 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:

```json
{
  "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

1. In Canny, go to **Your Subdomain > Settings > API** and add your webhook URL.
2. 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`.
3. 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.

```javascript
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:

```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_Y2FubnktZ3VpZGUtdGVzdC1zaWduaW5nLWtleS0zMmI=`, the ID `msg_2mZcFJ0RZbq6Vw3uWmQ1p9cX`, 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](/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-signature` and ignore the `canny-*` 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 `2xx` quickly 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](/docs/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 `objectType` first, 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](/webhooks/guides/implement-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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## 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](https://hookdeck.com) sits in front of your receiver, checks every Canny signature, and holds each event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Canny webhooks reliably in minutes.