Passer au contenu

Essayez un sujet, un nom de fonction ou un code d’erreur comme FORM_CLOSED.

English formerie.com

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

  1. Ouvrez votre formulaire, puis Livraison, et trouvez Webhooks.
  2. Choisissez Ajouter un webhook, donnez-lui un nom et collez votre adresse. Elle doit être publique et commencer par https://.
  3. Copiez le secret de signature. Il n’est affiché que cette fois-ci.
  4. 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

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" }
    ]
  }
}
CléCe que c’est
versionLa 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
idL’identifiant de l’événement, le même à chaque essai et à chaque renvoi. Servez-vous-en pour écarter les doublons
typesubmission.created, pour une nouvelle vraie réponse. C’est le seul événement pour l’instant
timestampLe moment où ce contenu a été préparé
testtrue seulement pour Envoyer un test, dont les réponses sont inventées
data.fieldsLes réponses, après vos règles de nettoyage, par nom de champ, imbriquées comme dans votre formulaire
data.answersLes 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.languageLa langue utilisée par le visiteur
data.submission.overPlantrue 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 :

  1. Joignez webhook-id, webhook-timestamp et le contenu brut avec des points : id.timestamp.contenu.
  2. Décodez votre secret à partir de base64, sans son préfixe whsec_, et utilisez-le comme clé pour le HMAC-SHA256 de ce texte.
  3. Encodez le résultat en base64, et comparez-le en temps constant aux valeurs v1, de webhook-signature. Il peut y en avoir deux, séparées par une espace, pendant un changement de secret. Une seule correspondance suffit.
  4. Refusez un webhook-timestamp qui 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

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 est en octets. Dans Flask, c’est request.get_data(), et dans 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 est file_get_contents('php://input'), et les en-têtes sont $_SERVER['HTTP_WEBHOOK_ID'] et ainsi de suite.

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

Chaque 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épondCe qui se passe
2xxLivré
Autre chose, un délai dépassé ou aucune connexionNouvel essai. Voir plus bas
3xxRaté, puis nouvel essai. Les redirections ne sont jamais suivies, alors mettez plutôt l’adresse à jour
410 GoneLe webhook se met en pause tout de suite, puisque vous indiquez que cette adresse est terminée
retry-after avec un échecLe 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.com ni ê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.