Webhooks
En lugar de sondear, puedes hacer que Accessful envíe por POST un evento firmado a tu servidor en cuanto un trabajo cambie de estado.
Cómo funciona el registro
Sección titulada «Cómo funciona el registro»No hay panel de control ni endpoint de registro. Adjuntas un callback por cada subida y tú eliges el secreto de firma — Accessful no emite ninguno.
- Subida multipart — envía los campos de formulario
webhookUrlysecret. - Subida por URL — envía los campos JSON
callbackUrlyhmacSignature.
curl -X POST "https://api.accessful.de/api/v1/upload-service/pdf/upload" \ -H "X-API-Key: $ACCESSFUL_API_KEY" \ -F "files=@documento.pdf" \ -F "webhookUrl=https://tu-app.example.com/hooks/accessful" \ -F "secret=$TU_WEBHOOK_SECRET"Eventos
Sección titulada «Eventos»type del evento |
Se dispara cuando |
|---|---|
case.queued |
El PDF fue aceptado y entró en la cola de análisis. Se dispara una vez y lleva la posición en la cola inicial. |
case.running |
El trabajo empezó a procesarse. Puede dispararse más de una vez (una por iteración). |
case.completed |
El resultado PDF/UA está listo para descargar. |
case.failed |
El procesamiento falló. |
case.canceled |
El caso fue cancelado. |
case.quota_exceeded |
Rechazado porque la cuota del contrato está agotada. |
El estado quota_pending no se entrega como webhook.
Payload
Sección titulada «Payload»El cuerpo de la petición es JSON. Content-Type: application/json.
{ "id": "f1d2c3b4-0000-4a1e-8f3c-2d6b5a9e1c40", "type": "case.completed", "apiVersion": "2026-06-26", "occurredAt": "2026-06-26T12:34:56Z", "data": { "caseId": "7c2f1e4a-9b0d-4a1e-8f3c-2d6b5a9e1c40", "fileName": "documento.pdf", "jobStatus": "completed" }}| Campo | Tipo | Notas |
|---|---|---|
id |
UUID | ID del evento. Úsalo como clave de idempotencia (ver abajo). |
type |
string | Uno de los tipos de evento anteriores. |
apiVersion |
string | Versión del contrato (2026-06-26). Fíjala para detectar cambios. |
occurredAt |
ISO-8601 | Cuándo se creó el evento; estable entre reintentos. |
data.caseId |
UUID | El caso al que se refiere este evento. |
data.fileName |
string | El nombre del archivo. |
data.jobStatus |
string | Estado bruto del trabajo, p. ej. queued, completed, failed, canceled, quota_exceeded. |
data.queuePosition |
integer | solo case.queued — rango inicial en la cola, empezando en 1. |
data.queueTotal |
integer | solo case.queued — número total de casos en espera en ese momento. |
data.estimatedWaitSeconds |
integer | solo case.queued — estimación aproximada de la espera en segundos. |
Los campos nulos se omiten del JSON.
Posición en la cola en case.queued
Sección titulada «Posición en la cola en case.queued»El evento case.queued es la contraparte basada en push de la posición en la cola de
job-status: se dispara una vez, en el
momento en que se acepta un PDF, y lleva la posición inicial para que puedas mostrar «eres el
7.º de la fila, ~5 min» sin sondear.
{ "id": "a3b1c2d4-0000-4a1e-8f3c-2d6b5a9e1c40", "type": "case.queued", "apiVersion": "2026-06-26", "occurredAt": "2026-06-26T12:30:00Z", "data": { "caseId": "7c2f1e4a-9b0d-4a1e-8f3c-2d6b5a9e1c40", "fileName": "documento.pdf", "jobStatus": "queued", "queuePosition": 7, "queueTotal": 23, "estimatedWaitSeconds": 300 }}Este es un evento discreto del ciclo de vida, no un flujo en tiempo real — la posición es
una instantánea en el momento de la aceptación y no se vuelve a enviar a medida que avanza la
cola. Para una posición que se actualiza continuamente, sondea GET /job-status/{caseId},
cuya respuesta lleva los mismos campos queue* mientras el caso espera. Los campos queue*
aparecen solo en case.queued; cualquier otro evento los omite.
Cabeceras de entrega
Sección titulada «Cabeceras de entrega»| Cabecera | Ejemplo | Propósito |
|---|---|---|
X-Accessful-Signature |
t=1749126896,v1=9f86d0… |
Firma HMAC — verifica esta |
X-Accessful-Webhook-Timestamp |
1749126896 |
Segundos Unix; igual que t= arriba |
X-Accessful-Event-Id |
f1d2c3b4-… |
Igual que id; clave de idempotencia |
X-Accessful-Event-Type |
case.completed |
Enrutado sin analizar el cuerpo |
X-Accessful-Case-Id |
7c2f1e4a-… |
El ID del caso |
X-Accessful-Delivery-Attempt |
1 |
Contador de intentos, empieza en 1 |
X-Signature |
n4bQgY… |
legacy HMAC en base64 solo sobre el cuerpo |
Verificar la firma
Sección titulada «Verificar la firma»La cabecera X-Accessful-Signature tiene la forma t=<unix>,v1=<hex>. Recalcúlala y
compárala en tiempo constante:
- Lee
tyv1de la cabecera. - Calcula
HMAC-SHA256(secret, "<t>.<cuerpo bruto>")y codifícalo en hexadecimal. La cadena firmada es el timestamp, un punto literal y luego el cuerpo bruto exacto de la petición — verifícalo antes de analizar el JSON. - Compara en tiempo constante contra
v1. - Opcionalmente, rechaza si
ttiene más de unos minutos (protección frente a repetición).
import crypto from 'node:crypto';
// `rawBody` deben ser los bytes exactos recibidos (p. ej. express.raw()).function verify(rawBody, signatureHeader, secret) { const parts = Object.fromEntries(signatureHeader.split(',').map((p) => p.split('='))); const expected = crypto .createHmac('sha256', secret) .update(`${parts.t}.${rawBody}`) .digest('hex');
const valid = crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected)); const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300; // 5 min return valid && fresh;}String[] kv = signatureHeader.split(","); // ["t=...", "v1=..."]long t = Long.parseLong(kv[0].substring(2));String v1 = kv[1].substring(3);
Mac mac = Mac.getInstance("HmacSHA256");mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));byte[] sig = mac.doFinal((t + "." + rawBody).getBytes(StandardCharsets.UTF_8));String expected = HexFormat.of().formatHex(sig);
boolean valid = MessageDigest.isEqual( expected.getBytes(StandardCharsets.UTF_8), v1.getBytes(StandardCharsets.UTF_8));Reintentos e idempotencia
Sección titulada «Reintentos e idempotencia»- Éxito = cualquier respuesta 2xx dentro del tiempo límite (5s para conectar, 10s para responder). Responde rápido y haz tu trabajo de forma asíncrona.
- Fallo (no-2xx, timeout o error de conexión) se reintenta hasta 10 veces con
retroceso exponencial y jitter — aproximadamente
10s → 30s → 1,5m → 4,5m → 13,5m → 40m → 2h, luego limitado a 6h, menos hasta un 20% de jitter. La ventana completa abarca varias horas. - Tras 10 intentos fallidos, el evento se abandona. Contacta con soporte para reenviarlo.
- Idempotencia: el mismo evento puede llegar más de una vez (p. ej. un reintento después
de que tu endpoint ya respondiera con éxito). Deduplica por
X-Accessful-Event-Id— es estable en cada reintento.