Aller au contenu

POC Webhook Emitter — HMAC + outbox + retry exponentiel

POC : poc-webhook-emitter/ (~600 lignes Kotlin pur, 0 dépendance externe runtime). Référence pour webhook-svc MVP.

Status : 18/18 tests passants. Démo CLI 8 cas (sign/verify/replay/retry/404/429/recover/24h-deadline).

Ce POC démontre les invariants critiques du module webhook signé en code exécutable :

  1. Signature HMAC-SHA256 déterministe (format Stripe t=<ts>,v1=<hex>)
  2. Vérification côté tenant avec replay protection 5 min + constant-time compare
  3. Outbox pattern in-memory mimant webhook_outbox Postgres
  4. Sweeper processant les entrées DUE selon retry policy
  5. Retry exponentiel 8 tentatives bornées sur 24 h
  6. Gestion HTTP : 2xx success / 4xx permanent (sauf 408/429) / 5xx retry / 429 avec Retry-After / timeout retry
  7. Final deadline : entry FAILED après 24 h sans success

poc-webhook-emitter/
├── build.gradle.kts // Kotlin 2.0, JUnit 5, AssertJ
├── src/main/kotlin/io/vitakyc/webhook/
│ ├── Model.kt // WebhookEvent, WebhookOutboxEntry, DeliveryAttempt, DeliveryStatus
│ ├── Signer.kt // HMAC-SHA256 sign() + verify() avec replay 5 min
│ ├── RetryPolicy.kt // calendrier exponentiel 1s/5s/30s/2m/10m/1h/6h/24h-final
│ ├── Outbox.kt // WebhookOutbox in-memory + queue + fetchDue + stats
│ ├── Sweeper.kt // HttpClient interface + sweep() avec gestion HTTP statuses
│ └── Main.kt // démo CLI 8 cas
└── src/test/kotlin/io/vitakyc/webhook/
└── WebhookTest.kt // 18 tests

TestVérifie
sign produces deterministic headerinvariant trivial mais critique — reproductibilité
verify accepts correct signature within toleranceround-trip valide
verify rejects wrong secretsécurité — mismatch HMAC
verify rejects altered bodysécurité — body tampering détecté
verify rejects out-of-tolerance timestampreplay > 5 min bloqué
verify rejects missing partsheader malformé bloqué
verify is case-sensitive on hex hmacconstant-time compare strict
TestVérifie
retry schedule respects exponential backoffdélais 1s, 5s, 30s, 2m, 10m, 1h, 6h
final deadline reached after 24habandon après 24 h
TestVérifie
queue then sweep — 200 OK marks DELIVERED in 1 attempthappy path
503 triggers RETRYING with next attempt scheduledretry transient
404 marks FAILED immediately (permanent client error)distinction permanent vs transient
408 timeout is retried (transient)exception 408 retry
429 with Retry-After respects headerrate-limit handling
8 attempts then FAILED if endpoint stays 503borne max attempts
503 then 200 on retry — final state DELIVEREDrecovery scenario
final deadline 24h — entry marked FAILEDabandon après deadline
outbox stats reflects all statusesdashboard fonctionnel
TestVérifie
roundtrip — emitter signs, tenant verifies same bodyl’émetteur signe, le tenant vérifie ; preuve d’interopérabilité

[1] Queue 3 events (case.decided / bio.verdict / aml.alert)
outbox stats: {PENDING=3}
[2] Sweep — first attempt
processed=3 stats={DELIVERED=3}
[3] Vérification HMAC côté tenant — succès
verify=Valid timestamp=1745000000
[4] Vérification HMAC — secret modifié → mismatch
verify=Invalid(reason=signature mismatch)
[5] Vérification HMAC — timestamp out of tolerance (1h trop tard)
verify=OutOfTolerance(ts=1745000000, now=1745003600)
[6] Endpoint failing 503 → retry exponentiel
attempt=1 status=RETRYING nextAt(+s)=1
attempt=2 status=RETRYING nextAt(+s)=5
attempt=3 status=RETRYING nextAt(+s)=30
attempt=4 status=RETRYING nextAt(+s)=120
attempt=5 status=RETRYING nextAt(+s)=600
attempt=6 status=RETRYING nextAt(+s)=3600
attempt=7 status=RETRYING nextAt(+s)=21600
attempt=8 status=FAILED nextAt(+s)=0
[7] Endpoint 404 → FAILED immédiat (client error permanent)
status=FAILED
[8] Endpoint 429 rate-limited → retry avec Retry-After 30s
status=RETRYING nextAt(+s)=30
─── Résumé final ───
DELIVERED : 3, FAILED : 2, RETRYING : 1

Élément POCProduction
WebhookOutbox (HashMap)Postgres webhook_outbox avec RLS multi-tenant + index sur next_attempt_at
Sweeper.sweep(now) synchroneCoroutine Kotlin polling 1s + batch 100 entries
HttpClient interfaceOkHttp ou Ktor avec timeout 30s, TLS 1.2+, mTLS opt-in
secret: String literalVault HSM read avec key path tenant/{tenantId}/webhook-secret
Signer.signidentique (même HMAC déterministe)
Signer.verify (côté tenant)code samples Node/Python/Java/PHP publiés (cf Webhooks spec §3)
RetryPolicyidentique bit-à-bit

Le re-test golden : un payload signé par le POC est vérifié OK par le code prod (et vice versa) — garanti par déterminisme HMAC.


  • Persistence Postgres + RLS multi-tenant + sweeper coroutine
  • HTTP réel (OkHttp/Ktor avec timeout, TLS, mTLS opt-in)
  • Vault lecture secret tenant + rotation
  • Notifications email/Slack sur 3 échecs ou FAILED final
  • Dashboard livraison UI tenant
  • Test endpoint /v1/webhooks/test pour valider conf tenant
  • Replay GUI admin tenant pour rejouer manuellement
  • Métriques Prometheus (throughput, latency, success rate, FAILED rate)
  • Idempotency cache côté tenant documentation pattern

Fenêtre de terminal
cd poc-webhook-emitter
./gradlew test # 18/18 tests verts
./gradlew run # démo CLI 8 cas

Dépendances : Kotlin 2.0.20, JVM 17, JUnit 5.11, AssertJ 3.26. Aucune dépendance runtime externe.