# 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](/fr/guides/n8n/), [Zapier](/fr/guides/zapier/) ou [Make](/fr/guides/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 |
| --- | --- |
| `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](/fr/guides/cleaning-answers/), 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](https://www.standardwebhooks.com/), 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](https://github.com/standard-webhooks/standard-webhooks/tree/main/libraries), 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.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](/fr/guides/quickstart-ai/) 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.
