Webhooks
Un webhook es un endpoint HTTPS tuyo que AllSign notifica con un POST
firmado cada vez que algo relevante pasa en tu tenant —un documento se crea, se
envía, se completa o se anula— sin que tengas que hacer polling. Todo endpoint v3 se crea firmado
(HMAC obligatorio) y sellado con una versión de contrato con fecha.
¿Cómo funcionan?
- Registras una URL
https://de tu servidor conPOST /v3/webhooksy eliges los eventos. - AllSign te devuelve un secreto de firma (
whsec_…) una sola vez. Guárdalo —no se vuelve a mostrar. - Cuando ocurre un evento, AllSign hace un
POSTa tu URL con el sobre del evento en el body y las cabeceras de firma Standard Webhooks (webhook-id/webhook-timestamp/webhook-signature). - Tu servidor verifica la firma, deduplica por
eventId, procesa el evento y responde2xxrápido.
Tu servidor ← POST (evento firmado) ← AllSign
Ids opacos con prefijo (whe_ endpoint, whd_ entrega, evt_
evento), camelCase en el wire, errores problem+json
(RFC 9457) con code en UPPER_SNAKE, y las listas de endpoints y entregas
paginan por cursor (startingAfter / endingBefore + hasMore).
Cada respuesta trae headers RateLimit-*.
Scopes. Crear, editar y rotar exige webhook:write; leer (listar,
consultar, entregas, catálogo) exige webhook:read; borrar exige
webhook:delete. Una key sin el scope recibe 403 PERMISSION_DENIED
con requiredScope.
Create endpoint
POST /v3/webhooks
Registra un endpoint firmado. Genera un secreto `whsec_` (**devuelto una sola vez**), fuerza HMAC, y estampa la versión de contrato con fecha (`apiVersion`) + el entorno de la key — no hay cruce `live`/`test`. Requiere `webhook:write`.
List endpoints
GET /v3/webhooks
Lista tus endpoints de webhook con paginación por cursor. **El secreto nunca aparece aquí** — solo `secretLast4`.
Retrieve endpoint
GET /v3/webhooks/{webhook_id}
Consulta un endpoint por su `id`. No incluye el secreto (solo `secretLast4`). Un `id` inexistente o de otro tenant responde **404 `WEBHOOK_NOT_FOUND`**.
Update endpoint
PATCH /v3/webhooks/{webhook_id}
Merge-patch: solo cambian los campos que envías. Puedes reasignar `url`, `events`, `description`, o pausar/reactivar con `disabled`. Enviar un campo desconocido o inmutable es un **422 `VALIDATION_ERROR`**. Requiere `webhook:write`.
Delete endpoint
DELETE /v3/webhooks/{webhook_id}
Elimina un endpoint. Requiere el scope `webhook:delete`. Las entregas en vuelo hacia ese endpoint se marcan como fallidas (`webhook deleted`).
Rotate secret
POST /v3/webhooks/{webhook_id}/rotate-secret
Acuña un secreto `whsec_` nuevo. El anterior se conserva como *secreto previo* durante una **ventana de 24 h** en la que el despachador firma con **ambos** — así rotas sin downtime. Requiere `webhook:write` y honra `Idempotency-Key` (un reintento con la misma llave reproduce el mismo secreto en vez de rotar dos veces).
List deliveries
GET /v3/webhooks/{webhook_id}/deliveries
El log de entregas de un endpoint — para depurar qué se envió, qué respondió tu servidor y cuántos intentos hubo. Pagina por cursor. Requiere `webhook:read`.
List events
GET /v3/webhooks/events
El catálogo **congelado** de eventos v3 a los que puedes suscribirte — la fuente de verdad contra la que valida `POST /v3/webhooks`. Requiere `webhook:read`.
El sobre del evento
Cada webhook v3 llega como un sobre camelCase con el payload específico dentro de
data. No hay un campo event a nivel raíz (eso era un alias
v2); el enrutamiento va por la cabecera AllSign-Event. Deduplica por eventId.
{
"eventId": "evt_7c9e6679742540de944be07fc1f90ae7",
"eventType": "document.completed",
"apiVersion": "2026-07-11",
"occurredAt": "2026-07-11T19:03:00.123Z",
"tenantId": "550e8400-e29b-41d4-a716-446655440000",
"livemode": true,
"data": { }
}
| Campo | Descripción |
|---|---|
eventId | ID único del evento (evt_…). Úsalo para deduplicar —un reintento del productor reusa el mismo eventId. |
eventType | El tipo de evento (ej. document.completed). Coincide con la cabecera AllSign-Event. |
apiVersion | Versión de contrato con fecha que congela la forma del data. |
occurredAt | Cuándo ocurrió (ISO-8601 UTC con sufijo Z, precisión de milisegundos). |
tenantId | Tu tenant. |
livemode | true si el evento nació en entorno live. |
data | El payload específico del evento (ver el catálogo). |
Cabeceras de cada entrega
AllSign firma con Standard Webhooks (standardwebhooks.com)
—el estándar abierto que ya adoptaron OpenAI, Anthropic, Twilio y Supabase, entre otros. La ventaja
práctica: puedes verificar con la librería oficial (standardwebhooks,
disponible en 10+ lenguajes) en vez de escribir tu propio verificador.
| Cabecera | Descripción |
|---|---|
webhook-id | ID estable del evento (evt_…) —el mismo en todos los reintentos de un mismo evento. Deduplica con esto. |
webhook-timestamp | Unix timestamp (segundos) con el que se firmó este intento. Un reintento trae uno nuevo. |
webhook-signature | La firma —ver Firma Standard Webhooks. |
AllSign-Event | Tipo de evento (ej. document.completed), metadata de conveniencia —no forma parte de lo firmado, no lo uses para verificar. |
AllSign-Delivery-Id | ID de este intento de entrega (whd_…) —cambia en cada reintento, a diferencia de webhook-id. |
webhook-id / webhook-timestamp / webhook-signature
van en minúsculas —así los define la spec Standard Webhooks (los nombres de
cabecera HTTP son case-insensitive de todos modos). AllSign-Event /
AllSign-Delivery-Id siguen el estilo Hyphenated-Pascal-Case del resto de la v3, sin
prefijo X- (RFC 6648).
Firma Standard Webhooks
Cada entrega v3 trae la cabecera webhook-signature:
webhook-signature: v1,g0hM9SsE+OTPJTGeGg9CTHqYPnJZQrfE7BMc4b1rBz8=
v1—la versión del esquema de firma (siemprev1hoy).- El valor tras la coma es el HMAC-SHA256 en base64 (no hex).
El material firmado es "{webhook-id}.{webhook-timestamp}." + cuerpo_crudo
—el id del evento, un punto, el timestamp, otro punto, y luego los bytes crudos del body
tal como llegan (nunca un JSON re-serializado). La clave HMAC es el contenido de tu secreto
después de quitarle el prefijo whsec_, decodificado de
base64url a bytes crudos —nunca la cadena whsec_… completa en UTF-8.
Ventana de repetición: 5 minutos. Rechaza cualquier entrega cuyo
webhook-timestamp difiera más de 300 s de tu reloj. El timestamp viaja en su
propia cabecera (webhook-timestamp), no empaquetado dentro de la firma —pero sigue
formando parte del material firmado, así que no se puede manipular sin invalidar la firma.
Durante una rotación de secreto la cabecera
trae dos tokens separados por espacio: v1,<firma-nueva> v1,<firma-previa>.
Verifica contra tus secretos candidatos y acepta si cualquiera hace match —así ninguna
entrega falla durante la ventana de 24 h.
Verificar la firma en tu servidor
La forma recomendada es la librería oficial standardwebhooks —ya maneja el parseo de
cabeceras, la ventana de repetición y la rotación. Si no puedes agregar la dependencia, este es el
fallback manual (Node.js), traducción 1:1 de la fórmula publicada:
import crypto from 'crypto'
// El secreto completo tal como lo devolvió AllSign, incluido el prefijo.
const SECRET = process.env.ALLSIGN_WEBHOOK_SECRET // "whsec_…"
const REPLAY_WINDOW_S = 300
function decodeKey(secret) {
const raw = secret.replace(/^whsec_/, '')
return Buffer.from(raw, 'base64url') // llave = secreto SIN el prefijo, decodificado
}
function verifyAllSign(rawBody, id, timestamp, signatureHeader) {
// Ventana de repetición: rechaza si el webhook-timestamp difiere >300s del reloj.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > REPLAY_WINDOW_S) return false
const signedContent = Buffer.concat([
Buffer.from(`${id}.${timestamp}.`, 'utf8'), // "{webhook-id}.{webhook-timestamp}."
rawBody, // los bytes CRUDOS del body, nunca un JSON re-serializado
])
const expected = crypto
.createHmac('sha256', decodeKey(SECRET))
.update(signedContent)
.digest('base64') // base64, no hex
// Durante una rotación el header trae varios "v1,<firma>" separados por espacio:
// acepta si ALGUNO hace match.
const candidates = signatureHeader
.split(' ')
.filter((tok) => tok.startsWith('v1,'))
.map((tok) => tok.slice(3))
return candidates.some((sig) => {
try {
return crypto.timingSafeEqual(Buffer.from(expected, 'base64'), Buffer.from(sig, 'base64'))
} catch {
return false
}
})
}
Catálogo de eventos
Los eventos v3 son un catálogo congelado y con versión con fecha.
document.* cubre el ciclo de vida del documento; nom151.constancia.issued
avisa cuando se emite la constancia de conservación. signer.declined está
reservado (el contrato está congelado para que te suscribas desde el día 1, pero
todavía no dispara).
| Evento | Categoría | Estado | Qué representa |
|---|---|---|---|
document.created | Documents | active | Se creó un documento vía la API. |
document.sent | Documents | active | El documento salió de creación y entró al ciclo de firma (primeras invitaciones despachadas). |
document.completed | Documents | active | Todas las partes firmaron y el PDF de evidencia está listo (se entrega por URL, no inline en base64). |
document.voided | Documents | active | El documento se anuló. La retención NOM-151 conserva el registro. |
signer.declined | Signers | reserved | Un firmante rechazó firmar. Reservado —aún no se emite. |
nom151.constancia.issued | Compliance | active | Se emitió la constancia de conservación NOM-151 de un documento completado. |
Payload: document.created
{
"eventId": "evt_7c9e6679742540de944be07fc1f90ae7",
"eventType": "document.created",
"apiVersion": "2026-07-11",
"occurredAt": "2026-07-11T18:04:00.000Z",
"tenantId": "550e8400-e29b-41d4-a716-446655440000",
"livemode": true,
"data": {
"documentId": "doc_5Qr9tA3fZwLZmp3D1bCdEfG",
"name": "Contrato de arrendamiento 2026.pdf",
"status": "draft",
"createdAt": "2026-07-11T18:04:00Z",
"createdViaApi": true,
"signers": [
{
"signerId": "sgr_63db6fa927094f689ea7bc640194bade",
"name": "Juan Pérez",
"email": "juan@empresa.com",
"phone": null,
"status": "waiting_for_signature"
}
]
}
}
Payload: document.sent
{
"eventId": "evt_a1b2c3d4e5f67890abcdef1234567890",
"eventType": "document.sent",
"apiVersion": "2026-07-11",
"occurredAt": "2026-07-11T18:10:00.000Z",
"tenantId": "550e8400-e29b-41d4-a716-446655440000",
"livemode": true,
"data": {
"documentId": "doc_5Qr9tA3fZwLZmp3D1bCdEfG",
"name": "Contrato de arrendamiento 2026.pdf",
"status": "awaiting_signatures",
"sentAt": "2026-07-11T18:10:00Z",
"signers": [
{
"signerId": "sgr_63db6fa927094f689ea7bc640194bade",
"email": "juan@empresa.com",
"phone": null,
"invitationChannel": "email",
"invitedAt": "2026-07-11T18:10:00Z"
}
]
}
}
Payload: document.completed
El PDF de evidencia se entrega por URL (el endpoint estable de la API que acuña una URL prefirmada fresca al acceder), nunca en base64 inline.
{
"eventId": "evt_b2c3d4e5f6a78901bcdef12345678901",
"eventType": "document.completed",
"apiVersion": "2026-07-11",
"occurredAt": "2026-07-11T19:03:00.000Z",
"tenantId": "550e8400-e29b-41d4-a716-446655440000",
"livemode": true,
"data": {
"documentId": "doc_5Qr9tA3fZwLZmp3D1bCdEfG",
"name": "Contrato de arrendamiento 2026.pdf",
"status": "completed",
"completedAt": "2026-07-11T19:03:00Z",
"evidencePdf": {
"url": "https://api.allsign.io/v3/documents/doc_5Qr9tA3fZwLZmp3D1bCdEfG?expand=evidencePdf",
"sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"sizeBytes": 204800,
"mimeType": "application/pdf"
},
"nom151": {
"constanciaUrl": "https://api.allsign.io/v3/documents/doc_5Qr9tA3fZwLZmp3D1bCdEfG?expand=nom151",
"serialNumber": "12345",
"issuedAt": "2026-07-11T19:03:30Z"
},
"signers": [
{
"signerId": "sgr_63db6fa927094f689ea7bc640194bade",
"name": "Juan Pérez",
"email": "juan@empresa.com",
"signedAt": "2026-07-11T19:02:00Z",
"authMethod": "FIRMA_ELECTRONICA_SIMPLE"
}
]
}
}
Payload: document.voided
{
"eventId": "evt_c3d4e5f6a7b89012cdef123456789012",
"eventType": "document.voided",
"apiVersion": "2026-07-11",
"occurredAt": "2026-07-11T20:00:00.000Z",
"tenantId": "550e8400-e29b-41d4-a716-446655440000",
"livemode": true,
"data": {
"documentId": "doc_5Qr9tA3fZwLZmp3D1bCdEfG",
"status": "voided",
"voidedAt": "2026-07-11T20:00:00Z",
"reason": "Reemplazado por una versión corregida",
"previousStatus": "awaiting_signatures",
"cancelledSignatures": 1,
"voidedBy": {
"actorType": "user",
"actorId": "usr_1a2b3c4d5e6f7g8h"
}
}
}
Payload: nom151.constancia.issued
{
"eventId": "evt_d4e5f6a7b8c90123def1234567890123",
"eventType": "nom151.constancia.issued",
"apiVersion": "2026-07-11",
"occurredAt": "2026-07-11T19:03:30.000Z",
"tenantId": "550e8400-e29b-41d4-a716-446655440000",
"livemode": true,
"data": {
"documentId": "doc_5Qr9tA3fZwLZmp3D1bCdEfG",
"constancia": {
"url": "https://api.allsign.io/v3/documents/doc_5Qr9tA3fZwLZmp3D1bCdEfG?expand=nom151",
"serialNumber": "12345",
"issuedAt": "2026-07-11T19:03:30Z",
"algorithm": "SHA256"
},
"evidenceSha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
}
}
El SDK @allsign/sdk (beta —aún no publicado en npm) trae helpers para
verificar la firma y tipar el sobre del evento.
Buenas prácticas
- Verifica la firma en cada entrega antes de procesar (código arriba).
- Deduplica por
eventId—puedes recibir el mismo evento más de una vez. - Responde
2xxrápido —procesa en background; unoutcomeTRANSIENTse reintenta. - Ramifica por
livemode—trata los eventostestylivepor caminos separados; nunca mezcles datos de prueba con producción. - Usa HTTPS —AllSign solo entrega a URLs
https://. - Rota el secreto periódicamente con rotate-secret; la ventana de 24 h evita downtime.