Skip to content

Smart Contract Webhook: Receive evmquery Monitor Alerts in TypeScript

Build a smart contract webhook receiver for evmquery monitors: payload shape, deduping on event id, digests, retries and forwarding to chat.

evmquery team6 min read
Try it: Contract InspectorProxy chain and full method schema on Ethereum, Base, BNB Smart Chain, Polygon. Free, no signup.

A webhook is the most flexible place for a monitor alert to land, because your own code decides what happens next: page someone, post to a chat room, open a ticket. This guide builds a small smart contract webhook receiver for evmquery Monitors and covers what the payload looks like, why you should dedupe, and how delivery behaves when your endpoint is slow or down.

TL;DR

A monitor POSTs JSON to your HTTPS URL. Respond with a 2xx within 10 seconds, dedupe on each event id because delivery is at-least-once, and treat the request as a notification you can confirm through the events API.

The payload

Every delivery is a POST with content-type: application/json. The body has the same shape whether it carries one event or a digest:

{
"version": 1,
"text": "USDC balance below 1,000: condition became true at block 21345678",
"monitor": {
"id": "3f8a1c52-0d4e-4b7a-9c1e-6a2d5b8e7f10",
"name": "USDC balance below 1,000",
"chainId": "evm_ethereum"
},
"events": [
{
"id": "b1c0a6de-7f31-4c1a-8d44-2e9f0a5c3b71",
"type": "triggered",
"createdAt": "2026-11-12T09:15:02.000Z",
"summary": "condition became true at block 21345678",
"payload": { "version": 1, "kind": "condition", "blockNumber": "21345678" }
}
],
"omitted": 0
}

The values above are illustrative. The fields:

  • version: the payload version, currently 1.
  • text: a ready-to-display summary, prefixed with the monitor name. Chat tools that read a top-level text field can render it as is.
  • monitor: id, name and chainId of the monitor that fired.
  • events: one or more events. type is triggered or paused, summary is a one-line description, and payload carries event-specific details.
  • omitted: how many further pending events were left out of this message and will follow later.

A change monitor summary reads like usdc.totalSupply() moved from 1000 to 1050 (+5.00%). Integers are exact onchain values, so divide by 10^decimals (1e6 for USDC) before display.

Digests and omitted events

If several events are waiting for the same channel, evmquery sends them as one digest of up to 50 events. The text lists the first 10 summaries and ends with and N more. The events array still carries every event in that message, and omitted counts any that did not fit and will arrive in a later message. Loop over events instead of assuming there is one.

Delivery rules

  • HTTPS only. The URL must be https, without credentials embedded in it.
  • Public address. The hostname must resolve to a public address. localhost and private network ranges are refused, so use a tunnel or a deployed endpoint while developing.
  • 10 second timeout. Respond within 10 seconds. Any non-2xx response counts as a failure, and redirects are not followed.
  • Retries with backoff. A failed channel backs off for 2 to the power of its consecutive failures, in minutes, capped at 6 hours. Pending deliveries expire after 24 hours.
  • At-least-once. The same event can arrive more than once. Dedupe on events[].id.

Channel failures never pause the monitor itself. The monitor keeps evaluating and recording events, and the web app shows the last failed sends.

A minimal receiver

This receiver uses only Node built-ins and runs directly on Node 22.18 or newer with node --env-file=.env receiver.ts.

import { createServer } from "node:http";
const SECRET_PATH = `/hooks/${process.env.HOOK_PATH_TOKEN}`;
const seen = new Set<string>();
type MonitorEvent = {
id: string;
type: "triggered" | "paused";
createdAt: string;
summary: string;
};
type Delivery = {
version: number;
text: string;
monitor: { id: string; name: string; chainId: string };
events: MonitorEvent[];
omitted: number;
};
async function handle(delivery: Delivery): Promise<void> {
const fresh = delivery.events.filter((e) => !seen.has(e.id));
if (fresh.length === 0) return;
const lines = fresh.map(
(e) =>
`${delivery.monitor.name} (${delivery.monitor.chainId}): ${e.summary}`,
);
await forward(lines.join("\n"));
fresh.forEach((e) => seen.add(e.id));
}
createServer((req, res) => {
if (req.method !== "POST" || req.url !== SECRET_PATH) {
res.writeHead(404).end();
return;
}
let body = "";
req.on("data", (chunk) => (body += chunk));
req.on("end", () => {
let delivery: Delivery;
try {
delivery = JSON.parse(body);
} catch {
res.writeHead(400).end();
return;
}
res.writeHead(200).end("ok");
handle(delivery).catch((err) => console.error("forward failed", err));
});
}).listen(3000);

Three choices are worth calling out. The path contains a random token, so only someone holding your monitor’s URL can reach the handler. The response goes out before the forwarding call, which keeps you well inside the 10 second timeout even when a chat API is slow. And the seen set drops events you have already handled. An in-memory set resets on restart, so in production keep processed event IDs in Redis or a database table with a unique constraint, ideally with a retention of a few days.

Generate the token with openssl rand -hex 24 and put it in .env as HOOK_PATH_TOKEN. Serve the receiver behind HTTPS, then use https://your-domain.example/hooks/<token> as the webhook URL.

Forward to Telegram, Discord or Slack

Forwarding is your receiver’s own code, so you pick the destination. For Telegram:

async function forward(text: string): Promise<void> {
const res = await fetch(
`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ chat_id: process.env.TELEGRAM_CHAT_ID, text }),
},
);
if (!res.ok) throw new Error(`Telegram returned ${res.status}`);
}

For a Discord incoming webhook, post { content: text } to the webhook URL. For a Slack incoming webhook, post { text }. The receiver is the only piece that changes, and the monitor does not care which one you pick. If you prefer a no-code route, the Safe alert in n8n shows the same idea with workflow nodes.

Confirm a notification through the API

A webhook tells you something happened. When you want to act on it, for example before moving funds, you can confirm it against evmquery with your API key:

curl -s "https://api.evmquery.com/api/v1/monitors/$MONITOR_ID/events?limit=5" \
-H "x-api-key: $EVMQUERY_API_KEY"

The response lists recent events with id, type, summary, createdAt and a delivery status. Matching the id from the webhook against this list is a simple way to check that the notification corresponds to a recorded event. Do the confirmation after you have already responded to the webhook, not inside the request.

Create the monitor with a webhook channel

You can attach the receiver in the web app, or when creating the monitor over REST:

curl -s -X POST https://api.evmquery.com/api/v1/monitors \
-H "content-type: application/json" \
-H "x-api-key: $EVMQUERY_API_KEY" \
-d '{
"name": "USDC balance below 1,000",
"chain": "evm_ethereum",
"schema": {
"contracts": { "token": { "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" } }
},
"expression": "token.balanceOf(\"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045\") < parseUnits(\"1000\", 6)",
"trigger": { "kind": "condition" },
"intervalSeconds": 900,
"channels": [{ "type": "webhook", "url": "https://your-domain.example/hooks/<token>" }]
}'

The response contains the monitor ID and a test run of the expression. A monitor can carry up to 5 channels, so you can add an email channel next to the webhook. The developers page lists the other REST routes, and the launch post has the Free plan limits.

Testing without waiting for a chain event

Point a condition monitor at something that is already true, such as a balance that is below a threshold you pick on purpose. Its first check fires immediately, and your receiver sees a real delivery. After the test, change or delete the monitor so it does not keep a slot.

Next steps

Share

Point a monitor at your own endpoint

Create a free account, add a webhook channel to a monitor and the payload below arrives at your receiver. The Free plan includes 3 active monitors.