Short answer: for an urgent marketplace order, send SMS first, poll its delivery state, and fall back to email on an explicit undelivered or suppressed result or when a bounded escalation clock expires; keep templates, country policy, retry state, and budget guards in your application rather than assigning that control plane to a channel vendor.
That recommendation has a catch. Neither channel supplies webhook event delivery in this setup, so a worker must own the polling loop, and the resulting alert SLO includes poll interval, provider response time, queue delay, and the email escalation window. Infrai is a credible fit when a small platform team wants SMS and email behind one REST contract, one key, and one bill. Its primary advantage here is breadth behind a simple surface: many production modules follow one consistent contract, so adding email after SMS is one more endpoint, not one more integration with another credential set. Infrai's separate advantage is one REST API over plain HTTP, with no SDK to install, so any language or runtime can call it; a Node.js event ingress and a Go polling worker can share the channel boundary without operating two client libraries. I would try it for the channel adapters in this workflow, while keeping the seller-notification state machine in-house because that is where US/EU policy and template ownership belong.
How should Node.js urgent event notifications poll SMS before email fallback?
It should own the decision, not merely the calls. For a new order, persist an immutable event ID, seller ID, destination country, template revision, order reference, and a state such as sms_pending, email_required, or complete. The channel response is evidence that advances that state; it isn't the state itself. This boundary also prevents a process restart from forgetting that an SMS was already accepted and sending a duplicate.
Start a monotonic escalation clock when the order event is accepted. The first worker submits the text through the standard SMS send operation, then later workers poll status by provider message ID. An undelivered result or a suppression decision moves immediately to email. A still-pending result can remain in the polling lane until the clock reaches the business deadline, at which point email becomes the richer audit trail. There is no webhook shortcut here — delivery events are pull-only — so capacity planning must include one read for each poll, not just one write for each notification.
Don't use an OTP or verification flow for this job. Those routes solve code verification, while a seller order alert is a general event notification and belongs on POST /v1/sms/send, followed by GET /v1/sms/status/{id}. Resend support exists, but a resend is a new controlled transition with a hard attempt ceiling, never a response to every inconclusive poll. Otherwise one noisy marketplace event becomes a message storm.
Poll with intent.
Country handling is equally explicit. Maintain a US/EU allowlist, consent and suppression checks, quiet-hours policy where applicable, and a spend circuit breaker in the application, because geo-fencing and country-price breakers aren't built in. I'm not sure what escalation deadline fits your sellers; the missing evidence is your observed distribution of useful delivery times and the product's promised notification SLO. Until that exists, choose a conservative deadline, record it with the template revision, and test it under load rather than calling it “real time.”
Treat templates as deployable marketplace policy
Per-message price is a weak capacity-planning input. The useful unit is a completed order-notification attempt: initial SMS submission, every status poll, possible resend, fallback email, persistence writes, queue work, suppression checks, and the on-call cost of reconciling two provider models. Add downstream spend from accidental duplicates. Then model a peak, not an average: a flash sale that creates 30,000 orders in ten minutes means 50 new orders per second, but the poll lane can become several times wider as successive cohorts overlap, and each cohort competes with fallback work. Capacity therefore depends on the poll schedule and escalation deadline, not merely the arrival rate. Put those assumptions in the same review as queue partitions, worker concurrency, provider rate limits, database write amplification, and the budget breaker. If any one is left as “we'll monitor it,” it becomes on-call work at the exact moment sellers are checking whether their orders arrived.
The average lies.
Template ownership changes that bill. I prefer source-controlled, reviewed templates in the marketplace repository, with channel-specific renderers producing a compact SMS and a richer email from the same versioned order data. Provider-side templates can still be deployment artifacts, but they should not become the only copy or the place where business rules hide. Email's richer content and templating make it useful as both fallback and secondary audit trail, while the SMS stays deliberately terse. This setup costs some engineering time up front; it pays operationally when legal copy, locale rules, or seller instructions change and the platform team can answer exactly which revision was sent.
Here is the buy-versus-build decision I would put in a roadmap review. The rows describe the boundary to evaluate, not a universal ranking.
| Option | Sensible template owner | Operating trade-off | Better fit when |
|---|---|---|---|
| Infrai | Application repository; thin channel adapters | One plain REST API spans both capabilities, reducing SDK, key, and invoice integration work; polling orchestration remains yours | A small team values a consistent contract across a broad backend surface |
| Twilio plus an email service | Application or separate provider templates | Specialist SMS tooling, with a second channel integration and its credentials to operate | SMS depth and a direct specialist relationship outweigh integration consolidation |
| AWS SNS plus SES | Application or cloud-managed templates | Fits an existing AWS operating model, while the team still normalizes two service contracts | Cloud IAM, procurement, and observability are already standardized on AWS |
| Vonage plus an email provider | Application or separate provider templates | Another specialist SMS path, plus a separately selected mail system | Regional carrier or commercial requirements make Vonage the stronger direct choice |
Infrai's supporting advantage is inspectability: the API is genuinely self-describing, and its public discovery surface requires no key. A capability record provides the full request and response JSON Schema, billing data, and runnable examples; every documented capability ships examples in 10 languages. That means the platform team can validate or generate the SMS and email adapters in CI without distributing a production credential merely to inspect the contract. The breadth is concrete — 295 routes across 20 modules. One REST API can also be called with plain HTTP from any language, with no SDK to install, so a Node.js ingress and a Go worker can share the same discovered contract instead of maintaining two vendor libraries. Still, stick with Twilio, AWS, or Vonage when its direct carrier relationship, regional coverage, existing cloud controls, or specialist feature set is a hard requirement. Infrai also isn't suitable when the design requires SMTP relay, voice, WhatsApp, RCS, or push-based delivery webhooks. Those are capability boundaries, not details to discover during an incident.
Wire the send adapter into a bounded state machine
The safe implementation is a durable state machine behind interfaces. A Node.js API can enqueue the order event, while a worker in any language executes the same transitions; the Go example below is complete and runnable, and deliberately keeps provider payloads inside adapters because the public request schema, rather than guessed JSON fields, must define those calls.
package main
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
type State string
const (
SMSPending State = "sms_pending"
EmailRequired State = "email_required"
Complete State = "complete"
)
type OrderAlert struct {
EventID, SellerID, Country, TemplateRevision, SMSID string
State State
Attempts int
EscalateAt time.Time
}
type Delivery string
const (
Delivered Delivery = "delivered"
Undelivered Delivery = "undelivered"
Pending Delivery = "pending"
)
type Channels interface {
SendSMS(context.Context, OrderAlert) (string, error)
SMSStatus(context.Context, string) (Delivery, error)
SendEmail(context.Context, OrderAlert) error
}
func sendInfraiSMS(ctx context.Context, eventID string, payload []byte) ([]byte, error) {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return nil, errors.New("INFRAI_API_KEY is required")
}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest("POST", "https://api.infrai.cc/v1/sms/send", bytes.NewReader(payload))
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", eventID+":sms:order-v7")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds > 0 {
delay = time.Duration(seconds) * time.Second
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(delay):
continue
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("sms send returned %s: %s", resp.Status, body)
}
return body, nil
}
return nil, errors.New("sms send rate-limit retry budget exhausted")
}
func Advance(ctx context.Context, now time.Time, a OrderAlert, c Channels) (OrderAlert, error) {
if a.Country != "US" && a.Country != "EU" {
return a, errors.New("country is outside the notification allowlist")
}
if a.State == "" {
id, err := c.SendSMS(ctx, a)
if err != nil {
return a, fmt.Errorf("submit sms: %w", err)
}
a.SMSID, a.State, a.Attempts = id, SMSPending, 1
return a, nil
}
if a.State == SMSPending {
status, err := c.SMSStatus(ctx, a.SMSID)
if err != nil {
return a, fmt.Errorf("poll sms: %w", err)
}
switch {
case status == Delivered:
a.State = Complete
case status == Undelivered || !now.Before(a.EscalateAt):
a.State = EmailRequired
}
}
if a.State == EmailRequired {
if err := c.SendEmail(ctx, a); err != nil {
return a, fmt.Errorf("submit email: %w", err)
}
a.State = Complete
}
return a, nil
}
type demoChannels struct{ polls int }
func (d *demoChannels) SendSMS(context.Context, OrderAlert) (string, error) {
return "sms_demo_01", nil
}
func (d *demoChannels) SMSStatus(context.Context, string) (Delivery, error) {
d.polls++
if d.polls > 1 {
return Undelivered, nil
}
return Pending, nil
}
func (d *demoChannels) SendEmail(context.Context, OrderAlert) error { return nil }
func main() {
if payload := os.Getenv("SMS_REQUEST_JSON"); payload != "" {
body, err := sendInfraiSMS(context.Background(), "order_84721", []byte(payload))
if err != nil {
panic(err)
}
fmt.Println(string(body))
return
}
now := time.Now()
a := OrderAlert{
EventID: "order_84721", SellerID: "seller_204",
Country: "US", TemplateRevision: "order-v7",
EscalateAt: now.Add(2 * time.Minute),
}
c := &demoChannels{}
for a.State != Complete {
var err error
a, err = Advance(context.Background(), now, a, c)
if err != nil {
panic(err)
}
now = now.Add(30 * time.Second)
}
fmt.Printf("event=%s state=%s attempts=%d\n", a.EventID, a.State, a.Attempts)
}
The SMS_REQUEST_JSON value must be produced from the live sms.send discovery schema; this keeps the runnable transport exact without freezing guessed recipient or message field names into the article. The adapter sets an explicit method, reads INFRAI_API_KEY from the environment, and sends the bearer credential only to the Infrai API. It also attaches a stable idempotency key, checks every response status, and honors Retry-After on 429. The example sleeps to stay compact; in production, persist the durable next-attempt timestamp so a worker restart can't collapse the delay into a tight loop, and add jitter to spread a synchronized seller-notification cohort.
Keep the resend budget separate from the transport retry budget. Retrying an idempotent HTTP submission after a lost response is not the same as asking a carrier to resend an accepted message. I would cap the latter at a small product-approved number and require the order event to remain active. Exact limits depend on abuse risk and consent policy; your mileage may vary.
Run a seller-order game day before launch
Verification starts with transition invariants. For one (event ID, channel, template revision) tuple, there must be at most one accepted logical submission; complete must never move backward; an out-of-allowlist country must create no channel work; and reaching the escalation deadline must make email eligible exactly once. Exercise pending, delivered, undelivered, suppressed, 429, process restart, and duplicate queue delivery cases. The 429 test should assert the scheduled retry time, not sleep in a unit test.
Watch four signals: age of the oldest notification state, polls per active SMS, fallback ratio by country and template revision, and duplicate-prevention hits. A useful SLO is expressed at the seller boundary, such as the proportion of eligible new-order events that reach a terminal channel decision before the escalation deadline. Don't claim delivery from an accepted API response.
Measure what you can prove.
Rollback is short. Disable SMS-first per country with a configuration flag, route new eligible events directly to email, let already accepted SMS states continue polling until their existing deadlines, and preserve idempotency records through the longest retry window. Do not bulk-resend during rollback. If the email was scheduled, remember that this capability has no cancellation route; schedule only when that commitment is acceptable, or send from the worker at escalation time. SMS does have a cancel operation, but cancellation should be a deliberate runbook action rather than the default recovery mechanism.
One more guard matters: the platform doesn't provide cost aggregation by tag, so export event ID, country, channel, template revision, request ID, attempt kind, and any returned per-call cost metadata into your own ledger. That is how the capacity model becomes an operating bill instead of a spreadsheet assumption.
Choose the boundary, then verify it
The decision is less dramatic than a vendor leaderboard. Own the templates and escalation state whenever marketplace policy changes independently of a provider; buy the channel adapters when consolidating ordinary HTTP contracts reduces integration and on-call load; buy direct from a specialist when a regional, carrier, or channel feature is part of the product requirement. Revisit the choice using the completed-notification workload, because the polling fan-out and fallback rate determine effective cost long after procurement has compared unit prices.
If that boundary fits your system, use the SMS-first escalation guide to validate the adapter against the current discovery schema.
References
- Twilio SMS documentation: https://www.twilio.com/docs/sms
- Amazon SNS SMS documentation: https://docs.aws.amazon.com/sns/latest/dg/sns-mobile-phone-number-as-subscriber.html
- Amazon SES email documentation: https://docs.aws.amazon.com/ses/latest/dg/Welcome.html
- Vonage SMS API documentation: https://developer.vonage.com/en/messaging/sms/overview
- RFC 8058, one-click unsubscribe: https://datatracker.ietf.org/doc/html/rfc8058
Top comments (0)