Receive events with webhooks
Get an HTTP request when Requests, tickets, comments, Updates, or conversations change, and verify that each request comes from Featurebase.
Written By Robi Rohumaa
Last updated About 3 hours ago
Webhooks send an HTTP POST request to your server when something changes in Featurebase: for example, a new Request, a status change, or a reply in a conversation. Use them to keep your own tools in sync without polling the API.
π‘ Webhooks are available on paid plans (Growth and higher).
Create an endpoint
Add a route to your server that accepts POST requests with a JSON body. Your endpoint must:
Use a public
https://URL.Return
200,201,202, or204within 5 seconds. Featurebase counts any other response as a failure.Keep the raw request body. You need the exact bytes to verify the signature.
Register the webhook
Open Settings β Developers β Webhooks in your Featurebase dashboard.
Add your endpoint URL and select the topics you want to receive.
Copy the signing secret. It starts with
whsec_. Store it as an environment variable, for exampleFEATUREBASE_WEBHOOK_SECRET.
You can also create webhooks with the API. The API also lets you add custom headers and set a timeout of up to 30 seconds. A workspace can have up to 10 webhooks.
Verify the signature
Every request has two headers:
X-Webhook-Signature: an HMAC-SHA256 signature, as a hex string.X-Webhook-Timestamp: the Unix time, in seconds, when Featurebase sent the request.
To verify a request:
Join the timestamp and the raw body with a dot:
{timestamp}.{rawBody}.Compute an HMAC-SHA256 of that string with your signing secret, as a hex string.
Compare the result with
X-Webhook-Signature. Use a constant-time comparison.Reject the request if the timestamp is more than 5 minutes old. This blocks replayed requests.
import crypto from "node:crypto";
const SECRET = process.env.FEATUREBASE_WEBHOOK_SECRET;
// rawBody: the request body exactly as received (Buffer or string)
export function verifyFeaturebaseWebhook(rawBody, headers) {
const signature = headers["x-webhook-signature"];
const timestamp = headers["x-webhook-timestamp"];
if (!signature || !timestamp) return false;
// Reject requests older than 5 minutes
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}import hashlib
import hmac
import os
import time
SECRET = os.environ["FEATUREBASE_WEBHOOK_SECRET"]
def verify_featurebase_webhook(raw_body: bytes, headers) -> bool:
"""raw_body: the request body exactly as received."""
signature = headers.get("X-Webhook-Signature")
timestamp = headers.get("X-Webhook-Timestamp")
if not signature or not timestamp or not timestamp.isdigit():
return False
# Reject requests older than 5 minutes
if abs(time.time() - int(timestamp)) > 300:
return False
signed_payload = timestamp.encode() + b"." + raw_body
expected = hmac.new(SECRET.encode(), signed_payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)<?php
function verifyFeaturebaseWebhook(string $rawBody, ?string $signature, ?string $timestamp): bool
{
if (!$signature || !$timestamp || !ctype_digit($timestamp)) {
return false;
}
// Reject requests older than 5 minutes
if (abs(time() - (int) $timestamp) > 300) {
return false;
}
$secret = getenv('FEATUREBASE_WEBHOOK_SECRET');
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $signature);
}
// Usage:
// $ok = verifyFeaturebaseWebhook(
// file_get_contents('php://input'),
// $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? null,
// $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? null
// );require "openssl"
# raw_body: the request body exactly as received (Rails: request.raw_post)
def verify_featurebase_webhook(raw_body, signature, timestamp)
return false if signature.nil? || timestamp.nil? || timestamp !~ /\A\d+\z/
# Reject requests older than 5 minutes
return false if (Time.now.to_i - timestamp.to_i).abs > 300
secret = ENV.fetch("FEATUREBASE_WEBHOOK_SECRET")
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}")
OpenSSL.secure_compare(expected, signature) # Ruby 3.0+
endpackage webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"os"
"strconv"
"time"
)
// rawBody: the request body exactly as received
func VerifyFeaturebaseWebhook(rawBody []byte, signature, timestamp string) bool {
if signature == "" || timestamp == "" {
return false
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
// Reject requests older than 5 minutes
if age := time.Now().Unix() - ts; age > 300 || age < -300 {
return false
}
mac := hmac.New(sha256.New, []byte(os.Getenv("FEATUREBASE_WEBHOOK_SECRET")))
mac.Write([]byte(timestamp + "."))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}π Verify the raw body before you parse it. If your framework parses the JSON and you serialize it again, the bytes can change and the signature will not match. Keep the signing secret on your server and never commit it to git.
Handle the event
Read topic to see what happened. data.item holds the object that changed.
import express from "express";
import { verifyFeaturebaseWebhook } from "./verify.js";
const app = express();
app.post(
"/webhooks/featurebase",
express.raw({ type: "application/json" }), // keep the raw body for the signature
(req, res) => {
if (!verifyFeaturebaseWebhook(req.body, req.headers)) {
return res.sendStatus(401);
}
res.sendStatus(200); // respond first, then do the work
const event = JSON.parse(req.body);
handleEvent(event).catch(console.error);
},
);
async function handleEvent(event) {
// The same event can arrive more than once
if (await alreadyProcessed(event.id)) return;
switch (event.topic) {
case "post.created":
// event.data.item is the new post
break;
case "conversation.user.replied":
// event.data.item is the new message
break;
}
await markProcessed(event.id);
}import json
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request
app = FastAPI()
@app.post("/webhooks/featurebase")
async def featurebase_webhook(request: Request, background: BackgroundTasks):
raw_body = await request.body() # keep the raw body for the signature
if not verify_featurebase_webhook(raw_body, request.headers):
raise HTTPException(status_code=401)
# The task runs after the 200 response is sent
background.add_task(handle_event, json.loads(raw_body))
return {"ok": True}
def handle_event(event: dict) -> None:
# The same event can arrive more than once
if already_processed(event["id"]):
return
if event["topic"] == "post.created":
... # event["data"]["item"] is the new post
elif event["topic"] == "conversation.user.replied":
... # event["data"]["item"] is the new message
mark_processed(event["id"])Respond first. Return a
2xxresponse before slow work, then process the event in the background.Skip duplicates. The same event can arrive more than once. Store each event
idand skip IDs you already handled.Do not rely on order. Events can arrive out of order. Use
createdAt, or get the latest state from the API.
Payload
Every event uses the same envelope. data.item uses the same format as the API. Payloads follow your workspace's default API version, which you set in Settings β Developers β API.
{
"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: a unique event ID that starts withnotif_. Use it to skip duplicates.topic: the event type, for examplepost.updated.organizationIdandwebhookId: your workspace and the webhook that sent the event.createdAt: when the event happened.data.item: the object that changed.data.changes: for update events, a list of{ field, oldValue, newValue }objects.conversationIdandconversationUrl: only on conversation events. The URL opens the conversation in your inbox.
Topics
Select the topics you need when you register the webhook.
Requests
post.created: a Request was created.post.updated: a Request changed, for example its title, status, board, or tags.data.changesshows what changed.post.deleted: a Request was deleted.post.voted: someone added or removed an upvote.data.itemis apost_votewithaction(addorremove),postId, andvoter.
Tickets
ticket.created: a ticket was created.ticket.updated: a ticket changed.ticket.deleted: a ticket was deleted.
Comments
comment.created: a comment was posted. Replies includeparentCommentId.comment.updated: a comment was edited.comment.deleted: a comment was deleted.
Updates
changelog.published: an Update was published. The API calls Updates changelogs.
Conversations: data.item is the conversation, without its messages. To get the messages, get the conversation with the API. Topics that change the conversation include data.changes.
conversation.user.created: a customer started a conversation.conversation.admin.assigned: the conversation was assigned to a teammate or team.conversation.admin.closed: a teammate closed the conversation.conversation.admin.opened: a teammate reopened the conversation.conversation.admin.snoozed: a teammate snoozed the conversation.conversation.admin.unsnoozed: the conversation was unsnoozed, by a teammate or when the snooze ended.conversation.priority.updated: the priority changed.conversation.handover_requested: the AI agent handed the conversation over to a human.conversation.contact.attached: a contact was added to the conversation.conversation.contact.detached: a contact was removed from the conversation.conversation.read: the conversation was marked as read.conversation.deleted: the conversation was deleted.
Messages: data.item is one message (a conversation part). Its partType tells you the kind of message. These topics never include data.changes.
conversation.user.replied: a customer replied.conversation.admin.replied: a teammate or the AI agent replied.conversation.admin.noted: a teammate added an internal note.conversation_part.redacted: a teammate redacted a message.
Retries and failures
A delivery fails when your endpoint returns another status code, takes longer than the timeout, or cannot be reached. Featurebase then retries up to 3 times: 1 minute, 1 hour, and 6 hours after each failed attempt.
If your endpoint keeps failing, Featurebase stops sending to it:
Paused: after 100 failed deliveries in a row. The first pause lasts 5 minutes, and each new pause is twice as long, up to 4 hours. Deliveries start again on their own after the pause.
Disabled: after 500 failed deliveries in 24 hours, or after 3 pauses in 24 hours. Fix your endpoint, then enable the webhook again in Settings β Developers β Webhooks, or set its
statustoactivewith the API.
β οΈ Featurebase does not keep events while a webhook is paused or disabled. After you fix your endpoint, use the API to get anything you missed.
Rotate the signing secret
Rotate the secret if it leaks, or as a routine precaution. Open the webhook in Settings β Developers β Webhooks and click Rotate secret, or refresh the secret with the API.
π The old secret stops working at once. Update the secret on your server right after you rotate it, or your endpoint will reject new events.
Next steps
Was this helpful?
Still need help? Ask the team