Webhooks
Events pushed to you, signed so you can prove they are ours.
Events
Add an endpoint in the dashboard and choose what it wants: request.completed for every request, spend.alert when a threshold is crossed, and report.ready for a scheduled summary as structured data.
The signing secret is shown once when you create the endpoint and never again. Replace the endpoint to rotate it.
Verifying a delivery
// x-vatan-signature: t=1700000000,v1=<hex> // signature = HMAC-SHA256(secret, "<timestamp>.<raw body>") const [t, v1] = header.split(",").map((p) => p.split("=")[1]); // Check the age FIRST. The signature on a captured delivery stays valid for // ever, so verifying the HMAC alone accepts a replay of last week's event. if (Math.abs(Date.now() / 1000 - Number(t)) > 300) throw new Error("too old"); const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"); if (!timingSafeEqual(Buffer.from(expected), Buffer.from(v1))) throw new Error("not from Vatan");
Sign the raw bytes you received rather than a re-serialised object. Key order and whitespace change, and every verification then fails for a reason that takes an afternoon to find.
The timestamp is inside the signed payload rather than merely alongside it, which is what makes rejecting an old delivery possible: it cannot be edited without breaking the signature. The rejecting is yours to do. An HMAC stays valid for ever, so checking it alone accepts a replay of last week's event quite happily, which is why the check above compares the age before it compares anything else. Five minutes is the usual window. Reject anything more than five minutes old.
Retries and the log
Answer with any 2xx. Anything else is retried six times over about an hour, backing off each time (10s, 30s, 2m, 5m, 15m, 30m), then marked failed. A 4xx is retried too, because in practice that usually means a deploy in progress rather than a permanently wrong request.
The delivery is kept with the number of attempts made and the status, response code and error from the most recent one. Not a row per attempt: what people need is which deliveries are failing and what the endpoint said, and six rows saying the same thing about the same event answers that no better. A webhook that stops silently is the classic failure of every system that has them, and "it is not firing" cannot be answered from logs that have rotated.