Aller au contenu

Webhooks signés — spec engineering

Module : webhook-svc (microservice JVM Kotlin + outbox Postgres + sweeper HTTP).

ADRs : ADR-002, ADR-032.

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.



POST /webhooks/vitakyc HTTP/1.1
Host: api.banque-x.tn
Content-Type: application/json
X-VitaKYC-Event-Id: evt_8a7f3c1e9d4b2a6f
X-VitaKYC-Event-Type: case.decided
X-VitaKYC-Tenant-Id: TN-BANQUEX
X-VitaKYC-Timestamp: 1745000000
X-VitaKYC-Delivery-Attempt: 1
X-VitaKYC-Idempotency-Key: idem_8a7f3c1e9d4b2a6f
X-VitaKYC-Signature: t=1745000000,v1=5e2c1f9e8a3d7b6c4f2e1a9d8c7b6a5e4f3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b
Content-Length: 412
{"case_id":"case_4127","decision":"APPROVED","decided_by":"agent_amine","confirmed_by":"agent_leila","decision_at":"2026-04-27T11:42:00Z"}
Code HTTPComportement VitaKYC
2xx (200, 201, 202, 204)succès, marqué DELIVERED
3xx redirectsuit jusqu’à 3 redirects max
4xx (sauf 408, 429)échec définitif, marqué FAILED après 1 tentative (mauvaise URL)
408 Request Timeoutretry
429 Too Many Requestsrespecte Retry-After header, backoff additionnel
5xxretry exponentiel
timeout 30 sretry
HeaderDescriptionExemple
X-VitaKYC-Event-IdUUID unique par eventevt_8a7f3c1e9d4b2a6f
X-VitaKYC-Event-Typetype d’event (cf catalog)case.decided
X-VitaKYC-Tenant-Idtenant émetteurTN-BANQUEX
X-VitaKYC-Timestampepoch seconds UTC1745000000
X-VitaKYC-Delivery-Attemptnuméro de tentative (1 = première)1
X-VitaKYC-Idempotency-Keydedup côté tenantidem_8a7f3c1e9d4b2a6f
X-VitaKYC-Signaturet=<ts>,v1=<hmac>voir §3

X-VitaKYC-Signature: t=<unix_timestamp>,v1=<hmac_hex>
val timestamp = (now.epochSecond).toString()
val canonicalBody = canonicalJson(payload)
val signedPayload = "$timestamp.$canonicalBody"
val secret = vault.readTenantWebhookSecret(tenantId) // 32+ bytes random
val hmac = hmacSha256(secret, signedPayload).toHex()
val header = "t=$timestamp,v1=$hmac"
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 };
}
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 True
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());
}
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']);
}

TentativeDélai après tentative précédenteCumul depuis 1ère tentative
1 (initial)00
21 s1 s
35 s6 s
430 s36 s
52 min2 min 36 s
610 min12 min 36 s
71 h1 h 12 min 36 s
86 h7 h 12 min 36 s
(FAILED)après 24 h depuis tentative 1webhook abandonné

Total : 8 tentatives sur 24 h. Couvre la majorité des incidents tenant (médiane 4 h résolution observée).

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, marque RATE_LIMITED au lieu de RETRYING
TriggerAction
3 échecs consécutifsemail DSI tenant + Slack interne SRE
Webhook FAILED après 24 hemail DSI tenant + ticket support auto
50+ FAILED en 1 h pour un tenantalerte critique SRE — probable panne URL tenant

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);
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)
}
}

MéthodeEndpointDescription
GET/v1/webhooks/configURLs + event types configurés
PUT/v1/webhooks/configmet à jour (admin)
POST/v1/webhooks/secret/rotategénère nouveau secret, garde l’ancien actif 24 h
GET/v1/webhooks/secret/currentrévèle le secret actuel (1 fois, modal masqué)
POST/v1/webhooks/testenvoie un faux event vers l’URL configurée
GET/v1/webhooks/deliveries?status=&from=&page=liste deliveries
GET/v1/webhooks/deliveries/:iddétail (payload + signature + response)
POST/v1/webhooks/deliveries/:id/replayrejoue manuellement
GET/v1/webhooks/kpis?period=last_7ddashboard livraison

Event TypeÉmis parSchémaUse case tenant
form.publishedform-designer-svc{tenantId, formId, version, hash, addedFields[], removedFields[]}sync configuration côté tenant si form Designer chez lui
bio.verdict.publishedbio-svc{verificationId, verdict, scoreConsolidated, subscores}onboarding callback côté banque
risk.evaluation.publishedrisk-matrix-svc{clientId, level, score, dimensions, policyVersion}mise à jour CDD level côté core banking
sanctions.screening.completedsanctions-svc{screeningId, decision, hits[], listVersion}freeze account si MATCH_DIRECT_SANCTIONS
case.decidedcase-mgmt-svc{caseId, decision, decidedBy, confirmedBy, decisionAt}unblock workflow chez banque
aml.alert.publishedtx-monitoring-svc{alertId, ruleId, accountId, severity, triggeringTxIds, aggregateValue}dashboard fraude banque

  • 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_outbox chiffré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).

MetricMVP cibleV2 cible
Latence p95 (queue → delivered)≤ 3 s≤ 1 s
Throughput≥ 1 000 webhooks/min≥ 10 000
Disponibilité webhook-svc99,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 %

┌────────────────────────────────────────────────────────┐
│ 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.)


  • webhook-svc dé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