# Webhooks

Au lieu d’interroger, vous pouvez demander à Accessful d’**envoyer par POST un événement
signé** à votre serveur dès qu’une tâche change d’état.

## Comment fonctionne l’enregistrement

**Il n’y a ni tableau de bord ni endpoint d’enregistrement**. Vous attachez un callback **à
chaque téléversement**, et **vous choisissez vous-même le secret de signature** — Accessful
n’en émet aucun.

- **Téléversement multipart** — envoyez les champs de formulaire `webhookUrl` et `secret`.
- **Téléversement par URL** — envoyez les champs JSON `callbackUrl` et `hmacSignature`.

```bash
curl -X POST "https://api.accessful.de/api/v1/upload-service/pdf/upload" \
  -H "X-API-Key: $ACCESSFUL_API_KEY" \
  -F "files=@document.pdf" \
  -F "webhookUrl=https://votre-app.example.com/hooks/accessful" \
  -F "secret=$VOTRE_WEBHOOK_SECRET"
```
**Caution:** Une seule URL de callback par **cas**, fixée au moment du téléversement. Si `webhookUrl`
**ou** `secret` est manquant, **aucun webhook n’est envoyé** — sans votre secret, la signature
ne peut pas être calculée.

## Événements

| `type` d’événement | Se déclenche quand |
| --- | --- |
| `case.running` | La tâche a commencé son traitement. Peut se déclencher plusieurs fois (une par itération). |
| `case.completed` | Le résultat PDF/UA est prêt à être téléchargé. |
| `case.failed` | Le traitement a échoué. |
| `case.canceled` | Le cas a été annulé. |
| `case.quota_exceeded` | Rejeté car le quota contractuel est épuisé. |

Les états `queued` et `quota_pending` ne sont **pas** livrés en tant que webhooks.

## Payload

Le corps de la requête est au format 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": "document.pdf",
    "jobStatus": "completed"
  }
}
```

| Champ | Type | Notes |
| --- | --- | --- |
| `id` | UUID | ID de l’événement. Utilisez-le comme **clé d’idempotence** (voir ci-dessous). |
| `type` | string | L’un des types d’événement ci-dessus. |
| `apiVersion` | string | Version du contrat (`2026-06-05`). Épinglez-la pour détecter les changements. |
| `occurredAt` | ISO-8601 | Date de création de l’événement ; stable d’une tentative à l’autre. |
| `data.caseId` | UUID | Le cas concerné par cet événement. |
| `data.fileName` | string | Le nom du fichier. |
| `data.jobStatus` | string | Statut brut de la tâche, p. ex. `completed`, `failed`, `canceled`, `quota_exceeded`. |

Les champs nuls sont omis du JSON.

### En-têtes de livraison

| En-tête | Exemple | Objet |
| --- | --- | --- |
| `X-Accessful-Signature` | `t=1749126896,v1=9f86d0…` | Signature HMAC — **à vérifier** |
| `X-Accessful-Webhook-Timestamp` | `1749126896` | Secondes Unix ; identique au `t=` ci-dessus |
| `X-Accessful-Event-Id` | `f1d2c3b4-…` | Égale `id` ; clé d’idempotence |
| `X-Accessful-Event-Type` | `case.completed` | Routage sans analyser le corps |
| `X-Accessful-Case-Id` | `7c2f1e4a-…` | L’ID du cas |
| `X-Accessful-Delivery-Attempt` | `1` | Compteur de tentatives, à partir de 1 |
| `X-Signature` | `n4bQgY…` | legacy HMAC base64 sur le corps uniquement |

## Vérifier la signature

L’en-tête `X-Accessful-Signature` a la forme `t=<unix>,v1=<hex>`. Recalculez-la et comparez en
temps constant :

1. Lisez `t` et `v1` dans l’en-tête.
2. Calculez `HMAC-SHA256(secret, "<t>.<corps brut>")` et encodez-le en hexadécimal. La chaîne
   signée est l’horodatage, un point littéral, puis le **corps brut exact de la requête** —
   vérifiez *avant* l’analyse JSON.
3. Comparez en temps constant avec `v1`.
4. Éventuellement, rejetez si `t` date de plus de quelques minutes (protection contre le rejeu).

```js
import crypto from 'node:crypto';

// `rawBody` doit correspondre aux octets exacts reçus (p. ex. 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` est un mécanisme de repli hérité : base64 de `HMAC-SHA256(secret, corps brut)` —
le corps uniquement, sans horodatage. Préférez `X-Accessful-Signature` ; n’utilisez l’en-tête
hérité que si vous ne pouvez pas lire le nouveau.

## Nouvelles tentatives et idempotence

- **Succès** = toute réponse **2xx** dans le délai imparti (5 s pour se connecter, 10 s pour
  répondre). Répondez vite et effectuez votre travail de manière asynchrone.
- **Échec** (non-2xx, délai dépassé ou erreur de connexion) est réessayé jusqu’à **10
  tentatives** avec un backoff exponentiel et du jitter — environ `10s → 30s → 1,5m → 4,5m → 13,5m → 40m → 2h`,
  puis plafonné à **6h**, moins jusqu’à 20 % de jitter. La fenêtre complète s’étend sur plusieurs heures.
- Après 10 tentatives échouées, l’événement est **abandonné**. Contactez le support pour une
  nouvelle livraison.
- **Idempotence :** le même événement peut arriver plus d’une fois (p. ex. une nouvelle
  tentative après que votre endpoint a déjà réussi). Dédupliquez sur `X-Accessful-Event-Id` —
  il est stable à chaque tentative.