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 export — combine 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.