What Is a Webhook? A Practical Guide (Plus a Real-World Bank Transfer Use Case)
Have you ever written something like this? js setInterval(async () => { const res = await fetch('https://api.example.com/orders/123/status'); const { status } = await res.json(); if (status === 'paid') { //
Have you ever written something like this?
js
setInterval(async () => {
const res = await fetch('https://api.example.com/orders/123/status');
const { status } = await res.json();
if (status === 'paid') {
// Finally!
}
}, 5000);
Every 5 seconds you ask the server: "Anything new?" And 99% of the time the answer is "Nope." It wastes requests, burns resources, and is still slow.
Webhooks exist to solve exactly this problem. In this post we'll cover:
What a webhook is and how it works
Webhooks vs. polling
Building a webhook endpoint in Node.js (with signature verification)
Best practices for reliable, secure webhooks
A very practical use case: getting near-instant bank balance notifications with PionPay
1. What is a webhook?
A webhook is an HTTP callback. When an event happens in system A, system A proactively sends an HTTP request (usually a POST) to a URL you provided, carrying data about that event.
In short: instead of you asking over and over, the other system tells you when something happens.
Real-life analogy: You order something online.
Polling = walking to the front door every 10 minutes to check if the courier has arrived.
Webhook = leaving your phone number so the courier calls you when they're outside.
Webhooks are often called a "reverse API" or "push API", because the direction is flipped: instead of the client calling the server, the server calls the client.
2. How do webhooks work?
A webhook flow has four steps:
- Register: You give the provider a public URL (an endpoint) to receive data.
- Event happens: Someone pushes code, a payment succeeds, money lands in an account...
- Deliver: The provider sends a POST request with a payload (usually JSON) to your URL.
- Acknowledge: Your server returns a 2xx to confirm receipt. If it doesn't, most providers will retry later.
You use webhooks every day, maybe without noticing:
- GitHub fires webhooks to trigger CI/CD on pushes and pull requests.
- Stripe fires webhooks when a payment succeeds or is refunded.
- Slack / Discord let you post messages into channels via incoming webhooks.
- Bank notification services fire webhooks when an account balance changes — more on this at the end.
3. Polling vs. webhooks
Webhooks don't always fully replace polling. Many solid systems use both: webhooks for real-time events, plus a periodic reconciliation job (polling) to catch anything that slipped through.
4. Building a webhook endpoint in Node.js
Let's build a webhook receiver with Express. It will do the three things every production endpoint needs:
- Verify the signature (make sure the request really came from the provider)
- Be idempotent (never process the same event twice)
- Respond fast, and do the heavy lifting later
Note: Header names, signing algorithms and payload shapes vary by provider. The code below uses the most common pattern: HMAC-SHA256 with an X-Signature header. For a real integration, follow your provider's docs.
bash
npm init -y
npm install express
js
// server.js
const express = require('express');
const crypto = require('crypto');
const app = express();
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
const processedEvents = new Set(); // Demo only: use a DB/Redis in production
// Compare signatures with timingSafeEqual to avoid timing attacks
function isValidSignature(rawBody, signature) {
if (!signature) return false;
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(rawBody)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Use express.raw to keep the original body bytes — required for signing
app.post('/webhooks/demo', express.raw({ type: 'application/json' }), (req, res) => {
// 1. Verify: did this really come from the provider?
if (!isValidSignature(req.body, req.get('X-Signature'))) {
return res.status(401).send('Invalid signature');
}
let event;
try {
event = JSON.parse(req.body.toString('utf8'));
} catch {
return res.status(400).send('Invalid JSON');
}
// 2. Idempotency: skip events we've already handled (providers may resend)
if (processedEvents.has(event.id)) {
return res.status(200).send('Already processed');
}
processedEvents.add(event.id);
// 3. Acknowledge IMMEDIATELY, process later
res.status(200).send('OK');
setImmediate(() => handleEvent(event));
});
async function handleEvent(event) {
// Update orders, send emails, post to Telegram...
console.log(`Received event ${event.type}:`, event.data);
}
app.listen(3000, () => console.log('Webhook server listening on port 3000'));
Why express.raw() instead of express.json()? The HMAC signature is computed over the exact raw bytes of the body. If you let Express parse the JSON and then JSON.stringify it back, key order or whitespace may change and the signature will never match. This is one of the most common bugs in first-time webhook integrations.
Test it with curl
Start the server:
bash
WEBHOOK_SECRET=my_secret node server.js
In another terminal, simulate a provider sending a signed webhook:
bash
WEBHOOK_SECRET=my_secret
BODY='{"id":"evt_001","type":"order.paid","data":{"orderId":"DH123","amount":150000}}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | sed 's/^.* //')
curl -X POST http://localhost:3000/webhooks/demo \
-H "Content-Type: application/json" \
-H "X-Signature: $SIG" \
-d "$BODY"
What you should see:
- First request → OK, and the server logs Received event order.paid: { orderId: 'DH123', amount: 150000 }
- Send the exact same request again → Already processed (idempotency works)
- Change X-Signature to anything else → 401 Invalid signature
- Exposing localhost for real providers
Providers can't reach your localhost. During development you can use:
- ngrok: ngrok http 3000 gives you a public HTTPS URL that tunnels to your machine.
- Cloudflare Tunnel (cloudflared): similar, and free.
- webhook.site: just to inspect what a provider sends, before writing any code.
5. Webhook best practices
Receiving a request is easy. Making it reliable in production is where it gets interesting.
Security
- Always use HTTPS for your webhook URL.
- Always verify signatures (HMAC, tokens, or whatever your provider supports). Your webhook URL is public — anyone can send fake requests to it.
- Compare signatures in constant time (crypto.timingSafeEqual), not with ===.
- If the provider publishes its IP ranges, consider IP allowlisting as an extra layer.
For anything involving money: don't blindly trust the payload. Cross-check the amount and order ID against your own data before updating status.
ReliabilityReturn 2xx as fast as possible. Most providers time out after a few seconds. Push heavy work (calling other APIs, sending emails, multi-table writes) onto a queue (BullMQ, RabbitMQ, SQS...).
Design for idempotency. Webhooks are usually delivered at least once — the same event can arrive 2–3 times. Store the event ID (or transaction ID) in your DB with a UNIQUE constraint.
Don't rely on ordering. A later event may arrive first.
Run a periodic reconciliation job to catch missed events (server downtime, bad deploys...).
Observability
- Log raw payloads (with sensitive data masked) for debugging.
- Alert when the webhook failure rate spikes.
6. Real-world use case: bank balance notifications with PionPay
Now for the use case where webhooks really shine, especially in Vietnam: automatic bank transfer confirmation.
The problem
In Vietnam, bank transfers — especially via QR codes (VietQR) — are one of the most popular ways to pay, whether for e-commerce, online courses, SaaS subscriptions or wallet top-ups. Yet confirmation is often still manual:
- The customer makes a transfer
- The customer sends a screenshot of the receipt via chat
- Staff open the banking app and search for the transaction
- Staff update the order by hand
The result: customers wait, staff waste time, mistakes happen, and there's a real risk of fake transfer screenshots. And if someone orders at 2 AM... the order just sits there until morning.
The solution: balance change webhooks
PionPay connects to your bank accounts via Open API and notifies you whenever your balance changes — via Webhook/API for system integration, or via Telegram/Messenger if you just want alerts.
Here's what an automated payment confirmation flow looks like with webhooks:
No more combing through bank statements. No more waiting for screenshots. Orders get confirmed 24/7, even at 2 AM.
Why PionPay?
- Fast: balance change notifications in about 2 seconds — no need to open a banking app or wait for an SMS.
- Reliable: 99.99% uptime.
- Multi-bank: supports 10+ Vietnamese banks including BIDV, ACB, TPBank and PGBank; manage multiple accounts from one dashboard.
- Secure: connects via Open API, encrypts data, never stores passwords and has no access to your account — it only receives transaction notifications.
- Multiple channels: Webhook/API for developers, Telegram/Messenger for operations teams.
- Extras: QR payment code generation, automatic transaction categorization, daily/weekly/monthly revenue tracking. Get started in a few steps
- Sign up at pionpay.vn.
- Link your bank account via Open API.
- Register your webhook URL (the endpoint from section 4) in the dashboard — or connect Telegram/Messenger if you only need alerts.
- Write your handler: match the transfer reference to an order ID, check the amount, update the status.
Every best practice from section 5 applies here, and with money involved they matter even more: verify requests, stay idempotent on the transaction ID, and always cross-check the amount before confirming an order.
Ideas for what to build
- E-commerce / landing pages: automatically mark orders as "Paid" when the money arrives.
- Online courses: activate student accounts right after the transfer.
- Wallet / account top-ups: credit balances automatically based on the transfer reference.
- SaaS: renew subscriptions automatically.
- Ops teams: post incoming payments to a Telegram group so the whole team can follow along.
7. Wrapping up
- A webhook = another system calls your URL when an event happens, instead of you asking over and over (polling).
- A good webhook endpoint needs HTTPS, signature verification, fast responses, idempotency, and a reconciliation fallback.
- For bank-transfer payments in Vietnam, balance change webhooks let you confirm orders almost instantly, cut down on errors and block fake receipts.
If you're still confirming bank transfers by hand, give PionPay a try — you can integrate it with a single endpoint like the one above.
What's the worst webhook bug you've run into — missed events, duplicate processing, signature mismatches? Share it in the comments!
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.

