Avisos por webhook de Docuten eArchiving — contrato v1

Metadato Valor
Versión v1
Estado Vigente desde la fase 06.6
URI https://docs.docuten.com/earchiving/webhook-notifications
Dónde se cita Cabecera de respuesta Link: <…>; rel="deprecation" de X-Webhook-Url (§9) y docs/api/ingest-error-codes.md
Implementación WebhookEventPayloads, WebhookSigner, WebhookHttpClient, WebhookUrlPolicy, TenantWebhookController, en earchiving-ingest/.../ingest/webhook/
Decisiones D-01 a D-19 de 06.6-CONTEXT.md (las de contrato son D-03, D-10, D-11 y D-17)
Fecha 2026-11-04

1. Resumen

Docuten eArchiving avisa a su sistema, sin que tenga que consultar, de dos cosas que le pasan a una ingesta:

El aviso es un POST con un cuerpo JSON firmado con HMAC-SHA256 a la URL que el administrador de su organización configura una sola vez (§2). Cada organización (tenant) tiene un webhook.

Qué tiene que hacer su receptor:

  1. Exponer un endpoint https que acepte POST con Content-Type: application/json.
  2. Verificar la firma de cada petición antes de fiarse del cuerpo (§6).
  3. Responder 2xx en menos de 10 segundos cuando haya recibido el aviso. Cualquier otra respuesta se reintenta (§7).
  4. Deduplicar por eventId: la entrega es «al menos una vez» y puede repetirse (§7).

2. Configurar el webhook

La configuración la gestiona el administrador del tenant (TENANT_ADMIN del propio tenant) o un PLATFORM_SUPER_ADMIN. El tenant sale siempre del claim tenant_id del token: no hay ningún identificador de tenant en las rutas, y no se puede configurar el webhook de otro tenant.

Operación Descripción
GET /api/v1/tenant/webhook Devuelve la URL y el estado (enabled, createdAt, updatedAt). Nunca el secreto. Mientras dura la rotación (§2.3), añade previousSecretValidUntil. 404 con errorCode WEBHOOK_NOT_CONFIGURED si no hay webhook.
PUT /api/v1/tenant/webhook Da de alta el webhook (201, con el campo secret, mostrado una sola vez) o cambia su url o su enabled (200, sin secreto). Cuerpo: { "url": "https://…", "enabled": true }; enabled es opcional (true en el alta, «sin cambio» al actualizar). Es idempotente: repetir la misma petición no cambia nada.
DELETE /api/v1/tenant/webhook Borra el webhook y sus secretos (204). Los avisos pendientes de entrega dejan de enviarse y quedan en el registro con WEBHOOK_NOT_CONFIGURED.
POST /api/v1/tenant/webhook/secret/rotate Rota el secreto (§2.3): devuelve el secreto nuevo una sola vez.
GET /api/v1/tenant/webhook/deliveries Registro de entregas (§8).

2.1 Alta

curl -X PUT https://<host>/api/v1/tenant/webhook \
  -H "Authorization: Bearer <token de un TENANT_ADMIN>" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://integraciones.ejemplo.com/hooks/earchiving" }'

Respuesta 201:

{
  "url": "https://integraciones.ejemplo.com/hooks/earchiving",
  "enabled": true,
  "createdAt": "2026-11-04T09:30:00Z",
  "updatedAt": "2026-11-04T09:30:00Z",
  "secret": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
}

2.2 El secreto se muestra una sola vez

secret son 64 caracteres hexadecimales (32 bytes aleatorios) y aparecen solo en la respuesta del alta y en la de la rotación, con Cache-Control: no-store. Guárdelo en su gestor de secretos al recibirlo: el servicio lo conserva cifrado y no puede volver a mostrárselo. Si lo pierde, rótelo.

2.3 Rotación con solapamiento de 24 h

POST /api/v1/tenant/webhook/secret/rotate genera un secreto nuevo y lo devuelve. El secreto anterior sigue firmando durante 24 h (previousSecretValidUntil): en ese periodo cada aviso lleva dos firmas v1= (§5), de modo que su receptor puede cambiar de secreto sin perder avisos. Pasadas las 24 h, solo queda el secreto nuevo. Una segunda rotación dentro del solapamiento sustituye al secreto anterior.

2.4 Cambiar o borrar la URL

Cambiar la URL con PUT conserva el secreto. Cada alta, cambio de URL, rotación y borrado queda en el log de auditoría del tenant (solo con el host de la URL y nunca con el secreto).


3. Reglas de la URL

La URL se valida al aceptarla y otra vez en cada envío (la resolución DNS se comprueba en el momento de conectar, de modo que un nombre que cambie de destino después del alta tampoco pasa).

Una URL que no cumple se rechaza con 400, errorCode WEBHOOK_URL_REJECTED y un reason de un conjunto cerrado: MALFORMED, URL_TOO_LONG, SCHEME_NOT_HTTPS, USERINFO_PRESENT, HOST_MISSING, HOST_UNRESOLVABLE o DESTINATION_FORBIDDEN. La respuesta no repite la URL ni la dirección resuelta.


4. Eventos

Todos los avisos son JSON con schemaVersion "1". Las fechas son ISO-8601 en UTC con milisegundos (2026-11-04T09:30:00.123Z). El orden de los campos es el de las tablas y es estable. Su receptor debe ignorar los campos que no conozca (§10).

Campos comunes: schemaVersion, eventId, eventType, occurredAt y sipId.

4.1 ingest.archived

El AIP se ha archivado: la saga ha terminado entera y el AIP está sellado y almacenado.

Campo Tipo Descripción
schemaVersion texto Siempre "1".
eventId UUID v4 Identificador del evento. Estable entre reintentos: sirve para deduplicar.
eventType texto ingest.archived.
occurredAt fecha Cuándo se produjo el evento.
sipId UUID El identificador que devolvió el 202 de la ingesta.
aipId UUID Identificador del AIP archivado.
aipHash objeto Huella del AIP, con algorithm y value.
sealedAt fecha Fecha de sellado cualificado del AIP (ver el matiz más abajo).

aipHash:

Campo Tipo Descripción
algorithm texto Siempre SHA-256.
value texto 64 caracteres hexadecimales en minúsculas: la raíz hash completa del AIP.
{
  "schemaVersion": "1",
  "eventId": "6f1c2a52-5d1f-4a0e-9c43-0b4f8a7d2e11",
  "eventType": "ingest.archived",
  "occurredAt": "2026-11-04T09:31:07.482Z",
  "sipId": "0b8e9f6a-3f6d-4c1e-8b54-2a7e61d0c9a3",
  "aipId": "d3f7a1c4-91b2-3e58-a6d0-4c2b8e5f7a19",
  "aipHash": {
    "algorithm": "SHA-256",
    "value": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
  },
  "sealedAt": "2026-11-04T09:31:05.000Z"
}

Qué es sealedAt. Es el genTime del token TSA del sello n.º 2: la fecha que certifica la autoridad de sellado de tiempo cualificada sobre el hash del AIP completo. No es la fecha interna del XML de atestación (esa es el reloj local del servicio, se firma antes y difiere en segundos). Para cualquier uso probatorio, use sealedAt.

Otras notas:

4.2 ingest.failed

La ingesta ha fallado en segundo plano. El motivo sale solo del catálogo de errores publicado (docs/api/ingest-error-codes.md): el aviso nunca incluye mensajes de excepción, trazas ni rutas.

Campo Tipo Descripción
schemaVersion texto Siempre "1".
eventId UUID v4 Identificador del evento, estable entre reintentos.
eventType texto ingest.failed.
occurredAt fecha Cuándo se produjo el fallo.
sipId UUID El identificador de la ingesta.
status texto FAILED, DEAD_LETTER o LAUNCH_FAILED (ver docs/ops/ingest-runbook.md para su significado).
errorCode texto Código del catálogo de errores (p. ej. CSIP_NONCONFORMANT). Programe contra este valor.
errorClass texto REJECTED (el paquete no es aceptable: corríjalo y reenvíelo) o INTERNAL_FAILURE (el fallo es de Docuten: no reenvíe todavía).
businessMessage texto Texto de negocio fijo del código. No lo use para lógica.
catalogVersion texto Versión del catálogo con la que se emitió el código (p. ej. 1.3.0).
failedStep texto Nombre del paso de la saga que falló. Opcional: se omite si no se conoce.
{
  "schemaVersion": "1",
  "eventId": "2b9e7d40-8c5a-4f33-a1d6-7e0c94b5f8a2",
  "eventType": "ingest.failed",
  "occurredAt": "2026-11-04T09:31:07.482Z",
  "sipId": "0b8e9f6a-3f6d-4c1e-8b54-2a7e61d0c9a3",
  "status": "FAILED",
  "errorCode": "CSIP_NONCONFORMANT",
  "errorClass": "REJECTED",
  "businessMessage": "El paquete no cumple la especificación E-ARK CSIP.",
  "catalogVersion": "1.3.0",
  "failedStep": "SIP_VALIDATED"
}

status:


5. Cabeceras y firma

Cada aviso lleva estas cabeceras, además de Content-Type: application/json:

Cabecera Valor
X-Earchiving-Signature t=<unix-seconds>,v1=<hex>. Durante la rotación del secreto lleva dos v1=, primero el del secreto vigente.
X-Earchiving-Event-Id El eventId del cuerpo (UUID v4). Estable entre reintentos. Úselo para deduplicar.
X-Earchiving-Event-Type El eventType del cuerpo: ingest.archived o ingest.failed.

La firma es:

v1 = hex( HMAC-SHA256( clave, t + "." + cuerpo_crudo ) )

Ejemplo con dos v1= durante la rotación (el secreto nuevo primero):

X-Earchiving-Signature: t=1700000000,v1=5e4794756710123db33aae34cfa3b61718e86dec195c06f4ef7b0b697e606bd4,v1=7598bb3f9283140d44336362e02ed888c07a4298ee51ae26effe0f978d89b391

6. Guía de verificación del receptor

Siga estos pasos, en este orden, con cada petición:

  1. Lea el cuerpo crudo de la petición como bytes, antes de que ningún framework lo interprete como JSON.
  2. Lea X-Earchiving-Signature y sepárela por comas: un t= y uno o más v1=. Si falta alguno, rechace.
  3. Compruebe el desfase: rechace la petición si |ahora − t| supera 5 minutos (300 segundos). Así una petición capturada no se puede reutilizar más tarde.
  4. Recompute HMAC-SHA256 con la clave igual al texto hexadecimal ASCII del secreto, sobre t + "." + cuerpo_crudo.
  5. Compare en tiempo constante (MessageDigest.isEqual en Java, hmac.compare_digest en Python) el resultado con cada v1 recibido. Acepte si coincide cualquiera: durante la rotación hay dos y su receptor puede tener solo el secreto viejo o solo el nuevo.
  6. Si la firma es válida, deduplique por eventId y responda 2xx.

6.1 Vector conocido

Compruebe su implementación con este vector antes de ponerla en producción:

Elemento Valor
Secreto (la clave, 64 caracteres ASCII) 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f
t t=1700000000 (1700000000)
Cuerpo crudo (7 bytes) {"a":1}
Mensaje firmado 1700000000.{"a":1}
v1 esperado 5e4794756710123db33aae34cfa3b61718e86dec195c06f4ef7b0b697e606bd4

Equivale a:

printf '1700000000.{"a":1}' | openssl dgst -sha256 -hmac 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f

Un segundo vector, con el secreto 202122…3f y el mismo t y cuerpo, da 7598bb3f9283140d44336362e02ed888c07a4298ee51ae26effe0f978d89b391.

6.2 Ejemplo en Java 21

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public final class WebhookVerifier {

    private static final long TOLERANCE_SECONDS = 300;

    private WebhookVerifier() {
    }

    /** {@code rawBody} son los bytes exactos recibidos; {@code secretHex} el texto de 64 caracteres. */
    public static boolean verify(String signatureHeader, byte[] rawBody, String secretHex, Instant now)
        throws Exception {
        Long timestamp = null;
        List<String> candidates = new ArrayList<>();
        for (String part : signatureHeader.split(",")) {
            if (part.startsWith("t=")) {
                try {
                    timestamp = Long.parseLong(part.substring(2));
                } catch (NumberFormatException e) {
                    return false;
                }
            } else if (part.startsWith("v1=")) {
                candidates.add(part.substring(3));
            }
        }
        if (timestamp == null || candidates.isEmpty()) {
            return false;
        }
        if (Math.abs(now.getEpochSecond() - timestamp) > TOLERANCE_SECONDS) {
            return false;
        }
        // La clave es el texto hex tal cual (bytes ASCII), SIN decodificar.
        byte[] key = secretHex.getBytes(StandardCharsets.US_ASCII);
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(key, "HmacSHA256"));
        mac.update((timestamp + ".").getBytes(StandardCharsets.US_ASCII));
        mac.update(rawBody);
        byte[] expected = HexFormat.of().formatHex(mac.doFinal()).getBytes(StandardCharsets.US_ASCII);
        boolean match = false;
        for (String candidate : candidates) {
            match |= MessageDigest.isEqual(expected, candidate.getBytes(StandardCharsets.US_ASCII));
        }
        return match;
    }
}

6.3 Ejemplo en Python 3

import hashlib
import hmac
import time


def verify(signature_header: str, raw_body: bytes, secret_hex: str, now: int | None = None,
           tolerance_seconds: int = 300) -> bool:
    """Verifica X-Earchiving-Signature. `raw_body` son los bytes exactos recibidos."""
    now = int(time.time()) if now is None else now
    timestamp = None
    candidates = []
    for part in signature_header.split(","):
        if part.startswith("t="):
            try:
                timestamp = int(part[2:])
            except ValueError:
                return False
        elif part.startswith("v1="):
            candidates.append(part[3:])
    if timestamp is None or not candidates:
        return False
    if abs(now - timestamp) > tolerance_seconds:
        return False
    key = secret_hex.encode("ascii")  # el texto hex tal cual, SIN decodificar
    message = str(timestamp).encode("ascii") + b"." + raw_body
    expected = hmac.new(key, message, hashlib.sha256).hexdigest()
    matches = [hmac.compare_digest(expected, c) for c in candidates]
    return any(matches)

Los dos ejemplos devuelven true/True con el vector de §6.1 y t igual a la hora actual simulada, y false/False si el cuerpo cambia ({"a":2}) o si el desfase supera 300 segundos.


7. Entrega y reintentos


8. Registro de entregas

GET /api/v1/tenant/webhook/deliveries lista las entregas del tenant, de la más reciente a la más antigua.

Parámetros (todos opcionales): status (PENDING, DELIVERED o FAILED), sipId, page (por defecto 0) y size (por defecto 20, de 1 a 100). Un filtro mal formado devuelve 400 con errorCode WEBHOOK_DELIVERY_FILTER_INVALID.

Cada elemento de content lleva: id, eventId, eventType, sipId, status, attemptCount, lastHttpStatus, lastError, createdAt, lastAttemptAt, nextAttemptAt y deliveredAt. Nunca lleva el cuerpo del aviso ni lo que respondió el receptor.

Estados:

Retención: 90 días. Las entregas terminadas (DELIVERED y FAILED) se borran pasados 90 días.

8.1 Códigos de lastError

lastError es un código corto de un conjunto cerrado, nunca texto libre:

Código Significado
TIMEOUT El receptor no conectó o no respondió dentro de 5 s (conexión) o 10 s (lectura).
CONNECT_ERROR No se pudo establecer la conexión (puerto cerrado, host inalcanzable).
TLS_ERROR Fallo del handshake TLS: certificado inválido, caducado o que no corresponde al host.
HTTP_<código> El receptor respondió con un estado no 2xx ni 3xx; por ejemplo HTTP_500 o HTTP_404.
REDIRECT_NOT_FOLLOWED El receptor respondió 3xx. No se siguen redirecciones: configure la URL final.
DESTINATION_REJECTED El nombre de la URL resuelve a un destino vedado (§3).
SECRET_UNAVAILABLE El servicio no pudo recuperar el secreto para firmar (incidencia de Docuten; se reintenta).
WEBHOOK_NOT_CONFIGURED Cuando se iba a enviar, el webhook se había borrado o deshabilitado. Terminal, sin reintento.
ATTEMPTS_EXHAUSTED La entrega agotó sus intentos sin que el último llegara a registrar un resultado (por ejemplo, el servicio se reinició durante el envío). Terminal.
INTERNAL_ERROR Error interno de Docuten al preparar el envío; se reintenta.

9. Deprecación de X-Webhook-Url

Hasta ahora, POST /api/v1/ingest/sip y POST /api/v1/ingest/file aceptaban la cabecera X-Webhook-Url para recibir un aviso sin firma por petición. Ya no envía nada.

Cómo migrar:

  1. Elija un endpoint https fijo para su organización e impleméntelo según §6.
  2. Dé de alta el webhook (§2.1) y guarde el secreto.
  3. Compruebe con una ingesta de prueba y consulte el registro de entregas (§8).
  4. Deje de enviar X-Webhook-Url.

Las diferencias respecto al aviso antiguo: la URL es del tenant y no de cada petición, el cuerpo está firmado, lleva el motivo del fallo y el aipId y la huella del AIP, y hay un ingest.failed también para FAILED y LAUNCH_FAILED.


10. Compatibilidad