Saltar al contenido
Desarrolladores

Especificación de firma de webhooks

Estado: NORMATIVO. Este documento es la fuente única de verdad del esquema de firma de webhooks de bb-sign. El dispatcher, el SDK de Java y el SDK de JavaScript lo implementan y remiten a esta página. La parte 1 define cómo se firma una entrega; la parte 2 define su contenido.

Header Valor Notas
X-BBSign-Signature t=<unix-seconds>,v1=<hex> Ver la gramática en la sección 2
X-BBSign-Event-Id UUID v4 Estable en cada reenvío del mismo evento. La clave de deduplicación del receptor
X-BBSign-Event-Type p. ej. envelope.completed Informativo; el payload prevalece sobre este encabezado
signature-header = element *( "," element )
element = scheme "=" value
scheme = "t" / "v1" / <future scheme>
  • Los elementos van separados por comas sin espacios. Los receptores deben tolerar espacios de todos modos.
  • t aparece exactamente una vez: segundos Unix, en base 10, sin parte fraccionaria.
  • v1 puede aparecer más de una vez durante la rotación del secreto. Un receptor debe aceptar el payload si cualquier valor v1 coincide. Los verificadores que leen solo el primer v1 impiden la rotación.
  • Los esquemas desconocidos deben ignorarse, no rechazarse. Esto permite introducir un futuro v2 sin afectar a los receptores existentes.
signed_string = <t> + "." + <raw_request_body>
signature = HMAC-SHA256(key = subscription_secret, message = signed_string)
v1 = lowercase hex encoding of signature // 64 chars
  • <t> es el mismo valor que va en el encabezado. Firmarlo es lo que impide la reproducción; firmar solo el cuerpo no basta.
  • raw_request_body son los bytes exactos recibidos, antes de cualquier interpretación, reserialización o conversión de juego de caracteres. No un objeto recodificado ni una versión reformateada.
  • La codificación es hex en minúsculas, no base64. Los receptores deberían comparar sin distinguir mayúsculas.
  • El cuerpo es JSON UTF-8, pero la verificación se define sobre bytes, no sobre texto.

300 segundos. Normativa, no sugerida.

Un receptor rechaza la entrega cuando abs(now - t) > 300. La ventana es simétrica, para tolerar desfases de reloj en ambas direcciones.

Los receptores ejecutan, en orden:

  1. Interpretar el encabezado. Si está mal formado: devolver false (sección 6).
  2. Extraer t; si no es un entero: devolver false.
  3. Comprobar abs(now - t) <= 300; en caso contrario, devolver false.
  4. Calcular la firma esperada sobre <t> + "." + raw_body.
  5. Comparar con cada valor v1 mediante una comparación en tiempo constante. Si alguno coincide: true.

La comparación en tiempo constante es obligatoria. Una comparación byte a byte con salida temprana revela el prefijo correcto de la firma a lo largo de intentos repetidos.

  • Java: MessageDigest.isEqual
  • Node: crypto.timingSafeEqual

5b. Firmas públicas de los helpers (normativo)

Sección titulada «5b. Firmas públicas de los helpers (normativo)»

Ambos SDKs exponen la misma forma, de modo que quien conoce uno puede leer el otro:

Java JavaScript
Simple Webhooks.verify(String signature, byte[] body, String secret) verifyWebhookSignature(signature, body, secret); la bolsa de opciones debe tener valor por defecto (opts = {}), o la forma de tres argumentos lanza una excepción
Con reloj Webhooks.verify(sig, body, secret, Duration tolerance, Instant now) verifyWebhookSignature(sig, body, secret, { toleranceSeconds, nowSeconds })
Devuelve boolean boolean
Lanza nunca (sección 6) nunca (sección 6)
Primitiva de tiempo constante MessageDigest.isEqual crypto.timingSafeEqual

body son bytes, no un objeto parseado; ver la sección 3.

6. Contrato de fallo: los verificadores nunca lanzan excepciones

Sección titulada «6. Contrato de fallo: los verificadores nunca lanzan excepciones»

verify(...) devuelve un booleano y no lanza excepciones con ninguna entrada. Los encabezados mal formados están bajo control de un posible atacante, y una excepción lanzada es a la vez un 500 en la ruta del cliente y un canal lateral.

En concreto:

Entrada Comportamiento requerido
Header ausente o vacío false
Header sin t o sin v1 false
t no es un entero false
t textualmente entero pero fuera de rango (t=99999999999999999999) false. Long.parseLong de Java lanza una excepción donde Number() de JS devuelve en silencio un flotante; esto cae de lleno dentro del contrato de nunca lanzar, y los lenguajes divergen por defecto. Interprete el valor de forma defensiva y acote t a un rango de época razonable
v1 no tiene 64 caracteres hex false; valida la forma antes de decodificar
v1 de longitud impar o no hex false. Tenga en cuenta que Buffer.from(x,'hex') trunca en silencio la entrada inválida, y timingSafeEqual lanza una excepción si las longitudes no coinciden. Ambos casos deben manejarse antes de comparar
Forma correcta, valor incorrecto false
Marca de tiempo caducada false

Ubicación: webhook-signature-vectors.json, el archivo contra el que se prueban ambos SDKs y el servidor, sin cambios. Ningún SDK define vectores propios: con vectores distintos, dos SDKs podrían discrepar entre sí y aun así aprobar cada uno sus pruebas.

Formato:

{
"version": 1,
"cases": [
{
"name": "valid-signature",
"secret": "<base64>",
"bodyUtf8": "{\"eventId\":\"...\",\"type\":\"envelope.completed\"}",
"header": "t=1754400000,v1=<hex>",
"now": 1754400060,
"expected": true
}
]
}

Contrato de campos: los cinco son obligatorios, y no hay un sexto.

Campo Significado
name identificador del caso
secret base64; se decodifica a bytes antes de usarlo como clave HMAC
bodyUtf8 el cuerpo de la petición como cadena UTF-8; las implementaciones lo codifican a bytes. Se llama así por la codificación, porque la sección 3 define la firma sobre bytes y «body» a solas lo dejaba ambiguo
header el valor literal de X-BBSign-Signature, no campos estructurados. Esto es lo que permite expresar los casos de rotación y de espacios
now el valor de reloj que se pasa al verificador (sección 4)
expected booleano

No existe un campo timestamp separado, de forma deliberada. Un borrador anterior lo incluía junto a header, y una implementación que leyera timestamp en lugar de interpretar header habría aprobado todos los vectores y aun así fallado con entregas reales. La única fuente de t es el encabezado.

Casos obligatorios, 16 en total; cada implementación los ejecuta todos:

Caso expected
firma válida, marca de tiempo fresca true
válida, exactamente a +300s true
válida, a +301s false
válida, a -301s false
cuerpo manipulado, un byte cambiado false
secreto incorrecto false
dos valores v1, el segundo correcto (rotación) true
esquema desconocido v2= presente junto a un v1 válido true
header con espacios después de las comas true
header ausente false
falta t false
v1 no es hex false
v1 con longitud incorrecta false
cuerpo vacío según el vector
cuerpo con UTF-8 no ASCII true
t fuera del rango numérico false

Adobe verifica un webhook exigiendo que el endpoint devuelva X-AdobeSign-ClientId al registrarlo, y no firma los payloads. bb-sign firma cada entrega, de modo que el receptor puede comprobar su origen e integridad aunque la URL sea conocida por terceros.

Migrar desde Adobe Acrobat Sign describe esta diferencia para quienes migran.

El esquema está congelado. Un cambio implica un nuevo identificador de esquema (v2=) emitido junto a v1 durante una ventana de deprecación, nunca una redefinición de v1. Esto es posible porque los receptores ignoran los esquemas desconocidos (sección 2).

Estado: NORMATIVO. La parte 1 establece que el payload prevalece sobre el encabezado; esta parte define el payload. Un payload definido de forma explícita da a los clientes un contrato estable: reciben la información necesaria para actuar, y los cambios internos de bb-sign no alteran lo que llega a su endpoint.

P1. El payload de salida es un DTO deliberado, nunca el evento interno

Sección titulada «P1. El payload de salida es un DTO deliberado, nunca el evento interno»

El dispatcher convierte cada evento interno en un DTO WebhookEvent que pertenece a la superficie pública. Ninguna clase interna llega a la red. Agregar un campo a un evento interno no tiene efecto sobre los clientes, salvo que también se modifique el DTO; esa separación es el propósito del diseño.

Cada entrega tiene la misma forma externa, para que un receptor pueda enrutarla antes de interpretar los detalles:

{
"id": "3f8c1c2e-7d0a-4f3b-9d2a-0a1b2c3d4e5f",
"type": "envelope.completed",
"apiVersion": "2026-08-09",
"createdAt": "2026-08-09T14:22:31Z",
"data": { }
}
Campo Notas
id Coincide exactamente con el header X-BBSign-Event-Id. Estable entre reenvíos; la clave de deduplicación
type Nombre público del evento. Sin sufijo .v1; el tipo interno del evento no es este campo
apiVersion Sello fechado del contrato. Solo informativo en v1: una suscripción no tiene campo de versión, así que un receptor no puede fijar una y esto no puede comportarse como una versión negociada. Le dice al receptor qué contrato produjo el payload
createdAt Cuándo ocurrió el evento. Distinto del t de la firma, que es cuándo se envió este intento (sección 2)
data Por tipo, abajo

orgId se omite de forma deliberada: una suscripción pertenece a una única organización, de modo que incluirlo no aportaría información al receptor y expondría un identificador interno en cada payload.

Exactamente seis. Los nombres son públicos y estables; no corresponden a las claves internas de enrutamiento.

type data
envelope.sent envelope
envelope.completed envelope
envelope.cancelled envelope
envelope.expired envelope
signer.signed envelope + signer
signer.viewed envelope + signer
"data": {
"envelope": {
"id": "uuid", "title": "string", "status": "SENT|COMPLETED|CANCELLED|EXPIRED",
"createdAt": "iso8601", "expiresAt": "iso8601|null"
},
"signer": {
"id": "uuid", "email": "string", "name": "string",
"signOrder": 1, "status": "PENDING|SIGNED", "signedAt": "iso8601|null"
}
}

Esta información basta para actuar sin una llamada adicional, que es el propósito de un webhook. Se excluyen de forma deliberada: el contenido de los documentos y las URL de descarga (una URL prefirmada dentro de un payload sigue siendo útil después de la entrega), ipAddress y userAgent de los eventos de firma (datos internos) y cualquier campo no listado arriba.

P3b. El cuerpo se materializa una vez, no por intento

Sección titulada «P3b. El cuerpo se materializa una vez, no por intento»

Si el cuerpo se generara en cada intento, un mismo X-BBSign-Event-Id podría llevar bytes distintos a lo largo de los reintentos: un envelope.sent reintentado después de completarse el sobre informaría status: COMPLETED, un estado que no existía cuando se emitió el evento, y un receptor que elimina duplicados por id de evento conservaría arbitrariamente la primera copia recibida.

El cuerpo se renderiza en el primer envío y se guarda tal cual en la fila de la entrega. Cada reintento reenvía exactamente esos bytes. Solo la firma se recalcula por intento (sección 2: t es la hora del intento; el cuerpo es la hora del evento). Un reenvío es idéntico byte a byte, no solo del mismo id de evento.

P3c. data se lee del estado actual al renderizar

Sección titulada «P3c. data se lee del estado actual al renderizar»

Los eventos internos llevan solo identificadores, de modo que el dispatcher carga el sobre (y, para signer.*, el firmante) al renderizar. Como el renderizado lee el estado actual, P3b lo fija al primer envío.

Los receptores deben ignorar los campos desconocidos. Pueden aparecer campos y tipos de evento nuevos sin cambiar apiVersion; una suscripción recibe solo los tipos que seleccionó. Eliminar un campo o cambiar su tipo rompe el contrato y genera un nuevo apiVersion.

Artefacto Dónde
Esta especificación Esta página, reflejada desde el repositorio de bb-sign
WebhookEvent, WebhookEventData, WebhookEventEnvelope, WebhookEventSigner components/schemas del contrato OpenAPI, para que ambos SDKs generen eventos tipados; ver la referencia de la API
Los vectores de prueba webhook-signature-vectors.json
La guía de los seis tipos Webhooks, que cita esta página en lugar de repetirla