Webhooks
Register an HTTPS endpoint and Roastnest posts signed events to it as feedback arrives and referrals convert — the programmatic counterpart to the built-in integrations.
Webhooks are outbound: Roastnest calls you. Where integrations deliver events into a fixed set of tools, a webhook delivers the same events to code you control, so you can update your own database, page an on-call engineer, or drive a workflow no integration covers.
Cloud mode only
Events are emitted by the Roastnest server, which only sees your data in cloud mode. In self-hosted mode the equivalent hooks are already in your hands — onFormSubmit for feedback and onEvent for referrals.
Registering an endpoint
Webhooks are configured per project, under Webhooks in the project sidebar. Managing them requires the webhooks.manage permission.
- Add an endpoint and paste the URL that will receive deliveries.
- Tick the events you want. At least one is required.
- Copy the signing secret shown once on creation, and store it as an environment variable on the receiving service.
- Use Send test to confirm the endpoint answers.
URL requirements
The destination is a URL your team types and a request our servers make, so it is validated rather than trusted. An endpoint must:
- use
https://; - resolve to a publicly routable address — loopback, private, link-local, CGNAT and cloud-metadata addresses are all rejected, including via
localhost,.internaland IPv4-mapped IPv6 literals; - carry no credentials in the URL;
- be at most 2048 characters.
A project can register up to 10 endpoints, each with a unique URL and an optional label of up to 64 characters. Registering a URL that already exists returns 409 — edit that endpoint's event list instead of adding a second row for the same destination.
The URL is re-checked on every delivery
A hostname that resolves publicly today can be re-pointed at an internal address tomorrow, so the check runs again immediately before each send, not just at registration. Redirects are not followed — respond 2xx at the registered URL rather than 301-ing elsewhere.
Events
Event names are a stable contract — they are sent in the X-Roastnest-Event header and stored on your subscription. New names get added over time; existing ones are not renamed.
| Prop | Type | Description |
|---|---|---|
feedback.created | feedback | A visitor submitted new feedback through the widget. |
feedback.updated | feedback | A teammate changed a feedback's status, category, priority or visibility. |
referral.created | referral | A referrer generated their referral link for the first time. Subsequent page loads by the same referrer do not re-fire it. |
referral.converted | referral | A conversion was tracked against a referral code. Fires for anonymous conversions too — check identifiedReferee. |
referral.reward_granted | referral | A qualifying conversion made a referrer or referee eligible for a reward. |
Request format
Every delivery is a JSON POST carrying five Roastnest headers.
POST /your-endpoint HTTP/1.1
Content-Type: application/json
User-Agent: RoastNest-Webhook/1.0
X-Roastnest-Event: feedback.created
X-Roastnest-Delivery: 5f0b2c8e-4a1d-4c77-9f3e-2b8a1d6e0c94
X-Roastnest-Timestamp: 1767091351
X-Roastnest-Signature: t=1767091351,v1=9c1f...e40a| Prop | Type | Description |
|---|---|---|
X-Roastnest-Event | string | The event name, matching the catalog above. |
X-Roastnest-Delivery | uuid | Unique per event and stable across its retries — use it as an idempotency key. |
X-Roastnest-Timestamp | string | Unix seconds at which the delivery was signed. |
X-Roastnest-Signature | string | t=<timestamp>,v1=<hex> — see Verifying signatures. |
User-Agent | string | Always RoastNest-Webhook/1.0. |
The body is the same envelope for every event, with the event-specific fields under data:
{
"id": "5f0b2c8e-4a1d-4c77-9f3e-2b8a1d6e0c94",
"event": "feedback.created",
"projectId": "prj_8f21c4",
"createdAt": "2026-01-14T09:22:31.004Z",
"data": { }
}Verifying signatures
Your endpoint is public, so anyone can POST to it. The signature is what distinguishes a real delivery from a forgery, and verifying it is not optional.
Each endpoint gets its own HMAC-SHA256 signing secret, generated server-side. The signature covers the timestamp and the raw body together:
signature = HMAC-SHA256(secret, "{timestamp}.{rawBody}")
header = X-Roastnest-Signature: t={timestamp},v1={signature}Signing the timestamp with the body is what makes replays detectable. A captured request can be re-sent byte for byte, but its t falls outside your tolerance window, and re-signing a fresh one requires the secret.
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verifyRoastnestSignature(rawBody, header, secret) {
// header looks like "t=1767091351,v1=9c1f...e40a"
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=").map((s) => s.trim()))
);
const timestamp = Number(parts.t);
const received = parts.v1;
if (!timestamp || !received) return false;
// Reject replays: the signature stays valid forever, the timestamp does not.
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(timestamp + "." + rawBody)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(received, "hex");
// Constant-time compare — a plain === leaks the secret one byte at a time.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Verify against the raw body
The signature covers the exact bytes sent. Parsing JSON and re-serializing changes key order and whitespace, so every signature will fail. Capture the raw buffer before any JSON middleware touches it.
import express from "express";
const app = express();
// The signature covers the exact bytes we sent. Parsing to an object and
// re-serializing changes key order and whitespace, so verification must run
// against the raw buffer — not req.body.
app.post(
"/webhooks/roastnest",
express.raw({ type: "application/json" }),
(req, res) => {
const rawBody = req.body.toString("utf8");
const signature = req.get("X-Roastnest-Signature");
if (!verifyRoastnestSignature(rawBody, signature, process.env.ROASTNEST_WEBHOOK_SIGNING_SECRET)) {
return res.sendStatus(401);
}
const payload = JSON.parse(rawBody);
// Acknowledge first, work afterwards — deliveries time out after 8s.
res.sendStatus(200);
void handleEvent(payload).catch(console.error);
}
);Responding
Return any 2xx to acknowledge. Anything else counts as a failure. Deliveries time out after 8 seconds, so acknowledge first and do the real work afterwards rather than holding the connection open while you process.
Only the first 64 KB of your response is read, and redirects are not followed.
Idempotency
A retry reuses the same X-Roastnest-Delivery value, so a handler that records delivery IDs can safely ignore duplicates — worth doing, since a timeout on your side after the work completed still counts as a failure and will be retried.
async function handleEvent(payload) {
// X-Roastnest-Delivery (payload.id) is stable across retries of the same
// event, so it works directly as an idempotency key.
const isNew = await deliveries.claim(payload.id);
if (!isNew) return;
switch (payload.event) {
case "feedback.created":
return onFeedbackCreated(payload.data);
case "referral.converted":
return onReferralConverted(payload.data);
default:
return; // Unknown events are normal — new ones get added.
}
}Retries and failures
A failed delivery is retried up to 3 attempts total, roughly 2 seconds then 10 seconds apart. Delivery is fire-and-forget: a slow or dead endpoint never delays or fails the request that produced the event.
| Prop | Type | Description |
|---|---|---|
2xx | success | Acknowledged. The failure counter resets to zero. |
4xx (not 429) | permanent | Treated as the endpoint rejecting the request itself — not retried, since resending the same bytes cannot help. |
429 and 5xx | transient | Retried on the schedule above. |
timeout / network | transient | Retried on the schedule above. |
Endpoints disable themselves after 10 consecutive failures
The counter resets on any success. Once it trips, the endpoint stops receiving events until you re-enable it in the dashboard — fix the receiver, send a test, then switch it back on. The last response code and error are shown on the endpoint so you are not debugging blind.
Testing
Send test posts a signed ping delivery immediately and reports the response code back. It exercises the real signing and delivery path, so a passing test means a real event would arrive too.
{
"id": "3b7d1f42-0c58-4e19-b6a2-9d40e7c1a835",
"event": "ping",
"projectId": "prj_8f21c4",
"createdAt": "2026-01-14T09:20:00.000Z",
"data": { "message": "This is a test delivery from RoastNest." }
}ping is not a subscribable event and only ever arrives from this button — handlers should ignore unrecognised event names rather than erroring on them. A failed test is recorded on the endpoint like any other delivery and does advance the failure counter, but it will never be the thing that disables the endpoint on its own.
Rotating the secret
The signing secret is shown in full when the endpoint is created, and can be revealed again later from the endpoint's menu. Rotating issues a new secret immediately and, by default, keeps the outgoing one valid for 24 hours: during that window every delivery carries a signature for both secrets, so a receiver still holding the old value keeps verifying and you can redeploy whenever suits.
Choose Rotate and revoke immediately instead when the old secret has leaked — an overlap would keep the exposed value working. Deliveries then fail verification until your receiver has the new secret.
Payloads
What follows is the data object for each event. Treat fields as additive — new ones may appear, so parse defensively rather than rejecting unknown keys.
feedback.created
reference is the human-readable ID shown in the dashboard, and reporter is null when the submission was anonymous. reporter.userId is your own id for that person when you supplied one.
{
"feedbackId": "fb_5a19d0",
"reference": "EC-42",
"feedbackIndex": 42,
"message": "The export button does nothing on Safari.",
"status": "open",
"category": "bug",
"priority": "high",
"isPublic": true,
"pageUrl": "https://acme.example.com/reports",
"reporter": {
"personId": "per_1c93af",
"userId": "8871",
"name": "Dana Whitfield",
"email": "dana@example.com"
},
"createdAt": "2026-01-14T09:22:31.004Z"
}feedback.updated
Carries the same fields plus changes, which lists only the fields that actually moved, each as a from/to pair. No event fires when an update changes nothing.
{
"feedbackId": "fb_5a19d0",
"reference": "EC-42",
"message": "The export button does nothing on Safari.",
"status": "in_progress",
"priority": "high",
"changes": {
"status": { "from": "open", "to": "in_progress" },
"priority": { "from": "medium", "to": "high" }
},
"updatedBy": "usr_77b201",
"createdAt": "2026-01-14T09:22:31.004Z"
}referral.created
{
"referralId": "ref_3d81b2",
"referralCode": "AYUSH123",
"referralLink": "https://acme.example.com/invite?ref=AYUSH123",
"referrer": {
"personId": "per_1c93af",
"userId": "8871",
"name": "Dana Whitfield",
"email": "dana@example.com"
},
"createdAt": "2026-01-14T09:22:31.004Z"
}referral.converted
identifiedReferee reports whether the conversion carried a referee identity at all. When it is false the conversion is still real, but nobody was named, and every field on referee other than personId comes back null.
{
"eventId": "evt_9a02f1",
"eventType": "purchase",
"eventValue": 49,
"currency": "USD",
"referralId": "ref_3d81b2",
"referralCode": "AYUSH123",
"identifiedReferee": true,
"referee": {
"personId": "per_5502ce",
"userId": "9042",
"name": "Sam Okonkwo",
"email": "sam@example.com"
},
"createdAt": "2026-01-14T09:24:02.771Z"
}Identity is a claim, not a proof
identifiedReferee: true means an id or email was sent with the conversion — not that anyone verified it. A referral code is public by design; it is in the link.
Check the conversion against your own order and user records before granting anything. You are the only party that can: we know a purchase was reported, you know whether it happened.
referral.reward_granted
Both parties are always included, whichever one is being paid. recipientRole is referrer or referee and names which of the two the reward is for, so a single qualifying conversion can produce two of these — one per side.
Each party carries the userId you identified them with, so you can apply the grant against your own user records without us ever holding an email. Treat rewardId as an idempotency key: it is stable across retries and manual resends.
{
"rewardId": "rwd_60c4aa",
"referralId": "ref_3d81b2",
"referralCode": "AYUSH123",
"eventId": "evt_9a02f1",
"recipientRole": "referrer",
"referrer": {
"personId": "per_1c93af",
"userId": "8871",
"name": "Dana Whitfield",
"email": "dana@example.com"
},
"referee": {
"personId": "per_5502ce",
"userId": "9042",
"name": "Sam Okonkwo",
"email": "sam@example.com"
},
"reward": {
"type": "monetary",
"amount": "$20"
},
"qualifyingEvent": "purchase",
"qualifyingValue": 49,
"createdAt": "2026-01-14T09:24:03.118Z"
}Troubleshooting
- Every signature fails. Almost always a re-serialized body. Verify against the raw bytes, and confirm you are using that endpoint's own signing secret for that endpoint.
- Deliveries stopped. Check whether the endpoint auto-disabled after 10 consecutive failures; the stored response code and error say why.
- The URL is rejected on save. It must be
https://on a publicly resolvable host. Tunnel to a public hostname when developing locally. - Events arrive twice. Expected under retries — deduplicate on
X-Roastnest-Delivery. - Timeouts under load. Acknowledge within 8 seconds and move the work to a queue.