In the Cativa dashboard, open Console > Webhooks and click Add listener. The flow asks for 3 things:
URL — your app’s public endpoint that will receive the POST (e.g. https://myapp.com/webhooks/cativa).
Events — which event names you want to listen to (snake_case — e.g. user_received_badge, paywall_payment_completed).
Secret — generated automatically. After you create the listener, open it and click Reveal secret to copy the value (format whsec_ + 64 hex characters). Only the tenant admin can reveal the secret — store it safely in your credentials vault.
Each listener has its own secret. If you register two listeners (e.g. one for staging and one for production), each signs deliveries with its own key.
After that, every event matching the listener’s rules is delivered via POST with Content-Type: application/json, X-Cativa-Signature, X-Cativa-Execution-Id and X-Cativa-Automation-Id.
v1 — HMAC-SHA256 (hex) over the string "<t>.<rawBody>", using the listener’s secret as the key.
To validate, do three steps:
Parse the header into t and v1.
Anti-replay: reject the request if |now - t| > 300 seconds (5 minutes is the industry standard). This prevents replay attacks with old captured requests.
Recompute the HMAC and compare in constant time.
Always compute the HMAC over the raw body (the exact string received in the request), never over re-serialized JSON. Re-serializing changes whitespace and key order, which invalidates the signature.
using System.Globalization;using System.Security.Cryptography;using System.Text;app.MapPost("/webhooks/cativa", async (HttpContext ctx) =>{ var secret = Encoding.UTF8.GetBytes( Environment.GetEnvironmentVariable("CATIVA_WEBHOOK_SECRET")!); // whsec_... using var ms = new MemoryStream(); await ctx.Request.Body.CopyToAsync(ms); var rawBody = ms.ToArray(); var header = ctx.Request.Headers["X-Cativa-Signature"].ToString(); long ts = 0; string? sig = null; foreach (var part in header.Split(',')) { var kv = part.Split('=', 2); if (kv.Length != 2) continue; if (kv[0] == "t") long.TryParse(kv[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out ts); else if (kv[0] == "v1") sig = kv[1]; } if (ts == 0 || sig is null) return Results.BadRequest("bad signature header"); var nowUnix = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); if (Math.Abs(nowUnix - ts) > 300) return Results.BadRequest("stale timestamp"); using var hmac = new HMACSHA256(secret); var data = Encoding.UTF8.GetBytes($"{ts}.").Concat(rawBody).ToArray(); var expected = Convert.ToHexString(hmac.ComputeHash(data)).ToLowerInvariant(); var expectedBytes = Encoding.UTF8.GetBytes(expected); var sigBytes = Encoding.UTF8.GetBytes(sig); if (expectedBytes.Length != sigBytes.Length || !CryptographicOperations.FixedTimeEquals(expectedBytes, sigBytes)) return Results.Unauthorized(); // ... process the event using rawBody return Results.Ok("ok");});
If your endpoint doesn’t reply with 2xx, Cativa retries on this curve:
30s → 5min → 30min → 2h → 6h → 24h
That’s 6 retries after the initial attempt — 7 deliveries total, covering roughly 33 hours. Each retry carries the same X-Cativa-Execution-Id (stable across retries of the same event) — use it as your idempotency key.Cativa honors the Retry-After header you return (capped at the next backoff window’s max).
The logic: status in the 4xx range (except 408 and 429) means “the request is wrong and retrying won’t help” — typically a client bug, deactivated URL or wrong auth. Status 5xx, 408, 429 and transport errors mean “try again later”.
If the 7th attempt also fails, the delivery is recorded internally as failed. v1 does not yet ship a Console UI to inspect failed deliveries — monitor your endpoint’s uptime on your own side, and if you suspect missed events, open a ticket at dev@cativa.digital and the team investigates internally.
Delivery is at-least-once. The same event can arrive more than once (retries after a timeout, connection loss while replying, etc.) — you have to detect duplicates on your side.The canonical key is the X-Cativa-Execution-Id header: it’s generated once per event and stays the same across all retries. Save that ID in the same transaction as your business logic: