# Webhooks

> Payloads de webhook signés, vérification de la signature HMAC-SHA256, calendrier des nouvelles tentatives et logs de livraison.

Les webhooks poussent chaque soumission acceptée vers votre serveur dès son arrivée. Les livraisons sont signées selon le schéma [Standard Webhooks](https://www.standardwebhooks.com), retentées pendant environ trois jours et journalisées tentative par tentative dans le tableau de bord. Des handlers prêts à l'emploi sont dans [Recevoir des webhooks](https://ikiform.com/fr/docs/guides/webhook-handlers.md).

## Ajouter un endpoint

1. Dans le tableau de bord, ouvrez **Webhooks** et choisissez **Add endpoint**.
2. Saisissez une URL HTTPS et choisissez un formulaire, ou tous les formulaires de l'espace de travail.
3. Copiez le secret de signature. Il commence par `whsec_` et n'est affiché en entier qu'une seule fois.
4. Envoyez un événement de test pour vérifier votre handler.

### URL des endpoints

- `https:` uniquement, sur le port par défaut 443, sans nom d'utilisateur ni mot de passe dans l'URL, jusqu'à 2 048 caractères.
- Un nom d'hôte public. Les adresses IP, `localhost` et les noms se terminant par `.local`, `.internal`, `.intranet`, `.lan`, `.home.arpa` ou `.onion` sont refusés, tout comme ikiform.com, workers.dev et pages.dev.
- Le nom d'hôte ne doit résoudre que vers des adresses publiques. Les plages privées, loopback, link-local et carrier-grade NAT sont refusées.
- L'URL est vérifiée à l'enregistrement puis avant chaque livraison. Un endpoint qui se met à résoudre vers une adresse privée est désactivé.
- Un espace de travail peut avoir jusqu'à 3 endpoints. Enterprise n'a pas de limite.

## Événements

| Type | Envoyé quand | Retenté |
| --- | --- | --- |
| `submission.created` | Une soumission est acceptée, ou une soumission spam est marquée comme légitime | Oui |
| `endpoint.test` | Vous choisissez **Send test** sur la page de l'endpoint | Non, une seule tentative |

Le spam ne déclenche jamais de webhook. Les soumissions mises en attente parce que l'espace de travail a dépassé sa limite mensuelle non plus.

## La requête

Chaque livraison est un `POST` avec un corps JSON et trois en-têtes de signature, envoyé par le service de livraison d'Ikiform avec le user agent `Ikiform-Webhooks/1.0`.

`En-têtes`:

```http
POST /webhooks/ikiform HTTP/1.1
content-type: application/json
user-agent: Ikiform-Webhooks/1.0
webhook-id: dlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp: 1792300000
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
```

| En-tête | Valeur |
| --- | --- |
| `webhook-id` | L'id de la livraison. Identique à chaque nouvelle tentative de la même livraison : utilisez-le pour ignorer les doublons |
| `webhook-timestamp` | Secondes Unix de la signature de cette tentative. Nouveau à chaque tentative |
| `webhook-signature` | `v1,` suivi d'un HMAC-SHA256 en base64. Plusieurs valeurs séparées par des espaces pendant la rotation d'un secret |

### Payloads

**submission.created** (`Corps`)

```json
{
  "id": "dlv_01k7v2d4n8r2p6s0w3y5b7c9e1",
  "type": "submission.created",
  "timestamp": "2026-10-17T12:00:00.000Z",
  "api_version": "2026-10-17",
  "data": {
    "submission_id": "sub_01k7v2c9t3e8m5q4x6z0a1b2c3",
    "form": { "id": "frm_xxxxxxxxxxxxxxxx", "name": "Contact" },
    "fields": { "name": "Ada", "email": "ada@example.com", "message": "Hello" },
    "meta": {
      "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
      "origin": "https://example.com",
      "referrer": "https://example.com/contact",
      "country": "GB",
      "subject": null,
      "reply_to": "ada@example.com"
    },
    "created_at": "2026-10-17T12:00:00.000Z",
    "truncated": false
  }
}
```

**endpoint.test** (`Corps`)

```json
{
  "id": "dlv_01k7v2f0a6c8e2g4j6m8p0r2t4",
  "type": "endpoint.test",
  "timestamp": "2026-10-17T12:00:00.000Z",
  "api_version": "2026-10-17",
  "data": {
    "endpoint_id": "whk_01k7v2e1b3d5f7h9k1m3n5q7s9",
    "message": "This is a test event from Ikiform."
  }
}
```

**Tronqué** (`Plus de 256 Ko`)

```json
{
  "id": "dlv_01k7v2d4n8r2p6s0w3y5b7c9e1",
  "type": "submission.created",
  "timestamp": "2026-10-17T12:00:00.000Z",
  "api_version": "2026-10-17",
  "data": {
    "submission_id": "sub_01k7v2c9t3e8m5q4x6z0a1b2c3",
    "form": { "id": "frm_xxxxxxxxxxxxxxxx", "name": "Contact" },
    "fields": {},
    "meta": { "origin": "https://example.com", "country": "GB" },
    "created_at": "2026-10-17T12:00:00.000Z",
    "truncated": true
  }
}
```

| Champ | Description |
| --- | --- |
| `id` | L'id de la livraison, identique à `webhook-id` |
| `type` | `submission.created` ou `endpoint.test` |
| `timestamp` | La date de création de la livraison, en ISO 8601 UTC. Identique à chaque nouvelle tentative |
| `api_version` | La version du payload avec laquelle l'endpoint a été créé, actuellement `2026-10-17` |
| `data.submission_id` | L'id de la soumission. Récupérez-la avec l'[API REST](https://ikiform.com/fr/docs/api.md#get-submission) |
| `data.form` | L'`id` et le `name` du formulaire |
| `data.fields` | Les valeurs stockées. Avec des champs définis, ce sont les valeurs validées et converties |
| `data.meta` | Les mêmes clés que le `meta` de l'API REST. Chaque clé est présente et n'importe quelle valeur peut être `null`. L'IP du visiteur et son hachage ne sont jamais envoyés |
| `data.meta.user_agent` | Le user agent du navigateur, jusqu'à 512 caractères |
| `data.meta.origin`, `referrer` | La provenance de l'envoi, quand le navigateur les a transmis |
| `data.meta.country` | Code pays à deux lettres, fourni par Cloudflare |
| `data.meta.subject`, `reply_to` | Issus des champs `_subject` et `_replyto` |
| `data.created_at` | La date de stockage de la soumission |
| `data.truncated` | `true` quand le corps dépasserait 256 Ko, auquel cas `fields` est vide |

N'importe quelle valeur de `meta` peut être `null`. Traitez les valeurs des champs comme des entrées non fiables, exactement comme un envoi de formulaire vers votre propre serveur.

## Vérifier la signature

La signature est un HMAC-SHA256 de `{webhook-id}.{webhook-timestamp}.{raw body}`, avec pour clé la partie du secret après `whsec_` décodée en base64, puis encodé en base64 et envoyé après `v1,` dans `webhook-signature`.

- Utilisez le corps brut de la requête exactement tel que reçu. Analyser puis resérialiser le JSON modifie les octets.
- L'en-tête peut contenir plusieurs signatures séparées par des espaces. Acceptez la requête si l'une des signatures `v1` correspond.
- Comparez en temps constant, et rejetez les horodatages éloignés de plus de 5 minutes de l'heure actuelle.

### Avec le SDK

`verifyWebhook` fait tout cela avec Web Crypto et renvoie l'événement analysé, ou lève une `WebhookVerificationError` avec une `reason`. Il accepte un objet `Headers` ou un simple objet comme le `req.headers` de Node.

`app/api/webhooks/ikiform/route.ts`:

```ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

export async function POST(request: Request) {
  const payload = await request.text();
  try {
    const event = await verifyWebhook({
      payload,
      headers: request.headers,
      secret: process.env.IKIFORM_WEBHOOK_SECRET!,
    });
    if (event.type === "submission.created") {
      console.log(event.data.fields);
    }
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof WebhookVerificationError) {
      return new Response(error.reason, { status: 400 });
    }
    throw error;
  }
}
```

### Sans le SDK

N'importe quelle bibliothèque Standard Webhooks fonctionne aussi. À la main, il suffit de quelques lignes :

**Node** (`verify.js`)

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

const secret = Buffer.from(process.env.IKIFORM_WEBHOOK_SECRET.replace(/^whsec_/, ""), "base64");

export function verify(body, headers) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"];
  if (!id || !timestamp || !signatures) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${id}.${timestamp}.${body}`).digest();
  return signatures.split(" ").some((entry) => {
    const [version, signature] = entry.split(",");
    if (version !== "v1" || !signature) return false;
    const received = Buffer.from(signature, "base64");
    return received.length === expected.length && timingSafeEqual(received, expected);
  });
}
```

**Python** (`verify.py`)

```python
import base64
import hashlib
import hmac
import os
import time

secret = base64.b64decode(os.environ["IKIFORM_WEBHOOK_SECRET"].removeprefix("whsec_"))


def verify(body: bytes, headers) -> bool:
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature")
    if not msg_id or not timestamp or not signatures or not timestamp.isdigit():
        return False
    if abs(time.time() - int(timestamp)) > 300:
        return False
    content = f"{msg_id}.{timestamp}.".encode() + body
    expected = base64.b64encode(hmac.new(secret, content, hashlib.sha256).digest()).decode()
    for entry in signatures.split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature, expected):
            return True
    return False
```

### Vérificateur de signature

Le vérificateur interactif de cette page contrôle un secret, les trois en-têtes de webhook et un corps brut avec le `verifyWebhook` du SDK, entièrement dans le navigateur. Il peut aussi générer un exemple de livraison correctement signé. Rien de ce que vous collez ne quitte la page.

## Répondre

| Votre réponse | Ce que fait Ikiform |
| --- | --- |
| N'importe quel `2xx` en moins de 15 secondes | Marque la livraison comme réussie |
| `410 Gone` | Fait échouer la livraison et désactive immédiatement l'endpoint |
| `429` ou `503` avec `Retry-After` | Réessaie après ce délai, mais pas plus tard que l'étape suivante du calendrier |
| Une redirection | Non suivie. Compte comme un échec |
| Tout autre statut, un timeout ou une erreur réseau | Réessaie selon le calendrier ci-dessous |

Faites le travail lent après avoir répondu, par exemple dans une file d'attente. Les 4 premiers Ko du corps de votre réponse sont enregistrés dans le log de livraison.

## Nouvelles tentatives

Une tentative échouée est retentée selon ce calendrier, chaque délai variant aléatoirement de 20 % au plus dans un sens ou dans l'autre. Après la 9e tentative, environ 3 j 6 h après la première, la livraison est marquée comme échouée.

| Tentative | Délai après la tentative précédente | Temps écoulé depuis la première tentative |
| --- | --- | --- |
| 1 | Immédiatement | 0 |
| 2 | 5 s | 5 s |
| 3 | 5 min | 5 min |
| 4 | 30 min | 35 min |
| 5 | 2 h | 2 h 35 min |
| 6 | 5 h | 7 h 35 min |
| 7 | 10 h | 17 h 35 min |
| 8 | 1 j | 1 j 18 h |
| 9 | 1 j 12 h | 3 j 6 h |

### Endpoints désactivés

Si toutes les livraisons vers un endpoint échouent pendant 5 jours, l'endpoint est désactivé et les propriétaires de l'espace de travail reçoivent un e-mail. Une réponse `410 Gone` ou une destination qui devient privée le désactive immédiatement. Réactivez-le depuis la page de l'endpoint une fois le problème corrigé.

## Logs de livraison

La page de chaque endpoint liste ses livraisons avec leur statut. Dépliez-en une pour voir chaque tentative : quand elle a eu lieu, si elle était automatique, manuelle ou un test, le code de statut, la durée, et les 4 premiers Ko de votre réponse ou l'erreur. Vous pouvez y relancer n'importe quelle livraison. Les tentatives sont conservées 30 jours et les livraisons 90 jours.

## Rotation des secrets

**Roll signing secret** crée un nouveau secret. Pendant les 24 heures suivantes, chaque livraison est signée à la fois avec l'ancien et le nouveau secret : `webhook-signature` contient alors deux valeurs, et vous pouvez déployer le nouveau secret sans perdre d'événement.
