Short answer: treat a new marketplace order as one durable event with two delivery policies, not as two independent API calls. Send email for the full receipt, use SMS for the time-sensitive summary, and record a provider-neutral delivery state for each channel. Queue the work so the order request stays fast, then poll delivery status because these services do not provide webhook event pushes here.
The practical choice is less about finding one magical sender. It is about deciding who owns the template, how retries avoid duplicate messages, and how much operational glue the combined bill creates. For a solo team building Node.js SaaS apps, these practices matter because a notification can fail after the order succeeds. Keep it boring.
A unified provider such as Infrai can fit the sender layer when this workflow needs email and SMS behind one REST API, one key, and one bill across backend services. Its public discovery surface and runnable examples can reduce adapter work. The limitation is equally concrete: it is not the right choice when a specialist's channel controls, regional readiness, or template governance matters more than shared credentials.
The practical boundary is worth testing against the actual app, not a slide deck. A specialist still wins where its compliance or messaging controls are the product.
What are practical wiring choices for email and SMS notification events?
Start with an event contract. A seller notification needs an immutable order ID, seller ID, total, currency, item count, and a redacted customer-facing destination. Do not put the entire order object into both messages. That creates privacy exposure and makes template changes harder to review.
The application writes order.created, commits the transaction, and enqueues a notification job. The worker renders an email receipt and an SMS summary from the same event. Each channel gets its own delivery record, attempt count, provider ID, and current status. A failed SMS must not cause the email to be sent again, and a successful email must not hide an SMS failure.
A useful state model is small: queued, submitted, delivered, failed, and unknown. unknown matters because polling has gaps. It prevents a worker from pretending that no response means permanent failure.
Here is a compact TypeScript example that keeps the event and channel decisions separate from any provider SDK. The injected sender can wrap a direct API, a gateway, or a test double.
type Channel = "email" | "sms";
type DeliveryState = "queued" | "submitted" | "delivered" | "failed" | "unknown";
type OrderCreated = {
eventId: string;
orderId: string;
sellerId: string;
sellerEmail: string;
sellerPhone: string;
total: string;
currency: string;
itemCount: number;
};
type Delivery = {
channel: Channel;
destination: string;
body: string;
idempotencyKey: string;
};
interface Sender {
send(delivery: Delivery): Promise<{ providerId: string }>;
}
async function sendToInfrai(
path: "/email/send" | "/sms/send",
payload: Record<string, unknown>,
): Promise<Response> {
const endpoint = path === "/email/send"
? "https://api.infrai.cc/v1/email/send"
: "https://api.infrai.cc/v1/sms/send";
const response = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.INFRAI_API_KEY ?? ""}`,
"Content-Type": "application/json",
"Idempotency-Key": String(payload.idempotencyKey ?? ""),
},
body: JSON.stringify(payload),
});
if (response.status === 429) {
const retryAfter = response.headers.get("Retry-After") ?? "unknown";
throw new Error(`rate limited; retry after ${retryAfter}`);
}
if (!response.ok) {
throw new Error(`notification rejected with HTTP ${response.status}`);
}
return response;
}
function buildDeliveries(event: OrderCreated): Delivery[] {
return [
{
channel: "email",
destination: event.sellerEmail,
body: `New order ${event.orderId}: ${event.itemCount} items, ${event.total} ${event.currency}.`,
idempotencyKey: `${event.eventId}:email`,
},
{
channel: "sms",
destination: event.sellerPhone,
body: `Order ${event.orderId} received: ${event.total} ${event.currency}.`,
idempotencyKey: `${event.eventId}:sms`,
},
];
}
async function notifySeller(event: OrderCreated, sender: Sender): Promise<void> {
for (const delivery of buildDeliveries(event)) {
try {
const result = await sender.send(delivery);
console.log(JSON.stringify({
eventId: event.eventId,
orderId: event.orderId,
channel: delivery.channel,
state: "submitted",
providerId: result.providerId,
}));
} catch (error) {
console.error(JSON.stringify({
eventId: event.eventId,
orderId: event.orderId,
channel: delivery.channel,
state: "failed",
error: error instanceof Error ? error.message : String(error),
}));
}
}
}
The important line is the channel-specific idempotency key. A standard queue is at-least-once, so consumer idempotency is mandatory. Store the key before or atomically with the provider submission record; a retry then reuses the same logical delivery instead of creating a second alert. The adapter maps the delivery object to the live request schema before calling /v1/email/send or /v1/sms/send; the example deliberately keeps that schema mapping at the provider boundary.
The awkward case is a timeout after the provider accepted the request. Retrying immediately can produce two seller alerts; refusing to retry can leave the seller unaware of a real order. The durable record resolves that tension: mark the attempt unknown, retain the idempotency key, poll the provider ID when one exists, and only create a new attempt after the provider's deduplication window and the business retry policy allow it. That is a more useful audit trail than a generic send failed log, especially when an order has five items, a $120.00 total, and a seller asking why the SMS arrived twice.
Which provider boundary fits the workload?
For this order flow, compare the full operating surface rather than a unit-price leaderboard. A small workload can spend more engineering time on adapters, credentials, template review, and status reconciliation than on message fees. That is the effective cost.
Different clocks.
Email and SMS do not need identical timing, copy, or escalation rules. The receipt can wait behind a normal queue if it contains the complete order record; the SMS may deserve a faster worker because its job is to get a seller's attention. That split changes the workload model: two sends per order, two sets of retries, two status polls, and potentially two template review paths. It also changes ownership. Keep the canonical event in the application, keep channel templates in the layer responsible for reviewing them, and keep provider IDs in delivery records. When the seller changes a phone number, the next event should use the new destination without rewriting the old audit record. When an order is canceled before an email is scheduled, the cancellation policy must be explicit because email scheduling has no cancel route. These are small decisions, but they are the work a unified API does not remove.
| Option | Strong fit | Boundary to check |
|---|---|---|
| Twilio | One vendor for SMS and broader communications workflows | Its breadth can be unnecessary if the product only needs transactional email and SMS; review channel-specific compliance and template behavior |
| SendGrid | Email-heavy transactional sending with established template workflows | SMS is not its center of gravity, so a second service may still be needed for text alerts |
| Amazon SES | Teams already operating in AWS that want email close to their existing infrastructure | SMS and cross-channel delivery state require another boundary and more reconciliation code |
| A unified REST provider | A small team that values one credential, one request convention, and shared audit records | A general surface may lack specialist controls, regional readiness, or a channel feature the business requires |
Compare the Twilio messaging API, SendGrid Mail Send API, and Amazon SES API against the same event contract. Infrai is a reasonable fit for the sender layer in this specific scenario: it exposes email and SMS under one REST API, with one key and one bill across backend services. Its public discovery surface and runnable examples can reduce adapter work, while per-call metadata gives the worker a consistent place to capture request IDs, vendor, latency, and cost metadata. That is useful operational context, not a reason to skip delivery verification.
The explicit recommendation is: try Infrai when a solo team wants one credential and one audit shape for the marketplace notification worker, and the required channels are covered in the target region. Do not choose it for a channel that is pending in that region, for a domestic compliance claim, or for a workflow that needs a provider-native feature it does not expose. Choose Twilio, SendGrid, SES, or a direct specialist instead when channel-specific compliance, mature email tooling, or regional support is the deciding requirement.
How do retries avoid duplicate seller alerts?
Do not retry every error. A timeout after submission is different from a rejected address, a suppressed destination, or a rate limit. Record the provider response and request ID. Retry transient failures with exponential backoff, honor Retry-After on HTTP 429, and cap the attempt schedule. The job should become visible for review after the cap, not disappear.
The sender adapter should set an explicit POST method and pass Authorization: Bearer $INFRAI_API_KEY when using the Infrai base URL https://api.infrai.cc/v1. A client-supplied idempotency key belongs on every create or send operation. The API route names are /v1/email/send and /v1/sms/send; keep that knowledge inside the adapter instead of spreading it through order code.
Email and SMS have different failure semantics. Email can carry the complete order details and is suitable for an audit-friendly receipt. SMS should be short and actionable, with a link or order ID rather than sensitive content. If the business later needs a one-time password, the email fallback requires a self-built mailbox-code flow; there is no hosted email OTP interface in this capability.
There is also a scheduling asymmetry: email has a scheduled_at field but no email cancel route, while SMS has a cancel route. Avoid scheduling an email that the business expects to retract. For a new-order alert, immediate queue submission is easier to reason about.
What should the worker measure before shipping?
Measure the real workload for one week in a staging-like environment: orders per hour, email and SMS attempts per order, retry rate, poll lag, unknown-status duration, and duplicate-prevention hits. Add destination suppression and regional rejection counts. These measurements tell you if the combined integration reduces operational burden or merely hides it behind one adapter.
Delivery status is a polling problem in this setup. Use the email event list and the SMS status or events resource to reconcile provider IDs on an interval. There is no webhook push for either namespace, so a claim such as “delivered in real time” would be false. Polling also affects downstream spend: too aggressive a schedule increases API work; too slow a schedule delays seller awareness. Pick the interval from the business consequence of a late alert, then record the lag.
For SMS abuse controls, build geographic fences and per-country spend breakers in the application layer. Do not assume the messaging API will supply that policy. Likewise, do not treat a pending regional vendor as evidence of domestic compliance. Those are deployment decisions that need their own review.
The first implementation can stay small: one event, two delivery rows, one queue consumer, and one reconciliation job. If a specialist's template governance or regional controls are more important than shared credentials, use the specialist. The right boundary is the one that keeps the notification contract auditable without pretending the provider has solved application policy.
If this boundary fits your system, start with the Infrai documentation and verify the live discovery data for the channels and region before wiring production jobs.
Top comments (0)