# Référence webhooks

Schéma du payload, types d'événements, signature, tentatives et points de terminaison REST d'abonnement.

## Référence webhooks

Schéma du payload, types d’événements, signature, comportement des tentatives, et points de terminaison REST d’abonnement pour Zapier et Make.

> ℹ️ **Vous cherchez le guide de configuration ?**
> <p>
>     Cette page documente le contrat du payload et l’API REST d’abonnement. Pour configurer un webhook personnalisé pour votre formulaire
>     dans l’interface, consultez <a href="/fr/integrations/webhooks">Webhooks personnalisés</a>.
>   </p>

<h2 id="request">Requête</h2>
<p>
  <code>POST &lt;your-url&gt;</code> avec <code>Content-Type: application/json</code>.
</p>

<h3 id="headers">En-têtes</h3>
<ul>
  <li>
    <code>Content-Type: application/json</code>
  </li>
  <li>
    <code>X-Formstep-Signature: t=&#123;timestamp&#125;,sha256=&#123;hex&#125;</code> — présent quand un secret de signature est configuré
    (voir ci-dessous)
  </li>
  <li>
    <code>X-Formstep-Event-Id</code> et <code>X-Formstep-Event-Type</code> — les mêmes valeurs que <code>id</code> et <code>type</code> dans
    le corps, pour dédupliquer et router avant l’analyse. Un <a href="/fr/requests/callbacks">callback de demande</a> envoie les deux mêmes.
  </li>
  <li>
    Tout en-tête personnalisé ajouté lors de la configuration. Ils sont fusionnés tels que fournis, sauf <code>Content-Type</code>, qui ne
    peut pas être remplacé. Les abonnements natifs créés via <code>webhooks.create</code> n’ont pas d’en-têtes personnalisés.
  </li>
</ul>

<h2 id="event-types">Types d’événements</h2>

<h3 id="subscription-events">Événements d’abonnement</h3>
<p>Lors de la configuration d’une intégration webhook, vous choisissez quel événement déclenche les livraisons :</p>

<p>
  Les trois événements <code>request_*</code> s’abonnent via <code>webhooks.create</code> et sont ce sur quoi écoutent les applications
  Formstep pour Zapier, Make et n8n. Chacun livre la même enveloppe qu’envoie un <a href="/fr/requests/callbacks">callback de demande</a>,
  signée avec le secret propre à l’abonnement. Les demandes de test n’atteignent jamais un abonnement, et{' '}
  <code>requests.replayCallback</code> ne renvoie que le callback. Un abonnement, un événement : <code>submission_created</code> correspond
  au trafic sur lien public et <code>request_completed</code> au trafic de demande, si bien qu’une demande terminée ne déclenche jamais{' '}
  <code>submission_created</code> et qu’un formulaire ayant les deux abonnements reçoit une seule livraison par achèvement. Une modification
  n’atteint qu’un abonnement <code>submission_updated</code>, jamais un abonnement <code>submission_created</code>. Le webhook personnalisé
  configuré dans les paramètres du formulaire n’a pas de choix d’événement : il reçoit les premières soumissions et les modifications
  indifféremment, à distinguer via <code>type</code>.
</p>

<h3 id="payload-event-types">Types d’événements dans le payload</h3>
<p>
  Le champ <code>type</code> dans le corps JSON indique ce qui s’est passé :
</p>
<ul>
  <li>
    <code>submission.completed</code> — une nouvelle soumission terminée
  </li>
  <li>
    <code>submission.updated</code> — une soumission existante a été modifiée
  </li>
  <li>
    <code>submission.abandoned</code> — un brouillon a été abandonné après la période d’inactivité configurée
  </li>
</ul>
<p>
  Un <a href="/fr/requests/callbacks">callback de demande</a> et un abonnement <code>request_*</code> utilisent la même enveloppe avec{' '}
  <code>request.completed</code>, <code>request.expired</code> et <code>request.canceled</code>, donc un seul analyseur lit les six.
</p>

<h2 id="payload">Structure du payload</h2>

```
{
  "id": "evt_abc123",
  "type": "submission.completed",
  "createdAt": "2026-04-25T12:34:56.000Z",
  "apiVersion": "2026-09-24",
  "test": false,
  "data": {
    "form": { "id": "frm_...", "name": "Contact", "snapshotId": "snp_..." },
    "submission": {
      "id": "sub_...",
      "respondentEmail": "alice@example.com",
      "submittedAt": "2026-04-25T12:34:56.000Z",
      "updatedAt": null,
      "editCount": 0,
      "pdfUrl": null,
      "language": "en"
    },
    "answers": {
      "email": "alice@example.com",
      "plan": "pro",
      "attendees": [{ "attendee_name": "Grace Hopper" }, { "attendee_name": "Alan Turing" }]
    },
    "display": {
      "email": "alice@example.com",
      "plan": "Pro",
      "attendees": "Grace Hopper, Alan Turing"
    }
  }
}
```

<h3 id="fields-vs-answers">answers et display</h3>
<p>
  <code>answers</code> est l'objet plat <code>{'{ field key: value }'}</code>, indexé par les clés de champ figées à la publication.
  Lisez-le quand un workflow bifurque ou stocke une valeur : <code>answers.email</code>, aucun tableau à parcourir. Une réponse à choix est
  la <strong>clé</strong> de l'option choisie — le <code>key</code> que <code>fields.list</code> liste pour cette option — donc c'est la
  même valeur quelle que soit la langue dans laquelle le répondant a répondu. Une date est une chaîne ISO, un nombre est un nombre, une
  sélection multiple un tableau de clés d'option. Les champs sans réponse sont omis, jamais envoyés comme <code>null</code>.
</p>
<p>
  <code>display</code> porte les mêmes clés avec du texte lisible par un humain : le libellé de l'option plutôt que sa clé, une date
  formatée, une liste jointe. Lisez-le quand une personne verra la valeur — un message Slack, une cellule de tableur, un e-mail.
</p>
<p>
  Un groupe répétable apparaît dans <code>answers</code> une seule fois, sous la clé de champ propre au groupe, comme un tableau d'objets de
  ligne indexés par la clé de champ de chaque membre — <code>answers.attendees[0].attendee_name</code> ci-dessus — et dans{' '}
  <code>display</code> comme une seule ligne avec les rangées jointes. Un membre n'est jamais remonté au premier niveau.{' '}
  <a href="/fr/building-forms/calculated-fields">Les champs calculés</a> apparaissent dans les deux maps, sous le nom du champ calculé comme
  clé (<code>answers.total</code>).
</p>
<h3 id="bookings-and-payments">Réservations et paiements</h3>
<p>
  Une question Planifier un rendez-vous et un champ Paiement portent chacun un objet dans <code>answers</code>, sous la clé de champ de la
  question, et une ligne de texte dans <code>display</code>. Les heures sont des instants ISO, donc un tableur ou un workflow peut les lire
  quelle que soit la langue dans laquelle le répondant a répondu :
</p>

```
{
  "book_a_call": {
    "status": "confirmed",
    "start": "2026-09-29T07:00:00.000Z",
    "end": "2026-09-29T07:30:00.000Z",
    "timeZone": "Europe/Oslo",
    "attendee": { "name": "Grace Hopper", "email": "grace@example.com" },
    "meetingUrl": "https://app.cal.com/video/...",
    "provider": "cal.com",
    "providerBookingId": "...",
    "eventTitle": "Intro call"
  },
  "pay_the_fee": {
    "status": "paid",
    "amount": 40,
    "currency": "USD",
    "amountRefunded": 0,
    "receiptUrl": "https://pay.stripe.com/receipts/...",
    "paidAt": "2026-09-24T10:12:00.000Z",
    "refundedAt": null,
    "disputedAt": null,
    "provider": "stripe",
    "providerPaymentIntentId": "pi_..."
  }
}
```

<p>
  Le <code>status</code> d'une réservation est <code>confirmed</code>, <code>rescheduled</code>, <code>cancelled</code>,{' '}
  <code>rejected</code> ou <code>no_show</code>. Celui d'un paiement est <code>paid</code>, <code>partially_refunded</code>,{' '}
  <code>refunded</code> ou <code>disputed</code>, et <code>amount</code> est exprimé dans l'unité principale de la devise : <code>40</code>{' '}
  vaut 40,00 $. Quand Cal.com déplace une réservation ou que Stripe rembourse un paiement après la soumission, Formstep met à jour l'objet,
  donc <code>submissions.list</code> et les événements suivants montrent l'état actuel ; aucun nouvel événement n'est envoyé pour ce
  changement.
</p>
<p>
  Avant l'<code>apiVersion</code> <code>2026-09-24</code>, une réservation était envoyée sous la forme d'une seule phrase dans{' '}
  <code>answers</code> et un paiement n'était pas envoyé du tout.
</p>

<p>
  Le mapping des champs du webhook s'applique aux deux maps à la fois : choisissez les champs « sélectionnés » et les autres sont omis ;
  renommez la colonne d'un champ et le nouveau nom devient sa clé dans <code>answers</code> et <code>display</code> à la fois. Un champ
  publié par le formulaire avant l'existence des clés de champ sort sous son id d'élément ; publiez à nouveau le formulaire pour lui donner
  une clé lisible.
</p>

<h3 id="schema">Titres et types des champs</h3>
<p>
  L'événement ne répète pas le titre et le type de chaque champ. Lisez-les depuis <code>fields.list</code>, qui est stable par{' '}
  <code>data.form.snapshotId</code>, ce qui permet de mettre en cache la liste des champs et de ne la recharger que quand l'id du snapshot
  change. Un récepteur qui ne peut pas faire un second appel peut activer <strong>Envoyer la liste des champs avec chaque événement</strong>{' '}
  dans les paramètres du webhook ; l'événement porte alors <code>data.schema</code>, une entrée par champ. Une question à choix liste ses{' '}
  <code>options</code> et une matrice ses <code>rows</code> et <code>columns</code>, chacune comme <code>{'{ key, label }'}</code>, si bien
  que les clés dans <code>answers</code> se résolvent en libellés sans second appel :
</p>

```
[
  { "key": "email", "title": "Email", "type": "email", "group": null },
  { "key": "plan", "title": "Plan", "type": "radio", "group": null, "options": [{ "key": "free", "label": "Free" }, { "key": "pro", "label": "Pro" }] },
  { "key": "attendee_name", "title": "Attendee name", "type": "text", "group": "attendees" }
]
```

<h3 id="request-block">Le bloc request</h3>
<p>
  Sur le <a href="/fr/integrations/webhooks">webhook personnalisé</a> configuré dans les paramètres du formulaire, une soumission qui répond
  à une <a href="/fr/requests/overview">demande</a> porte un objet supplémentaire à l'intérieur de <code>data</code>, <code>request</code>.
  Il est absent de chaque soumission sur lien public, ce qui permet à ce récepteur de distinguer les deux canaux. Un abonnement Zapier, Make
  ou n8n ne le voit jamais : le trafic de demande atteint un abonnement sous forme de <code>request.completed</code>, qui porte le bloc
  request complet.
</p>

```
"request": {
  "id": "kd7...",
  "externalId": "run-42",
  "metadata": { "crm_record": "rec_88" }
}
```

<ul>
  <li>
    <code>id</code> — la demande à laquelle cette soumission a répondu. Passez-la à <code>requests.get</code> pour le tableau complet.
  </li>
  <li>
    <code>externalId</code> et <code>metadata</code> — votre propre suivi, exactement tel que vous l'avez fourni dans{' '}
    <code>requests.create</code>. Chacun n'est présent que s'il a été défini.
  </li>
</ul>

> ℹ️ **Les webhooks ne sont pas des callbacks**
> <p>
>     Un webhook de soumission se déclenche sur une soumission ; le bloc request nomme seulement la demande à laquelle elle a répondu. Un{' '}
>     <a href="/fr/requests/callbacks">callback</a> se déclenche quand une demande se termine — terminée, expirée, ou annulée — et porte{' '}
>     <code>context</code> et <code>outcome</code>. L'expiration et l'annulation n'ont pas de soumission, donc aucun webhook de soumission ne
>     se déclenche jamais pour elles. Pour entendre la fin d'une demande sans URL de callback, abonnez-vous à <code>request_completed</code>,{' '}
>     <code>request_expired</code> ou <code>request_canceled</code> via <code>webhooks.create</code>.
>   </p>

<h3 id="submission-pdf">PDF de la soumission</h3>
<p>
  <code>data.submission.pdfUrl</code> est un lien vers le PDF de la soumission. Un formulaire avec un webhook personnalisé ou un abonnement
  Zapier, Make ou n8n conserve un PDF de chaque soumission, donc ses événements contiennent le lien. Il vaut <code>null</code> uniquement
  lorsque la soumission n’a pas de PDF.
</p>

<h3 id="submission-language">Langue de la soumission</h3>
<p>
  <code>data.submission.language</code> est le code BCP-47 de la langue dans laquelle le répondant a soumis le formulaire (pour les
  formulaires traduits). Il est <code>null</code> pour les formulaires en une seule langue. Utilisez-le pour aiguiller ou conditionner le
  traitement en fonction de la langue du répondant, sans recourir à une recherche séparée.
</p>

<h2 id="abandoned-submissions">Payloads de soumissions abandonnées</h2>
<p>
  Quand vous vous abonnez à <code>submission_abandoned</code>, Formstep vérifie toutes les heures les brouillons inactifs. Si un brouillon
  est resté inactif au-delà de la fenêtre d’inactivité configurée, Formstep déclenche une livraison.
</p>
<p>
  Les intégrations API natives doivent envoyer <code>idleWindow</code> à <code>webhooks.create</code>. Les valeurs acceptées sont{' '}
  <code>12h</code>, <code>1d</code>, <code>3d</code>, et <code>1w</code>. Il n’y a pas de valeur par défaut implicite ; un abonnement
  abandonné sans valeur est rejeté.
</p>

<p>La structure du payload est identique à celle d’une soumission complète. Deux différences :</p>
<ul>
  <li>
    <strong>Les réponses peuvent être incomplètes</strong> — seules les questions auxquelles le répondant a répondu apparaissent dans{' '}
    <code>answers</code> et <code>display</code>.
  </li>
  <li>
    <strong>
      <code>submittedAt</code>
    </strong>{' '}
    — utilise l’horodatage de livraison en remplacement, puisque le répondant n’a jamais formellement soumis.
  </li>
</ul>
<p>
  Chaque intégration se déclenche au plus une fois par brouillon abandonné. Après la livraison, le brouillon est exclu des vérifications
  futures.
</p>

<h2 id="signing">Signature</h2>
<p>
  Chaque webhook a son propre secret de signature — défini dans l’interface quand vous configurez un webhook personnalisé, ou avec le
  paramètre optionnel <code>signingSecret</code> (32 à 255 caractères) sur <code>webhooks.create</code>. Ce n’est pas le secret de signature
  des demandes de l’espace de travail utilisé pour les <a href="/fr/requests/callbacks">callbacks de demande</a>, mais l’en-tête et
  l’algorithme sont identiques, donc un seul vérificateur gère les deux.
</p>
<p>
  Chaque livraison signée porte <code>X-Formstep-Signature: t=&#123;seconds&#125;,sha256=&#123;hex&#125;</code>. Le digest est un
  HMAC-SHA256 de <code>&#123;t&#125;.&#123;raw body&#125;</code>, avec votre secret comme clé. Deux règles : hachez le corps{' '}
  <strong>brut</strong> avant tout parsing ou re-sérialisation, et comparez en temps constant.
</p>

```
import crypto from 'node:crypto'

  if (!header) return false
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=', 2)))
  if (!parts.t || !parts.sha256) return false

const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex')
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(parts.sha256, 'hex')
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false

return Math.abs(Date.now() / 1000 - Number(parts.t)) <= toleranceSeconds
}
```

```
import hashlib, hmac, time
def verify_formstep_webhook(raw_body: bytes, header: str, secret: str, tolerance_seconds: int = 300) -> bool:
  parts = dict(p.split('=', 1) for p in header.split(',') if '=' in p)
  t, received = parts.get('t'), parts.get('sha256')
  if not t or not received:
    return False
  expected = hmac.new(secret.encode(), f'{t}.'.encode() + raw_body, hashlib.sha256).hexdigest()
  if not hmac.compare_digest(expected, received):
    return False
  return abs(time.time() - int(t)) <= tolerance_seconds
```

<p>
  Formstep n’impose pas de fenêtre anti-rejeu, donc la tolérance ci-dessus est à votre choix. Changer le secret prend effet dès la prochaine
  tentative, y compris les reprises déjà en vol — mettez à jour votre récepteur en premier.
</p>

> ⚠️ **Vérifiez toujours en production**
> <p>Sans vérification, quiconque découvre votre URL peut poster de fausses soumissions.</p>

<h2 id="retries">Nouvelles tentatives</h2>
<p>
  Formstep effectue jusqu’à 5 tentatives par livraison — 1 initiale et 4 reprises — espacées d’au moins 1, 2, 4 et 8 minutes. Formstep
  recherche les tentatives dues toutes les 30 minutes, une nouvelle tentative peut donc arriver jusqu’à une demi-heure après la fin de son
  recul, et la dernière tentative environ deux heures après la première. Un en-tête <code>Retry-After</code> sur votre réponse est respecté
  quand il demande plus de temps que la prochaine étape du recul. Une livraison est considérée comme échouée si votre endpoint :
</p>
<ul>
  <li>Retourne un statut non-2xx</li>
  <li>Expire</li>
  <li>Réinitialise la connexion</li>
</ul>
<p>
  Un cas n’est jamais retenté : une destination bloquée, non résolvable, ou qui résout vers une adresse privée. L’URL est revalidée — DNS
  compris — immédiatement avant chaque tentative, donc un hôte qui cesse d’être autorisé fait échouer la livraison immédiatement plutôt que
  de consommer le budget.
</p>
<p>
  Après 5 livraisons consécutives échouées, l’intégration est mise en pause. Corrigez l’endpoint et réactivez-le depuis Paramètres du
  formulaire → Intégrations ; une livraison réussie réinitialise le compteur.
</p>
<p>
  Les livraisons retentées du même événement réutilisent le même <code>id</code>, donc dédupliquez en stockant les ids traités. Un événement
  réellement nouveau — une personne qui modifie sa réponse, par exemple — arrive avec un nouvel <code>id</code> et{' '}
  <code>type: "submission.updated"</code> : au webhook personnalisé configuré dans les paramètres du formulaire, ou à un abonnement{' '}
  <code>submission_updated</code>. <code>createdAt</code> correspond au moment où l’événement a été mis en file, pas au moment de la
  tentative, donc il reste identique d’une reprise à l’autre aussi. Pour distinguer les modifications, lisez{' '}
  <code>data.submission.editCount</code> : il s’incrémente à chaque modification, et <code>data.submission.updatedAt</code> indique quand la
  dernière a eu lieu.
</p>

<h2 id="testing">Tests</h2>
<p>
  Le panneau de configuration et de détail de l’intégration disposent tous deux d’un bouton <strong>Envoyer un test</strong>. Il envoie un
  exemple de l’événement abonné à votre URL afin que vous puissiez vérifier la connexion sans attendre une vraie soumission ou demande. Les
  mêmes exemples sont disponibles via l’API sous <code>submissions.sample</code> et <code>requests.sample</code>.
</p>
<p>Pour le développement local, exposez votre serveur de développement avec un tunnel :</p>

```
# ngrok
ngrok http 3000

# cloudflare tunnel

cloudflared tunnel --url http://localhost:3000
```

> 💡 **Développement local**
> <p>Utilisez l’URL du tunnel comme endpoint webhook, puis cliquez sur Envoyer un test pour vérifier le flux complet.</p>

<h2 id="next-steps">Prochaines étapes</h2>
<div class="not-prose grid gap-3 sm:grid-cols-2">
  - [Configuration des webhooks](/fr/integrations/webhooks) — Configurer les webhooks pour votre formulaire
  - [Plans et tarifs](/fr/subscription-billing/plans-pricing) — Comparer les fonctionnalités API et les limites par plan
</div>
