Webhooks
How Gyvar tells you money landed, and how to verify that it was us.
Gyvar posts a signed JSON body to your endpoint when something happens to money in your project. Deliveries are emitted from a transactional outbox, so an event is recorded in the same transaction as the ledger write that caused it - we do not lose events because a delivery attempt failed.
Events
| Event | Meaning |
|---|---|
deposit.success | Funds arrived and were credited to your balance. |
payout.success | An outbound payment settled. |
payout.failed | An outbound payment failed terminally. |
transfer.success | An internal transfer completed. |
transfer.failed | An internal transfer failed. |
Payload
{
"event": "deposit.success",
"event_id": "evt_...",
"created_at": "2026-09-01T10:04:11Z",
"data": {
"amount": "25.00",
"amount_minor": "25000000",
"rail": "base"
}
}amount is the decimal string you display. amount_minor is the integer you do
arithmetic on.
Verifying a delivery
Every request carries X-Gyvar-Signature:
X-Gyvar-Signature: t=1788281646,v1=5f2a...The signed string is the timestamp, a literal dot, then the raw request body:
<t>.<body>HMAC-SHA256, hex encoded. The secret is used as the key verbatim, including its
whsec_ prefix - there is no decoding step to get wrong.
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verify(secret, header, rawBody, now = Date.now()) {
const parts = Object.fromEntries(
header.split(',').map((p) => {
const i = p.indexOf('=')
return [p.slice(0, i), p.slice(i + 1)]
}),
)
// Reject stale deliveries. The timestamp is inside the signed material, so a
// replayer cannot advance it to slip past this check.
if (Math.abs(now / 1000 - Number(parts.t)) > 300) return false
const expected = createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex')
// One v1= per valid secret; during a rotation there are two.
return header
.split(',')
.filter((p) => p.startsWith('v1='))
.some((p) => {
const got = Buffer.from(p.slice(3), 'hex')
const want = Buffer.from(expected, 'hex')
return got.length === want.length && timingSafeEqual(got, want)
})
}Verify against the raw body
Sign the bytes you received, before any JSON parse or re-serialize. Round-tripping through a parser reorders keys and changes whitespace, and the signature will not match.
Deduplicate on event_id from the body
We also send X-Gyvar-Event-Id, X-Gyvar-Event and X-Gyvar-Attempt. These are
conveniences for your logs and are not authenticated. The HMAC covers only the
timestamp and the body, so any intermediary past TLS termination can rewrite those
headers without invalidating the signature.
A receiver that deduplicates on the header is defeatable by exactly the party the
signature exists to defend against. Deduplicate on event_id inside the verified
body.
Retries
A delivery is retried with backoff until it is acknowledged. Return 2xx promptly -
acknowledge first, then do your work asynchronously. A slow handler becomes a retried
handler, and a retried handler is why you need the dedup above.
Expect at-least-once delivery, and expect events out of order.
Rotating a secret
Register the new secret, and for the overlap both the current and previous secret sign
each delivery - one v1= component per secret. A receiver holding either one matches, so
rotation is a three-step deploy with no cutover instant rather than a swap that breaks
whoever redeploys second.
- Add the new secret. Both now sign.
- Deploy your receiver with the new secret.
- Revoke the old one.
Clock skew
Five minutes, matching the request signing window deliberately - having already implemented that scheme, you should not have to learn a second number for this one.