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.
1. Encabezados
Sección titulada «1. Encabezados»| 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 |
2. Gramática del encabezado de firma
Sección titulada «2. Gramática del encabezado de firma»signature-header = element *( "," element )element = scheme "=" valuescheme = "t" / "v1" / <future scheme>- Los elementos van separados por comas sin espacios. Los receptores deben tolerar espacios de todos modos.
taparece exactamente una vez: segundos Unix, en base 10, sin parte fraccionaria.
v1puede aparecer más de una vez durante la rotación del secreto. Un receptor debe aceptar el payload si cualquier valorv1coincide. Los verificadores que leen solo el primerv1impiden la rotación.- Los esquemas desconocidos deben ignorarse, no rechazarse. Esto permite introducir un futuro
v2sin afectar a los receptores existentes.
3. Qué se firma
Sección titulada «3. Qué se firma»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_bodyson 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.
4. Ventana de tolerancia
Sección titulada «4. Ventana de tolerancia»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.
5. Algoritmo de verificación
Sección titulada «5. Algoritmo de verificación»Los receptores ejecutan, en orden:
- Interpretar el encabezado. Si está mal formado: devolver false (sección 6).
- Extraer
t; si no es un entero: devolver false. - Comprobar
abs(now - t) <= 300; en caso contrario, devolver false. - Calcular la firma esperada sobre
<t> + "." + raw_body. - Comparar con cada valor
v1mediante 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 |
7. Vectores de prueba
Sección titulada «7. Vectores de prueba»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 |
8. Comparación con Adobe Acrobat Sign
Sección titulada «8. Comparación con Adobe Acrobat Sign»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.
9. Cambiar este documento
Sección titulada «9. Cambiar este documento»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).
Parte 2. El payload del evento
Sección titulada «Parte 2. El payload del evento»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.
P2. Envoltorio
Sección titulada «P2. Envoltorio»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.
P3. Tipos de evento y su data
Sección titulada «P3. Tipos de evento y su data»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.
P4. Solo aditivo
Sección titulada «P4. Solo aditivo»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.
P5. Dónde vive cada parte
Sección titulada «P5. Dónde vive cada parte»| 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 |