# Canny Webhooks

[Canny](https://canny.io) collects product feedback: boards, posts, comments and votes. Its webhooks notify your server as those things happen, and every request is signed following the Standard Webhooks specification.

## When to Use This Skill

* How do I receive Canny webhooks?
* How do I verify a Canny webhook signature (`webhook-signature`)?
* Where do I find my Canny webhook signing secret?
* How do I handle `post.created`, `post.status_changed`, `comment.created` or `vote.created`?
* Why does my handler reject Canny retries or replays after a few minutes?
* What are the `canny-timestamp`, `canny-nonce` and `canny-signature` headers?

## Verification (core)

| Header | Value |
| --- | --- |
| `webhook-id` | Unique message ID |
| `webhook-timestamp` | Unix seconds, covered by the signature |
| `webhook-signature` | One or more space-separated `v1,<base64>` entries (two for 24 hours after you regenerate the secret) |

Signed content is `{webhook-id}.{webhook-timestamp}.{raw body}`, HMAC-SHA256, base64. The key is the secret without `whsec_`, **base64-decoded**. Accept the request if any `v1,` entry matches. Canny's docs recommend an official Standard Webhooks library:

```javascript
const { Webhook } = require('standardwebhooks');

// rawBody: exact request body string, NOT re-serialised JSON
function verifyCannyWebhook(rawBody, headers, secret = process.env.CANNY_WEBHOOK_SECRET) {
  return new Webhook(secret).verify(rawBody, {
    'webhook-id': headers['webhook-id'],
    'webhook-timestamp': headers['webhook-timestamp'],
    'webhook-signature': headers['webhook-signature'],
  }); // returns the parsed event; throws -> respond 400
}

```

Python: `from standardwebhooks.webhooks import Webhook`, then `Webhook(secret).verify(await request.body(), {...same three headers...})`.

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/canny-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/canny-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/canny-webhooks/examples/fastapi/).

### The 5-minute window and Hookdeck retries

The `standardwebhooks` and `svix` libraries **always** reject a `webhook-timestamp` more than 5 minutes from your clock, and the window can't be turned off. Hookdeck retries and replays resend the **original** request, including its original `webhook-timestamp`. A delivery retried or replayed more than 5 minutes after Canny sent it fails the library check even though the signature is genuine. If your handler sits behind Hookdeck retries, use the manual verifier in [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/canny-webhooks/references/verification.md): it checks the same HMAC over `{id}.{timestamp}.{raw body}` and compares every `v1,` entry in constant time, and lets you drop or widen the window.

### Legacy `canny-*` headers

Teams that used webhooks before signing secrets existed also receive `canny-timestamp`, `canny-nonce` and `canny-signature`. They are **legacy and being removed**. That signature covers only the nonce (signed with your API key), not the body or timestamp. Ignore them and verify `webhook-signature`.

## Event Envelope

```json
{ "created": "2017-07-15T22:11:00.000Z",
  "objectType": "post",
  "type": "post.created",
  "object": { "id": "6a2889c586d7b8843bf4cf05", "title": "An awesome feature request", "status": "in progress", "board": { "name": "Feature Requests" } } }

```

Dispatch on the body's `type`. There is no event ID in the body, so dedupe on the `webhook-id` header.

## Common Event Types

| Event | Fired 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 / a tag is removed |
| `post.jira_issue_linked` / `post.jira_issue_unlinked` | A Jira issue is linked to / 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 / unvotes on a post |

> **Full payloads:** [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/canny-webhooks/references/overview.md) and [Canny's webhook docs](https://developers.canny.io/api-reference).

## Environment Variables

```bash
CANNY_WEBHOOK_SECRET=whsec_xxxxx   # signing secret from API settings; handlers fail closed (500) without it

```

Add a webhook in **Your Subdomain > Settings > API**. The signing secret appears on that page once a webhook exists.

## Local Development

```bash
# Express / Next.js (use 8000 for FastAPI). No install or account required.
npx hookdeck-cli listen 3000 canny --path /webhooks/canny

```

Add the printed URL as a webhook in Canny's API settings, then create a post or vote in your Canny board.

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/canny-webhooks/references/overview.md) - Event catalog, payload envelope
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/canny-webhooks/references/setup.md) - Adding a webhook, signing secret, rotation
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/canny-webhooks/references/verification.md) - Library and manual verification (configurable window), gotchas, debugging