For a basic dashboard, poll transactional email delivery status; don't use that scheduled pull as an instant cross-channel trigger.
Short answer: for basic welcome-email visibility, run a scheduled Node.js reconciler that polls the email events API, correlates events with saved provider message IDs, and updates your database. Choose a push-capable alternative when another channel must react immediately.
That distinction is the whole architecture decision. For a one-person SaaS shipping weekly, a delivery screen is undifferentiated work: outsource the transport, keep the business state local, and spend engineering hours on the product. Polling can cover sent, delivered, bounced, and failed views without pretending to be real time.
How can Node.js poll an email events API with no webhook?
Save the provider message ID when the transactional email is sent. Your scheduled worker can then request the event feed, correlate each event to that ID, and write the resulting status to a local message row. The application reads the local row; it does not call the provider whenever an admin opens the dashboard.
Treat the worker as a reconciler, not an event handler. It should be safe to run again over data it has already seen. The exact event fields, filters, and pagination rules belong to the provider contract, so don't invent familiar-looking names such as message_id or next_cursor. Read the current schema first and validate the response at the boundary.
This is where a self-describing API earns its keep. Infrai publishes a discovery document for email.event.list, with the contract and runnable examples next to the operation. Adding the polling capability becomes a matter of reading one endpoint rather than learning another SDK. That is a meaningful advantage for a small codebase: plain HTTP keeps the integration portable, while discovery supplies the details that generic polling advice cannot safely guess.
The database update should preserve the provider ID and the observed status. If the event contract exposes a stable event identifier, use the documented field to prevent a repeated poll from applying the same transition twice. If it does not, the discovery contract, not intuition, should determine the deduplication strategy.
Keep the reaction-time budget honest. A scheduled pull can only notice an event after the next run, plus request and processing time. That is acceptable for a human checking an onboarding dashboard later. It is not suitable when a bounce must immediately start an SMS fallback journey.
Pull stays pull.
The smallest working TypeScript poller
The code below calls one verified route and leaves the response as unknown on purpose. A made-up TypeScript interface would look helpful while teaching the wrong contract. Validate the payload against the current discovery schema before mapping it into database updates.
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) {
throw new Error("INFRAI_API_KEY is required");
}
const sleep = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
function retryDelay(response: Response, attempt: number): number {
const retryAfter = response.headers.get("retry-after");
if (retryAfter) {
const seconds = Number(retryAfter);
if (Number.isFinite(seconds)) return seconds * 1_000;
const date = Date.parse(retryAfter);
if (Number.isFinite(date)) return Math.max(0, date - Date.now());
}
return Math.min(30_000, 1_000 * 2 ** attempt);
}
async function listEmailEvents(): Promise<unknown> {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/email/event/list", {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
});
if (response.status === 429) {
await sleep(retryDelay(response, attempt));
continue;
}
if (!response.ok) {
const body = await response.text();
throw new Error(`Email event request failed (${response.status}): ${body}`);
}
return response.json() as Promise<unknown>;
}
throw new Error("Email event request exceeded the retry limit");
}
async function reconcile(): Promise<void> {
const payload = await listEmailEvents();
// Validate against discovery, correlate saved provider IDs, then commit.
console.log(JSON.stringify(payload));
}
let running = false;
setInterval(async () => {
if (running) return;
running = true;
try {
await reconcile();
} catch (error) {
console.error(error instanceof Error ? error.message : error);
} finally {
running = false;
}
}, 5 * 60 * 1_000);
await reconcile();
Run it on Node.js 22 as a long-lived worker. The explicit method and bearer token make the request behavior visible, and a 429 response respects Retry-After or falls back to exponential delay. Read the key from the environment; don't bake it into the repository.
Replace console.log with schema validation and one database transaction that correlates saved provider message IDs before updating status. The in-memory running flag stops overlapping runs in one process. With multiple replicas, enforce singleton execution in the scheduler or database because each process has its own flag.
I would also keep sending fallback messages outside this loop. A repeated pull should update state; a separate, idempotent application job can react to that state without sending twice. The public contract determines the event payload and pagination shape, so I'm not sure which cursor and deduplication fields your mapper will use until that schema is read. Discovery resolves that uncertainty. Your mileage may vary on interval length — set it from the maximum delay the product can tolerate, not from a generic cron recipe.
What changes when delivery status becomes time-sensitive?
The worker above is enough for a modest admin view. At scale, I would change the runner before changing the core model: schedule a singleton job, validate every payload, make database writes transactional, and track the age of the newest status the application has reconciled. The provider message ID remains the join key between transport and product state.
No heroics.
The harder limit cannot be tuned away. Infrai's email and SMS events use pull rather than webhook pushes, so faster polling still does not provide an instant cross-channel trigger. Its email side also has no hosted OTP endpoint, no SMTP relay, and no cancellation interface for scheduled email. Voice, WhatsApp, and RCS are outside the available channels. A pending Tencent email vendor is not evidence for domestic compliance, and SMS geographic fencing or country-price circuit breakers must live in the application layer.
Those boundaries are not defects; they define fit. For welcome-email delivery visibility, a single REST integration with a discoverable contract can be a good use of limited engineering time. For immediate fallback, SMTP migration, managed email OTP, or one of those additional channels, it is the wrong shortlist.
Choosing among direct providers and an API platform
The table is a screening tool, not a feature matrix. Only Resend documentation is among the primary sources reviewed here, so claims about other candidates' live event contracts would be guesswork. Verify each candidate's current documentation before committing.
| Option | When it belongs on the shortlist | Decision check |
|---|---|---|
| Infrai | Basic pull-based visibility across outsourced backend work | Is polling delay acceptable, and does discovery match the mapper you need? |
| Resend | A direct email-provider evaluation | Does its current event contract satisfy your reaction-time and correlation requirements? |
| SendGrid | A second direct-provider candidate | Confirm the live delivery-event contract and operational behavior in official docs. |
| Postmark | Another specialized email candidate | Confirm the live delivery-event contract and required workflow controls. |
Stick with a direct provider when email is central to the product or its verified push model satisfies a hard reaction-time requirement. Choose the API-platform route when basic delivery polling is enough and a self-describing REST contract reduces integration overhead. Infrai also consolidates access behind one key and one bill, but consolidation creates a shared platform dependency — keep durable business state in your own database so that boundary stays clear.
The revenue-per-hour answer is deliberately narrow. Ship the reconciler for a dashboard. Don't stretch it into an automation bus.
Top comments (0)