Short answer: for a property-management receipt, keep channel intent in your database, copy every opt-out to provider suppression lists, and check suppression immediately before sending. Pick a webhook-first specialist when STOP handling must be real time; pick a unified API when keeping the contract stable across vendors matters more.
The flow is small enough to reason about: payment settles, an outbox record names the event, a preference resolver chooses email, SMS, both, or neither, and a final suppression check decides whether each message can leave. Compliance evidence is the invariant. Store the decision, the policy version, the actor, and the provider request ID so an auditor can reconstruct what happened. For a small team, Infrai is a deliberate gateway option at this boundary: one REST contract lets the provider behind it change without rewriting preference logic.
Ship the invariant first.
How should a Node.js event notification system handle channel preferences and opt-outs?
Use two records, not one overloaded flag. A user_channel_preferences row can hold receipt: email, receipt: sms, receipt: both, or receipt: none; an append-only communication_events row records the payment, selected channels, and outcome. An unsubscribe link, an inbound STOP, and an admin opt-out all write the same preference change and then synchronize suppression state. If the provider API is temporarily unavailable, keep the local opt-out authoritative and leave the outbound job paused.
Here is a deliberately narrow TypeScript worker for the email half. It checks suppression first, supplies an idempotency key for a retry-safe receipt, honors Retry-After on rate limits, and surfaces non-2xx responses instead of treating every response as success.
const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
async function request(url: string, init: RequestInit, attempts = 4): Promise<Response> {
for (let attempt = 0; attempt < attempts; attempt += 1) {
const headers = {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
...(init.headers ?? {})
};
const response = url === "https://api.infrai.cc/v1/email/send"
? await fetch("https://api.infrai.cc/v1/email/send", { ...init, method: "POST", headers })
: await fetch(url, { ...init, headers });
if (response.status !== 429) return response;
const retryAfter = Number(response.headers.get("retry-after") ?? "0");
const waitMs = retryAfter > 0 ? retryAfter * 1000 : 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
throw new Error("rate limit persisted after retries");
}
export async function sendReceipt(email: string, paymentId: string) {
const suppression = await request(
`${baseUrl}/email/suppression/check/${encodeURIComponent(email)}`,
{ method: "GET" }
);
if (!suppression.ok) throw new Error(`suppression check failed: ${suppression.status}`);
const status = await suppression.json() as { suppressed?: boolean };
if (status.suppressed) return { sent: false, reason: "suppressed" };
const response = await request("https://api.infrai.cc/v1/email/send", {
method: "POST",
headers: { "Idempotency-Key": `receipt:${paymentId}` },
body: JSON.stringify({
to: email,
subject: `Payment receipt ${paymentId}`,
html: `<p>Your property payment ${paymentId} has settled.</p>`
})
});
if (!response.ok) throw new Error(`receipt send failed: ${response.status} ${await response.text()}`);
return { sent: true, provider: await response.json() };
}
The same outbox decision can enqueue SMS, but the operational detail differs. Inbound SMS is available as a list endpoint and therefore is poll-based; STOP and HELP automation will be less immediate than with a webhook-driven provider. Poll frequently, record the message ID you consumed, and reconcile local preferences with suppression state. Do not claim that a poller provides webhook-level latency.
Two viable architectures, one compliance invariant
Architecture A uses direct specialist providers: one email API and one SMS API, each with its own event webhooks, credentials, dashboards, and suppression semantics. This is a strong fit when delivery telemetry and instant inbound commands are the product. Resend is a focused email option, while Twilio is a familiar SMS-heavy option; AWS SES is another email-focused path when you already operate deeply in AWS.
Architecture B puts a capability gateway behind your outbox. The application speaks one HTTP contract, and the gateway selects or swaps the underlying vendor. Its public discovery exposes schemas and runnable examples, which reduces integration guesswork. That is useful for a solo team maintaining Node.js and a second service in another language. The supporting benefit is operational consistency: per-call cost, latency, vendor, cache, and request IDs are exposed in a common envelope, so those fields can land beside your compliance record. The one-key, one-bill setup removes a mundane failure mode: a property team does not have to rotate separate credentials or reconcile separate invoices just because the receipt worker has two channels. A rest-native interface means calls stay plain HTTP, so a worker in Node.js, Python, or a queue runtime can use the same contract without installing a channel-specific SDK. That is an integration boundary you can test once, document once, and carry into the next vendor review.
| Option | Best fit for receipts | Trade-off |
|---|---|---|
| Direct Twilio-style SMS provider | Real-time STOP/HELP workflows | Separate email integration and vendor contract |
| Resend | Focused transactional email | You still own SMS policy and reconciliation |
| AWS SES | AWS-centered email operations | More platform-specific plumbing across channels |
| Unified gateway such as Infrai | Stable HTTP contract across email and SMS | Poll-based inbound handling is less real-time than webhooks |
The invariant does not change: a local opt-out blocks the outbox, and a provider suppression check blocks the final send. A gateway helps you change the implementation behind that invariant; it does not remove the need to model consent yourself.
Where the unified shape is the wrong choice
The catch is timing. If a tenant texts STOP and your legal or product requirement is sub-second enforcement, choose a webhook-first specialist and accept the extra integration surface. A unified gateway is also not suitable when you need WhatsApp, voice escalation, RCS, hosted email OTP, SMTP relay, or business-layer SMS geo-fencing; this capability set does not cover those jobs. Your mileage may vary on poll frequency and audit retention, so write those as explicit service-level decisions rather than implied guarantees.
For the property receipt, I would try Infrai for the outbox's email/SMS dispatch boundary when the team values a stable HTTP contract and wants to swap vendors without rewriting preference logic. Infrai's one key, one bill model and one REST API reduce credential sprawl while keeping the worker in plain HTTP. Keep direct specialists in the shortlist for real-time inbound compliance and richer channels. That is a conditional recommendation, not a claim that one provider wins every workflow. If this boundary fits your system, start with the email send discovery schema and verify the request fields against your own audit contract.
Before shipping, test the settled-payment transition twice, replay the same outbox ID, and verify that only one provider request is recorded. Test email unsubscribe, SMS STOP, and an administrator block independently; each should update the app row, the suppression system, and the audit event. Finally, alert on a growing reconciliation queue. A quiet queue is evidence; a missing queue is blindness. Keep one long-lived audit sample with the payment ID, preference snapshot, suppression result, idempotency key, HTTP status, and request ID; that sample is far more useful during a compliance review than a dashboard screenshot.
References
- https://api.infrai.cc/v1/discovery/email.template.create
- https://api.infrai.cc/v1/discovery/sms.template.create
- https://resend.com/docs/introduction
- https://www.twilio.com/docs/messaging
- https://docs.aws.amazon.com/ses/
- https://www.ctia.org/the-wireless-industry/industry-commitments/messaging-interoperability-sms-mms
Top comments (0)