Guide to Formbricks Webhooks: Features and Best Practices
Formbricks is an open-source survey platform for in-app, website and link surveys, available as a cloud service or self-hosted. Its webhooks send survey responses to your systems as they come in: when a respondent starts a survey, as answers are added, and when they finish. Teams use them to push answers into a CRM or data warehouse, alert a team on low scores, or start a follow-up workflow the moment a survey is complete.
What are Formbricks webhooks?
A Formbricks webhook belongs to one environment and listens to the triggers and surveys you pick. When a response changes, Formbricks sends a JSON POST with the event name, the webhook's ID and the full response object.
Formbricks implements the Standard Webhooks specification. Every new webhook gets a whsec_ signing secret automatically, and its requests carry webhook-id, webhook-timestamp and webhook-signature. The signature header is only sent when the webhook has a secret, which matters for webhooks created before Formbricks added signing.
Formbricks webhook features
| Feature | Details |
|---|---|
| Configuration | Configuration > Integrations > Manage Webhooks, or the v2 Management API |
| Scope | One environment. Filter by trigger and by survey |
| Signature | Standard Webhooks HMAC-SHA256 |
| Signature header | webhook-signature, with webhook-id and webhook-timestamp |
| Secret | Per webhook, whsec_..., generated on creation and viewable later |
| Body | JSON |
| Event routing | Body field event |
| Response window | 2xx within 5 seconds (WEBHOOK_ when self-hosted) |
| Retries | Up to 5 attempts in about 4 minutes, then dropped |
| Private URLs | Blocked unless a self-hosted instance allows them |
| Source IPs | Not published |
Events and payloads
| Event | Triggered When | Common Use Cases |
|---|---|---|
responseCreated | A new survey response is created | Track survey starts, notify a team |
responseUpdated | A response gains or changes answers | Stream partial answers, update a CRM record |
responseFinished | The respondent completes the survey | Send answers to a database, trigger follow-up workflows |
The body wraps the response object:
| Field | Meaning |
|---|---|
event | The event type, for example responseCreated. This is what handlers switch on |
webhookId | The id of the Formbricks webhook that sent the delivery |
data | The response object |
data.data | The answers, keyed by question id |
data.ttc | Time spent per question (_total appears once the response is finished) |
data.finished / data.endingId | Whether the respondent completed the survey, and which ending card they reached |
data.survey | A summary of the survey (title, status, type, timestamps) |
data.surveyId | The survey id; use it to route responses |
This is the responseCreated sample from the webhook samples library. The IDs are placeholders from Formbricks' docs:
{
"data": {
"contact": null,
"contactAttributes": null,
"createdAt": "2025-07-24T07:47:29.507Z",
"data": {
"welcome_card_cta": "clicked"
},
"displayId": "displayId",
"endingId": null,
"finished": false,
"id": "responseId",
"language": "en",
"meta": {
"country": "DE",
"url": "https://app.formbricks.com/s/surveyId",
"userAgent": {
"browser": "Chrome",
"device": "desktop",
"os": "macOS"
}
},
"singleUseId": null,
"survey": {
"createdAt": "2025-07-20T10:30:00.000Z",
"status": "inProgress",
"title": "Customer Satisfaction Survey",
"type": "link",
"updatedAt": "2025-07-24T07:45:00.000Z"
},
"surveyId": "surveyId",
"tags": [],
"ttc": {
"welcome_card_cta": 2154.700000047684
},
"updatedAt": "2025-07-24T07:47:29.507Z",
"variables": {}
},
"event": "responseCreated",
"webhookId": "webhookId"
}
Answers in data.data are keyed by question ID, not question text, so keep a map of your survey's question IDs. responseUpdated can fire several times as a respondent moves through a survey, and the response only has ttc._total and finished: true once they reach the end.
Setting up Formbricks webhooks
- Open Configuration in the left sidebar, then the Integrations tab, and click Manage Webhooks then Add Webhook.
- Enter your endpoint URL and click Test Endpoint. The endpoint must be reachable and accept
POSTbefore Formbricks lets you save. - Pick the triggers and the surveys to listen to, and save.
- Copy the signing secret, shown straight away, into
FORMBRICKS_.WEBHOOK_ SECRET
With the API v2 webhook endpoints, the create response includes the secret. Development and production are separate environments, each with its own webhooks and secrets.
Formbricks refuses URLs on private or internal addresses such as localhost, 10.x.x.x or 192.168.x.x to prevent server-side request forgery. A trusted self-hosted instance can set DANGEROUSLY_ to allow them. For local development, a public tunnel is simpler: create a Hookdeck source with the Formbricks type, paste the secret, register the source URL, and forward it with the Hookdeck CLI:
npx hookdeck-cli listen 3000 formbricks --path /webhooks/formbricks
Test Endpoint sends a request through the source, so start the CLI before you save the webhook.
Securing Formbricks webhooks
Decode the secret after whsec_, compute HMAC-SHA256 over <webhook-id>.<webhook-timestamp>.<raw body>, base64-encode it, and compare with each v1, entry in webhook-signature. Formbricks' Node sample takes split(',')[1] of the header, which reads only one signature; the function below checks every entry:
const crypto = require('node:crypto');
function decodeBase64(value) {
if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
const bytes = Buffer.from(value, 'base64');
return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
? bytes : null;
}
function verifySignature(rawBody, headers, secret) {
if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
const prefix = headers['webhook-id'] !== undefined ? 'webhook' : 'svix';
const id = headers[`${prefix}-id`];
const timestamp = headers[`${prefix}-timestamp`];
const signature = headers[`${prefix}-signature`];
if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
!/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
const key = decodeBase64(secret.replace(/^whsec_/, ''));
if (!key) return false;
const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
const expected = crypto.createHmac('sha256', key).update(message).digest();
return candidates.some((candidate) => {
const received = decodeBase64(candidate);
if (!received) return false;
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
Formbricks' sample also rejects timestamps older than about five minutes. Its own retries finish within minutes, but a delivery that Hookdeck retries or replays later still carries the original webhook-timestamp, and Hookdeck applies no timestamp tolerance itself, so the check above is left out.
The five-second window rules out calling a CRM inside the request, so the receiver stores the response and leaves the rest to a worker:
const express = require('express');
// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
try {
const value = JSON.parse(rawBody.toString('utf8'));
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
} catch {
return null;
}
}
// responses.upsert(webhookId, event) stores the latest version and resolves once durable.
function createFormbricksReceiver(responses) {
const app = express();
app.post('/webhooks/formbricks', express.raw({ type: '*/*' }), async (req, res) => {
const secret = process.env.FORMBRICKS_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500);
if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);
const event = parseJsonObject(req.body);
if (!event) return res.sendStatus(400); // signed, but not a JSON object
try {
await responses.upsert(req.headers['webhook-id'], event);
} catch {
return res.sendStatus(503); // 5xx is retried; most other 4xx are final
}
return res.sendStatus(200);
});
return app;
}
Hookdeck's Formbricks source always answers Formbricks with 200. A request whose signature fails is recorded as failed verification in Hookdeck and stops there.
Make Formbricks webhooks production-ready. Hookdeck Event Gateway verifies every survey response event, stores it, and keeps retrying your service long after Formbricks' four-minute retry window has closed.
Formbricks webhook limitations and pain points
Retries end within minutes
The Problem: Survey responses submitted during a short outage never reach you.
Why It Happens: Formbricks makes up to five attempts, about 30 seconds, then 1, 2 and 4 minutes after the first. After the last one the event is dropped, and Formbricks doesn't replay it.
Workarounds:
- Keep the endpoint highly available, and answer as soon as the response is stored.
- Backfill missed responses through the Formbricks API after an outage.
How Hookdeck Can Help: Hookdeck accepts each response on Formbricks' first attempt, then retries your service for as long as you configure.
Some errors are final
The Problem: A 400 from a validation layer means Formbricks never tries again.
Why It Happens: Only network errors, timeouts, redirects, 408, 429 and 5xx are retried. Other 4xx responses are treated as a rejection.
Workarounds:
- Return
5xxfor temporary problems on your side and2xxfor anything you want to ignore.
How Hookdeck Can Help: With Hookdeck in front, Formbricks always sees 200, and your own error codes only decide what Hookdeck retries.
Legacy webhooks are unsigned
The Problem: One webhook's requests have no webhook-signature and fail verification.
Why It Happens: The header is sent only when a secret is configured. Webhooks created before Formbricks added signing have none, and Hookdeck's configuration alert for this source says the same.
Workarounds:
- Create a new webhook with the same triggers and surveys, which gets a secret, and delete the old one.
How Hookdeck Can Help: Until you replace it, you can leave verification off on a Hookdeck source that receives the unsigned webhook, and still get its delivery history and retries.
Changes cancel queued deliveries
The Problem: After editing a webhook, some pending deliveries disappear.
Why It Happens: Deleting a webhook, or removing a trigger or survey from it, cancels its queued and retrying deliveries.
Workarounds:
- Make webhook changes when your endpoint is healthy and nothing is retrying.
How Hookdeck Can Help: Events already accepted by a Hookdeck source are stored there, so later changes on the Formbricks side don't affect them.
Best practices
Treat responseFinished as the final answer
responseCreated and responseUpdated describe a response in progress. If your workflow needs complete answers, act on responseFinished, or check data.finished.
Upsert by response ID
Several events arrive for the same response. Upsert on data.id so each update replaces the previous version, and use the webhook-id header to skip retried duplicates. Details are in our guide to idempotent webhook processing.
Route on surveyId
One webhook can listen to many surveys. Use data.surveyId to send each survey's responses to the right place.
Keep personal data out of logs
Answers, contactAttributes and meta can include personal data. Log the event name and IDs instead of the body.
Conclusion
Formbricks implements Standard Webhooks cleanly and offers a Test Endpoint check. Its retry window is short and 4xx responses are final, so keep the receiver available and reserve 4xx for requests you really reject.
Hookdeck Event Gateway takes each survey response as soon as Formbricks sends it and holds it until your systems are ready. Get started at no cost.