Gareth Wilson Gareth Wilson

Guide to Formbricks Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationConfiguration > Integrations > Manage Webhooks, or the v2 Management API
ScopeOne environment. Filter by trigger and by survey
SignatureStandard Webhooks HMAC-SHA256
Signature headerwebhook-signature, with webhook-id and webhook-timestamp
SecretPer webhook, whsec_..., generated on creation and viewable later
BodyJSON
Event routingBody field event
Response window2xx within 5 seconds (WEBHOOK_DELIVERY_TIMEOUT_MS when self-hosted)
RetriesUp to 5 attempts in about 4 minutes, then dropped
Private URLsBlocked unless a self-hosted instance allows them
Source IPsNot published

Events and payloads

EventTriggered WhenCommon Use Cases
responseCreatedA new survey response is createdTrack survey starts, notify a team
responseUpdatedA response gains or changes answersStream partial answers, update a CRM record
responseFinishedThe respondent completes the surveySend answers to a database, trigger follow-up workflows

The body wraps the response object:

FieldMeaning
eventThe event type, for example responseCreated. This is what handlers switch on
webhookIdThe id of the Formbricks webhook that sent the delivery
dataThe response object
data.dataThe answers, keyed by question id
data.ttcTime spent per question (_total appears once the response is finished)
data.finished / data.endingIdWhether the respondent completed the survey, and which ending card they reached
data.surveyA summary of the survey (title, status, type, timestamps)
data.surveyIdThe 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

  1. Open Configuration in the left sidebar, then the Integrations tab, and click Manage Webhooks then Add Webhook.
  2. Enter your endpoint URL and click Test Endpoint. The endpoint must be reachable and accept POST before Formbricks lets you save.
  3. Pick the triggers and the surveys to listen to, and save.
  4. 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_ALLOW_WEBHOOK_INTERNAL_URLS=1 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 5xx for temporary problems on your side and 2xx for 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.