formbasedocs
Aller à l'applicationAppli

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/json
  • X-formbase-Signature: t={timestamp},sha256={hex} — présent quand un secret de signature est configuré (voir ci-dessous)

  • X-formbase-Event-Id et X-formbase-Event-Type — les mêmes valeurs que id et type dans 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 via webhooks.create n’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énementQuand il se déclenche
submission_createdUn répondant complète et soumet le formulaire. C'est le comportement par défaut.
submission_updatedUn répondant modifie une soumission déjà envoyée, quand le formulaire autorise la modification après l'envoi.
submission_abandonedUne soumission en brouillon est restée inactive après la fenêtre configurée. Nécessite le suivi des soumissions partielles (Pro).
request_completedUn destinataire termine une demande sur le formulaire. Porte le bloc request et les réponses.
request_expiredUne demande sur le formulaire expire avant que le destinataire ne la termine. Bloc request seul.
request_canceledUne 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ée

  • submission.updated — une soumission existante a été modifiée

  • submission.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

POST body
json
{
  "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
idL'id de l'événement. Les nouvelles tentatives le réutilisent — dédupliquez dessus.
typeUn des six types d'événements ci-dessus.
createdAtQuand l'événement a été mis en file, pas quand cette tentative de livraison a eu lieu. Stable d'une reprise à l'autre.
apiVersionLe 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.
testtrue pour une livraison d'exemple ou de test, et pour une demande créée en mode test. Toujours présent.
data.form.snapshotIdLa 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.updatedAtQuand le répondant a modifié la soumission pour la dernière fois, ou null jusqu'au premier changement.
data.submission.editCountLe 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.answersChaque réponse, indexée par clé de champ. Chaque réponse apparaît une fois.
data.displayTexte lisible par un humain pour chaque réponse, sous les mêmes clés.
data.schemaOptionnel : 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 :

data.answers
json
{
  "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 :

data.schema
json
[
  { "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.

Ajouté à data
json
"request": {
  "id": "kd7...",
  "externalId": "run-42",
  "metadata": { "crm_record": "rec_88" }
}
  • id — la demande à laquelle cette soumission a répondu. Passez-la à requests.get pour le tableau complet.

  • externalId et metadata — votre propre suivi, exactement tel que vous l’avez fourni dans requests.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 heuresPour les relances le jour même
1 jourPar défaut — un délai raisonnable avant de relancer
3 joursPour les formulaires moins urgents
1 semainePour 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 answers et display.

  • 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.

verify.ts
ts
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
}
verify.py
python
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_seconds

formbase 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.

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 :

bash
# ngrok
ngrok http 3000

# cloudflare tunnel

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

Développement local

Utilisez l’URL du tunnel comme endpoint webhook, puis cliquez sur Envoyer un test pour vérifier le flux complet.

Prochaines étapes