Développeurs
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 ?
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 Webhooks personnalisés.
Requête
POST <your-url> avec Content-Type: application/json.
En-têtes
Content-Type: application/jsonX-formbase-Signature: t={timestamp},sha256={hex}— présent quand un secret de signature est configuré (voir ci-dessous)X-formbase-Event-IdetX-formbase-Event-Type— les mêmes valeurs queidettypedans le corps, pour dédupliquer et router avant l’analyse. Un callback de demande envoie les deux mêmes.Tout en-tête personnalisé ajouté lors de la configuration. Ils sont fusionnés tels que fournis, sauf
Content-Type, qui ne peut pas être remplacé. Les abonnements natifs créés viawebhooks.createn’ont pas d’en-têtes personnalisés.
Types d’événements
Événements d’abonnement
Lors de la configuration d’une intégration webhook, vous choisissez quel événement déclenche les livraisons :
| Événement | Quand il se déclenche |
|---|---|
| submission_created | Un répondant complète et soumet le formulaire. C'est le comportement par défaut. |
| submission_updated | Un répondant modifie une soumission déjà envoyée, quand le formulaire autorise la modification après l'envoi. |
| submission_abandoned | Une soumission en brouillon est restée inactive après la fenêtre configurée. Nécessite le suivi des soumissions partielles (Pro). |
| request_completed | Un destinataire termine une demande sur le formulaire. Porte le bloc request et les réponses. |
| request_expired | Une demande sur le formulaire expire avant que le destinataire ne la termine. Bloc request seul. |
| request_canceled | Une demande sur le formulaire est annulée. Bloc request seul. |
Les trois événements request_* s’abonnent via webhooks.create et sont ce sur quoi écoutent les applications
formbase pour Zapier, Make et n8n. Chacun livre la même enveloppe qu’envoie un callback de demande,
signée avec le secret propre à l’abonnement. Les demandes de test n’atteignent jamais un abonnement, et
requests.replayCallback ne renvoie que le callback. Un abonnement, un événement : submission_created correspond
au trafic sur lien public et request_completed au trafic de demande, si bien qu’une demande terminée ne déclenche jamais
submission_created et qu’un formulaire ayant les deux abonnements reçoit une seule livraison par achèvement. Une modification
n’atteint qu’un abonnement submission_updated, jamais un abonnement submission_created. 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 type.
Types d’événements dans le payload
Le champ type dans le corps JSON indique ce qui s’est passé :
submission.completed— une nouvelle soumission terminéesubmission.updated— une soumission existante a été modifiéesubmission.abandoned— un brouillon a été abandonné après la période d’inactivité configurée
Un callback de demande et un abonnement request_* utilisent la même enveloppe avec
request.completed, request.expired et request.canceled, donc un seul analyseur lit les six.
Structure du payload
{
"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"
}
}
}| Clé | Ce que c'est |
|---|---|
| id | L'id de l'événement. Les nouvelles tentatives le réutilisent — dédupliquez dessus. |
| type | Un des six types d'événements ci-dessus. |
| createdAt | Quand l'événement a été mis en file, pas quand cette tentative de livraison a eu lieu. Stable d'une reprise à l'autre. |
| apiVersion | Le contrat du payload, sous forme de date. Il change quand une clé est supprimée, renommée ou change de signification. Les nouvelles clés arrivent sans changement. |
| test | true pour une livraison d'exemple ou de test, et pour une demande créée en mode test. Toujours présent. |
| data.form.snapshotId | La version publiée à laquelle le répondant a répondu. Les clés de champ, titres et types sont fixés par snapshot. |
| data.submission.updatedAt | Quand le répondant a modifié la soumission pour la dernière fois, ou null jusqu'au premier changement. |
| data.submission.editCount | Le nombre de fois où le répondant a modifié la soumission après l'avoir envoyée : 0 sur submission.completed, 1 dès la première modification. |
| data.answers | Chaque réponse, indexée par clé de champ. Chaque réponse apparaît une fois. |
| data.display | Texte lisible par un humain pour chaque réponse, sous les mêmes clés. |
| data.schema | Optionnel : la liste des champs (key, title, type, group, et les clés d'option ou de ligne et de colonne avec leurs libellés), quand le webhook a été configuré pour l'envoyer. |
answers et display
answers est l’objet plat { field key: value }, indexé par les clés de champ figées à la publication.
Lisez-le quand un workflow bifurque ou stocke une valeur : answers.email, aucun tableau à parcourir. Une réponse à choix est
la clé de l’option choisie — le key que fields.list 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 null.
display 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.
Un groupe répétable apparaît dans answers 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 — answers.attendees[0].attendee_name ci-dessus — et dans
display comme une seule ligne avec les rangées jointes. Un membre n’est jamais remonté au premier niveau.
Les champs calculés apparaissent dans les deux maps, sous le nom du champ calculé comme
clé (answers.total).
Réservations et paiements
Une question Planifier un rendez-vous et un champ Paiement portent chacun un objet dans answers, sous la clé de champ de la
question, et une ligne de texte dans display. 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 :
{
"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_..."
}
}Le status d’une réservation est confirmed, rescheduled, cancelled,
rejected ou no_show. Celui d’un paiement est paid, partially_refunded,
refunded ou disputed, et amount est exprimé dans l’unité principale de la devise : 40
vaut 40,00 $. Quand Cal.com déplace une réservation ou que Stripe rembourse un paiement après la soumission, formbase met à jour l’objet,
donc submissions.list et les événements suivants montrent l’état actuel ; aucun nouvel événement n’est envoyé pour ce
changement.
Avant l’apiVersion 2026-09-24, une réservation était envoyée sous la forme d’une seule phrase dans
answers et un paiement n’était pas envoyé du tout.
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 answers et display à 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.
Titres et types des champs
L’événement ne répète pas le titre et le type de chaque champ. Lisez-les depuis fields.list, qui est stable par
data.form.snapshotId, 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 Envoyer la liste des champs avec chaque événement
dans les paramètres du webhook ; l’événement porte alors data.schema, une entrée par champ. Une question à choix liste ses
options et une matrice ses rows et columns, chacune comme { key, label }, si bien
que les clés dans answers se résolvent en libellés sans second appel :
[
{ "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" }
]Le bloc request
Sur le webhook personnalisé configuré dans les paramètres du formulaire, une soumission qui répond
à une demande porte un objet supplémentaire à l’intérieur de data, request.
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 request.completed, qui porte le bloc
request complet.
"request": {
"id": "kd7...",
"externalId": "run-42",
"metadata": { "crm_record": "rec_88" }
}id— la demande à laquelle cette soumission a répondu. Passez-la àrequests.getpour le tableau complet.externalIdetmetadata— votre propre suivi, exactement tel que vous l’avez fourni dansrequests.create. Chacun n’est présent que s’il a été défini.
Les webhooks ne sont pas des callbacks
Un webhook de soumission se déclenche sur une soumission ; le bloc request nomme seulement la demande à laquelle elle a répondu. Un
callback se déclenche quand une demande se termine — terminée, expirée, ou annulée — et porte
context et outcome. 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 à request_completed,
request_expired ou request_canceled via webhooks.create.
PDF de la soumission
data.submission.pdfUrl 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 null uniquement
lorsque la soumission n’a pas de PDF.
Langue de la soumission
data.submission.language est le code BCP-47 de la langue dans laquelle le répondant a soumis le formulaire (pour les
formulaires traduits). Il est null 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.
Payloads de soumissions abandonnées
Quand vous vous abonnez à submission_abandoned, formbase vérifie toutes les heures les brouillons inactifs. Si un brouillon
est resté inactif au-delà de la fenêtre d’inactivité configurée, formbase déclenche une livraison.
Les intégrations API natives doivent envoyer idleWindow à webhooks.create. Les valeurs acceptées sont
12h, 1d, 3d, et 1w. Il n’y a pas de valeur par défaut implicite ; un abonnement
abandonné sans valeur est rejeté.
| Fenêtre d'inactivité | Description |
|---|---|
| 12 heures | Pour les relances le jour même |
| 1 jour | Par défaut — un délai raisonnable avant de relancer |
| 3 jours | Pour les formulaires moins urgents |
| 1 semaine | Pour les formulaires à faible fréquence |
La structure du payload est identique à celle d’une soumission complète. Deux différences :
Les réponses peuvent être incomplètes — seules les questions auxquelles le répondant a répondu apparaissent dans
answersetdisplay.submittedAt— utilise l’horodatage de livraison en remplacement, puisque le répondant n’a jamais formellement soumis.
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.
Signature
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 signingSecret (32 à 255 caractères) sur webhooks.create. Ce n’est pas le secret de signature
des demandes de l’espace de travail utilisé pour les callbacks de demande, mais l’en-tête et
l’algorithme sont identiques, donc un seul vérificateur gère les deux.
Chaque livraison signée porte X-formbase-Signature: t={seconds},sha256={hex}. Le digest est un
HMAC-SHA256 de {t}.{raw body}, avec votre secret comme clé. Deux règles : hachez le corps
brut avant tout parsing ou re-sérialisation, et comparez en temps constant.
import crypto from 'node:crypto'
export function verifyFormbaseWebhook(rawBody: string, header: string | undefined, secret: string, toleranceSeconds = 300): boolean {
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_formbase_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_secondsformbase 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.
Vérifiez toujours en production
Sans vérification, quiconque découvre votre URL peut poster de fausses soumissions.
Nouvelles tentatives
formbase effectue jusqu’à 5 tentatives par livraison — 1 initiale et 4 reprises — espacées d’au moins 1, 2, 4 et 8 minutes. formbase
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 Retry-After 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 :
- Retourne un statut non-2xx
- Expire
- Réinitialise la connexion
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.
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.
Les livraisons retentées du même événement réutilisent le même id, 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 id et
type: “submission.updated” : au webhook personnalisé configuré dans les paramètres du formulaire, ou à un abonnement
submission_updated. createdAt 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
data.submission.editCount : il s’incrémente à chaque modification, et data.submission.updatedAt indique quand la
dernière a eu lieu.
Tests
Le panneau de configuration et de détail de l’intégration disposent tous deux d’un bouton Envoyer un test. 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 submissions.sample et requests.sample.
Pour le développement local, exposez votre serveur de développement avec un tunnel :
# ngrok
ngrok http 3000
# cloudflare tunnel
cloudflared tunnel --url http://localhost:3000Développement local
Utilisez l’URL du tunnel comme endpoint webhook, puis cliquez sur Envoyer un test pour vérifier le flux complet.