Skip to content

Webhooks

Send every new response to your own systems, the moment it arrives. A webhook is a signed POST to an address you choose, beside your email notifications and independent of them.

Using a tool instead of your own server? Go straight to n8n, Zapier or Make.

Add a webhook

  1. Open your form, then Delivery, and find Webhooks.
  2. Choose Add a webhook, give it a name, and paste your address. It must be public and start with https://.
  3. Copy the signing secret. It’s shown only this once.
  4. Choose Send test. It sends made-up answers with "test": true, and shows you what your server answered.

Each form can have up to 5 webhooks, and a workspace up to 20, on every plan. Adding a webhook or changing its address needs a recent sign-in, and emails the workspace’s owners and admins.

What you receive

HTTP
POST /your/path HTTP/1.1
content-type: application/json
user-agent: Formerie-Webhooks/1
webhook-id: evt_5n7hq2kz8tm4xc
webhook-timestamp: 1793577600
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
formerie-event: submission.created
formerie-delivery: whd_7d2k4m9pq3rstv
formerie-attempt: 1
JSON
{
  "version": 1,
  "id": "evt_5n7hq2kz8tm4xc",
  "type": "submission.created",
  "timestamp": "2026-11-02T18:40:00.000Z",
  "test": false,
  "data": {
    "workspace": { "id": "ws_2kq8m4tz7hn3xd" },
    "form": { "id": "fm_t77txbvg43nmar", "name": "Contact", "version": 3 },
    "submission": {
      "id": "sub_9f3kq7tz2hm8nw",
      "receivedAt": "2026-11-02T18:39:59.000Z",
      "language": "fr-CA",
      "overPlan": false,
      "origin": "https://acme.example",
      "pageUrl": "https://acme.example/contact"
    },
    "fields": {
      "name": "Marie",
      "contact": { "email": "marie@example.com" },
      "topic": "sales"
    },
    "answers": [
      { "path": "name", "label": "Nom", "text": "Marie", "value": "Marie" },
      { "path": "contact.email", "label": "Contact / Courriel", "text": "marie@example.com", "value": "marie@example.com" },
      { "path": "topic", "label": "Sujet", "text": "Ventes", "value": "sales" }
    ]
  }
}
KeyWhat it is
versionThe shape of this body. It changes only for a breaking change. New keys can appear at any time, so ignore the ones you don’t know
idThe event’s ID, the same on every try and resend. Use it to drop repeats
typesubmission.created, for a new real response. It’s the only event today
timestampWhen this body was built
testtrue only for Send test, whose answers are made up
data.fieldsThe answers, after your cleaning rules, by field name, nested as your form is
data.answersThe same answers as a list, each with its label in the visitor’s language, and text, which is how the value reads: a choice’s label, or Yes and No
data.submission.languageThe language the visitor used
data.submission.overPlantrue when the response arrived past what your plan includes that month. It’s for your information: the response is stored and sent like any other

Never sent: verification tokens, spam details, the visitor’s IP address or browser, your recipients, or any field you set up as a password input.

Test entries don’t fire webhooks. Entries from a test key, a test link or localhost are stored apart and send nothing, whatever your webhooks are set to. Only Send test reaches your address during testing.

Check the signature

Check it on every request, so nobody can send you fake responses. Formerie follows Standard Webhooks, a public specification:

  1. Join webhook-id, webhook-timestamp and the raw body with dots: id.timestamp.body.
  2. Decode your secret from base64, without its whsec_ prefix, and use it as the key for HMAC-SHA256 of that text.
  3. Encode the result in base64, and compare it in constant time with the v1, values in webhook-signature. There can be two, separated by a space, while you change secrets. One match is enough.
  4. Refuse a webhook-timestamp more than 5 minutes from now, so a captured request can’t be replayed.

Use the raw body, exactly as received. Parsing and re-encoding JSON changes it, and the signature won’t match.

Node

JavaScript
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyFormerieWebhook(secret, id, timestamp, signatures, rawBody) {
  if (!id || !timestamp || !signatures) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

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

  return signatures.split(" ").some((signature) => {
    const [version, value] = signature.split(",");
    if (version !== "v1" || !value) return false;
    const given = Buffer.from(value, "base64");
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}

Python

Python
import base64
import hashlib
import hmac
import time


def verify_formerie_webhook(secret, id, timestamp, signatures, raw_body):
    if not id or not timestamp or not signatures:
        return False
    if abs(time.time() - int(timestamp)) > 300:
        return False

    key = base64.b64decode(secret.removeprefix("whsec_"))
    content = f"{id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, content, hashlib.sha256).digest()).decode()

    return any(
        signature.startswith("v1,") and hmac.compare_digest(signature[3:].encode(), expected.encode())
        for signature in signatures.split(" ")
    )

raw_body is bytes. In Flask it’s request.get_data(), and in Django request.body.

PHP

PHP
<?php

function verifyFormerieWebhook(string $secret, string $id, string $timestamp, string $signatures, string $rawBody): bool
{
    if ($id === '' || $timestamp === '' || $signatures === '') {
        return false;
    }
    if (abs(time() - (int) $timestamp) > 300) {
        return false;
    }

    $key = base64_decode(preg_replace('/^whsec_/', '', $secret));
    $expected = base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$rawBody}", $key, true));

    foreach (explode(' ', $signatures) as $signature) {
        if (str_starts_with($signature, 'v1,') && hash_equals($expected, substr($signature, 3))) {
            return true;
        }
    }
    return false;
}

$rawBody is file_get_contents('php://input'), and the headers are $_SERVER['HTTP_WEBHOOK_ID'] and so on.

Go

Go
package webhook

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"math"
	"strconv"
	"strings"
	"time"
)

func VerifyFormerieWebhook(secret, id, timestamp, signatures string, rawBody []byte) bool {
	if id == "" || timestamp == "" || signatures == "" {
		return false
	}
	sent, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil || math.Abs(float64(time.Now().Unix()-sent)) > 300 {
		return false
	}
	key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
	if err != nil {
		return false
	}

	mac := hmac.New(sha256.New, key)
	mac.Write([]byte(id + "." + timestamp + "."))
	mac.Write(rawBody)
	expected := mac.Sum(nil)

	for _, signature := range strings.Split(signatures, " ") {
		value, found := strings.CutPrefix(signature, "v1,")
		if !found {
			continue
		}
		given, err := base64.StdEncoding.DecodeString(value)
		if err == nil && hmac.Equal(given, expected) {
			return true
		}
	}
	return false
}

Ruby

Ruby
require "base64"
require "openssl"

def verify_formerie_webhook(secret, id, timestamp, signatures, raw_body)
  return false if [id, timestamp, signatures].any? { |value| value.nil? || value.empty? }
  return false if (Time.now.to_i - timestamp.to_i).abs > 300

  key = Base64.strict_decode64(secret.delete_prefix("whsec_"))
  digest = OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{raw_body}")
  expected = Base64.strict_encode64(digest)

  signatures.split(" ").any? do |signature|
    signature.start_with?("v1,") && OpenSSL.secure_compare(signature.delete_prefix("v1,"), expected)
  end
end

Each sample above is run against real signatures, good and bad, before it’s published here. You can also use a Standard Webhooks library, since the secret and headers are in its format.

Answer quickly, and expect repeats

Answer with any 2xx within 15 seconds. Do slow work afterward. Anything else counts as a failed try:

Your server answersWhat happens
2xxDelivered
Anything else, a timeout, or no connectionTried again. See below
3xxFailed, and tried again. Redirects are never followed, so update the address instead
410 GoneThe webhook pauses right away, since you’ve said this address is finished
retry-after beside a failureThe next try waits at least that long, up to 12 hours

A response gets 8 tries over about two days: at once, then after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours, with a little randomness. Each try has a new webhook-timestamp and signature.

A delivery can arrive twice, because no system can rule that out. Keep the webhook-id of what you’ve handled, and skip one you’ve seen. Resend keeps the same ID for this reason.

A busy webhook is held to 120 deliveries a minute. Beyond that they wait a minute, without losing a try.

When it keeps failing

Under each webhook, Deliveries shows every response’s status, and each try’s status code and time. Nothing there holds the answers themselves. You can Resend any delivery for 30 days.

If a delivery uses every try, the workspace’s owners and admins get an email, at most one a day for each webhook. After 3 such deliveries in a row, the webhook pauses and they’re told. While it’s paused:

  • Your responses are safe, and nothing is sent.
  • New responses wait, for up to 30 days.
  • Send test still works, so you can check a fix.
  • Resume sends everything that waited from the last 7 days. Anything older stays listed, and Resend still sends it.

A passing test or delivery starts the count again.

Change your secret

Choose Change secret, and copy the new one. For 24 hours, each request is signed with both, so you can update your server with no gap. If the old one leaked, choose End the old one now.

Which addresses work

Formerie checks your address when you save it and again before each send, on our servers:

  • It must use https, on the standard port, with no username or password in it.
  • It must be a name that resolves on the public internet. A private, loopback, link-local or cloud metadata address is refused, by number or by what the name resolves to.
  • It can’t be on formerie.com or be a form’s own custom domain.

Treat the address as a secret if it holds a token, as automation tools’ addresses do. After saving, Formerie shows only the start of it, and never holds it in logs. To change it, paste a new one.

Where your data goes

A webhook sends your visitors’ answers to the address you chose, wherever that is, and you’re responsible for that system. Mention it in your own privacy notice if it applies. Removing a response here also ends any retries, and removes its delivery log. A copy your server already received is yours to remove.

From an AI assistant (coming soon)

Once MCP is open to everyone, an assistant connected through it can read a form’s webhooks and deliveries, send a test, resend, pause and resume. Adding a webhook, changing its address, changing its secret and removing it stay in the app, because an address is where your answers go. A secret is never shown to an assistant.