Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Recipe — Webhook server for state changes · Boson Protocol
Skip to content

Recipe: Webhook server for state changes

Listen to the Diamond's events on your target chains, dedupe across reorgs, and POST to your fulfillment system.

import express from "express"
import { ethers } from "ethers"
// There is no ProtocolDiamondABI exportcombine per-facet ABIs from
// @bosonprotocol/common/dist/cjs/abis/*.json. Use getConfigFromConfigId, not
// getDefaultConfig.
import { getConfigFromConfigId } from "@bosonprotocol/common"
import offerAbi from "@bosonprotocol/common/dist/cjs/abis/IBosonOfferHandler.json"
import exchangeAbi from "@bosonprotocol/common/dist/cjs/abis/IBosonExchangeHandler.json"
import disputeAbi from "@bosonprotocol/common/dist/cjs/abis/IBosonDisputeHandler.json"
import { Pool } from "pg"
 
const db = new Pool({ connectionString: process.env.DATABASE_URL })
const config = getConfigFromConfigId("production-137-0")
const provider = new ethers.providers.JsonRpcProvider(process.env.RPC_URL)
const diamond = new ethers.Contract(
  config.contracts.protocolDiamond,
  [...offerAbi, ...exchangeAbi, ...disputeAbi],
  provider,
)
 
const CONFIRMATIONS = 3
 
async function processEvent(evt: ethers.Event) {
  const dedupeKey = `${evt.transactionHash}:${evt.logIndex}`
  const { rowCount } = await db.query(
    "INSERT INTO processed_events (key) VALUES ($1) ON CONFLICT DO NOTHING",
    [dedupeKey],
  )
  if (rowCount === 0) return // already processed
 
  // Wait for confirmations
  const confirmations = await provider.getTransactionReceipt(evt.transactionHash)
    .then(r => provider.getBlockNumber().then(head => head - r.blockNumber + 1))
  if (confirmations < CONFIRMATIONS) {
    await db.query("DELETE FROM processed_events WHERE key=$1", [dedupeKey])
    return // try again on next pass
  }
 
  // Forward to your backend
  await fetch(process.env.BACKEND_WEBHOOK_URL!, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      event: evt.event,
      args: evt.args,
      txHash: evt.transactionHash,
      logIndex: evt.logIndex,
      blockNumber: evt.blockNumber,
    }),
  })
}
 
for (const event of ["BuyerCommitted", "VoucherRedeemed", "ExchangeCompleted", "DisputeRaised"]) {
  diamond.on(event, (...args) => processEvent(args.at(-1)))
}
 
// Health endpoint
const app = express()
app.get("/health", (_, res) => res.json({ ok: true }))
app.listen(3000)

Schema

CREATE TABLE processed_events (
  key TEXT PRIMARY KEY,
  processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

Catch-up on restart

For a long-running listener, use a last_block_number checkpoint:

const last = await db.query("SELECT last_block FROM listener_state")
const events = await diamond.queryFilter("*", last + 1, "latest")
for (const evt of events) await processEvent(evt)

Production hardening

  • Alert on subgraph staleness alongside this — your event listener might miss events under RPC instability; the subgraph is the fallback.
  • HMAC-sign the POST body before sending to your backend.
  • Retry with exponential backoff on backend failures.

Related