BunnyDoc
APIAPI de firma electrónica

Webhooks

Recibe eventos en tiempo real —sobre enviado, visto, firmado, completado y más— enviados a un endpoint HTTPS que tú controlas, sin tener que sondear.

Los webhooks envían eventos a un endpoint HTTPS que tú controlas en el momento en que ocurre algo, así no tienes que sondear el estado. Esta es la forma prevista de seguir los sobres hasta que se completan.

Los endpoints se gestionan en la app en Configuración → Webhooks (propietario o administrador), o mediante la API /v1/webhooks — que es lo que usan los conectores iPaaS (Zapier, Make) para registrar sus disparadores instantáneos.

1. Registrar un endpoint

Proporciona una URL HTTPS, elige los eventos que quieres (o déjalo vacío para todo lo que tu plan permita) y, opcionalmente, define un secreto compartido y la cabecera en la que debe llegar.

Se te muestra un secreto de firma una sola vez. Guárdalo: no se puede recuperar de nuevo, solo rotar.

2. Verificar la propiedad

Un endpoint nuevo empieza como pending_verification y no recibe ningún evento hasta que demuestra que te pertenece. Enviamos por POST un evento 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": "…" }
}

Responde 200 devolviendo el challenge, ya sea como cuerpo sin procesar o como JSON:

Cuerpo de la respuesta
{ "challenge": "3f9a…" }

El endpoint pasa entonces a active.

Cambiar la URL reinicia la verificación

Es intencionado: de lo contrario, un endpoint podría verificarse en una dirección que controlas y luego redirigirse a otra.

3. Verificar que cada petición procede realmente de nosotros

Dos mecanismos independientes. Prefiere la firma; el secreto compartido es la opción rápida.

Firma (recomendado)

Cada petición lleva estas cabeceras:

X-BunnyDoc-Signature: t=1800000000,v1=5257a869e7…
X-BunnyDoc-Event: envelope.completed
X-BunnyDoc-Delivery: 018f…
X-BunnyDoc-Timestamp: 1800000000

v1 es HMAC-SHA256(signing_secret, "<t>.<cuerpo sin procesar>"), codificado en hexadecimal.

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;
}

Firma los bytes del cuerpo sin procesar

Calcula el HMAC sobre el cuerpo de la petición sin procesar, antes de cualquier análisis JSON. Reserializar cambia los espacios y el orden de las claves, y la firma no coincidirá.

Secreto compartido (rápido)

Si defines uno, llega tal cual en la cabecera que elijas (por defecto X-Webhook-Secret). Compáralo con tu valor guardado. Viaja en cada petición, así que prefiere la firma siempre que puedas: la firma nunca pone una clave en el cable y además prueba que el contenido no se alteró ni se reprodujo.

4. Sobre del payload

Todos los eventos tienen la misma forma externa; solo cambia data.

Sobre del evento
{
  "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. Semántica de entrega

  • Al menos una vez. Puedes recibir el mismo evento dos veces. Deduplica por event_id.
  • Sin orden. envelope.completed puede llegar antes que envelope.signer_signed. Ordena por occurred_at, no por orden de llegada.
  • Responde rápido. Devuelve 2xx en menos de 10 segundos; encola tu propio trabajo en lugar de hacerlo en línea. No seguimos redirecciones: apunta el endpoint a su URL final.
  • Reintentos. Cualquier 5xx, 408, 429, tiempo de espera o error de red se reintenta a 1 min → 5 min → 30 min → 2 h → 6 h (5 intentos). Otros 4xx no se reintentan: significan que la petición te llegó y fue rechazada.
  • Autodesactivación. Tras 20 fallos consecutivos, el endpoint se desactiva y se envía un correo al propietario de la cuenta. Reactívalo en Configuración.
  • El registro de entregas se conserva 7 días —éxitos y fallos—. Después, una entrega no puede inspeccionarse ni reproducirse.

6. Eventos de firma electrónica

Los eventos que puedes recibir dependen del nivel de tu plan. Advanced incluye todo lo de standard.

Standard — ciclo de vida del sobre

EventoSe dispara cuando
envelope.sentEl sobre se envió a sus destinatarios.
envelope.viewedUn destinatario lo abrió por primera vez.
envelope.signer_signedUn destinatario completó su firma.
envelope.partially_signedAlgunos, pero no todos, los firmantes han terminado.
envelope.completedTodos los firmantes terminaron; el sobre está completo.
envelope.declinedUn destinatario rechazó firmar.
envelope.voidedEl remitente anuló o canceló el sobre.
envelope.awaiting_paymentEl sobre está bloqueado a la espera de un pago del documento.
envelope.reminder_sentSe envió por correo un recordatorio de caducidad a un destinatario (un evento por destinatario).

Advanced — datos, documentos, pagos, envío masivo, formularios

EventoSe dispara cuando
envelope.document_readyEl documento firmado final ya existe y se puede descargar — incluye un enlace CDN con caducidad.
envelope.field_valuesAl completarse: incluye todos los valores de campo capturados (el evento de extracción de datos).
payment.pendingSe inició un pago del documento y está pendiente de confirmación.
payment.succeededUn pago del documento se realizó con éxito.
payment.failedUn pago del documento falló.
payment.refundedSe reembolsó un pago del documento (también se dispara en reembolsos parciales: comprueba fully_refunded).
bulk_send.download_readyUn paquete de descarga de envío masivo terminó de generarse.
form.submittedSe envió una respuesta de formulario.

No hay evento envelope.expired, por diseño

Los sobres tienen una fecha de caducidad y un barrido de recordatorios, pero nunca pasan a un estado "caducado", así que ese evento nunca podría dispararse. Haz el seguimiento de los sobres pendientes con envelope.sent / envelope.reminder_sent y tu propio temporizador.

Payloads del ciclo de vida

Todos los eventos de ciclo de vida llevan el id y el título del sobre. Los que dispara una persona concreta —envelope.viewed, envelope.signer_signed, envelope.declined— añaden ese destinatario, cuando sabemos quién es.

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 rechazo es terminal para todo el sobre: nadie más puede firmarlo después, y no llegará ningún envelope.completed ni envelope.document_ready. Para actuar sobre él necesitas al destinatario, por eso va en el payload; el motivo que escribió queda registrado en la pista de auditoría, no en el evento.

envelope.voided lo inicia el remitente, así que no lleva destinatario:

envelope.voided
{
  "event": "envelope.voided",
  "data": { "envelope_id": "018f…", "title": "MSA — Acme Corp" }
}

Trata envelope.declined y envelope.voided como las dos formas en que un sobre puede terminar sin documento firmado. Si estás esperando un sobre, suscríbete a ambos junto con envelope.completed: de lo contrario, un sobre rechazado o anulado simplemente se queda en silencio.

EventoCampos 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

Los campos de destinatario son best-effort

recipient_name y recipient_email se omiten cuando no los tenemos para ese destinatario. Apoya tus propios registros en recipient_id.

envelope.field_values — qué se incluye

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" }
      }
    ]
  }
}

Siempre se excluyen:

  • signature e initials — marcas capturadas, no datos. (date_signed y date se incluyen.)
  • Campos enmascarados — se omiten por completo, no se redactan. Están cifrados en reposo, así que los valores sensibles nunca salen de la plataforma.
  • Bloques solo de maquetación (text_block, image_block, divider).

Los campos sin rellenar aparecen con "value": null y "filled": false, para que puedas distinguir "preguntado pero sin responder" de "no preguntado".

7. Obtener el archivo completado por URL — envelope.document_ready

Este es el evento que te entrega el documento final. Se dispara cuando el archivo fusionado, sellado y firmado con AATL ya existe de verdad, y su payload incluye una URL de descarga CDN con caducidad, así puedes archivar un PDF firmado sin una segunda llamada autenticada.

No descargues el documento en envelope.completed

envelope.completed se dispara en el instante en que termina el último firmante, antes de que hayamos fusionado, sellado y firmado el PDF. Todavía no hay nada que descargar, y una integración que reaccione descargando compite con nuestro pipeline y pierde. Espera a envelope.document_ready.

Debes suscribirte a este evento por su nombre

Es el único evento que una lista events vacía ("envíamelo todo") no cubre: como lleva una credencial, exigimos una marca deliberada en lugar de heredar el consentimiento que diste a un catálogo que no contenía ninguna. Márcalo en Configuración → Webhooks, o nómbralo en events al crear la suscripción desde la API. GET /v1/webhooks/events lo devuelve tanto en events como en 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"
    }
  }
}
CampoNotas
document.filenameNombre de archivo sugerido.
document.content_typeNormalmente application/pdf; application/zip si el sobre está configurado para mantener sus documentos como archivos separados. No des por hecho que es PDF: lee este campo.
document.size_bytesPuede ser null si falló la consulta del tamaño. El enlace sigue siendo válido.
document.aatl_signedSi el archivo lleva la firma de larga duración AATL.
document.download_urlEnlace con caducidad — haz GET, sin cabecera de autenticación.
document.expires_atCuándo caduca ese enlace. Compruébalo siempre antes de descargar.

download_url es una credencial al portador

Cualquiera que la tenga puede descargar el documento hasta expires_at: no hay ninguna otra autenticación. Trátala como una contraseña: no la registres en logs, no la reenvíes y no la guardes en un sistema con más audiencia que el propio documento.

El reloj de 24 horas empieza cuando construimos el evento, no cuando lo recibes. Eso cubre de sobra toda la ventana de reintentos, pero una reproducción desde el registro de entregas días después contendrá un enlace muerto. Si expires_at ya pasó, no reintentes la URL: recupera el documento a través de la API autenticada.

Dos cosas más que suelen sorprender:

  • Solo sobres individuales. Los sobres enviados como parte de un envío masivo no disparan este evento: recibirías uno por destinatario. Para lotes, suscríbete a bulk_send.download_ready.
  • Una vez por sobre. A diferencia del resto de eventos, este se deduplica por nuestro lado, así que un trabajo interno reintentado no lo vuelve a publicar. (Tu endpoint todavía puede ver la misma entrega dos veces por un reintento HTTP: sigue deduplicando por event_id.)

8. Gestionar suscripciones desde la API

En lugar de la app, una integración puede gestionar sus propios endpoints con una clave de API. Esto es lo que permite que los conectores iPaaS (Zapier, Make) funcionen como disparadores instantáneos.

Ambas rutas requieren que los webhooks estén incluidos en tu plan, además del scope correspondiente en la clave.

MétodoRutaScopePara qué
GET/v1/webhooks/eventswebhooks:readLos eventos a los que puedes suscribirte ahora mismo
GET/v1/webhookswebhooks:readListar tus suscripciones
POST/v1/webhookswebhooks:writeSuscribir una target_url a eventos
DELETE/v1/webhooks/{id}webhooks:writeCancelar la suscripción
Suscribirse al evento del documento completado
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 se devuelve solo en esta llamada de creación: guárdalo ya. Todo lo demás (forma del payload, verificación de la firma, reintentos, desactivación automática) es exactamente como se describe arriba: una suscripción creada aquí es un endpoint normal.

Verificación de endpoints creados desde la API

Una suscripción normalmente empieza en pending_verification y debe devolver el reto (§2) antes de que se entregue nada — la misma regla que en la app. La única excepción: si target_url pertenece a una plataforma de integración verificada (Zapier, Make), se crea active de inmediato, porque los catch-hooks de esas plataformas no pueden devolver un reto y su URL propia de tu cuenta ya se te entregó durante una conexión autenticada. Cualquier otra URL —incluido tu propio servidor— sigue empezando en pending_verification: completa el reto desde tu endpoint y mira status en la respuesta de creación para saber en qué caso estás.

Un array events vacío significa "todo lo que mi plan permita", excepto envelope.document_ready, que debes nombrar explícitamente (ver §7).

En esta página