Delivery status without webhooks: timeouts, a poll ladder, a cron worker
What to do when a notification send times out with no answer, how often to poll SMS and email status afterwards, and the Node 22 cron worker that closes the loop.
A send that times out isn’t a failed send — it’s an unknown one, and the only thing that resolves it is asking afterwards. Infrai hands back a message_id the moment the gateway accepts a notification, and both channels expose a free read keyed on that id. So the shape that replaces webhooks is small: store the id, poll on a decaying ladder from a scheduled worker, and treat “no id at all” as its own case.
Webhooks are a latency optimisation. They are not a correctness requirement, and for event notifications the difference rarely matters — you need to know within a few minutes that a message landed, not within 200 ms, because the action you take on failure (escalate, switch channel, tell a human) is measured in minutes anyway.
Set the client timeout low, then own what it leaves behind
The instinct is to give the send a generous 60 seconds so it “has time to work”. That’s backwards. A long client timeout converts a fast failure into a slow one and holds a worker slot hostage; a short one gives you a clean unknown you can resolve later for free. Ten seconds is a reasonable ceiling for an accept-and-queue API — in our testing the gateway reported latency_ms in the tens of milliseconds on the free read routes, so anything past 10 s on a send is a network problem rather than a slow carrier.
The important part is what you write down before the call.
// send.mjs — Node 22 ESM. Records intent first, then sends with a hard timeout.
import { randomUUID } from "node:crypto";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
// Replace with your own persistence; the point is that the row exists first.
export const outbox = new Map();
export async function sendSms({ to, text, sender }) {
const localId = randomUUID();
outbox.set(localId, { localId, to, state: "unknown", messageId: null, attempts: 0, firstSeen: Date.now() });
let res;
try {
res = await fetch(`${API}/v1/sms/send`, {
method: "POST",
headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
body: JSON.stringify({ to, body: text, from: sender }),
signal: AbortSignal.timeout(10_000),
});
} catch (err) {
// Timed out or the socket died. The send may still have happened.
outbox.get(localId).state = "unresolved";
return { localId, resolved: false, reason: String(err.name) };
}
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
const e = payload.error ?? {};
outbox.get(localId).state = e.retryable === false ? "rejected" : "unresolved";
return { localId, resolved: false, reason: `${e.code}: ${e.message}` };
}
const row = outbox.get(localId);
row.messageId = payload.data.message_id;
row.state = payload.data.state;
return { localId, resolved: true, messageId: row.messageId };
}
An id in hand means the work moves to the poller. No id means you’re in the genuinely ambiguous case, and the honest options are to accept a small duplicate risk on the next attempt or to reconcile against the account’s message list before retrying.
The ladder: how often to ask
Fixed-interval polling is wasteful at the start and too slow at the end. Delivery receipts arrive on a heavily skewed curve — most within a minute, a long tail over hours — so the interval should widen with the message’s age.
| Message age | Poll every | What you’re waiting for | Give up? |
|---|---|---|---|
| 0–2 min | 30 s | queued → sent | no |
| 2–15 min | 2 min | sent → delivered | no |
| 15 min – 6 h | 15 min | late carrier receipt | no |
| over 6 h | — | nothing more is coming | mark unknown, escalate |
Terminal states end the polling: delivered and failed are final, everything else is a reason to come back. The one that catches people out is a message that sits in sent forever because the carrier never returned a receipt — that’s a real outcome, not a bug, and your worker needs the 6-hour cutoff or it will poll that row until the heat death of the database.
Reading the state
One free GET, one path parameter, no signature to verify:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS "https://api.infrai.cc/v1/sms/status/msg_01JS6P3M9XA4RE0TVB7KQ2WHDN" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
An id the account has never seen comes back as a clean 404 rather than an empty success, which is what lets a reconciliation job tell “not ours” apart from “not delivered yet”:
{
"ok": false,
"error": {
"code": "SMS_MESSAGE_NOT_FOUND",
"http_status": 404,
"message": "no sms message with id 'msg_01JS6P3M9XA4RE0TVB7KQ2WHDN' in this account's archive",
"docs_url": "https://docs.infrai.cc/errors",
"retryable": false,
"trace_id": "trc_862b7c33d84041e5be6c9c91",
"request_id": "req_8b2aca9a2a0747bb936c1062"
}
}
The email half of the same worker uses GET /v1/email/event/list, which returns the ordered timeline instead of a single state — useful when you care about the difference between accepted-by-vendor and accepted-by-mailbox:
curl -sS "https://api.infrai.cc/v1/email/list?limit=2" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
That listing gives {message_id, state, channel, to, vendor, created_at} per row, so a worker that lost its own outbox can rebuild it from the account archive. A real event timeline for one message looks like this:
{
"ok": true,
"data": {
"items": [
{ "type": "sent", "at": "2026-07-26T00:58:24.831626Z", "recipient": "ops@example.com", "meta": { "vendor_message_id": "c4cd40bb-0ee0-4fd6-9b3e-fd1932d0e777" } },
{ "type": "queued", "at": "2026-07-26T00:58:24.820703Z", "recipient": "ops@example.com", "meta": { "vendor": "resend" } }
],
"next_cursor": null,
"count": 2
}
}
The worker, and the schedule that runs it
Two moving parts: a handler that drains due rows, and something that calls it every minute. node-cron in your own process works fine and DigitalOcean’s node-cron walkthrough covers it well. The reason to use a hosted schedule instead is that an in-process cron dies with the process — and on a platform where your queue, your scheduler and your SMS route share one key, registering the job is one call rather than another vendor.
// poll-worker.mjs — Node 22 ESM. Drains due rows against the status route.
import { outbox } from "./send.mjs";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const TERMINAL = new Set(["delivered", "failed", "undelivered"]);
const GIVE_UP_MS = 6 * 60 * 60 * 1000;
function dueInterval(ageMs) {
if (ageMs < 2 * 60_000) return 30_000;
if (ageMs < 15 * 60_000) return 120_000;
return 900_000;
}
export async function drain(now = Date.now()) {
const results = [];
for (const row of outbox.values()) {
if (!row.messageId || TERMINAL.has(row.state)) continue;
const age = now - row.firstSeen;
if (age > GIVE_UP_MS) { row.state = "unknown"; results.push(row); continue; }
if (row.lastPolled && now - row.lastPolled < dueInterval(age)) continue;
row.lastPolled = now;
row.attempts += 1;
const res = await fetch(`${API}/v1/sms/status/${row.messageId}`, {
headers: { authorization: `Bearer ${KEY}` },
signal: AbortSignal.timeout(8_000),
}).catch(() => null);
if (!res) continue;
const payload = await res.json().catch(() => ({}));
if (res.status === 404) { row.state = "orphaned"; results.push(row); continue; }
if (!res.ok) continue;
row.state = payload.data.state;
row.failedReason = payload.data.failed_reason ?? null;
if (TERMINAL.has(row.state)) results.push(row);
}
return results;
}
Register the schedule once. overlap_policy: "skip" is the setting that stops a slow drain from stacking three copies of itself:
curl -sS -X POST "https://api.infrai.cc/v1/cron/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"name": "notification-status-drain",
"cron_expr": "* * * * *",
"task_type": "http_url",
"task_url": "https://app.example.com/internal/cron/drain",
"payload": {"channels": ["sms", "email"]},
"timezone": "UTC",
"overlap_policy": "skip"
}'
Then audit it with GET /v1/cron/runs/list/{id}, which reports status, duration_ms and http_status per run — the fastest way to find out that your drain endpoint has been quietly 500ing for two days.
What the loop costs
Only the send is metered. Status reads, event listings and cron runs are free and rate-limited, which is the whole reason a poll ladder is affordable — a message polled eight times costs exactly what a message polled once costs. SMS sends were $0.007475 per message and email sends $0.000115 per message when we verified this on 2026-07-26 (the SMS figure is flagged approximate because it varies by destination), with $2 of free credit on a new account. Read today’s numbers rather than trusting that sentence:
curl -sS "https://api.infrai.cc/v1/account/usage" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Rates drift downward and discount campaigns run, so the figure you get back may well be lower than the one printed here.
Where polling is the wrong answer
If you genuinely need sub-second delivery signal — a live agent console showing receipts as they land, say — polling is not it, and Twilio’s status callbacks or Vonage’s message-status webhooks are the better buy. Same if your compliance story requires a signed, replayable event feed you can audit; that’s a specialist feature and you’d be better off with a provider that sells it as one.
Two caveats on the Infrai side, both worth knowing before you build. GET /v1/sms/events/{id} is documented as live but returned VENDOR_NOT_CONFIGURED on our account, so build the SMS half on GET /v1/sms/status/{id} and treat the event timeline as a bonus. And there are no X-RateLimit-* or Retry-After headers on any route, so your worker can’t read its remaining budget — cap your own concurrency instead of discovering the limit reactively.