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:
{
"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:
{ "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: 1800000000v1 es HMAC-SHA256(signing_secret, "<t>.<cuerpo sin procesar>"), codificado en hexadecimal.
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.
{
"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.completedpuede llegar antes queenvelope.signer_signed. Ordena poroccurred_at, no por orden de llegada. - Responde rápido. Devuelve
2xxen 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). Otros4xxno 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
| Evento | Se dispara cuando |
|---|---|
envelope.sent | El sobre se envió a sus destinatarios. |
envelope.viewed | Un destinatario lo abrió por primera vez. |
envelope.signer_signed | Un destinatario completó su firma. |
envelope.partially_signed | Algunos, pero no todos, los firmantes han terminado. |
envelope.completed | Todos los firmantes terminaron; el sobre está completo. |
envelope.declined | Un destinatario rechazó firmar. |
envelope.voided | El remitente anuló o canceló el sobre. |
envelope.awaiting_payment | El sobre está bloqueado a la espera de un pago del documento. |
envelope.reminder_sent | Se envió por correo un recordatorio de caducidad a un destinatario (un evento por destinatario). |
Advanced — datos, documentos, pagos, envío masivo, formularios
| Evento | Se dispara cuando |
|---|---|
envelope.document_ready | El documento firmado final ya existe y se puede descargar — incluye un enlace CDN con caducidad. |
envelope.field_values | Al completarse: incluye todos los valores de campo capturados (el evento de extracción de datos). |
payment.pending | Se inició un pago del documento y está pendiente de confirmación. |
payment.succeeded | Un pago del documento se realizó con éxito. |
payment.failed | Un pago del documento falló. |
payment.refunded | Se reembolsó un pago del documento (también se dispara en reembolsos parciales: comprueba fully_refunded). |
bulk_send.download_ready | Un paquete de descarga de envío masivo terminó de generarse. |
form.submitted | Se 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.
{
"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:
{
"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.
| Evento | Campos 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 |
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
{
"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:
signatureeinitials— marcas capturadas, no datos. (date_signedydatesí 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.
{
"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"
}
}
}| Campo | Notas |
|---|---|
document.filename | Nombre de archivo sugerido. |
document.content_type | Normalmente 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_bytes | Puede ser null si falló la consulta del tamaño. El enlace sigue siendo válido. |
document.aatl_signed | Si el archivo lleva la firma de larga duración AATL. |
document.download_url | Enlace con caducidad — haz GET, sin cabecera de autenticación. |
document.expires_at | Cuá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étodo | Ruta | Scope | Para qué |
|---|---|---|---|
GET | /v1/webhooks/events | webhooks:read | Los eventos a los que puedes suscribirte ahora mismo |
GET | /v1/webhooks | webhooks:read | Listar tus suscripciones |
POST | /v1/webhooks | webhooks:write | Suscribir una target_url a eventos |
DELETE | /v1/webhooks/{id} | webhooks:write | Cancelar la suscripción |
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 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).