Webhooks
Recevez des événements en temps réel — enveloppe envoyée, vue, signée, terminée, et plus — poussés vers un endpoint HTTPS que vous contrôlez, sans jamais interroger l'API.
Les webhooks poussent les événements vers un endpoint HTTPS que vous contrôlez dès qu'il se passe quelque chose, ce qui vous évite d'interroger l'API pour connaître le statut. C'est la manière prévue de suivre les enveloppes jusqu'à leur achèvement.
Les endpoints se gèrent dans l'app sous Paramètres → Webhooks (propriétaire ou administrateur),
ou par programmation via l'API /v1/webhooks — c'est ce
qu'utilisent les connecteurs iPaaS (Zapier, Make) pour enregistrer leurs déclencheurs instantanés.
1. Enregistrer un endpoint
Fournissez une URL HTTPS, choisissez les événements souhaités (ou laissez vide pour tout ce que votre plan autorise) et, facultativement, définissez un secret partagé ainsi que l'en-tête dans lequel il doit arriver.
Un secret de signature vous est présenté une seule fois. Conservez-le : il ne peut plus être récupéré, seulement renouvelé.
2. Vérifier la propriété
Un nouvel endpoint démarre en pending_verification et ne reçoit aucun événement tant qu'il n'a
pas prouvé qu'il vous appartient. Nous envoyons en POST un événement webhook.verification :
{
"event_id": "018f2c3d-…",
"event": "webhook.verification",
"occurred_at": "2026-07-22T10:00:00.000Z",
"company_id": "018f0a1b-…",
"data": { "challenge": "3f9a…", "instructions": "…" }
}Répondez 200 en renvoyant le challenge, soit dans le corps brut, soit en JSON :
{ "challenge": "3f9a…" }L'endpoint passe alors à active.
Changer l'URL réinitialise la vérification
C'est volontaire — sinon un endpoint pourrait être vérifié à une adresse que vous contrôlez puis redirigé ailleurs.
3. Vérifier que chaque requête vient bien de nous
Deux mécanismes indépendants. Préférez la signature ; le secret partagé est l'option rapide.
Signature (recommandé)
Chaque requête porte ces en-têtes :
X-BunnyDoc-Signature: t=1800000000,v1=5257a869e7…
X-BunnyDoc-Event: envelope.completed
X-BunnyDoc-Delivery: 018f…
X-BunnyDoc-Timestamp: 1800000000v1 est HMAC-SHA256(signing_secret, "<t>.<corps brut de la requête>"), encodé en hexadécimal.
const crypto = require('crypto');
function verify(rawBody, header, signingSecret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
const expected = crypto
.createHmac('sha256', signingSecret)
.update(`${parts.t}.${rawBody}`)
.digest('hex');
// Constant-time compare, and reject stale timestamps to block replays.
const a = Buffer.from(expected), b = Buffer.from(parts.v1);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false;
return Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t)) <= toleranceSeconds;
}Signez les octets bruts du corps
Calculez le HMAC sur le corps brut de la requête, avant tout parsing JSON. Une re-sérialisation change les espaces et l'ordre des clés, et la signature ne correspondra pas.
Secret partagé (rapide)
Si vous en définissez un, il arrive tel quel dans l'en-tête que vous choisissez (par défaut
X-Webhook-Secret). Comparez-le à votre valeur stockée. Il circule à chaque requête ; préférez donc
la signature quand vous le pouvez — la signature ne met jamais de clé sur le réseau et prouve en plus
que la charge utile n'a pas été altérée ni rejouée.
4. Enveloppe du payload
Tous les événements ont la même forme externe ; seul data diffère.
{
"event_id": "018f2c3d-…",
"event": "envelope.completed",
"occurred_at": "2026-07-22T10:00:00.000Z",
"company_id": "018f0a1b-…",
"data": { "envelope_id": "018f…", "title": "MSA — Acme Corp" }
}5. Sémantique de livraison
- Au moins une fois. Vous pouvez recevoir le même événement deux fois. Dédupliquez sur
event_id. - Sans ordre.
envelope.completedpeut arriver avantenvelope.signer_signed. Triez paroccurred_at, pas par ordre d'arrivée. - Répondez vite. Renvoyez
2xxen moins de 10 secondes ; mettez votre propre traitement en file plutôt que de le faire en ligne. Nous ne suivons pas les redirections : pointez l'endpoint sur son URL finale. - Nouvelles tentatives. Tout
5xx,408,429, dépassement de délai ou erreur réseau est réessayé à 1 min → 5 min → 30 min → 2 h → 6 h (5 tentatives). Les autres4xxne sont pas réessayés — ils signifient que la requête vous est parvenue et a été rejetée. - Désactivation automatique. Après 20 échecs consécutifs, l'endpoint est désactivé et le propriétaire du compte reçoit un e-mail. Réactivez-le dans les Paramètres.
- Le journal de livraison est conservé 7 jours — succès et échecs. Passé ce délai, une livraison ne peut plus être inspectée ni rejouée.
6. Événements de signature électronique
Les événements que vous pouvez recevoir dépendent du niveau de votre plan. Advanced inclut tout ce qui est dans standard.
Standard — cycle de vie de l'enveloppe
| Événement | Se déclenche quand |
|---|---|
envelope.sent | L'enveloppe a été envoyée à ses destinataires. |
envelope.viewed | Un destinataire l'a ouverte pour la première fois. |
envelope.signer_signed | Un destinataire a terminé sa signature. |
envelope.partially_signed | Certains signataires, mais pas tous, ont terminé. |
envelope.completed | Tous les signataires ont terminé ; l'enveloppe est complète. |
envelope.declined | Un destinataire a refusé de signer. |
envelope.voided | L'expéditeur a annulé l'enveloppe. |
envelope.awaiting_payment | L'enveloppe est bloquée en attente d'un paiement de document. |
envelope.reminder_sent | Un rappel d'expiration a été envoyé par e-mail à un destinataire (un événement par destinataire). |
Advanced — données, documents, paiements, envoi en masse, formulaires
| Événement | Se déclenche quand |
|---|---|
envelope.document_ready | Le document signé final existe et est téléchargeable — porte un lien CDN à durée limitée. |
envelope.field_values | À l'achèvement — porte toutes les valeurs de champ capturées (l'événement d'extraction de données). |
payment.pending | Un paiement de document a été initié et attend confirmation. |
payment.succeeded | Un paiement de document a réussi. |
payment.failed | Un paiement de document a échoué. |
payment.refunded | Un paiement de document a été remboursé (se déclenche aussi pour les remboursements partiels : vérifiez fully_refunded). |
bulk_send.download_ready | Un paquet de téléchargement d'envoi en masse a fini d'être généré. |
form.submitted | Une réponse de formulaire a été soumise. |
Pas d'événement envelope.expired, par conception
Les enveloppes ont une date d'expiration et un balayage de rappels, mais ne passent jamais à un état
« expiré », donc un tel événement ne pourrait jamais se déclencher. Suivez les enveloppes en attente
avec envelope.sent / envelope.reminder_sent et votre propre minuteur.
Charges utiles du cycle de vie
Chaque événement de cycle de vie porte l'id et le titre de l'enveloppe. Ceux déclenchés par une
personne précise — envelope.viewed, envelope.signer_signed, envelope.declined — ajoutent ce
destinataire, lorsque nous savons de qui il s'agit.
{
"event_id": "018f2c3d-…",
"event": "envelope.declined",
"occurred_at": "2026-07-22T10:00:00.000Z",
"company_id": "018f0a1b-…",
"data": {
"envelope_id": "018f…",
"title": "MSA — Acme Corp",
"recipient_id": "018f…",
"recipient_name": "Jane Doe",
"recipient_email": "jane@acme.com"
}
}Un refus est terminal pour toute l'enveloppe : plus personne ne peut la signer ensuite, et aucun
envelope.completed ni envelope.document_ready ne suivra. Pour y réagir il vous faut le
destinataire — d'où sa présence dans la charge utile ; le motif qu'il a saisi est enregistré dans la
piste d'audit, pas dans l'événement.
envelope.voided est déclenché par l'expéditeur : il n'y a donc pas de destinataire dessus.
{
"event": "envelope.voided",
"data": { "envelope_id": "018f…", "title": "MSA — Acme Corp" }
}Considérez envelope.declined et envelope.voided comme les deux façons dont une enveloppe peut se
terminer sans document signé. Si vous attendez une enveloppe, abonnez-vous aux deux en plus de
envelope.completed — sinon une enveloppe refusée ou annulée devient simplement silencieuse.
| Événement | Champs de data |
|---|---|
envelope.sent | envelope_id, title |
envelope.viewed | envelope_id, title, recipient_id, recipient_name, recipient_email |
envelope.signer_signed | envelope_id, title, recipient_id, recipient_name, recipient_email |
envelope.partially_signed | envelope_id, title |
envelope.completed | envelope_id, title |
envelope.declined | envelope_id, title, recipient_id, recipient_name, recipient_email |
envelope.voided | envelope_id, title |
envelope.awaiting_payment | envelope_id |
envelope.reminder_sent | envelope_id, title, recipient_id, recipient_email, reminder_type |
Les champs destinataire sont au mieux disponibles
recipient_name et recipient_email sont omis lorsque nous ne les connaissons pas pour ce
destinataire. Appuyez vos propres enregistrements sur recipient_id.
envelope.field_values — ce qui est inclus
{
"event": "envelope.field_values",
"data": {
"envelope_id": "018f…",
"title": "MSA — Acme Corp",
"completed_at": "2026-07-22T10:00:00.000Z",
"fields": [
{
"field_id": "018f…",
"type": "text",
"label": "Company name",
"value": "Acme Ltd",
"filled": true,
"recipient": { "id": "018f…", "role": "Client", "name": "Jane Doe", "email": "jane@acme.com" }
}
]
}
}Toujours exclus :
signatureetinitials— des marques capturées, pas des données. (date_signedetdatesont inclus.)- Champs masqués — entièrement omis, pas caviardés. Ils sont chiffrés au repos, de sorte que les valeurs sensibles ne quittent jamais la plateforme.
- Blocs purement de mise en page (
text_block,image_block,divider).
Les champs non remplis apparaissent avec "value": null et "filled": false, ce qui vous permet de
distinguer « demandé mais sans réponse » de « non demandé ».
7. Récupérer le fichier terminé par URL — envelope.document_ready
C'est l'événement qui vous remet le document final. Il se déclenche une fois que le fichier fusionné, tamponné et signé AATL existe réellement, et sa charge utile porte une URL de téléchargement CDN à durée limitée — vous pouvez donc archiver un PDF signé sans second aller-retour authentifié.
Ne récupérez pas le document sur envelope.completed
envelope.completed se déclenche à l'instant où le dernier signataire termine — avant que nous
ayons fusionné, tamponné et signé le PDF. Il n'y a encore rien à télécharger, et une intégration qui
y réagit en récupérant le fichier fait la course avec notre pipeline et la perd. Attendez
envelope.document_ready.
Vous devez vous abonner à cet événement nommément
C'est le seul événement qu'une liste events vide (« envoyez-moi tout ») ne couvre pas : parce
qu'il porte un identifiant d'accès, nous exigeons une case cochée délibérément plutôt que d'hériter
d'un consentement donné à un catalogue qui n'en contenait aucun. Cochez-le dans Paramètres →
Webhooks, ou nommez-le dans events lors de la
création de l'abonnement via l'API.
GET /v1/webhooks/events le renvoie à la fois dans events et dans explicit_subscription_only.
{
"event_id": "018f2c3d-…",
"event": "envelope.document_ready",
"occurred_at": "2026-07-22T10:00:00.000Z",
"company_id": "018f0a1b-…",
"data": {
"envelope_id": "018f…",
"title": "MSA — Acme Corp",
"completed_at": "2026-07-22T10:00:00.000Z",
"document": {
"filename": "MSA_Acme_Corp.pdf",
"content_type": "application/pdf",
"size_bytes": 284119,
"aatl_signed": true,
"download_url": "https://cdn.bunnydoc.com/…?Expires=…&Signature=…",
"expires_at": "2026-07-23T10:00:00.000Z"
}
}
}| Champ | Remarques |
|---|---|
document.filename | Nom de fichier suggéré. |
document.content_type | application/pdf en principe ; application/zip si l'enveloppe est configurée pour conserver ses documents en fichiers séparés. Ne supposez pas PDF — lisez ce champ. |
document.size_bytes | Peut valoir null si la lecture de la taille a échoué. Le lien reste valide. |
document.aatl_signed | Indique si le fichier porte la signature longue durée AATL. |
document.download_url | Lien à durée limitée — faites un GET, sans en-tête d'authentification. |
document.expires_at | Date d'expiration du lien. Vérifiez-la toujours avant de télécharger. |
download_url est un identifiant porteur
Quiconque le détient peut récupérer le document jusqu'à expires_at — il n'y a aucune autre
authentification. Traitez-le comme un mot de passe : ne le journalisez pas, ne le transmettez pas, ne
le stockez pas dans un système dont l'audience est plus large que celle du document lui-même.
Le compte à rebours de 24 heures démarre à la construction de l'événement, pas à sa réception.
Cela couvre largement toute la fenêtre de nouvelles tentatives, mais un rejeu depuis le journal de
livraison plusieurs jours plus tard contiendra un lien mort. Si expires_at est dépassé, ne
réessayez pas l'URL — récupérez le document via l'API authentifiée.
Deux points qui surprennent souvent :
- Enveloppes individuelles uniquement. Les enveloppes envoyées dans un envoi en masse ne
déclenchent pas cet événement — vous en recevriez un par destinataire. Abonnez-vous à
bulk_send.download_readypour les lots. - Une seule fois par enveloppe. Contrairement à tous les autres événements, celui-ci est
dédupliqué de notre côté : un job interne réessayé ne le republiera pas. (Votre endpoint peut
toujours voir la même livraison deux fois suite à une nouvelle tentative HTTP — continuez à
dédupliquer sur
event_id.)
8. Gérer les abonnements via l'API
Au lieu de l'app, une intégration peut gérer ses propres endpoints avec une clé API. C'est ce qui permet aux connecteurs iPaaS (Zapier, Make) de fonctionner en déclencheurs instantanés.
Les deux routes exigent que les webhooks soient inclus dans votre plan, plus le scope correspondant sur la clé.
| Méthode | Chemin | Scope | Objet |
|---|---|---|---|
GET | /v1/webhooks/events | webhooks:read | Les événements auxquels vous pouvez vous abonner maintenant |
GET | /v1/webhooks | webhooks:read | Lister vos abonnements |
POST | /v1/webhooks | webhooks:write | Abonner une target_url à des événements |
DELETE | /v1/webhooks/{id} | webhooks:write | Se désabonner |
curl -X POST "https://api2.bunnydoc.com/v1/webhooks" \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{
"target_url": "https://example.com/hooks/bunnydoc",
"events": ["envelope.document_ready", "envelope.declined", "envelope.voided"]
}'{
"id": "018f…",
"url": "https://example.com/hooks/bunnydoc",
"events": ["envelope.document_ready", "envelope.declined", "envelope.voided"],
"status": "pending_verification",
"created_at": "2026-07-22T10:00:00.000Z",
"signing_secret": "whsec_…"
}signing_secret n'est renvoyé que sur cet appel de création — conservez-le immédiatement. Tout
le reste (forme de la charge utile, vérification de signature, nouvelles tentatives, désactivation
automatique) est exactement tel que décrit ci-dessus : un abonnement créé ici est un endpoint
ordinaire.
Vérification des endpoints créés via l'API
Un abonnement démarre normalement en pending_verification et doit renvoyer le défi
(§2) avant toute livraison — la même règle que dans l'app. Seule
exception : si target_url appartient à une plateforme d'intégration validée (Zapier, Make),
elle est créée active immédiatement, car les catch-hooks de ces plateformes ne peuvent pas renvoyer
un défi et leur URL propre à votre compte vous a déjà été délivrée lors d'une connexion authentifiée.
Toute autre URL — y compris votre propre serveur — démarre en pending_verification : complétez le
défi depuis votre endpoint, et regardez status dans la réponse de création pour savoir dans quel
cas vous êtes.
Un tableau events vide signifie « tout ce que mon plan autorise » — sauf
envelope.document_ready, que vous devez nommer explicitement (voir
§7).