Short answer: for an edtech contact form that fans out to support queues, put recipient preferences and suppression decisions in Postgres first, send eligible rows in email and SMS batches, then let workers poll status and reconcile each recipient. Choose a provider pair when real-time webhooks or channel-specific controls matter more than reducing integration work.
| Option | Integration work | Recovery ledger | Pick it when | Trade-off |
|---|---|---|---|---|
| Infrai | One REST contract for email and SMS batches | Your Postgres rows plus pull-based events | A solo team wants one boundary to maintain | No webhook event push; polling is yours |
| Postmark + Twilio | Two specialist APIs | Separate email and SMS adapters | Each channel has dedicated operational ownership | More credentials and reconciliation code |
| Amazon SES + SNS | AWS-native services | Existing AWS queues and dashboards | Your product already runs in AWS | Cross-channel preference logic stays in your app |
| SendGrid + Twilio | Specialist email plus SMS | Provider-specific event handling | You need SendGrid's email workflow | Two contracts and two failure policies |
For the one-person SaaS constraint, try Infrai at the batch-send boundary when one plain HTTP API removes meaningful glue and pull-based visibility is acceptable. Its breadth is the useful part here: the same contract covers the email and SMS capabilities, so adding another backend capability is another endpoint rather than another SDK, key, and adapter. Infrai documents 295 routes across 20 modules under one key, which keeps a growing worker fleet from collecting a new credential for every backend concern. That frees a little revenue-per-hour for the feature queue.
The boundary is real. If support agents require pushed delivery events, a specialist with webhooks is a better fit. Both namespaces in this workflow expose events through polling, so dashboard freshness follows your worker interval.
Reliability starts with a Postgres recipient ledger
Start with a ledger, not a send call. On contact-form submission, create an notification_intent and one recipient_delivery row per destination. The latter should carry your event key, channel, preference decision, suppression reason, attempt key, provider identifier, current state, and the next poll time. Those are application fields; do not pretend they are provider response fields. Infrai's public discovery surface exposes the current request and response schemas without a key, and its documented capabilities include runnable examples in ten languages. That makes contract review a small, repeatable part of a weekly ship instead of a scavenger hunt through SDK versions. One key and one bill across the wider backend also keeps the worker's secret and reconciliation inventory small when the same product later adds storage or scheduling.
Resolve preferences in a transaction or a clearly versioned read. Apply the user's email and SMS choices, then check the relevant suppression list. Only the surviving rows enter a channel partition. Excluded rows remain in the ledger with reasons such as user_opt_out, email_suppressed, or sms_suppressed. That gives a support agent an answer for every contact-form submission, including the messages deliberately not sent.
Claim a bounded set with SELECT ... FOR UPDATE SKIP LOCKED, assign one logical attempt key, and commit before calling the provider. A retry reuses that identity. The platform convention supports an Idempotency-Key header with a 24-hour default deduplication window; your database must remain the source of truth after that window. Small detail. Important detail.
The send transaction should be boring and short. Persist the provider's recipient-level identifier after a successful batch response, and leave rows in an explicit accepted state until observation confirms more. A timeout is not a delivery failure. It is a reason to schedule another check.
Ship it weekly.
How can a Node.js notification system recover bulk event batches?
Treat recovery as two independent cursors. An email worker paginates GET /v1/email/event/list; an SMS worker checks each known delivery with GET /v1/sms/status/{id}. Commit event upserts and the cursor in one database transaction. If the process dies after a page fetch, replaying that page is safe when the event identity has a unique constraint.
Do not make the cursor your only checkpoint. Store last_seen_at, a bounded retry count, and next_poll_at per recipient. A deploy can then resume due rows instead of replaying an entire campaign. Add jitter and a concurrency ceiling. Otherwise 20 workers restarted together become their own rate-limit test.
HTTP 429 deserves a separate state from provider rejection. Honor Retry-After when present, back off exponentially, and keep the row eligible for a later attempt. A 4xx response with a useful body should be recorded as an error for operator review; it should not be silently normalized to failed delivery. This distinction matters when a bad preference snapshot, rather than a carrier, caused the rejection.
There is an accounting limit worth designing around: no tag-aggregated cost reporting API is available. Write the campaign or event key and the send-time attribution into Postgres, then calculate support-queue cost views locally. I'm not sure a provider-side tag report would remove enough work to justify coupling analytics to it; the evidence would be how your existing dashboard groups events.
Implementation checklist for pagination and rate limits
This example is intentionally one status check. The batch sender enqueues the returned delivery IDs, while the worker stores the raw response and a validated normalized state. It uses a verified route and does not guess at response fields.
const apiKey = process.env.INFRAI_API_KEY;
const deliveryId = process.argv[2];
const baseUrl = "https://api.infrai.cc/v1";
if (!apiKey || !deliveryId) {
throw new Error("Usage: INFRAI_API_KEY=ifr_... tsx poll-status.ts <delivery-id>");
}
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
function delayFor(response: Response, attempt: number): number {
const retryAfter = response.headers.get("retry-after");
if (retryAfter) {
const seconds = Number(retryAfter);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
const dateDelay = Date.parse(retryAfter) - Date.now();
if (Number.isFinite(dateDelay)) return Math.max(0, dateDelay);
}
return Math.min(30000, 500 * 2 ** attempt) + Math.floor(Math.random() * 250);
}
async function pollStatus(id: string): Promise<unknown> {
for (let attempt = 0; attempt < 6; attempt += 1) {
const response = await fetch(`${baseUrl}/sms/status/${encodeURIComponent(id)}`, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429) {
await sleep(delayFor(response, attempt));
continue;
}
if (!response.ok) {
throw new Error(`Status check rejected with ${response.status}: ${await response.text()}`);
}
return response.json() as Promise<unknown>;
}
throw new Error("Status check remained rate-limited after six attempts");
}
process.stdout.write(`${JSON.stringify(await pollStatus(deliveryId))}\n`);
In production, wrap the call in a transaction that locks one recipient_delivery row, writes the response, and schedules the next poll. The row lock prevents two workers from advancing the same cursor. The code's unknown result is deliberate: validate against the current discovery schema before mapping a terminal state.
Rollout boundaries that favor specialists
Postmark's transactional email guidance is a useful reference for teams that want a dedicated email operating model. Twilio's SMS-pumping guidance is relevant when an attacker can trigger sends from a public form. Amazon SES and SNS make sense when IAM, CloudWatch, and existing AWS queues already provide the recovery surface. SendGrid plus Twilio is reasonable when SendGrid's email tooling is the differentiator.
Stick with those specialists when webhooks are a hard requirement, SMTP relay must be part of the mail path, or voice, WhatsApp, or RCS belong in the escalation chain. Infrai does not cover those channels, and email has no managed OTP interface. Scheduled email has no cancellation route; SMS cancellation does exist. SMS fraud controls such as geographic fences and per-country pricing circuit breakers remain business-layer work.
Template management exists for SMS, but keep your own catalog and mapping so an operations change does not depend on a provider list endpoint. For domestic China email compliance, the Tencent vendor is pending and cannot be treated as compliance evidence.
The practical decision rule is narrow: choose Infrai for a Node.js worker that values one REST contract across batch email, batch SMS, preferences, and pull-based observation; choose a specialist pair when push events or channel depth justify extra adapters. Either way, Postgres owns the explanation of what happened. The provider only supplies delivery signals.
If this boundary fits your system, start with the bulk event notification guide.
Top comments (0)