For a marketplace compliance notice, the least complex design is an application-owned event record, a versioned template contract, and asynchronous delivery workers for email and SMS. Record the decision before making a network request. Then reconcile delivery status by polling and append each transition to the audit trail.
Keep the audit row boring.
The choice that matters most is who owns the template. Keep regulated copy in the Node.js application when review history and channel parity matter. Use hosted templates when copy operations are the bottleneck and the vendor's editor is worth the coupling. A provider-neutral adapter is the useful middle layer: the contract stays put while the service behind it changes.
| Option | Template owner | Audit evidence | Best fit |
|---|---|---|---|
| Application-owned adapter | Your service | Your event log plus provider status | Marketplace compliance notices |
| Provider-owned templates | Messaging vendor | Vendor records plus your message IDs | Operational copy with frequent edits |
| REST aggregation layer | Shared backend surface | Your event log plus normalized status | A small team avoiding several SDKs |
My default is the first row. It keeps legal text, recipient selection, and the effective version under one release process. Choose the second when an editor removes more work than it creates. Choose the third when a plain HTTP contract reduces integration friction across the rest of the backend too.
What must the event record prove?
An event is not a send call. It is a decision with an attempted delivery attached to it. For a marketplace item, store the item or transaction identifier, recipient identifier, notice type, template version, selected channels, creation time, and an application-generated event ID before any provider request starts.
Create one delivery record per channel. Each record needs its provider message ID, current status, last status check, retry count, and error detail. Keep the original payload or a content hash according to your retention policy. accepted is evidence that a provider accepted a request. It is not evidence that a person read the notice.
This distinction matters during a dispute. One sent_at column cannot explain a partial failure, a second channel, or a template change. The causal chain can.
Consider a notice that must reach a marketplace seller by both channels. The application commits the notice event with template version compliance-2026-04, then enqueues email and SMS work using the same event ID. If email is accepted and SMS fails, the audit record preserves that split instead of presenting one misleading overall status. If the copy changes later, the old version remains attached to the original decision. A status poll can add delivered, failed, or unknown as a new transition without rewriting the evidence that existed when the notice was created. This is the difference between an audit trail and a log line that says “notification sent.”
That is the useful unit.
The event ID should also be the idempotency key for the create operation. A retry that creates a second compliance notice is a product bug, not a networking detail. The write path must be safe to run again, and the audit entry should say whether the attempt was new or deduplicated.
Should templates live in the app or the vendor?
Application-owned templates make review explicit. A pull request can show the text change, its locale, variables, and effective date. One version can produce an email variant and an SMS variant while the event record points to both. The cost is operational: your service owns validation, previewing, localization, and rules for fitting a notice into a short message.
Vendor-owned templates shorten the path from copy edit to production. SendGrid is a sensible fit for an email-first product that already uses its dynamic templates and delivery tooling. Twilio is stronger for a messaging-first product with an established SMS integration and a broader communications roadmap. Amazon SES fits an AWS-heavy service where email is the main requirement and delivery belongs close to existing cloud controls.
These products are not interchangeable send buttons. SendGrid leaves SMS outside the center of its email workflow. Twilio can make its platform vocabulary feel like the application's vocabulary unless the adapter stays small. SES is a narrower answer for a two-channel notice, so the SMS path and the shared audit model remain yours.
For a solo SaaS, the revenue-per-hour test is practical: which ownership model lets the product ship weekly without making every copy edit a custom deployment, while still making a compliance review boring? If the answer differs by notice type, split the policy. Do not force password resets, receipts, and regulatory notices into one template system.
How do email and SMS events stay non-blocking?
The request that changes marketplace state should write the domain event and return. A worker reads that event and attempts the selected channels. A separate scheduled worker polls delivery status. These namespaces do not provide webhook event pushes, so the audit view must show that a delivery is awaiting reconciliation rather than pretending the initial response settled it.
Here is the boundary I would put in front of any provider. It makes template ownership explicit and keeps the business event independent from a vendor SDK. The retry policy is deliberately bounded, honors Retry-After, and saves the attempt after each provider result. A four-attempt ceiling is enough to prevent a stuck worker from turning a temporary provider error into an unbounded queue.
type Channel = "email" | "sms";
type NoticeEvent = {
id: string;
recipientId: string;
templateVersion: string;
channels: Channel[];
};
type DeliveryAttempt = {
eventId: string;
channel: Channel;
status: "queued" | "accepted" | "delivered" | "failed";
providerMessageId?: string;
attempts: number;
error?: string;
};
type Capability = {
path: string;
available: boolean;
vendors_ready?: string[];
vendors_pending?: string[];
};
type Discovery = { capabilities: Capability[] };
async function loadNotificationCapabilities(): Promise<Capability[]> {
const baseUrl = process.env.INFRAI_BASE_URL;
const key = process.env.INFRAI_API_KEY;
if (!baseUrl || !key) throw new Error("INFRAI_BASE_URL and INFRAI_API_KEY are required");
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(`${baseUrl}/v1/discovery`, {
method: "GET",
headers: { Authorization: `Bearer ${key}` },
});
if (response.ok) {
const discovery = (await response.json()) as Discovery;
return discovery.capabilities.filter((capability) =>
capability.path.includes("/email/") || capability.path.includes("/sms/"),
);
}
if (response.status !== 429 && response.status < 500) {
throw new Error(`Discovery failed (${response.status}): ${await response.text()}`);
}
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1000
: Math.min(30_000, 500 * 2 ** attempt);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
throw new Error("Discovery failed after four attempts");
}
The discovery call is a startup check, not the delivery worker itself. Generate the concrete path from the returned path field, then map the send operation behind the same application transport. The application still owns the event ID, template version, and audit write. Retries must remain idempotent, and a 429 should use the provider's retry hint rather than a tight loop. A worker should also persist the last error so an operator can distinguish a failed send from an unresolved delivery status.
The snapshot behind this check is dated 2026-10-28. Treat capability readiness as a deployment input, not as a promise about latency or uptime. The base URL should be configured to the provider's documented API host in each environment.
One REST aggregation layer can be a reasonable option here. Infrai exposes a plain REST surface, a public self-describing discovery document, runnable examples for documented capabilities in 10 languages, and 295 routes across 20 modules under one key. That combination reduces a solo founder's specific friction: checking the available capability does not require installing another SDK, and switching the backend behind the adapter does not require changing the event contract. It does not remove the need for application-owned records or polling.
Which competitor fits the boundary?
There is no universal winner. Choose around the workflow already operated.
SendGrid fits an email-first service that values hosted email templates and email delivery tooling. Pick it when the SMS portion is small or already has a separate, well-owned adapter. Its email orientation is a boundary, not a defect.
Twilio fits a messaging-first service where SMS is central and the communications roadmap may expand. Its broad product surface can be useful, but it increases the importance of keeping provider status names and template IDs behind your own interface.
Amazon SES fits an AWS-centric service with email as the main channel. It keeps email close to existing cloud controls, yet a two-channel compliance notice still needs a separate SMS integration and one audit schema.
A shared REST layer fits a one-person team that values one HTTP convention across backend capabilities. Its limitations are concrete: it is not suitable if a required region, vendor, hosted template workflow, or delivery state is unavailable. SendGrid, Twilio, or SES may be the better choice then. The abstraction buys optionality only when the records and decision rules above it remain yours.
What should the weekly operating loop check?
Schedule status polling instead of treating a send response as final delivery. Pull email event records and SMS event records, match them by provider message ID, and append a status transition rather than overwriting the only row. The email event list and SMS status or event lookups have different provider shapes, so normalize them into your own state machine.
Poll newer notices more often than old delivered records. Keep failed and unknown states visible. Do not silently turn an unresolved status into success.
The boundaries are important. There is no hosted email OTP interface, no SMTP relay, and no voice, WhatsApp, or RCS channel. Email scheduling cannot be canceled, while SMS has a cancel operation. There is no tag-aggregated cost report, and SMS anti-abuse rules such as geographic fencing or per-country spend breakers belong in the application. A pending domestic email vendor cannot be treated as domestic compliance evidence.
The decision rule is straightforward: own the compliance template and event record, outsource delivery mechanics, and keep the adapter narrow enough to replace. That gives a marketplace notice a durable audit trail without making a solo SaaS maintain every communications primitive.
Top comments (0)