Demandes
Callbacks et signature
Quand une demande atteint sa fin — terminée, expirée, ou annulée — formbase envoie en POST une notification signée vers l'URL fournie par votre automatisation. Cet appel est ce qui reprend l'exécution.
Ce qui se déclenche, et quand
| Événement | Quand |
|---|---|
| request.completed | Le destinataire a soumis. Porte les réponses. |
| request.expired | L'expiration est passée pendant que la demande était encore en attente. |
| request.canceled | Vous ou votre automatisation l'avez retirée. |
Les trois arrivent à la même URL, alors testez type avant de supposer qu’il y a des réponses. C’est tout l’intérêt de se
déclencher sur chaque fin : un workflow mis en pause sur un client reprend qu’il ait répondu, vous ait ignoré, ou ait été annulé.
Ce qui arrive
{
"id": "evt_kj7...",
"type": "request.completed",
"createdAt": "2026-03-04T09:31:40.000Z",
"apiVersion": "2026-09-24",
"test": false,
"data": {
"request": {
"id": "kd7...",
"externalId": "run-42",
"status": "completed",
"outcome": "approve",
"language": "en",
"recipient": { "email": "ada@acme.com", "name": "Ada" },
"metadata": { "runId": "run-42" },
"context": { "case_id": "CASE-9" },
"createdAt": "2026-03-04T09:20:00.000Z",
"completedAt": "2026-03-04T09:31:40.000Z"
},
"form": { "id": "j57...", "name": "Vendor onboarding", "snapshotId": "kx2..." },
"submission": {
"id": "jd7...",
"respondentEmail": "ada@acme.com",
"submittedAt": "2026-03-04T09:31:40.000Z",
"updatedAt": null,
"editCount": 0,
"pdfUrl": null,
"language": "en"
},
"answers": { "company_name": "Acme", "contacts": [{ "name": "Ada" }] },
"display": { "company_name": "Acme", "contacts": "Ada" }
}
}data.requestest toujours là — y compris votreexternalId, vosmetadata, et votrecontext, inchangés. Elle porte l’horodatage de la fin qui s’est produite (completedAt,expiredAt, oucanceledAtavec uncancelReasonoptionnel).outcomen’est présent que lorsque le destinataire a répondu à une question de décision —approve,decline, ouchanges.form,submission,answersetdisplayn’apparaissent qu’à la fin réussie, exactement dans la forme que porte un webhook de soumission.form.snapshotIdest la version publiée exacte à laquelle le destinataire a répondu ;submission.pdfUrlest une URL uniquement quand le formulaire conserve un PDF de soumission, et null sinon.testvauttruequand la demande a été créée en mode test — bifurquez dessus, ou ignorez l’événement.answersest indexé par clé de champ, les groupes répétables étant imbriqués comme un objet par instance. Une réponse à choix est la clé de l’option depuisfields.list, pas son libellé ; le libellé se trouve dansdisplay, sous la même clé.Le POST arrive en
Content-Type: application/jsonavecUser-Agent: formbase, et porteX-formbase-Event-Id,X-formbase-Event-TypeetX-formbase-Signature— pour dédupliquer et router avant même d’analyser le corps.
Dédupliquez sur id
id est stable à travers chaque nouvelle tentative et chaque rejeu du même événement. Si votre récepteur risque d’agir deux
fois sur le même id — une facture en double, un ticket en double — mémorisez les ids que vous avez déjà traités.
Vérifier la signature
Chaque callback porte un en-tête de signature, X-formbase-Signature: t={secondes unix},sha256={hex}. La
valeur hex est un HMAC-SHA256 de l’horodatage, d’un point, et du corps brut de la requête, calculé avec le
secret de signature de demande de votre espace de travail.
Deux règles, quel que soit le langage utilisé :
Hachez le corps brut, avant tout analyse ou re-sérialisation. Un JSON réencodé n’est pas les mêmes octets.
Comparez en temps constant —
crypto.timingSafeEqual,hmac.compare_digest— jamais avec==.
import crypto from 'node:crypto'
export function verifyFormbaseCallback(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
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_callback(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_secondsLe secret de signature de demande
Un seul secret par espace de travail signe chaque callback qui en provient. Trouvez-le dans OAuth et clés API dans la
barre latérale de l’espace de travail, dans la carte Secret de signature de demande. Il est masqué par défaut ;
Révéler le secret l’affiche et le bouton copier le copie. Ce n’est pas une valeur à usage unique — vous pouvez revenir le
consulter plus tard. Le secret est créé la première fois qu’il est nécessaire, donc un espace de travail qui n’a jamais ouvert cette carte
et jamais créé de demande avec un callbackUrl n’en a encore aucun.
Régénérer n'offre aucun délai de grâce
Seul le propriétaire de l’espace de travail peut régénérer le secret, et dès qu’il le fait, l’ancien cesse de fonctionner — y compris pour les callbacks déjà en cours de nouvelle tentative. Mettez d’abord à jour votre récepteur, puis régénérez. Il n’y a aucune fenêtre où les deux secrets sont acceptés.
Nouvelles tentatives
Un callback obtient huit tentatives : la première, puis sept nouvelles tentatives espacées d’au moins 1, 2, 4, 8, 16, 32
et 60 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 intervalle, et la dernière tentative arrive environ quatre heures après la fin de la demande. Chaque
tentative porte les mêmes octets et le même id : la charge utile est figée au moment où la demande s’est terminée, donc une
nouvelle tentative décrit ce qui s’est passé alors, pas ce à quoi ressemble la demande maintenant. L’URL de destination et le secret de
signature sont lus à chaque tentative, non figés avec elle.
| Votre réponse | Ce que fait formbase |
|---|---|
| 2xx | Terminé. Le callback est marqué comme livré. |
| 408, 429, 5xx | Nouvelle tentative, en respectant Retry-After si vous en envoyez un. |
| Autre 4xx | Arrêt. Votre point de terminaison a rejeté l'appel, retenter les mêmes octets ne peut rien changer. |
| Délai dépassé ou erreur de connexion | Nouvelle tentative selon le même planning. |
| URL bloquée | Arrêt immédiat. Un hôte qui ne se résout pas, une adresse privée, ou une URL non-HTTPS ne peut jamais devenir autorisé. Les redirections ne sont jamais suivies, donc un 3xx s'arrête aussi. |
Si le budget est épuisé — votre récepteur était indisponible tout l’après-midi — le callback n’est pas perdu. La demande obtient un badge
Callback échoué, le propriétaire de l’espace de travail reçoit un e-mail unique avec l’hôte, le motif et le nombre de
tentatives, et les réponses restent lisibles via requests.get. Pour la renvoyer, ouvrez la demande dans la
page Demandes et appuyez sur Rejouer, ou appelez
requests.replayCallback. Cela renvoie la même charge utile figée avec le même id, ce qui est exactement ce que
veut un récepteur qui déduplique.
Les abonnements entendent les mêmes événements
Une URL de callback appartient à une seule demande. Quand chaque demande d’un formulaire doit atteindre le même récepteur, abonnez-vous
une seule fois à la place : les applications formbase pour Zapier et n8n le font pour vous, et webhooks.create le fait depuis
du code avec request_completed, request_expired ou request_canceled comme type d’événement. Un
abonnement reçoit cette même enveloppe, signée avec son propre secret plutôt qu’avec le secret de signature de demande de l’espace de
travail, avec son propre id d’événement et son propre budget de nouvelles tentatives. Une demande qui a à la fois une URL de callback et
un abonnement correspondant se déclenche deux fois, une fois vers chacun. Rejouer ne renvoie que le callback ; un
abonnement retente de lui-même et se met en pause après cinq tentatives échouées.
Une URL de reprise n'est pas une authentification
Les outils de workflow vous remettent une URL de reprise difficile à deviner, et il est tentant de considérer ça comme une preuve. C’est un secret porteur — il peut fuiter dans des journaux, et il ne vous dit pas que le corps n’a pas été altéré. Vérifiez la signature dans la branche de reprise aussi.