# 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

**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 `webhookUrl` y `secret`.
- **Subida por URL** — envía los campos JSON `callbackUrl` y `hmacSignature`.

```bash
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"
```
**Caution:** Una URL de callback por **caso**, fijada en el momento de la subida. Si falta `webhookUrl`
**o** `secret`, **no se envía ningún webhook** — sin tu secreto no se puede calcular la firma.

## Eventos

| `type` del evento | Se dispara cuando |
| --- | --- |
| `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. |

Los estados `queued` y `quota_pending` **no** se entregan como webhooks.

## Payload

El cuerpo de la petición es JSON. `Content-Type: application/json`.

```json
{
  "id": "f1d2c3b4-0000-4a1e-8f3c-2d6b5a9e1c40",
  "type": "case.completed",
  "apiVersion": "2026-06-05",
  "occurredAt": "2026-06-05T12: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-05`). 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. `completed`, `failed`, `canceled`, `quota_exceeded`. |

Los campos nulos se omiten del JSON.

### 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

La cabecera `X-Accessful-Signature` tiene la forma `t=<unix>,v1=<hex>`. Recalcúlala y
compárala en tiempo constante:

1. Lee `t` y `v1` de la cabecera.
2. 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.
3. Compara en tiempo constante contra `v1`.
4. Opcionalmente, rechaza si `t` tiene más de unos minutos (protección frente a repetición).

```js
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;
}
```
```java
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));
```
**Note:** `X-Signature` es un mecanismo de reserva heredado: base64 de `HMAC-SHA256(secret, cuerpo
bruto)` — solo el cuerpo, sin timestamp. Prefiere `X-Accessful-Signature`; usa la cabecera
heredada solo si no puedes leer la nueva.

## 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.