| 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 |
Docuten eArchiving avisa a su sistema, sin que tenga que consultar, de dos cosas que le pasan a una ingesta:
ingest.archived: el AIP se ha archivado y sellado.ingest.failed: la ingesta ha fallado, con el motivo del catálogo de errores
(docs/api/ingest-error-codes.md).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:
https que acepte POST con Content-Type: application/json.2xx en menos de 10 segundos cuando haya recibido el aviso. Cualquier otra respuesta se
reintenta (§7).eventId: la entrega es «al menos una vez» y puede repetirse (§7).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). |
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"
}
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.
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.
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).
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).
https. Sin credenciales en la URL (https://usuario:clave@… se rechaza).0.0.0.0/8,
10.0.0.0/8, 100.64.0.0/10 (CGNAT), 127.0.0.0/8, 169.254.0.0/16 (incluye los metadatos de nube
169.254.169.254), 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.168.0.0/16, 198.18.0.0/15,
198.51.100.0/24, 203.0.113.0/24, 168.63.129.16/32, 224.0.0.0/4 y 240.0.0.0/4; IPv6 ::, ::1,
100::/64, 2001:db8::/32, 64:ff9b:1::/48, fe80::/10, fec0::/10, fc00::/7 y ff00::/8, y las
direcciones IPv6 que embeben una IPv4 vedada (::ffff:a.b.c.d, ::ffff:0:a.b.c.d, 64:ff9b::/96,
2002::/16).3xx cuenta como fallo de entrega
(REDIRECT_NOT_FOLLOWED). Configure la URL final.TIMEOUT).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.
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.
ingest.archivedEl 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:
DEAD_LETTER que se reanuda y termina bien emite después su ingest.archived.ingest.archived se
envía igualmente: el archivado y el sellado son lo que el aviso garantiza.ingest.failedLa 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:
FAILED: un paso de la saga falló.DEAD_LETTER: un sellado cualificado agotó sus reintentos. Docuten decide si reanuda la ingesta.LAUNCH_FAILED: la saga no llegó a arrancar. Su errorCode es INTERNAL_PROCESSING_ERROR (el mismo que
un FAILED de nivel de job).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 ) )
t es el instante de firma en segundos Unix, tal como aparece en la cabecera X-Earchiving-Signature.cuerpo_crudo son los bytes exactos que recibe. No lo vuelva a serializar.v1 va en hexadecimal en minúsculas.Ejemplo con dos v1= durante la rotación (el secreto nuevo primero):
X-Earchiving-Signature: t=1700000000,v1=5e4794756710123db33aae34cfa3b61718e86dec195c06f4ef7b0b697e606bd4,v1=7598bb3f9283140d44336362e02ed888c07a4298ee51ae26effe0f978d89b391
Siga estos pasos, en este orden, con cada petición:
X-Earchiving-Signature y sepárela por comas: un t= y uno o más v1=. Si falta alguno, rechace.|ahora − t| supera 5 minutos (300 segundos). Así
una petición capturada no se puede reutilizar más tarde.HMAC-SHA256 con la clave igual al texto hexadecimal ASCII del secreto, sobre
t + "." + cuerpo_crudo.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.eventId y responda 2xx.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.
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;
}
}
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.
2xx se perdió). El servicio no garantiza «exactamente
una vez». Su receptor debe ser idempotente y deduplicar por eventId (cuerpo) o
X-Earchiving-Event-Id (cabecera), que se conservan idénticos en cada reintento.2xx es correcta. Cualquier otra —3xx (no se sigue), 4xx, 5xx, error de
conexión, de TLS o un timeout— cuenta como fallo y se reintenta, incluidos los 4xx.FAILED y no se reintenta más. El
registro de entregas (§8) la conserva con su último error. Docuten es avisado en su log, pero no hay
reenvío manual: si su receptor estuvo caído más de 22 horas, reconcilie consultando el estado de las
ingestas (GET /api/v1/ingest/jobs).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:
PENDING: encolada o a la espera de un reintento (nextAttemptAt).DELIVERED: el receptor respondió 2xx.FAILED: terminal (8 intentos agotados, o no había webhook configurado y habilitado).Retención: 90 días. Las entregas terminadas (DELIVERED y FAILED) se borran pasados 90 días.
lastErrorlastError 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. |
X-Webhook-UrlHasta 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.
Deprecation: @1793664000 (RFC 9745; la fecha es el 2026-11-03) y
Link: <https://docs.docuten.com/earchiving/webhook-notifications>; rel="deprecation".Cómo migrar:
https fijo para su organización e impleméntelo según §6.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.
v1. Los cambios futuros solo pueden ser aditivos (campos nuevos al final de un
evento, valores nuevos documentados) o exigirán una schemaVersion nueva. Un campo, un nombre de cabecera
o un algoritmo de firma existentes no cambian dentro de v1.status, errorCode y
errorClass (el catálogo de errores crece de forma aditiva).ingest.archived e
ingest.failed. Distinga por eventType.