# Callbacks et signature

Reprenez votre workflow quand une demande se termine, et prouvez que l'appel vient bien de Formstep.

## Callbacks et signature

Quand une demande atteint sa fin — terminée, expirée, ou annulée — Formstep envoie en POST une notification signée vers l'URL fournie par votre automatisation. Cet appel est ce qui reprend l'exécution.

<h2 id="what-fires">Ce qui se déclenche, et quand</h2>

<p>
  Les trois arrivent à la même URL, alors testez <code>type</code> 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é.
</p>

<h2 id="payload">Ce qui arrive</h2>

```
{
  "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" }
  }
}
```

<ul>
  <li>
    <code>data.request</code> est toujours là — y compris votre <code>externalId</code>, vos <code>metadata</code>, et votre{' '}
    <code>context</code>, inchangés. Elle porte l'horodatage de la fin qui s'est produite (<code>completedAt</code>, <code>expiredAt</code>,
    ou <code>canceledAt</code> avec un <code>cancelReason</code> optionnel).
  </li>
  <li>
    <code>outcome</code> n'est présent que lorsque le destinataire a répondu à une{' '}
    <a href="/fr/requests/decisions-and-approvals">question de décision</a> — <code>approve</code>, <code>decline</code>, ou{' '}
    <code>changes</code>.
  </li>
  <li>
    <code>form</code>, <code>submission</code>, <code>answers</code> et <code>display</code> n'apparaissent qu'à la fin réussie, exactement
    dans la forme que porte un <a href="/fr/developers/webhooks-reference#payload">webhook de soumission</a>. <code>form.snapshotId</code>{' '}
    est la version publiée exacte à laquelle le destinataire a répondu ; <code>submission.pdfUrl</code> est une URL uniquement quand le
    formulaire conserve un PDF de soumission, et null sinon.
  </li>
  <li>
    <code>test</code> vaut <code>true</code> quand la demande a été créée en{' '}
    <a href="/fr/requests/creating-requests#test-mode">mode test</a> — bifurquez dessus, ou ignorez l'événement.
  </li>
  <li>
    <code>answers</code> est indexé par <a href="/fr/requests/field-keys">clé de champ</a>, les groupes répétables étant imbriqués comme un
    objet par instance. Une réponse à choix est la <strong>clé</strong> de l'option depuis <code>fields.list</code>, pas son libellé ; le
    libellé se trouve dans <code>display</code>, sous la même clé.
  </li>
  <li>
    Le POST arrive en <code>Content-Type: application/json</code> avec <code>User-Agent: Formstep</code>, et porte{' '}
    <code>X-Formstep-Event-Id</code>, <code>X-Formstep-Event-Type</code> et <code>X-Formstep-Signature</code> — pour dédupliquer et router
    avant même d'analyser le corps.
  </li>
</ul>

> ⚠️ **Dédupliquez sur id**
> <p>
>     <code>id</code> 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.
>   </p>

<h2 id="verify">Vérifier la signature</h2>

<p>
  Chaque callback porte un en-tête de signature, <code>X-Formstep-Signature: t=&#123;secondes unix&#125;,sha256=&#123;hex&#125;</code>. La
  valeur hex est un HMAC-SHA256 de l'horodatage, d'un point, et du corps brut de la requête, calculé avec le{' '}
  <strong>secret de signature de demande</strong> de votre espace de travail.
</p>

<p>Deux règles, quel que soit le langage utilisé :</p>

<ol>
  <li>
    Hachez le corps <strong>brut</strong>, avant tout analyse ou re-sérialisation. Un JSON réencodé n'est pas les mêmes octets.
  </li>
  <li>
    Comparez en temps constant — <code>crypto.timingSafeEqual</code>, <code>hmac.compare_digest</code> — jamais avec <code>==</code>.
  </li>
</ol>

  
    
```
import crypto from 'node:crypto'

  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_formstep_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_seconds
```

  

<h2 id="secret">Le secret de signature de demande</h2>

<p>
  Un seul secret par espace de travail signe chaque callback qui en provient. Trouvez-le dans <strong>OAuth et clés API</strong> dans la
  barre latérale de l'espace de travail, dans la carte <strong>Secret de signature de demande</strong>. Il est masqué par défaut ;{' '}
  <strong>Révéler le secret</strong> 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 <code>callbackUrl</code> n'en a encore aucun.
</p>

> ❗ **Régénérer n'offre aucun délai de grâce**
> <p>
>     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. <strong>Mettez d'abord à jour votre récepteur, puis régénérez.</strong> Il n'y a
>     aucune fenêtre où les deux secrets sont acceptés.
>   </p>

<h2 id="retries">Nouvelles tentatives</h2>

<p>
  Un callback obtient <strong>huit tentatives</strong> : la première, puis sept nouvelles tentatives espacées d'au moins 1, 2, 4, 8, 16, 32
  et 60 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 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 <code>id</code> : 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.
</p>

<p>
  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{' '}
  <strong>Callback échoué</strong>, 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 <code>requests.get</code>. Pour la renvoyer, ouvrez la demande dans la{' '}
  <a href="/fr/requests/managing-requests">page Demandes</a> et appuyez sur <strong>Rejouer</strong>, ou appelez{' '}
  <code>requests.replayCallback</code>. Cela renvoie la même charge utile figée avec le même <code>id</code>, ce qui est exactement ce que
  veut un récepteur qui déduplique.
</p>

<h2 id="subscriptions">Les abonnements entendent les mêmes événements</h2>

<p>
  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 Formstep pour Zapier et n8n le font pour vous, et <code>webhooks.create</code> le fait depuis
  du code avec <code>request_completed</code>, <code>request_expired</code> ou <code>request_canceled</code> 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. <strong>Rejouer</strong> ne renvoie que le callback ; un
  abonnement retente de lui-même et se met en pause après cinq tentatives échouées.
</p>

> 💡 **Une URL de reprise n'est pas une authentification**
> <p>
>     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.
>   </p>

<div class="not-prose grid gap-3 sm:grid-cols-2">
  - [Dépannage](/fr/requests/troubleshooting) — Quand un callback continue d'échouer.
  - [Référence des webhooks](/fr/developers/webhooks-reference) — Les maps answers et display qu'une fin de demande porte, en détail.
</div>
