The most requested webhook on Ethereum is also the simplest to describe: tell my backend when this token moves, or when this wallet receives funds. Exchange deposit flows, payment confirmations, treasury alerts and airdrop trackers are all variations of that one sentence. Search for an ERC-20 transfer webhook and you will mostly find hosted products that charge per delivered event, but underneath every one of them sits a primitive small enough to read in one sitting: an eth_subscribe log filter on one side and an HTTP POST on the other.

This post builds the whole thing in exactly 50 lines of Node.js with ethers.js v6. The script subscribes to Transfer events over a WebSocket connection to an Ethereum node, filters by token contract and optionally by recipient, decodes each log into a clean JSON payload, waits out reorgs behind a confirmation buffer, signs the payload with an HMAC and delivers it with exponential-backoff retries. After the listing we walk through the parts that separate a demo from something you can trust: reorgs, disconnects, duplicate delivery, and why polling eth_getLogs on a timer is the worse version of this design.

If you want the wider map first, hosted webhook providers versus DIY, Beacon API event streams, mempool alerts, our Ethereum webhooks guide covers the whole landscape. And if you need a config-driven service that fans many filters out to many URLs, we built that in an earlier tutorial. This one is deliberately narrower: one token, one endpoint, and the production trimmings the minimal examples usually skip.

The pipeline

Every stage of the script maps to one box below. The node pushes matching logs over a local WebSocket subscription, the script decodes them, holds each one for N blocks in case the chain reorganizes underneath it, then signs and POSTs the survivors. A log that gets reorged out inside the window is dropped silently; your backend never hears about a transfer that no longer exists.

ERC-20 transfer webhook pipeline: the node pushes matching logs via eth_subscribe, the script decodes them with parseLog, holds each one for N confirmations with a canonical-hash check, then signs the payload with HMAC-SHA256 and POSTs it with retries; logs reorged out inside the window are dropped and never fired

The complete script

Here is the entire service, all 50 lines of it. It needs Node 18 or newer for the built-in fetch, npm install ethers for the client, and "type": "module" in your package.json.

// transfer-webhook.js: ERC-20 Transfer webhooks in 50 lines (ethers v6, Node 18+)
import { WebSocketProvider, Interface, zeroPadValue } from "ethers";
import { createHmac } from "node:crypto";

const RPC_URL = process.env.RPC_URL || "ws://eth:8545"; // the co-located node on BLAZED.sh
const HOOK_URL = process.env.HOOK_URL || "http://localhost:8080/hooks/transfers";
const SECRET = process.env.HOOK_SECRET || "change-me";
const TOKEN = process.env.TOKEN || "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; // USDC
const TO = process.env.TO ? zeroPadValue(process.env.TO, 32) : null; // optional recipient filter
const CONFIRMATIONS = Number(process.env.CONFIRMATIONS || 3);

const iface = new Interface(["event Transfer(address indexed from, address indexed to, uint256 value)"]);
const provider = new WebSocketProvider(RPC_URL);
const pending = new Map(); // idempotency key -> payload, waiting out the confirmation window

provider.on({ address: TOKEN, topics: [iface.getEvent("Transfer").topicHash, null, TO] }, (log) => {
  const key = `${log.blockHash}:${log.transactionHash}:${log.index}`;
  if (log.removed) return pending.delete(key); // reorged out before we fired
  const { args } = iface.parseLog(log);
  pending.set(key, {
    id: key, token: log.address, from: args.from, to: args.to,
    value: args.value.toString(), // raw base units, scale by the token's decimals downstream
    blockNumber: log.blockNumber, blockHash: log.blockHash, txHash: log.transactionHash, logIndex: log.index,
  });
});

provider.on("block", async (head) => {
  for (const [key, event] of pending) {
    if (head - event.blockNumber < CONFIRMATIONS) continue;
    pending.delete(key);
    const block = await provider.getBlock(event.blockNumber);
    if (block?.hash !== event.blockHash) continue; // block was reorged out, never fire
    deliver({ ...event, confirmations: head - event.blockNumber });
  }
});

async function deliver(payload, attempt = 1) {
  const body = JSON.stringify(payload);
  const sig = createHmac("sha256", SECRET).update(body).digest("hex");
  try {
    const res = await fetch(HOOK_URL, {
      method: "POST", body,
      headers: { "Content-Type": "application/json", "X-Idempotency-Key": payload.id, "X-Signature": `sha256=${sig}` },
    });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
  } catch (err) {
    if (attempt >= 5) return console.error(`giving up on ${payload.id}: ${err.message}`);
    setTimeout(() => deliver(payload, attempt + 1), 2 ** attempt * 1000); // 2s, 4s, 8s, 16s
  }
}

Run it locally by pointing RPC_URL at any WebSocket endpoint; subscriptions need a persistent connection, so a plain HTTP URL will not work here. On BLAZED.sh you leave the default alone: ws://eth:8545 is the fully synced mainnet node running on the same host as your code. IPC is the maximum-performance local transport in general and remains a planned BLAZED.sh direction; today the supported product endpoint is that local WebSocket.

Filtering: token, recipient, or both

The filter object on line 16 is where the node does the heavy lifting. Its first topic is the event signature, the keccak256 hash of Transfer(address,address,uint256), which ethers computes for us via topicHash instead of us pasting the magic 0xddf252ad... constant. The second topic is the sender and the third is the recipient, because both are declared indexed in the event. Passing null in a position matches anything, and passing an address zero-padded to 32 bytes pins that position. So [TRANSFER, null, TO] means any sender, one specific recipient, and swapping the positions to [TRANSFER, FROM, null] watches outgoing transfers instead. Set the TO env var to a wallet address and the node only wakes your process for transfers into that wallet; leave it unset and you get every transfer of the token.

One caveat before you widen the net: if you drop the address pin to watch a wallet across all tokens, ERC-721 transfers match the same signature but carry four topics because the token ID is indexed too, and parseLog against the ERC-20 fragment will not decode them. Guard on log.topics.length === 3 in that case. With the token contract pinned, as in the listing, the problem never comes up.

Reorgs: why the script waits N blocks

A log delivered at the chain head is a statement about the current best block, not a fact. Small reorganizations happen routinely on mainnet, and when a block is orphaned every log it contained is retroactively gone. Subscriptions signal this by re-emitting affected logs with removed set to true, which is why line 18 evicts them from the buffer. But relying on the removed signal alone is fragile: if your process misses it, you have already told your backend about a transfer that never happened, and clawing back a webhook is much harder than delaying one.

The script therefore never fires on first sight. Each decoded event goes into the pending map, and only after CONFIRMATIONS new blocks does the block handler release it, with one extra safety: it re-reads the block at that height and checks the hash still matches the one the log arrived with. If the chain moved sideways in the meantime, the entry is dropped and the POST never happens. That read is a single eth_getBlockByNumber per delivered event, which costs 1 credit on BLAZED.sh and a few milliseconds on the local socket.

Choosing N is a policy question, not a technical one. Three confirmations, about 36 seconds, hides the vast majority of reorgs and suits alerting and dashboards. For money-moving decisions, exchanges typically wait longer, and if you need certainty rather than probability you wait for finality, roughly two epochs or about 13 minutes, at which point the block cannot be reorged without a third of the validator set getting slashed. The script makes the window an env var precisely so the same 50 lines serve both moods.

Signing and idempotency

Retries plus confirmations give you at-least-once delivery, and at-least-once always means duplicates eventually. The script attaches two headers to make that safe for the receiver. X-Idempotency-Key carries blockHash:transactionHash:logIndex, the canonical identity of a log on Ethereum; the same transfer redelivered after a timeout carries the same key, while the same transfer re-included in a different block after a reorg gets a new key, which is exactly what you want. Receivers keep a small table of processed keys and drop repeats.

X-Signature is an HMAC-SHA256 of the exact request body under a shared secret, so your endpoint can reject forged posts from anyone who discovers the URL. Verification on the receiving side is a few lines, with the usual caveats that you must hash the raw body bytes before any JSON re-serialization and compare in constant time:

import { createHmac, timingSafeEqual } from "node:crypto";

function verifySignature(rawBody, signatureHeader, secret) {
  const expected = `sha256=${createHmac("sha256", secret).update(rawBody).digest("hex")}`;
  return signatureHeader.length === expected.length &&
    timingSafeEqual(Buffer.from(signatureHeader), Buffer.from(expected));
}

Disconnects: backfill on reconnect

WebSockets drop. Over the public internet they drop often enough that resume logic is the first thing hosted-RPC users build; on a local socket to a node on the same machine it is rare, but your own process still restarts on deploys and crashes. Any gap between the socket closing and the subscription being re-established is a window where transfers happened and nobody was listening, and a webhook consumer notices missing deposits very quickly.

Timeline of a webhook service disconnect: blocks 100 to 104 are covered by the live eth_subscribe stream, blocks 105 to 107 pass while the socket is down and contain a missed transfer log, and after reconnecting an eth_getLogs backfill spans from the last delivered block minus the confirmation window up to the head, re-emitting the overlap safely because idempotency keys deduplicate downstream

The fix is a catch-up query on every (re)start. Persist a cursor, the highest block you have delivered from, somewhere durable, then lift the subscription callback in the listing into a named handleLog function and reuse it for historical logs:

const TRANSFER = iface.getEvent("Transfer").topicHash;

async function backfill(fromBlock) {
  const head = await provider.getBlockNumber();
  const logs = await provider.getLogs({
    address: TOKEN, topics: [TRANSFER, null, TO],
    fromBlock, toBlock: head, // cursor minus the confirmation window, up to head
  });
  for (const log of logs) handleLog(log); // same path as the live subscription
}

Start the backfill a few blocks before the cursor, at least CONFIRMATIONS back, so events that were sitting unconfirmed in the buffer when the process died get picked up again. The overlap re-emits logs you already delivered, and that is fine: they carry identical idempotency keys, so the receiver’s dedupe table absorbs them. For long outages remember that eth_getLogs has block-range and result caps on every provider, 1,000 blocks per request on BLAZED.sh; our eth_getLogs block range guide covers the adaptive chunking that makes arbitrarily large gaps safe to walk.

Why polling is the worse version of this

The alternative architecture you will see in older tutorials is a timer: every 15 seconds, call eth_getLogs for the range since the last poll and POST whatever comes back. It works, and it is the right fallback when all you have is an HTTP endpoint, but it loses on every axis that made you want webhooks in the first place.

Latency first: with a 15-second interval you learn about a transfer 7.5 seconds late on average and 15 seconds late in the worst case, on a chain that produces a block every 12. A push subscription hands you the log as soon as the node indexes the block. Cost second: every poll is a real RPC request even when it returns nothing, and at a 15-second cadence that is 5,760 requests a day of mostly empty responses. On per-request credit accounting that is real spend purchasing zero information, whereas a subscription is one open stream that only carries data when there is data. And reliability third: polling does not exempt you from any of the machinery above, since reorgs, cursors and duplicate handling apply identically; you take on all the same edge cases and get slower delivery for it. The transport trade-offs behind this, and why a persistent socket beats request-response for event streams generally, are covered in IPC vs HTTP vs WebSocket.

Running it on the node

Everything above runs against any Ethereum WebSocket endpoint. What changes on BLAZED.sh is where the script sits: it deploys onto the same host as a fully synced Ethereum mainnet node and subscribes over the local socket, so the hop from “node indexed the block” to “your handler is running” is sub-10ms, and there is no shared gateway between you and the node imposing rate limits on the stream. Ethereum mainnet is the chain live in production today. The fastest way to try the script is the browser editor at panel.blazed.sh: scripts come with ethers.js pre-installed, so you paste the listing, set HOOK_URL and HOOK_SECRET, and run. For the bigger config-driven variant with a Dockerfile, the earlier webhook tutorial walks through the container route.

The cost profile suits the workload. Your subscription grants a monthly credit balance under per-request accounting; the service holds one eth_subscribe stream, spends 1 credit on the confirmation-check read per delivered event, and the outbound webhook POSTs are plain HTTP that draw no credits at all. A transfer-webhook service is close to the cheapest thing you can run on the platform, because almost all of its traffic is the node pushing to you rather than you asking the node.

Wrapping up

Fifty lines is genuinely enough for a production-shaped ERC-20 transfer webhook once the node is close and the failure modes are handled deliberately: a topic filter so the node does the matching, a confirmation buffer with a canonical-hash check so reorgs never reach your backend, an HMAC and an idempotency key so delivery is verifiable and repeatable, and a backfill path so restarts do not swallow events. For the surrounding landscape, from hosted providers to Beacon API streams and mempool alerts, start at our Ethereum webhooks guide.