# Guide to Featurebase Webhooks: Features and Best Practices

Featurebase webhooks tell your systems when something changes in your feedback and support workspace: a customer posts a feature request, a request changes status, someone upvotes, an Update is published, or a conversation gets a reply. Featurebase combines feedback boards, roadmaps, changelogs and a support inbox, and webhooks keep your own tools in sync with it without polling the API.

This guide covers how Featurebase webhooks are registered and signed, the topics and envelope, the retry and pause rules, and the practices that keep a Featurebase receiver reliable.

## What are Featurebase webhooks?

A Featurebase webhook is an HTTPS URL subscribed to a list of topics. Webhooks are available on paid plans (Growth and higher), and a workspace can have up to 10. Each event arrives as a `POST` with a JSON envelope that names the `topic` and carries the changed object in `data.item`.

Every request has two headers: `X-Webhook-Signature`, a hex HMAC-SHA256 of `<timestamp>.<raw body>`, and `X-Webhook-Timestamp`, the Unix time in seconds. The signing secret starts with `whsec_`, but this isn't Standard Webhooks. The whole string, prefix included, is the HMAC key, and there are no `webhook-id` or `webhook-signature` headers.

## Featurebase webhook features

| Feature | Details |
| --- | --- |
| Plans | Growth and higher |
| Configuration | **Settings > Developers > Webhooks**, or `POST /v2/webhooks` |
| Limit | 10 webhooks per workspace |
| Signature header | `X-Webhook-Signature`, hex |
| Timestamp header | `X-Webhook-Timestamp`, Unix seconds |
| Scheme | HMAC-SHA256 |
| Signed content | `<timestamp>.<raw body>` |
| Secret | `whsec_...`, used whole as the key. Rotatable; the old secret stops at once |
| Response window | 5 seconds by default. The API can set up to 30 seconds |
| Success codes | `200`, `201`, `202` or `204` only |
| Retries | Up to 3: 1 minute, 1 hour and 6 hours after each failed attempt |
| Pause and disable | Paused after 100 failures in a row. Disabled after 500 failures, or 3 pauses, in 24 hours |
| Custom headers | Through the API |
| Source IPs | Not published |

## Common events

Featurebase documents 27 topics:

| Area | Topics |
| --- | --- |
| Requests (posts) | `post.created`, `post.updated`, `post.deleted`, `post.voted` |
| Tickets | `ticket.created`, `ticket.updated`, `ticket.deleted` |
| Comments | `comment.created`, `comment.updated`, `comment.deleted` |
| Updates | `changelog.published` |
| Conversations | `conversation.user.created`, `conversation.admin.assigned`, `conversation.admin.closed`, `conversation.admin.opened`, `conversation.admin.snoozed`, `conversation.admin.unsnoozed`, `conversation.priority.updated`, `conversation.handover_requested`, `conversation.contact.attached`, `conversation.contact.detached`, `conversation.read`, `conversation.deleted` |
| Messages | `conversation.user.replied`, `conversation.admin.replied`, `conversation.admin.noted`, `conversation_part.redacted` |

Featurebase calls feature requests "Requests" in the product and `post` in the API, and calls changelogs "Updates". For `post.voted`, `data.item` is a `post_vote` with `action` (`add` or `remove`), `postId` and `voter`. For conversation topics, `data.item` is the conversation without its messages. For message topics, it's a single message, and `data.changes` never appears.

Every event uses the same envelope. Here is Featurebase's `post.updated` example:

```json
{
  "object": "notification_event",
  "id": "notif_0193a70c-3015-757c-a260-22ae37c86608",
  "topic": "post.updated",
  "organizationId": "6595518396205e06b897ad65",
  "webhookId": "675346db13af7340748ce850",
  "createdAt": "2026-10-09T12:00:00.000Z",
  "data": {
    "object": "notification_event_data",
    "item": {
      "object": "post",
      "id": "67546dfb6e1363426b90707f",
      "title": "Dark mode for the dashboard",
      "content": "<p>Please add a dark theme.</p>",
      "boardId": "6755d0970b5d5b1fefdf54f4",
      "postUrl": "https://feedback.example.com/p/dark-mode-for-the-dashboard",
      "createdAt": "2026-10-01T09:30:00.000Z",
      "updatedAt": "2026-10-09T12:00:00.000Z"
    },
    "changes": [
      { "field": "title", "oldValue": "Dark mode", "newValue": "Dark mode for the dashboard" }
    ]
  }
}

```

`id` is a unique event ID that starts with `notif_`. `object` is always `notification_event`, so route on `topic`, not `object`. `data.item` follows your workspace's default API version, set in **Settings > Developers > API**, and nothing in the delivery says which version that is. Conversation events also carry `conversationId` and `conversationUrl`.

## Setting up Featurebase webhooks

1. Build an endpoint on a public `https://` URL that accepts `POST`, keeps the raw body, and returns `200`, `201`, `202` or `204` within 5 seconds.
2. In Featurebase, open **Settings > Developers > Webhooks**, add the URL and select topics.
3. Copy the signing secret, which starts with `whsec_`, and store it as `FEATUREBASE_WEBHOOK_SECRET`.

Creating the webhook through the API (`POST /v2/webhooks`) also lets you add custom headers and raise the timeout to 30 seconds.

**With Hookdeck.** Featurebase is a supported source type in Hookdeck. Create a source with the Featurebase type and paste the whole secret, including the `whsec_` prefix. Featurebase uses it as a plain HMAC key. Use the Hookdeck source URL as the webhook URL in Featurebase. The source accepts `POST` only and checks `X-Webhook-Signature` against `<X-Webhook-Timestamp>.<raw body>`. A request that fails the check still gets a `200` from Hookdeck, but Hookdeck marks it as failed verification and doesn't deliver it to your service.

## Securing Featurebase webhooks

Join the timestamp header, a literal `.`, and the raw body bytes. Compute an HMAC-SHA256 keyed with the whole secret as a UTF-8 string, and compare the hex digests in constant time. Don't strip `whsec_`, don't base64-decode the secret, and don't use a Standard Webhooks library.

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

function verifyFeaturebaseSignature(rawBody, signature, timestamp, secret) {
  if (!signature || !timestamp || !secret) return false;
  if (!/^[0-9a-f]{64}$/i.test(signature)) return false; // validate before decoding

  // The key is the whole whsec_... string.
  const signed = Buffer.concat([Buffer.from(`${timestamp}.`, 'latin1'), rawBody]);
  const expected = crypto.createHmac('sha256', secret).update(signed).digest();
  const received = Buffer.from(signature, 'hex');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

// Optional replay check, off by default (0). Hookdeck retries and replays resend the original timestamp.
const TOLERANCE_SECONDS = Number(process.env.FEATUREBASE_WEBHOOK_TOLERANCE_SECONDS || 0);

const app = express();

app.post('/webhooks/featurebase', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.FEATUREBASE_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  const timestamp = req.get('x-webhook-timestamp');
  if (!verifyFeaturebaseSignature(req.body, req.get('x-webhook-signature'), timestamp, secret)) {
    return res.sendStatus(401);
  }
  if (TOLERANCE_SECONDS > 0 && Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event.id, route on event.topic, then process asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac
import re

HEX_64 = re.compile(r"[0-9a-fA-F]{64}")

def verify_featurebase_signature(raw_body: bytes, signature, timestamp, secret: str) -> bool:
    if not signature or not timestamp or not secret:
        return False
    if not HEX_64.fullmatch(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)

```

Featurebase's own samples reject any request whose timestamp is more than 5 minutes old. Hookdeck applies no timestamp tolerance, and its retries and replays resend the original headers, so a delivery Hookdeck retries after a 5-minute outage would fail that check. Behind Hookdeck, leave the window off or make it wider than your retry schedule, and rely on deduplicating by `id`.

> **Make Featurebase webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `X-Webhook-Signature` header with your whole `whsec_` secret, stores every request, ticket and conversation event, and delivers it to your services with retries you control.

## Featurebase webhook limitations and pain points

### A `whsec_` secret that isn't Standard Webhooks

**The Problem:** Verification fails on every delivery with the correct secret.

**Why It Happens:** The `whsec_` prefix and the `X-Webhook-*` header names suggest Standard Webhooks, whose libraries strip the prefix, base64-decode the rest and expect a `webhook-id` header and a base64 signature. Featurebase uses the whole string as the key and sends a hex digest.

**Workarounds:**

* Use a plain HMAC-SHA256 keyed with the full secret, as above.

**How Hookdeck Can Help:** Hookdeck's Featurebase source uses the secret exactly as pasted, so there's nothing to strip or decode.

### Events are dropped while a webhook is paused

**The Problem:** After an outage, some events never arrive, even once your endpoint is back.

**Why It Happens:** Featurebase retries a failed delivery only three times. After 100 failures in a row it pauses the webhook, starting at 5 minutes and doubling up to 4 hours, and after 500 failures or 3 pauses in 24 hours it disables it. Featurebase says it "does not keep events while a webhook is paused or disabled".

**Workarounds:**

* Watch for paused or disabled webhooks and re-enable them in settings or through the API.
* After an outage, use the API to fetch anything you missed.

**How Hookdeck Can Help:** Hookdeck accepts each delivery as soon as it arrives, so your outages don't count as Featurebase failures, then delivers to your service with [automatic retries](/docs/retries) and lets you replay stored events.

### A strict 5-second window and a short list of success codes

**The Problem:** Deliveries count as failures even though your handler worked.

**Why It Happens:** Featurebase accepts only `200`, `201`, `202` or `204`, within 5 seconds by default. A slow handler, a redirect or another `2xx` code such as `203` fails.

**Workarounds:**

* Return `200` or `204` straight after verification and do the work in the background.
* Raise the timeout, up to 30 seconds, when you create the webhook through the API.

**How Hookdeck Can Help:** Hookdeck answers Featurebase immediately, and your service can take as long as it needs behind it.

### Rotation stops the old secret at once

**The Problem:** Events fail verification between a rotation and your next deploy.

**Why It Happens:** Featurebase says the old secret "stops working at once" when you click **Rotate secret**.

**Workarounds:**

* Deploy the new secret immediately after rotating, and accept both secrets in your verifier during the changeover.

**How Hookdeck Can Help:** With Hookdeck in front, you update the secret in one place, on the source.

## Best practices

### Verify against the raw body

Featurebase tells you to verify before you parse, because re-serialising the JSON changes the bytes. Use `express.raw()` in Express, `await request.arrayBuffer()` in a Next.js route handler, and `await request.body()` in FastAPI.

### Deduplicate on `id`

The same event can arrive more than once. Store each `notif_...` ID and skip ones you've already handled. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Don't rely on order

Events can arrive out of order. Use `createdAt`, or fetch the latest state from the API, before overwriting data.

### Fetch conversation messages from the API

Conversation topics carry the conversation without its messages. Use `conversationId` to fetch them when you need the thread.

### Acknowledge fast and process asynchronously

Return a `2xx` before slow work. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Featurebase signs each event with `X-Webhook-Signature`, a hex HMAC-SHA256 of `<X-Webhook-Timestamp>.<raw body>`, keyed with the whole `whsec_` secret. Verify with a plain HMAC rather than a Standard Webhooks library, deduplicate on the `notif_` ID, answer within 5 seconds with one of the four accepted codes, and keep any timestamp check optional so retried events still pass.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks every Featurebase signature, and holds each event until your service has processed it, so an outage on your side doesn't pause the webhook or lose events.

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