Gyvar docs

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

EventMeaning
deposit.successFunds arrived and were credited to your balance.
payout.successAn outbound payment settled.
payout.failedAn outbound payment failed terminally.
transfer.successAn internal transfer completed.
transfer.failedAn 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.

  1. Add the new secret. Both now sign.
  2. Deploy your receiver with the new secret.
  3. 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.

On this page