VIZ.cx
← Learn

React to events with webhooks

10 minintermediate

A live WebSocket is great for a dashboard, but a poor fit for a serverless function or a backend that shouldn't hold an open socket. Webhooks flip it around: viz.cx watches the chain for you and POSTs matching operations to your URL — signed, with retries.

The delivery payload is identical to the live stream — { op_id, timestamp, op_type, body } — so code that handles one handles both.

Step 1 — Install the SDK

npm install @viz-cx/api @viz-cx/core

Step 2 — Register a webhook

Registration is authenticated by signature challenge: you sign a server-issued nonce with your regular key. The API returns an id and a secret — the secret is shown only once and is used to verify every delivery.

import { createApiClient } from '@viz-cx/api'
import { keys, wif } from '@viz-cx/core'

// Registering a webhook is an authenticated call. A Signer proves you
// control the account by signing a server-issued nonce with your key.
const signer = {
  account: 'alice',
  sign: (bytes: Uint8Array) => keys.sign(bytes, wif(process.env.VIZ_REGULAR_KEY!)),
}

const api = createApiClient({ auth: signer })

const { id, secret } = await api.webhooks.create({
  url: 'https://myapp.example/hooks/viz',
  filter: { op_type: 'transfer', account: 'alice' },  // both fields optional
})

// The secret is shown ONCE. Store it — you'll need it to verify deliveries.
console.log('Webhook id:', id)
console.log('Signing secret:', secret)

Step 3 — Receive and verify deliveries

Every POST carries an X-Viz-Signature: sha256=<hex> header — an HMAC-SHA256 of the raw body keyed by your secret. Verify it against the raw bytes before trusting anything, then acknowledge quickly.

import express from 'express'
import crypto from 'node:crypto'

const SECRET = process.env.VIZ_WEBHOOK_SECRET!
const app = express()

// Verify against the RAW body — parsing first would change the bytes.
app.post('/hooks/viz', express.raw({ type: 'application/json' }), (req, res) => {
  const got = req.header('X-Viz-Signature') ?? ''
  const want = 'sha256=' + crypto.createHmac('sha256', SECRET).update(req.body).digest('hex')
  if (got.length !== want.length ||
      !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want))) {
    return res.status(401).end()
  }

  // Same shape as the live stream: { op_id, timestamp, op_type, body }
  const { op_type, body, timestamp } = JSON.parse(req.body.toString())
  console.log(timestamp, op_type, body)

  res.status(200).end()   // ack fast — anything under 500 counts as delivered
})

app.listen(3000)

Delivery retries three times with backoff on any 5xx or network error, then drops that event — so respond fast and keep your handler idempotent (dedupe on op_id).

Manage your webhooks

// List your webhooks (returns rows without the secret)
const hooks = await api.webhooks.list()

// Remove one when you're done
await api.webhooks.delete(id)

Narrow deliveries at registration with the filter — by op_type, by account, or both — so you only get the ops you actually care about.