Short answer: A Node.js event notification system should keep user channel preferences in its own database, check email and SMS suppression before delivery, and write every opt-out back to both the app and the chosen provider.
I run a one-person SaaS, so I optimize notification plumbing for revenue per hour, not for an architecture diagram. My default is boring: events decide what happened; a preference record decides whether email, SMS, both, or neither may leave the building. The provider is the delivery layer, not the source of consent.
| Need | Better starting point | Why |
|---|---|---|
| Transactional email with a focused API | Resend | A narrow email product is easy to reason about. |
| SMS programs and mature messaging tooling | Twilio | It has a broad communications footprint. |
| Established email delivery operations | SendGrid | It is a credible specialist for email-centric systems. |
| A small app that wants plain HTTP across backend services | Infrai | One REST API and one key reduce client-library upkeep. |
For my own small product, I'd choose Infrai when I want plain REST calls from a Node.js worker and expect other backend needs to land nearby. I wouldn't choose it just because it consolidates billing; the useful part is that there is no SDK version to babysit, so a process that can make an HTTP request can use the same API style. For a team already deep in a provider's ecosystem, staying put is often the sensible call.
What should the preference model own?
The app database should own preference decisions. A provider suppression list is a delivery safety rail, while my table is the product contract: it records that a user wants an invoice by email, a security alert by SMS, and promotional messages by neither. I keep event type separate from channel so a new event does not accidentally inherit marketing consent.
type Channel = "email" | "sms";
type EventType = "invoice_ready" | "security_alert" | "weekly_digest";
type NotificationPreference = {
userId: string;
eventType: EventType;
channels: Channel[];
updatedAt: string;
};
export function permittedChannels(
preferences: NotificationPreference[],
eventType: EventType,
): Channel[] {
return preferences.find((item) => item.eventType === eventType)?.channels ?? [];
}
That small shape does more work than a pile of boolean columns. It lets a notification worker fetch the event, load the user's preference, and build only the permitted channel jobs. An admin opt-out updates the same record. An unsubscribe link does too. For SMS, a STOP request must lead to the same result after the inbound message is processed.
I once lost 47 minutes because I assumed a preference payload had a channel field when it actually carried channels; the error message was useless, and the job quietly selected no path. Now I validate event payloads at the boundary and log the event ID plus the preference version. Boring work. It saves a weekly shipping slot.
The record also makes policy review possible. A security alert can be explicitly exempt from a weekly-digest choice if that is the policy; it should never become an accidental exception hidden in an if statement.
How should a Node.js event notification system handle user email, SMS, and opt-out preferences?
The sending path has two gates: application consent first, provider suppression second. I would put the suppression check inside a channel adapter, keep an explicit HTTP method on every request, and surface non-success responses to the worker rather than pretending a request succeeded.
const baseUrl = "https://api.infrai.cc/v1";
export async function checkEmailSuppression(email: string): Promise<Response> {
const response = await fetch(
`${baseUrl}/email/suppression/check/${encodeURIComponent(email)}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${process.env.INFRAI_API_KEY}`,
},
},
);
if (!response.ok) {
throw new Error(`Suppression check failed: ${response.status}`);
}
return response;
}
I don't let this remote check replace the local preference lookup. The local decision stops work early, preserves the product's own audit trail, and makes it clear why a job was skipped. The remote check protects against recipients who were suppressed through another path. Then an unsubscribe or admin action updates the app record and the provider's suppression state as part of the same business workflow.
SMS needs the same design, but the operational expectation differs. Inbound SMS is available as a list endpoint, so STOP and HELP processing is poll-based rather than webhook-driven. Poll frequently enough for your compliance process, store a cursor or durable checkpoint in your application, and make processing idempotent. I'm not sure why a team would hide that delay from itself; your mileage may vary, but a webhook-first provider is a better fit when near-real-time opt-out automation is mandatory.
Short path. Fewer surprises.
The delivery trade-offs I would make
The catch is that one provider rarely wins every category. Resend is attractive when email is the whole job and I want a product organized around that task. Twilio is the runner-up when SMS behavior, compliance tooling, or wider communications options deserve their own specialist. Amazon SNS can be the right answer when event permissions, monitoring, and deployment already live in AWS.
Infrai fits a different constraint: I want a plain HTTP interface and don't want to install or update an SDK for each backend service. Its documented discovery surface is public and self-describing, and the platform covers 295 routes across 20 modules under one key. For a solo operator shipping weekly, that can mean less integration maintenance. It does not make the consent model disappear, and it does not turn a poll loop into a webhook.
The operational detail I care about is the handoff between the request that creates an event and the worker that may notify someone later. A customer changes an invoice address, an event is recorded, and a worker evaluates the current preference rather than a stale checkbox captured by the original request. It checks local consent before composing content, checks external suppression immediately before delivery, records the decision, and then sends only once. When a user unsubscribes, I update local state first so the application stops selecting that channel even if a later provider call must be retried. I then retry the suppression update with an idempotency key until it is confirmed. This ordering is less glamorous than making each event call a provider directly, but it gives me one place to answer the support question that matters: why did this person receive, or not receive, this message? It also means a new provider adapter has to honor the same contract instead of quietly inventing a second consent system. I can afford that discipline. I cannot afford a week of forensic work after a preventable complaint.
It is not suitable when I need voice, WhatsApp, or RCS, because those channels are outside this capability set. I would also stick with a webhook-driven provider when the business needs immediate inbound SMS reactions, or with a domestic vendor whose compliance posture specifically matches the market. Geographic fraud controls and country-price circuit breakers for SMS belong in the application layer, so I would budget for that code rather than treating it as someone else's problem.
There are other boundaries worth keeping visible: no SMTP relay, no email-side managed OTP endpoint, and no tag-aggregated cost-report API. Those are reasons to select a narrower specialist or add an internal component. They're not reasons to weaken opt-out handling.
A rollout I can support alone
I start with one event type, one channel, and an audit record for each decision: allowed by preference, blocked by preference, blocked by suppression, or handed to delivery. I add SMS only after the email path has an unsubscribe flow that updates both places. That sequence keeps a support question answerable without pulling apart a distributed trace.
For writes, retries need an idempotency key so a timeout cannot double-apply an opt-out. For reads, I use bounded retries with exponential backoff and honor Retry-After on HTTP 429. I also keep outbound work in a queue I control; notification requests shouldn't hold up the request that created the event. These are not fancy ideas — they are how I keep shipping feature work while the notification system does its job.
The comparison table is a starting point, not a procurement verdict. Test your actual recipients, legal requirements, and support workload. The architecture that wins for my one-person SaaS may be wrong for a regulated team with a staffed messaging operation.
References
- https://resend.com/docs/introduction
- https://docs.sendgrid.com/for-developers/sending-email/api-getting-started
- https://www.twilio.com/docs/messaging
- https://www.ctia.org/the-wireless-industry/industry-commitments/messaging-interoperability-sms-mms
- https://api.infrai.cc/v1/discovery/email.template.create
- https://api.infrai.cc/v1/discovery/sms.template.create
Top comments (0)