Webhooks
Envoyez chaque nouvelle réponse à vos propres systèmes, dès qu’elle arrive. Un webhook est un POST signé vers une adresse de votre choix, à côté de vos avis par courriel et indépendant d’eux.
Vous utilisez un outil plutôt que votre propre serveur? Passez directement à n8n, Zapier ou Make.
Ajouter un webhook
- Ouvrez votre formulaire, puis Livraison, et trouvez Webhooks.
- Choisissez Ajouter un webhook, donnez-lui un nom et collez votre adresse. Elle doit être publique et commencer par
https://. - Copiez le secret de signature. Il n’est affiché que cette fois-ci.
- Choisissez Envoyer un test. Le test envoie des réponses inventées avec
"test": true, et vous montre ce que votre serveur a répondu.
Chaque formulaire peut avoir jusqu’à 5 webhooks, et un espace de travail jusqu’à 20, dans tous les forfaits. Pour ajouter un webhook ou changer son adresse, il faut vous être connecté récemment, et les propriétaires et administrateurs de l’espace de travail en reçoivent un courriel.
Ce que vous recevez
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" }
]
}
}| Clé | Ce que c’est |
|---|---|
version | La forme de ce contenu. Elle change seulement pour un changement incompatible. De nouvelles clés peuvent apparaître à tout moment, alors ignorez celles que vous ne connaissez pas |
id | L’identifiant de l’événement, le même à chaque essai et à chaque renvoi. Servez-vous-en pour écarter les doublons |
type | submission.created, pour une nouvelle vraie réponse. C’est le seul événement pour l’instant |
timestamp | Le moment où ce contenu a été préparé |
test | true seulement pour Envoyer un test, dont les réponses sont inventées |
data.fields | Les réponses, après vos règles de nettoyage, par nom de champ, imbriquées comme dans votre formulaire |
data.answers | Les mêmes réponses sous forme de liste, chacune avec son libellé dans la langue du visiteur, et text, soit la valeur telle qu’elle se lit : le libellé d’un choix, ou Oui et Non |
data.submission.language | La langue utilisée par le visiteur |
data.submission.overPlan | true quand la réponse est arrivée au-delà de ce que votre forfait inclut ce mois-là. C’est à titre d’information : la réponse est conservée et envoyée comme les autres |
Jamais envoyés : les jetons de vérification, les détails sur le pourriel, l’adresse IP ou le navigateur du visiteur, vos destinataires, ni aucun champ configuré comme mot de passe.
Les entrées de test ne déclenchent pas les webhooks. Les entrées venant d’une clé de test, d’un lien de test ou de localhost sont conservées à part et n’envoient rien, peu importe vos webhooks. Pendant vos essais, seul Envoyer un test atteint votre adresse.
Vérifier la signature
Vérifiez-la à chaque requête, pour que personne ne puisse vous envoyer de fausses réponses. Formerie suit Standard Webhooks, une spécification publique :
- Joignez
webhook-id,webhook-timestampet le contenu brut avec des points :id.timestamp.contenu. - Décodez votre secret à partir de base64, sans son préfixe
whsec_, et utilisez-le comme clé pour le HMAC-SHA256 de ce texte. - Encodez le résultat en base64, et comparez-le en temps constant aux valeurs
v1,dewebhook-signature. Il peut y en avoir deux, séparées par une espace, pendant un changement de secret. Une seule correspondance suffit. - Refusez un
webhook-timestampqui s’écarte de plus de 5 minutes de l’heure actuelle, pour qu’une requête interceptée ne puisse pas être rejouée.
Utilisez le contenu brut, exactement comme il est reçu. Analyser puis réencoder le JSON le change, et la signature ne correspondra plus.
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 est en octets. Dans Flask, c’est request.get_data(), et dans 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 est file_get_contents('php://input'), et les en-têtes sont $_SERVER['HTTP_WEBHOOK_ID'] et ainsi de suite.
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
endChaque exemple ci-dessus est exécuté avec de vraies signatures, bonnes et mauvaises, avant d’être publié ici. Vous pouvez aussi utiliser une bibliothèque Standard Webhooks, puisque le secret et les en-têtes sont dans son format.
Répondez vite, et attendez-vous à des doublons
Répondez avec un 2xx en moins de 15 secondes, et faites le travail plus long ensuite. Tout le reste compte comme un essai raté :
| Votre serveur répond | Ce qui se passe |
|---|---|
2xx | Livré |
| Autre chose, un délai dépassé ou aucune connexion | Nouvel essai. Voir plus bas |
3xx | Raté, puis nouvel essai. Les redirections ne sont jamais suivies, alors mettez plutôt l’adresse à jour |
410 Gone | Le webhook se met en pause tout de suite, puisque vous indiquez que cette adresse est terminée |
retry-after avec un échec | Le prochain essai attend au moins ce délai, jusqu’à 12 heures |
Une réponse a droit à 8 essais sur environ deux jours : tout de suite, puis après 1 minute, 5 minutes, 30 minutes, 2 heures, 6 heures, 12 heures et 24 heures, avec un peu de hasard. Chaque essai a un nouveau webhook-timestamp et une nouvelle signature.
Une livraison peut arriver deux fois, car aucun système ne peut l’exclure. Gardez le webhook-id de ce que vous avez traité, et sautez ce que vous avez déjà vu. Renvoyer garde le même identifiant pour cette raison.
Un webhook occupé est limité à 120 livraisons par minute. Au-delà, elles attendent une minute, sans perdre d’essai.
Quand les échecs se répètent
Sous chaque webhook, Livraisons montre l’état de chaque réponse, ainsi que le code d’état et l’heure de chaque essai. Rien n’y contient les réponses elles-mêmes. Vous pouvez utiliser Renvoyer sur n’importe quelle livraison pendant 30 jours.
Si une livraison utilise tous ses essais, les propriétaires et administrateurs de l’espace de travail reçoivent un courriel, au plus un par jour pour chaque webhook. Après 3 livraisons de suite dans ce cas, le webhook se met en pause et ils en sont avisés. Pendant la pause :
- Vos réponses sont en sécurité, et rien n’est envoyé.
- Les nouvelles réponses attendent, jusqu’à 30 jours.
- Envoyer un test fonctionne toujours, pour vérifier une correction.
- Reprendre envoie tout ce qui a attendu dans les 7 derniers jours. Ce qui est plus ancien reste dans la liste, et Renvoyer peut encore l’envoyer.
Un test ou une livraison réussis remettent le compte à zéro.
Changer votre secret
Choisissez Changer le secret, et copiez le nouveau. Pendant 24 heures, chaque requête est signée avec les deux, pour que vous puissiez mettre votre serveur à jour sans interruption. Si l’ancien a fuité, choisissez Mettre fin à l’ancien maintenant.
Les adresses acceptées
Formerie vérifie votre adresse quand vous l’enregistrez et de nouveau avant chaque envoi, sur nos serveurs :
- Elle doit utiliser
https, sur le port standard, sans nom d’utilisateur ni mot de passe. - Elle doit être un nom résolu sur l’Internet public. Une adresse privée, de bouclage, lien-local ou de métadonnées infonuagiques est refusée, en chiffres ou selon ce vers quoi le nom pointe.
- Elle ne peut pas être sur
formerie.comni être le domaine personnalisé d’un formulaire.
Traitez l’adresse comme un secret si elle contient un jeton, comme celles des outils d’automatisation. Une fois l’adresse enregistrée, Formerie n’en montre que le début et ne la garde jamais dans ses journaux. Pour la changer, collez-en une nouvelle.
Où vont vos données
Un webhook envoie les réponses de vos visiteurs à l’adresse que vous avez choisie, où qu’elle soit, et ce système est sous votre responsabilité. Mentionnez-le dans votre propre politique de confidentialité si c’est pertinent. Retirer une réponse ici met aussi fin aux nouveaux essais et efface son journal de livraison. Une copie déjà reçue par votre serveur est à vous de la retirer.
À partir d’un assistant IA (bientôt)
Quand MCP sera ouvert à tous, un assistant connecté par ce moyen pourra lire les webhooks et les livraisons d’un formulaire, envoyer un test, renvoyer, mettre en pause et reprendre. Ajouter un webhook, changer son adresse, changer son secret et le retirer restent dans l’application, car une adresse, c’est là où vont vos réponses. Un secret n’est jamais montré à un assistant.