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
- Open your form, then Delivery, and find Webhooks.
- Choose Add a webhook, give it a name, and paste your address. It must be public and start with
https://. - Copy the signing secret. It’s shown only this once.
- 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
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{
"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" }
]
}
}| Key | What it is |
|---|---|
version | The 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 |
id | The event’s ID, the same on every try and resend. Use it to drop repeats |
type | submission.created, for a new real response. It’s the only event today |
timestamp | When this body was built |
test | true only for Send test, whose answers are made up |
data.fields | The answers, after your cleaning rules, by field name, nested as your form is |
data.answers | The 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.language | The language the visitor used |
data.submission.overPlan | true 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:
- Join
webhook-id,webhook-timestampand the raw body with dots:id.timestamp.body. - Decode your secret from base64, without its
whsec_prefix, and use it as the key for HMAC-SHA256 of that text. - Encode the result in base64, and compare it in constant time with the
v1,values inwebhook-signature. There can be two, separated by a space, while you change secrets. One match is enough. - Refuse a
webhook-timestampmore 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
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
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
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
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
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
endEach 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 answers | What happens |
|---|---|
2xx | Delivered |
| Anything else, a timeout, or no connection | Tried again. See below |
3xx | Failed, and tried again. Redirects are never followed, so update the address instead |
410 Gone | The webhook pauses right away, since you’ve said this address is finished |
retry-after beside a failure | The 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.comor 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.