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 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

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.
checkout.successAn order is settled. This is what a shop fulfils on.
checkout.failedAn 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:

chainnetworkwhat the leg carries
bitcoinlightninginvoice
anyonchainaddress + hash
absentabsenta 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.

  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