Webhooks signés — spec engineering
Module :
webhook-svc(microservice JVM Kotlin + outbox Postgres + sweeper HTTP).POC : poc-webhook-emitter.
Ce document spécifie le protocole de webhooks sortants VitaKYC. Tous les modules (form-designer-svc, bio-svc, risk-matrix-svc, sanctions-svc, case-mgmt-svc, tx-monitoring-svc) émettent leurs events via cette interface unifiée. Un développeur tenant qui le lit doit pouvoir construire son endpoint de vérification sans question résiduelle.
1. Vue d’ensemble
Section intitulée « 1. Vue d’ensemble »2. Protocole HTTP
Section intitulée « 2. Protocole HTTP »2.1 Request émise par VitaKYC
Section intitulée « 2.1 Request émise par VitaKYC »POST /webhooks/vitakyc HTTP/1.1Host: api.banque-x.tnContent-Type: application/jsonX-VitaKYC-Event-Id: evt_8a7f3c1e9d4b2a6fX-VitaKYC-Event-Type: case.decidedX-VitaKYC-Tenant-Id: TN-BANQUEXX-VitaKYC-Timestamp: 1745000000X-VitaKYC-Delivery-Attempt: 1X-VitaKYC-Idempotency-Key: idem_8a7f3c1e9d4b2a6fX-VitaKYC-Signature: t=1745000000,v1=5e2c1f9e8a3d7b6c4f2e1a9d8c7b6a5e4f3d2c1b0a9f8e7d6c5b4a3f2e1d0c9bContent-Length: 412
{"case_id":"case_4127","decision":"APPROVED","decided_by":"agent_amine","confirmed_by":"agent_leila","decision_at":"2026-04-27T11:42:00Z"}2.2 Response attendue
Section intitulée « 2.2 Response attendue »| Code HTTP | Comportement VitaKYC |
|---|---|
2xx (200, 201, 202, 204) | succès, marqué DELIVERED |
3xx redirect | suit jusqu’à 3 redirects max |
4xx (sauf 408, 429) | échec définitif, marqué FAILED après 1 tentative (mauvaise URL) |
408 Request Timeout | retry |
429 Too Many Requests | respecte Retry-After header, backoff additionnel |
5xx | retry exponentiel |
| timeout 30 s | retry |
2.3 Headers standardisés
Section intitulée « 2.3 Headers standardisés »| Header | Description | Exemple |
|---|---|---|
X-VitaKYC-Event-Id | UUID unique par event | evt_8a7f3c1e9d4b2a6f |
X-VitaKYC-Event-Type | type d’event (cf catalog) | case.decided |
X-VitaKYC-Tenant-Id | tenant émetteur | TN-BANQUEX |
X-VitaKYC-Timestamp | epoch seconds UTC | 1745000000 |
X-VitaKYC-Delivery-Attempt | numéro de tentative (1 = première) | 1 |
X-VitaKYC-Idempotency-Key | dedup côté tenant | idem_8a7f3c1e9d4b2a6f |
X-VitaKYC-Signature | t=<ts>,v1=<hmac> | voir §3 |
3. Signature HMAC-SHA256
Section intitulée « 3. Signature HMAC-SHA256 »3.1 Format
Section intitulée « 3.1 Format »X-VitaKYC-Signature: t=<unix_timestamp>,v1=<hmac_hex>3.2 Calcul (côté VitaKYC)
Section intitulée « 3.2 Calcul (côté VitaKYC) »val timestamp = (now.epochSecond).toString()val canonicalBody = canonicalJson(payload)val signedPayload = "$timestamp.$canonicalBody"val secret = vault.readTenantWebhookSecret(tenantId) // 32+ bytes randomval hmac = hmacSha256(secret, signedPayload).toHex()val header = "t=$timestamp,v1=$hmac"3.3 Vérification (côté tenant) — Node.js
Section intitulée « 3.3 Vérification (côté tenant) — Node.js »const crypto = require('crypto');
function verifyVitaKYCWebhook(req, secret) { const sigHeader = req.header('X-VitaKYC-Signature') || ''; const parts = Object.fromEntries(sigHeader.split(',').map(p => p.split('='))); const timestamp = parts.t; const provided = parts.v1;
// 1. Replay protection: 5 min tolerance const now = Math.floor(Date.now() / 1000); if (Math.abs(now - parseInt(timestamp)) > 300) { throw new Error('timestamp out of tolerance'); }
// 2. Recompute HMAC const signedPayload = `${timestamp}.${req.rawBody}`; // raw body, not parsed const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex');
// 3. Constant-time compare if (!crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(expected))) { throw new Error('signature mismatch'); }
// 4. (Optional) idempotency dedup const idemKey = req.header('X-VitaKYC-Idempotency-Key'); if (alreadyProcessed(idemKey)) return { ok: true, deduped: true }; markProcessed(idemKey);
return { ok: true };}3.4 Vérification — Python
Section intitulée « 3.4 Vérification — Python »import hmac, hashlib, time
def verify_vitakyc_webhook(headers, raw_body, secret): sig_header = headers.get('X-VitaKYC-Signature', '') parts = dict(p.split('=') for p in sig_header.split(',')) ts = int(parts['t']); provided = parts['v1']
if abs(int(time.time()) - ts) > 300: raise ValueError('timestamp out of tolerance')
signed = f"{ts}.{raw_body}".encode('utf-8') expected = hmac.new(secret.encode('utf-8'), signed, hashlib.sha256).hexdigest()
if not hmac.compare_digest(provided, expected): raise ValueError('signature mismatch')
return True3.5 Vérification — Java (Spring Boot)
Section intitulée « 3.5 Vérification — Java (Spring Boot) »import javax.crypto.Mac;import javax.crypto.spec.SecretKeySpec;import java.nio.charset.StandardCharsets;import java.security.MessageDigest;
public boolean verifyVitaKycWebhook(HttpServletRequest req, String rawBody, String secret) throws Exception { String sigHeader = req.getHeader("X-VitaKYC-Signature"); Map<String, String> parts = Arrays.stream(sigHeader.split(",")) .map(p -> p.split("=")) .collect(Collectors.toMap(p -> p[0], p -> p[1])); long ts = Long.parseLong(parts.get("t")); String provided = parts.get("v1");
long now = System.currentTimeMillis() / 1000; if (Math.abs(now - ts) > 300) throw new SecurityException("timestamp out of tolerance");
String signed = ts + "." + rawBody; Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); byte[] hmacBytes = mac.doFinal(signed.getBytes(StandardCharsets.UTF_8)); String expected = HexFormat.of().formatHex(hmacBytes);
return MessageDigest.isEqual(provided.getBytes(), expected.getBytes());}3.6 Vérification — PHP
Section intitulée « 3.6 Vérification — PHP »function verify_vitakyc_webhook($headers, $raw_body, $secret) { $parts = []; foreach (explode(',', $headers['X-VitaKYC-Signature']) as $p) { [$k, $v] = explode('=', $p, 2); $parts[$k] = $v; } if (abs(time() - (int)$parts['t']) > 300) throw new Exception('timestamp out of tolerance'); $expected = hash_hmac('sha256', $parts['t'] . '.' . $raw_body, $secret); return hash_equals($expected, $parts['v1']);}4. Retry policy
Section intitulée « 4. Retry policy »4.1 Calendrier exponentiel
Section intitulée « 4.1 Calendrier exponentiel »| Tentative | Délai après tentative précédente | Cumul depuis 1ère tentative |
|---|---|---|
| 1 (initial) | 0 | 0 |
| 2 | 1 s | 1 s |
| 3 | 5 s | 6 s |
| 4 | 30 s | 36 s |
| 5 | 2 min | 2 min 36 s |
| 6 | 10 min | 12 min 36 s |
| 7 | 1 h | 1 h 12 min 36 s |
| 8 | 6 h | 7 h 12 min 36 s |
| (FAILED) | après 24 h depuis tentative 1 | webhook abandonné |
Total : 8 tentatives sur 24 h. Couvre la majorité des incidents tenant (médiane 4 h résolution observée).
4.2 Backoff additionnel sur 429
Section intitulée « 4.2 Backoff additionnel sur 429 »Si réponse 429 Too Many Requests avec header Retry-After: <seconds> :
- VitaKYC respecte
Retry-After - N’incrémente pas
delivery_attempt(le 429 est un signal de throttle, pas un échec) - Si
Retry-After> 1 h, marqueRATE_LIMITEDau lieu deRETRYING
4.3 Notifications d’échec
Section intitulée « 4.3 Notifications d’échec »| Trigger | Action |
|---|---|
| 3 échecs consécutifs | email DSI tenant + Slack interne SRE |
| Webhook FAILED après 24 h | email DSI tenant + ticket support auto |
| 50+ FAILED en 1 h pour un tenant | alerte critique SRE — probable panne URL tenant |
5. Outbox pattern
Section intitulée « 5. Outbox pattern »5.1 Schéma SQL
Section intitulée « 5.1 Schéma SQL »CREATE TABLE webhook_outbox ( event_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, event_type VARCHAR(64) NOT NULL, url TEXT NOT NULL, payload JSONB NOT NULL, signed_payload TEXT NOT NULL, -- "${ts}.${canonical_body}" signature VARCHAR(64) NOT NULL, -- HMAC hex timestamp_unix BIGINT NOT NULL, status VARCHAR(16) NOT NULL, -- PENDING | DELIVERED | RETRYING | FAILED | RATE_LIMITED delivery_attempt INT NOT NULL DEFAULT 0, next_attempt_at TIMESTAMPTZ, last_response_code INT, last_response_body TEXT, -- truncated 1KB delivered_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CHECK (status IN ('PENDING','DELIVERED','RETRYING','FAILED','RATE_LIMITED')));
CREATE INDEX idx_webhook_outbox_pending ON webhook_outbox (next_attempt_at)WHERE status IN ('PENDING', 'RETRYING');
ALTER TABLE webhook_outbox ENABLE ROW LEVEL SECURITY;CREATE POLICY webhook_outbox_tenant_isolation ON webhook_outbox USING (tenant_id = current_setting('app.current_tenant_id')::uuid);5.2 Sweeper
Section intitulée « 5.2 Sweeper »suspend fun sweep() { while (isRunning) { val batch = repo.fetchDue(limit = 100, now = Instant.now()) for (row in batch) { try { val resp = httpClient.post(row.url) { headers { addAll(row.allHeaders()) } body = row.payload } handleResponse(row, resp) } catch (t: Throwable) { handleFailure(row, t) } } delay(1.seconds) }}6. API REST (admin tenant)
Section intitulée « 6. API REST (admin tenant) »| Méthode | Endpoint | Description |
|---|---|---|
GET | /v1/webhooks/config | URLs + event types configurés |
PUT | /v1/webhooks/config | met à jour (admin) |
POST | /v1/webhooks/secret/rotate | génère nouveau secret, garde l’ancien actif 24 h |
GET | /v1/webhooks/secret/current | révèle le secret actuel (1 fois, modal masqué) |
POST | /v1/webhooks/test | envoie un faux event vers l’URL configurée |
GET | /v1/webhooks/deliveries?status=&from=&page= | liste deliveries |
GET | /v1/webhooks/deliveries/:id | détail (payload + signature + response) |
POST | /v1/webhooks/deliveries/:id/replay | rejoue manuellement |
GET | /v1/webhooks/kpis?period=last_7d | dashboard livraison |
7. Catalog des events VitaKYC
Section intitulée « 7. Catalog des events VitaKYC »| Event Type | Émis par | Schéma | Use case tenant |
|---|---|---|---|
form.published | form-designer-svc | {tenantId, formId, version, hash, addedFields[], removedFields[]} | sync configuration côté tenant si form Designer chez lui |
bio.verdict.published | bio-svc | {verificationId, verdict, scoreConsolidated, subscores} | onboarding callback côté banque |
risk.evaluation.published | risk-matrix-svc | {clientId, level, score, dimensions, policyVersion} | mise à jour CDD level côté core banking |
sanctions.screening.completed | sanctions-svc | {screeningId, decision, hits[], listVersion} | freeze account si MATCH_DIRECT_SANCTIONS |
case.decided | case-mgmt-svc | {caseId, decision, decidedBy, confirmedBy, decisionAt} | unblock workflow chez banque |
aml.alert.published | tx-monitoring-svc | {alertId, ruleId, accountId, severity, triggeringTxIds, aggregateValue} | dashboard fraude banque |
8. Sécurité
Section intitulée « 8. Sécurité »- Secret tenant : 32 bytes aléatoires (
crypto.randomBytes(32)), stocké chiffré KMS Vault, jamais loggué. - Rotation : à l’initiative tenant via
/secret/rotate. L’ancien secret reste actif 24 h pour absorber les déploiements en cours. - mTLS optionnel : pour tenants tier-1 paranoïaques, on peut requérir un certificat client TLS (cert pinning bidirectionnel).
- TLS 1.2+ uniquement côté HTTP client VitaKYC. TLS 1.0 / 1.1 refusé.
- No body logging : les payloads contenant PII (bio.verdict, risk.evaluation) ne sont pas dans les logs SRE applicatifs en clair. Stockés en
webhook_outboxchiffrés au repos. - Rate limit côté VitaKYC : maximum 100 webhooks/s/tenant pour éviter d’enliser le tenant.
- Audit append-only : chaque tentative tracée dans
webhook_audit(tenant_id, event_id, attempt, response_code, signed_by_secret_version).
9. Performance et capacité
Section intitulée « 9. Performance et capacité »| Metric | MVP cible | V2 cible |
|---|---|---|
| Latence p95 (queue → delivered) | ≤ 3 s | ≤ 1 s |
| Throughput | ≥ 1 000 webhooks/min | ≥ 10 000 |
Disponibilité webhook-svc | 99,5 % | 99,9 % |
| Outbox storage par tenant (30 j) | ≤ 50 MB | ≤ 30 MB |
| Latence first attempt success | ≥ 95 % | ≥ 99 % |
| Final FAILED rate (24 h) | ≤ 0,5 % | ≤ 0,1 % |
10. Dashboard livraison (UI tenant — admin)
Section intitulée « 10. Dashboard livraison (UI tenant — admin) »┌────────────────────────────────────────────────────────┐│ Webhooks · TN-BANQUEX · derniers 7 jours │├────────────────────────────────────────────────────────┤│ Total : 5 247 Delivered : 5 218 Failed : 3 ││ Avg latency : 1.2 s · Success first attempt : 97.4 % │├────────────────────────────────────────────────────────┤│ [Toutes ▾] [bio.verdict ▾] [case.decided ▾] ... │├────────────────────────────────────────────────────────┤│ Time Event URL Status Tries ││ 11:47:02 case.decided /cb ✅ 200 1 ││ 11:42:15 aml.alert.published /cb ⚠ 502 4 ││ 11:38:44 bio.verdict.published /cb ✅ 200 1 ││ [Replay] [Détails] │└────────────────────────────────────────────────────────┘(Mockup à intégrer en Workflow 14 si demandé. Pour MVP, dashboard intégré dans la page admin Sanctions / AML.)
11. Checklist go-live MVP
Section intitulée « 11. Checklist go-live MVP »-
webhook-svcdéployé avec outbox Postgres + sweeper coroutine - Signature HMAC-SHA256 + replay 5 min implémentés et testés
- 6 event types catalog supportés (form, bio, risk, sanctions, case, aml)
- Retry exponentiel 8 tentatives sur 24 h validé en CI
- Secret rotation avec fenêtre transition 24 h fonctionnelle
- Test endpoint disponible
- Replay GUI fonctionnel
- Notifications email + Slack pour 3 échecs / FAILED final
- Code samples Node, Python, Java, PHP publiés (cf §3.3-3.6)
- Dashboard livraison KPIs disponible
- Pilote tenant : 1 banque TN, 500 webhooks/jour, ≥ 99 % success rate
- Documentation tenant (developer portal) publiée
12. Références
Section intitulée « 12. Références »- ADR-032
- POC poc-webhook-emitter — implémentation Kotlin
- Convention industrie : Stripe webhooks signature, GitHub webhooks
- Standards : RFC 8785 JSON Canonicalization, HMAC RFC 2104
- ADRs liés : ADR-002 RLS multi-tenant, ADR-031 outbox pattern AML