Event notifications: webhook vs polling, and which provider layer you need
Twilio status callbacks, SendGrid's event webhook, Knock and Courier, or one account-level endpoint on Infrai — how the delivery-signal choice really differs, with Node 22 code.
Take webhooks if you already operate a public HTTPS endpoint and you need to react to a failed delivery in seconds; poll otherwise. That’s the whole decision, and it’s rarely where the real cost is. What actually differs between providers is how many places you have to configure the same thing — and on Infrai the answer is one, because a single account-level endpoint carries every module’s events and you filter by type at registration.
Most “notification provider” comparisons blur three separate products together, which is why they’re hard to act on. Sort them first.
Three layers, not one market
| Layer | Products | What it gives you | What it doesn’t |
|---|---|---|---|
| Channel API | Twilio, Plivo, Vonage, SendGrid, Infrai sms.send / email.send | Actually delivers the message | Preferences, batching by user, digests |
| Delivery-signal transport | Twilio status callbacks, SendGrid event webhook, Infrai account.webhooks.* | Tells you what happened to it | Nothing about whether to send |
| Orchestration | Knock, Courier, Customer.io | Per-user preferences, channel routing, templates, digests | The delivery itself — they sit on top of a channel API |
Knock and Courier are genuinely good at layer three. If your requirement is “users choose which events reach them on which channel, with quiet hours and a digest”, buy one rather than building it — they still need a channel API underneath, and still bill separately from whoever delivers the bytes.
This page is about layer two.
One endpoint, filtered by event type
Twilio wants a StatusCallback URL per message or per messaging service; SendGrid’s event webhook is configured in its own console; a second vendor means a second endpoint and a second signature scheme. Infrai has one account webhook config and a global event catalog, so sms.delivered, email.bounced and topup.failed arrive at the same handler.
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/account/webhooks/register" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/infrai",
"events": ["sms.delivered", "sms.failed", "email.bounced", "email.complained"]
}'
{
"ok": true,
"data": {
"webhook_id": "whk_3nQ8vR1tKp",
"secret": "whsec_2f9c41d7a8b34e0f95c6",
"active": true
}
}
Read that response carefully: secret is returned once and never again. Store it where your receiver can reach it before you move on, and note that url has to be public HTTPS — the server runs an SSRF check and rejects loopback and private ranges, so a tunnel is required for local development rather than optional.
Confirm what’s registered whenever you’re unsure:
curl -sS "https://api.infrai.cc/v1/account/webhooks/list" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Then fire a real delivery at your endpoint with POST /v1/account/webhooks/test/{id} before you rely on it. The response tells you whether it was delivered and what status your server returned — which beats discovering the misconfiguration during an incident.
The receiver, with signatures verified
Signing is HMAC-SHA256 over the raw body, presented in an X-Infrai-Signature header. Two rules decide whether this is safe: compare with a timing-safe function, and hash the raw bytes rather than a re-serialised object, because JSON.stringify of a parsed payload is not guaranteed to reproduce what was signed.
import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.INFRAI_WEBHOOK_SECRET;
if (!SECRET) throw new Error("INFRAI_WEBHOOK_SECRET is not set");
function valid(rawBody, header) {
if (!header) return false;
const expected = createHmac("sha256", SECRET).update(rawBody).digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(String(header).trim(), "utf8");
return a.length === b.length && timingSafeEqual(a, b);
}
const seen = new Set();
createServer((req, res) => {
if (req.method !== "POST") {
res.writeHead(405).end();
return;
}
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
const raw = Buffer.concat(chunks);
if (!valid(raw, req.headers["x-infrai-signature"])) {
res.writeHead(401).end("bad signature");
return;
}
let event;
try {
event = JSON.parse(raw.toString("utf8"));
} catch {
res.writeHead(400).end("bad json");
return;
}
const key = event.delivery_id ?? event.id ?? raw.toString("utf8");
if (!seen.has(key)) {
seen.add(key);
queueMicrotask(() => handle(event));
}
res.writeHead(200).end("ok");
});
}).listen(8080);
function handle(event) {
console.log(`${event.type ?? "unknown"} received`);
}
Three things in there are not decoration. Answer 200 before you do the work, or a slow database write turns into a retry storm. De-duplicate, because at-least-once delivery means you will see the same event twice. And keep the handler total — an unhandled event type should be logged, not thrown, since the catalog is an open set that gains new values in minor releases.
Failed deliveries are recoverable, and this is the part that decides whether webhooks are safe to depend on: GET /v1/account/webhooks/deliveries/{id} pages through delivery history with delivery_id, event, response_status and attempts, so the ten minutes where a bad deploy returned 500 to everything is a list you can walk rather than a hole in your data — you pull the deliveries whose response_status isn’t 2xx, replay them through the same handler that would have run, and because the handler is already idempotent from the de-duplication step above, replaying an event that did land costs you nothing but a wasted lookup, which is exactly the property you want when you’re reconciling under pressure at two in the morning.
That is the difference between a transport you trust and one you merely receive.
When polling is the better answer
If you’re serving from a background worker with no inbound HTTP, or your compliance story makes a public endpoint expensive, read the events instead. Both event readers are scoped to one message rather than being an account-wide feed — GET /v1/email/event/list requires a message_id query parameter and GET /v1/sms/events/{id} takes the id in the path. That shape matters for your design: you poll the handful of sends you’re tracking, not a firehose.
curl -sS "https://api.infrai.cc/v1/email/event/list?message_id=msg_DgOWYJSuArAxcSI9MCzYLSJp" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [
{
"type": "sent",
"at": "2026-07-25T00:30:03.405585Z",
"recipient": "ada@example.com",
"message_id": "msg_DgOWYJSuArAxcSI9MCzYLSJp",
"meta": {"vendor_message_id": "a0636ff4-56f6-4a52-8f49-23d60cb58cc7"}
},
{
"type": "queued",
"at": "2026-07-25T00:30:03.394546Z",
"recipient": "ada@example.com",
"message_id": "msg_DgOWYJSuArAxcSI9MCzYLSJp",
"meta": {"vendor": "resend"}
}
],
"next_cursor": null,
"count": 2
}
}
Events are ordered newest-first with a next_cursor for paging, and the reads are free and rate-limited. The trade-off is honest: you learn about a bounce on your poll interval, and you have to keep a list of the message ids still worth watching. Under maybe 50,000 messages a month that’s a non-issue — above it, the per-message read pattern is exactly where webhooks start paying for themselves.
What any of this costs
The transport is free on Infrai — registering, listing, testing, inspecting deliveries and reading event lists are all free per-call routes, rate-limited, and none of them consume trial credit. Only the send is billable: verified 2026-07-26, $0.007475 per SMS and $0.000115 per email. Confirm both against the live document rather than this page:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-o discovery.json
python3 -c "
import json
doc = json.load(open('discovery.json'))
caps = doc.get('data', doc).get('capabilities', [])
for c in caps:
if c['id'] in ('sms.send', 'email.send'):
b = c['billing']
print(c['id'], b['price_usd'], b['unit'], 'billable' if b['is_billable'] else 'free')
"
Rates here move down over time, so treat the figures above as a ceiling. Twilio and SendGrid charge nothing for their callbacks either — nobody in this market bills for the webhook, which is precisely why per-callback pricing is the wrong axis to compare on.
The limitations worth knowing
Webhook configuration is per account, not per module or per tenant, so fan-out to different downstream services is something you build behind your own endpoint. Events are at-least-once with retries — if your consumer isn’t idempotent, the retry policy will find that out for you.
If you need per-message callback URLs that differ by campaign, stick with Twilio, where the callback is a property of the send rather than the account.
What one credential buys is that the same key registering this webhook also sends the SMS, sends the email, runs the cron that sweeps stuck messages, records the error when your handler throws, and answers “which tenant caused this month’s spend” as one query. That’s the argument — not the callback.