Webhooks

Send every completed response to n8n, Make, Zapier, your own endpoint, or a Discord or Slack channel as a signed request (Pro).

A webhook sends each completed response to a URL you choose, as it arrives. Point it at an n8n or Make webhook trigger, a Zapier “Catch Hook”, your own server, or a Discord or Slack channel.

Add a webhook

  1. Open the form's settings

    In the form, open Settings and scroll to Webhooks. Owners, admins, and editors can manage them.

  2. Add your endpoint

    Select Add webhook and paste the URL. It must start with https://. Leave Format on JSON for n8n, Make, Zapier, and your own code; Discord and Slack have their own formats (see Send to Discord or Slack).

  3. Copy the signing secret

    The signing secret (it starts with whsec_) is shown once. Store it with your endpoint. To get a new one, delete the webhook and add it again.

  4. Send a test event

    Send test posts a signed sample with placeholder answers for each question, marked "test": true(in Discord and Slack, a message titled “Test event”), and shows the status code your endpoint returned. Tests are not added to the delivery history.

  • Each form can have up to 5 webhooks; each one gets every response.
  • Switch a webhook off to pause it, or delete it (waiting retries are dropped).
  • URLs must be public: private, local, and cloud metadata addresses are refused, and checked again before every delivery.

Send to Discord or Slack

Discord and Slack only accept their own message format, so Formbear can send each response as a ready-made message instead of JSON.

  1. Create the incoming webhook

    In Discord, open the channel’s settings, then Integrations, Webhooks, and New Webhook, and copy its URL. In Slack, add an app with Incoming Webhooks turned on, pick the channel, and copy the webhook URL.

  2. Paste the URL in Formbear

    In the form’s Settings, select Add webhook and paste the URL.

  3. Formbear picks the matching format

    A discord.com or discordapp.com URL selects Discord message, and a hooks.slack.com URL selects Slack message. You can change it before adding. Then select Send test.

  • A chat message shows the form title, each answered question with its answer as text, and when it was submitted. It never mentions anyone.
  • Discord shows up to 25 answers (each up to 1,024 characters, the whole message up to 6,000) and Slack up to 48 (each up to about 3,000 characters). Longer answers are cut with an ellipsis, and the message says how many answers were left out.
  • The request is signed with the same headers as below, over the message body actually sent.
  • The format is fixed once the webhook is created. To change it, delete the webhook and add it again.
  • If a JSON webhook points at Discord or Slack, its deliveries fail with HTTP 400 and the Webhooks section says which format to use.

When it fires

The event is response.completed: one request per webhook each time someone finishes the form. Partial submissions never fire it, a duplicate submission sends once, and a response deleted before delivery is not sent.

JSON payload

The JSON format (the default) sends this body.

json
{
  "event": "response.completed",
  "deliveryId": "V1StGXR8_Z5jdHi6B-myT",
  "createdAt": "2026-10-30T09:41:07.512Z",
  "form": {
    "id": "8f1c3a52-2d6e-4b7a-9f0e-6a1d2c3b4e5f",
    "publicId": "kX9pQ2aL",
    "title": "Customer survey"
  },
  "response": {
    "id": "0b6e9c1d-7f4a-4e2b-8c3d-5a6b7c8d9e0f",
    "submittedAt": "2026-10-30T09:41:06.930Z",
    "answers": {
      "q_name": "Ada Lovelace",
      "q_plan": "opt_pro",
      "q_score": 4
    }
  },
  "fields": {
    "q_name": { "label": "Your name", "type": "short_text" },
    "q_plan": {
      "label": "Which plan are you on?",
      "type": "single_select",
      "options": [
        { "id": "opt_free", "label": "Free" },
        { "id": "opt_pro", "label": "Pro" }
      ]
    },
    "q_score": { "label": "How likely are you to recommend us?", "type": "rating" }
  }
}
The body of a response.completed request in the JSON format.
  • answers is keyed by each question’s stable field id. fields maps those ids to the question’s label and type.
  • Choice answers (single select, multi select, dropdown) are option ids; the field’s options list maps them to their labels.
  • File upload answers carry each file’s name, size, and type, not the file itself. Open the response in Formbear to download it.
  • fields describes the form as it is published when the request is sent; unanswered questions are left out of answers.
  • deliveryId is the same on every retry of one delivery: use it to ignore duplicates.

Headers

Requests are signed with the Standard Webhooks convention, so its open-source verifiers work too. Every format carries the same headers.

HeaderValue
Content-Typeapplication/json
Formbear-Eventresponse.completed
webhook-idThe delivery id, the same as deliveryId in the body.
webhook-timestampWhen this attempt was sent, in Unix seconds.
webhook-signaturev1, followed by the base64 HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{body}, keyed with the secret’s base64-decoded bytes (the part after whsec_).

Verify the signature

Recompute the signature over the raw body with your secret, compare it in constant time, and refuse timestamps more than five minutes from now. In Node:

js
import crypto from "node:crypto";

// rawBody is the request body exactly as received, before JSON.parse.
// secret is the whsec_... value shown when you added the webhook.
export function verifyFormbearWebhook(rawBody, headers, secret) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"];
  if (!id || !timestamp || !signatures) return false;

  // Refuse anything more than 5 minutes old (or ahead): a replayed request.
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!(age <= 300)) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = crypto
    .createHmac("sha256", key)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest("base64");

  return signatures.split(" ").some((entry) => {
    const [version, signature = ""] = entry.split(",");
    return (
      version === "v1" &&
      signature.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
    );
  });
}
verify.js: no dependencies beyond Node itself.
js
import express from "express";
import { verifyFormbearWebhook } from "./verify.js";

const app = express();

// Keep the raw body: the signature covers the exact bytes Formbear sent.
app.post(
  "/formbear",
  express.text({ type: "application/json" }),
  (req, res) => {
    if (!verifyFormbearWebhook(req.body, req.headers, process.env.FORMBEAR_SECRET)) {
      return res.sendStatus(401);
    }
    const event = JSON.parse(req.body);
    // Answer fast, then do the slow work: Formbear waits 10 seconds at most.
    res.sendStatus(204);
    handleResponse(event);
  },
);
Using it in an Express route.

Retries

Any 2xx status counts as delivered. Anything else fails: another status, no answer within 10 seconds, a connection or certificate error, or a redirect (redirects are not followed, so use the final URL). The response body is ignored. Failed deliveries are retried on this schedule:

AttemptWhen
1Right after the response is submitted
21 minute after attempt 1 fails
35 minutes later
430 minutes later
52 hours later
612 hours later, the last try

A retry can arrive a few minutes after its slot. Answer quickly and do slow work after responding.

Delivery history

Each webhook’s Deliveries view lists its latest fifty attempts with the status and HTTP code. Retrying means a retry is scheduled; Failed means Formbear gave up. History is kept for 30 days and never includes the answers or your endpoint’s reply.

Where your data goes

Formbear stays in the EU (see data residency), but once a response reaches your endpoint, where it is stored is up to that service. Check that it fits what you tell your respondents.

The form builder your respondents deserve.

Free forever, with no response caps.