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?

  1. Registras una URL https:// de tu servidor con POST /v3/webhooks y eliges los eventos.
  2. AllSign te devuelve un secreto de firma (whsec_…) una sola vez. Guárdalo —no se vuelve a mostrar.
  3. Cuando ocurre un evento, AllSign hace un POST a tu URL con el sobre del evento en el body y las cabeceras de firma Standard Webhooks (webhook-id / webhook-timestamp / webhook-signature).
  4. Tu servidor verifica la firma, deduplica por eventId, procesa el evento y responde 2xx rá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": { }
}
CampoDescripción
eventIdID único del evento (evt_…). Úsalo para deduplicar —un reintento del productor reusa el mismo eventId.
eventTypeEl tipo de evento (ej. document.completed). Coincide con la cabecera AllSign-Event.
apiVersionVersión de contrato con fecha que congela la forma del data.
occurredAtCuándo ocurrió (ISO-8601 UTC con sufijo Z, precisión de milisegundos).
tenantIdTu tenant.
livemodetrue si el evento nació en entorno live.
dataEl 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.

CabeceraDescripción
webhook-idID estable del evento (evt_…) —el mismo en todos los reintentos de un mismo evento. Deduplica con esto.
webhook-timestampUnix timestamp (segundos) con el que se firmó este intento. Un reintento trae uno nuevo.
webhook-signatureLa firma —ver Firma Standard Webhooks.
AllSign-EventTipo de evento (ej. document.completed), metadata de conveniencia —no forma parte de lo firmado, no lo uses para verificar.
AllSign-Delivery-IdID 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 (siempre v1 hoy).
  • 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).

EventoCategoríaEstadoQué representa
document.createdDocumentsactiveSe creó un documento vía la API.
document.sentDocumentsactiveEl documento salió de creación y entró al ciclo de firma (primeras invitaciones despachadas).
document.completedDocumentsactiveTodas las partes firmaron y el PDF de evidencia está listo (se entrega por URL, no inline en base64).
document.voidedDocumentsactiveEl documento se anuló. La retención NOM-151 conserva el registro.
signer.declinedSignersreservedUn firmante rechazó firmar. Reservado —aún no se emite.
nom151.constancia.issuedComplianceactiveSe 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 2xx rápido —procesa en background; un outcome TRANSIENT se reintenta.
  • Ramifica por livemode —trata los eventos test y live por 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.