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 business. 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. |
checkout.success | An order is settled. This is what a shop fulfils on. |
checkout.failed | An order will not be paid. data.failure_code says why. |
The first five describe a movement - money changing hands. The checkout pair describes
an order, which is not the same thing: one order can take two payments and a top-up, and
a shop must ship exactly once. A checkout payment fires both: one deposit.success per
payment that arrives, and one checkout.success when the order is settled. To fulfil
orders, act on checkout.success. See Checkout.
Payload
{
"event": "deposit.success",
"event_id": "evt_4641ef04b54e4403bfc7338e5ecd239f",
"created_at": "2026-09-24T15:09:13Z",
"data": {
"id": "3a452aa4-88bd-4ba5-9e10-0c358c237036",
"reference": "DEP_S5KBV3QVC3IPV4VT",
"amount": "0.00000400",
"amount_minor": "400",
"fee": "0.00000000",
"fee_minor": "0",
"currency": "BTC",
"chain": "bitcoin",
"network": "lightning",
"invoice": "lnbc4u1p4t2084pp5..."
}
}amount is the decimal string you display. amount_minor is the integer you do
arithmetic on. Both are rendered from the same ledger value, so they cannot disagree.
Every amount is a string, always with the asset's full number of decimal places and
never trimmed - "0.00000400", not "0.000004". So the amount declares its own scale: count
the places. There is no decimals field beside it, and you do not need one, because every
asset has at least six places (BTC 8, USDT and USDC 6) and the point is always there.
A fiat payout is the exception, and it does carry settlement_decimals. Some currencies have
no decimal places at all - settlement_amount: "31875" in UGX is 31,875 shillings, and looks
identical to its minor-unit form - so on that leg read the field rather than counting.
fee is deducted from amount, never added. What reached your balance is
amount - fee; there is no net field, because that subtraction is yours to do and
publishing it would have been one more number to keep in agreement.
null is never sent. A field that does not apply is omitted, so switch on network:
chain | network | what the leg carries |
|---|---|---|
bitcoin | lightning | invoice |
| any | onchain | address + hash |
| absent | absent | a fiat payout: settlement_* + destination |
A Lightning row carries no payment_hash. The bolt11 in invoice already holds the
payment hash in its p field - decode it if you need it.
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.