# 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_DELIVERY_TIMEOUT_MS` 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](https://github.com/hookdeck/webhook-samples/blob/main/providers/formbricks/latest/responseCreated.json). The IDs are placeholders from Formbricks' docs:

```json
{
  "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](https://formbricks.com/docs/api-v2-reference/management-api-%3E-webhooks/get-webhooks), 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](/docs/cli):

```bash
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:

```javascript
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:

```javascript
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](/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](/docs/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](/webhooks/guides/implement-webhook-idempotency).

### 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](https://hookdeck.com) takes each survey response as soon as Formbricks sends it and holds it until your systems are ready. [Get started at no cost](https://dashboard.hookdeck.com/signup).