BunnyDoc
APIAPI de signature électronique

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 :

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 :

Corps de la réponse
{ "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: 1800000000

v1 est HMAC-SHA256(signing_secret, "<t>.<corps brut de la requête>"), encodé en hexadécimal.

verify.js
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.

Enveloppe de l'événement
{
  "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.completed peut arriver avant envelope.signer_signed. Triez par occurred_at, pas par ordre d'arrivée.
  • Répondez vite. Renvoyez 2xx en 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 autres 4xx ne 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énementSe déclenche quand
envelope.sentL'enveloppe a été envoyée à ses destinataires.
envelope.viewedUn destinataire l'a ouverte pour la première fois.
envelope.signer_signedUn destinataire a terminé sa signature.
envelope.partially_signedCertains signataires, mais pas tous, ont terminé.
envelope.completedTous les signataires ont terminé ; l'enveloppe est complète.
envelope.declinedUn destinataire a refusé de signer.
envelope.voidedL'expéditeur a annulé l'enveloppe.
envelope.awaiting_paymentL'enveloppe est bloquée en attente d'un paiement de document.
envelope.reminder_sentUn 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énementSe déclenche quand
envelope.document_readyLe 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.pendingUn paiement de document a été initié et attend confirmation.
payment.succeededUn paiement de document a réussi.
payment.failedUn paiement de document a échoué.
payment.refundedUn paiement de document a été remboursé (se déclenche aussi pour les remboursements partiels : vérifiez fully_refunded).
bulk_send.download_readyUn paquet de téléchargement d'envoi en masse a fini d'être généré.
form.submittedUne 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.

envelope.declined
{
  "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.

envelope.voided
{
  "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énementChamps de data
envelope.sentenvelope_id, title
envelope.viewedenvelope_id, title, recipient_id, recipient_name, recipient_email
envelope.signer_signedenvelope_id, title, recipient_id, recipient_name, recipient_email
envelope.partially_signedenvelope_id, title
envelope.completedenvelope_id, title
envelope.declinedenvelope_id, title, recipient_id, recipient_name, recipient_email
envelope.voidedenvelope_id, title
envelope.awaiting_paymentenvelope_id
envelope.reminder_sentenvelope_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

envelope.field_values
{
  "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 :

  • signature et initials — des marques capturées, pas des données. (date_signed et date sont 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.

envelope.document_ready
{
  "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"
    }
  }
}
ChampRemarques
document.filenameNom de fichier suggéré.
document.content_typeapplication/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_bytesPeut valoir null si la lecture de la taille a échoué. Le lien reste valide.
document.aatl_signedIndique si le fichier porte la signature longue durée AATL.
document.download_urlLien à durée limitée — faites un GET, sans en-tête d'authentification.
document.expires_atDate 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_ready pour 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éthodeCheminScopeObjet
GET/v1/webhooks/eventswebhooks:readLes événements auxquels vous pouvez vous abonner maintenant
GET/v1/webhookswebhooks:readLister vos abonnements
POST/v1/webhookswebhooks:writeAbonner une target_url à des événements
DELETE/v1/webhooks/{id}webhooks:writeSe désabonner
S'abonner à l'événement du document terminé
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"]
      }'
201 Created
{
  "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).

Sur cette page