# Featurebase Webhooks

[Featurebase](https://featurebase.app) is a feedback, roadmap, changelog and customer support platform. Its webhooks (Growth plan and higher) report Requests (`post.*`), tickets, comments, Updates (changelogs) and inbox conversations. Each delivery is a `POST` signed with a hex HMAC-SHA256 in `X-Webhook-Signature`, computed over `{X-Webhook-Timestamp}.{raw body}`.

This is **not** Standard Webhooks: nothing is base64-decoded, there is no id header and the digest is hex. Do not use the `standardwebhooks` or `svix` libraries; verify manually with the code below.

## When to Use This Skill

* How do I receive Featurebase webhooks?
* How do I verify a Featurebase webhook signature (`X-Webhook-Signature`)?
* Where do I find my Featurebase webhook signing secret?
* How do I handle `post.created`, `comment.created` or `changelog.published` events?
* Why is my Featurebase webhook signature verification failing?
* Should I reject old `X-Webhook-Timestamp` values?

## Verification (core)

The signing secret starts with `whsec_`. Paste the whole secret, including the whsec_ prefix. Featurebase uses it as a plain HMAC key. Do not strip the prefix or base64-decode it.

```javascript
const crypto = require('crypto');

function verifyFeaturebaseWebhook(rawBody, signature, timestamp, secret) {
  // rawBody: Buffer of the exact bytes received. Validate the format before decoding.
  if (!secret || !signature || !timestamp || !/^[0-9a-fA-F]{64}$/.test(signature)) return false;
  const signed = Buffer.concat([Buffer.from(timestamp, 'latin1'), Buffer.from('.'), rawBody]);
  const expected = crypto.createHmac('sha256', Buffer.from(secret, 'utf8')).update(signed).digest();
  const provided = Buffer.from(signature.toLowerCase(), 'hex'); // hex is case-insensitive
  return provided.length === expected.length && crypto.timingSafeEqual(provided, expected);
}
// Headers: x-webhook-signature, x-webhook-timestamp. Return 401 when this is false.

```

```python
import hashlib, hmac, re

def verify_featurebase_webhook(raw_body: bytes, signature: str, timestamp: str, secret: str) -> bool:
    if not secret or not signature or not timestamp or not re.fullmatch(r"[0-9a-fA-F]{64}", signature):
        return False
    signed = timestamp.encode("latin-1") + b"." + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).digest()
    return hmac.compare_digest(bytes.fromhex(signature), expected)  # bytes, never str

```

Rules that matter:

* Sign **bytes**: the timestamp header value as received (latin1), a literal `.`, then the raw body bytes. Never `JSON.parse`/`JSON.stringify` or decode and re-encode the body first.
* Read the raw body: `express.raw({ type: '*/*' })` in Express, `await request.arrayBuffer()` in Next.js, `await request.body()` in FastAPI.
* Check the signature is exactly 64 hex characters before decoding. Compare the whole header value as one digest, with a constant-time comparison.
* Missing or empty headers, a malformed signature or a mismatch: respond `401`.
* **Timestamp window is off by default.** Hookdeck retries and replays resend the original headers and timestamp, and Hookdeck applies no tolerance itself, so a fixed window would reject legitimate retries. Featurebase's own sample rejects timestamps older than 5 minutes. If you receive directly and want that, opt in with `FEATUREBASE_WEBHOOK_TOLERANCE_SECONDS` (unset or `0` = off). `X-Webhook-Timestamp` is Unix seconds.

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

## Events

Dispatch on the body's `topic` field; `data.item` holds the changed object and update events add `data.changes`. Common topics: `post.created`, `post.updated`, `post.deleted`, `post.voted`, `ticket.created`, `ticket.updated`, `ticket.deleted`, `comment.created`, `comment.updated`, `comment.deleted`, `changelog.published`, `conversation.user.created`, `conversation.admin.assigned`, `conversation.admin.closed`. The full list of 27 topics is in [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/featurebase-webhooks/references/overview.md).

## Delivery

Featurebase needs `200`, `201`, `202` or `204` within 5 seconds (the API can raise the timeout to 30 seconds); anything else is a failure. It retries up to 3 times, pauses a webhook after 100 consecutive failures and disables it after 500 failures or 3 pauses in 24 hours. Events are not kept while a webhook is paused or disabled. The event `id` (`notif_...`) is the same on every retry, so dedupe on it.

## Environment Variables

```bash
FEATUREBASE_WEBHOOK_SECRET=whsec_your_signing_secret_here
# Optional, unset or 0 = off:
FEATUREBASE_WEBHOOK_TOLERANCE_SECONDS=300

```

## Local Development

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

```

Add the printed URL in **Settings → Developers → Webhooks** (see [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/featurebase-webhooks/references/setup.md)). Paste the whole secret, including the whsec_ prefix. Featurebase uses it as a plain HMAC key.

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/featurebase-webhooks/references/overview.md) - All 27 topics, payload shape, delivery and retries
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/featurebase-webhooks/references/setup.md) - Dashboard and API setup, signing secret, rotation
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/featurebase-webhooks/references/verification.md) - Signature details, timestamp tolerance, gotchas, debugging