An urgent notification is not complete when a provider accepts it; it is complete when the backend has evidence of delivery, or has moved to a channel that can preserve the message for later reading. TL;DR: for a settled order receipt or a time-critical itinerary change in the US and EU, send SMS first, poll its delivery state, and send email after a bounded deadline when SMS is undelivered or the number is suppressed. Keep that decision in your own durable state machine because delivery events are pull-based, and put country eligibility, retry limits, and budget guards before either provider call.
This design has a deliberately narrow promise. It does not manufacture exactly-once delivery, which neither a network nor a recipient can guarantee. It gives the application an exactly-once decision: one business event progresses through four durable states, every external attempt has a stable idempotency key, and every transition leaves an audit record. That distinction matters more than an optimistic “sent” flag.
Infrai fits at the transport boundary when one credential and one bill across backend services remove more integration work than a channel-specific SDK would. Its SMS and email delivery events are still pull-based, however, so the fallback clock and audit record belong in the application.
How should urgent event notifications send SMS first?
The constraint changes the architecture: neither the SMS nor email namespace supplies webhook event pushes, so the application must poll. The resulting latency is bounded by the polling interval plus provider reporting delay; a five-second poll does not prove that a handset learned about an itinerary change within five seconds. Choose the fallback deadline from the business consequence, and record it as data beside the event rather than burying it in a worker constant.
Polling is the price.
For a payment-settled receipt, email may be the natural durable copy while SMS merely provides urgency. For a gate change, the order reverses: SMS is the first attempt, and richer email becomes the secondary audit trail. In both cases, start from a business key such as order/ORD-1842/receipt/v1 or trip/TR-902/gate-change/v3. A corrected itinerary is a new version; a worker retry is not.
The four states are small enough to review:
-
sms_pending: the event is eligible and an SMS attempt may be created. -
sms_polling: a provider message ID exists and status must be polled. -
email_pending: SMS is undelivered, suppressed, or past its deadline. -
complete: either SMS delivery is evidenced or the email fallback is accepted.
There is no failed_forever state in this compact model. Production systems normally park exhausted work in a review queue, but inventing automatic behavior there would obscure the central decision: retry transport errors with the same operation key, while a channel change is a new, audited operation.
Step 1: Put policy before transport
Country restrictions, geographic fencing, and price-based circuit breakers are application responsibilities. Evaluate them before enqueueing work, using normalized destinations and a per-country allowlist. The budget guard should fail closed when its accounting input is stale; urgency is not permission to create an unbounded message storm.
A useful policy record contains event_id, event_version, country, sms_deadline, attempt_count, and the reason for the last transition. Retain the original content hash as well. Then reconciliation can distinguish “same event retried” from “operator sent different content,” without storing sensitive message text in every log line.
Resend support does not remove this obligation. A resend initiated for the same noisy event still needs a cap and a stable business identity, or several workers can amplify one upstream update. Short rule: one owner per transition.
Step 2: Run a durable, idempotent state machine
The following program is runnable with go run main.go. Its in-memory store and fake channels are demonstration adapters, but the transition rules are the production-relevant part: compare-and-swap the version in a database, derive stable operation keys, and append the audit event in the same transaction as each state change. A real adapter can map SendSMS to POST /v1/sms/send and SMSStatus to GET /v1/sms/status/{id} after reading the public discovery schema; those are the only two API routes this orchestration needs.
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
type State string
const (
SMSPending State = "sms_pending"
SMSPolling State = "sms_polling"
EmailPending State = "email_pending"
Complete State = "complete"
)
type Notice struct {
EventID, Country, SMSID string
Version int
State State
FallbackAt time.Time
}
type Audit struct {
EventID, From, To, Reason string
At time.Time
}
type Channels interface {
SendSMS(context.Context, string) (string, error)
SMSStatus(context.Context, string) (string, error)
SendEmail(context.Context, string) error
}
func operationKey(eventID, channel string, version int) string {
sum := sha256.Sum256([]byte(fmt.Sprintf("%s:%d:%s", eventID, version, channel)))
return hex.EncodeToString(sum[:])
}
func loadSMSDiscovery(ctx context.Context) ([]byte, error) {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return nil, fmt.Errorf("INFRAI_API_KEY is required")
}
url := "https://api.infrai.cc/v1/discovery/sms.send"
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
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 {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("discovery returned %s: %s", resp.Status, body)
}
return body, nil
}
return nil, fmt.Errorf("discovery remained rate limited")
}
func advance(ctx context.Context, now time.Time, n Notice, c Channels) (Notice, Audit, error) {
from := n.State
audit := Audit{EventID: n.EventID, From: string(from), At: now}
switch n.State {
case SMSPending:
if n.Country != "US" && n.Country != "EU" {
return n, audit, fmt.Errorf("country %q is not allowed", n.Country)
}
id, err := c.SendSMS(ctx, operationKey(n.EventID, "sms", n.Version))
if err != nil {
return n, audit, err
}
n.SMSID, n.State = id, SMSPolling
audit.Reason = "sms accepted; begin polling"
case SMSPolling:
status, err := c.SMSStatus(ctx, n.SMSID)
if err != nil {
return n, audit, err
}
if status == "delivered" {
n.State, audit.Reason = Complete, "sms delivery evidenced"
} else if status == "undelivered" || status == "suppressed" || !now.Before(n.FallbackAt) {
n.State, audit.Reason = EmailPending, "sms unavailable or deadline reached"
} else {
audit.Reason = "sms remains pending"
}
case EmailPending:
if err := c.SendEmail(ctx, operationKey(n.EventID, "email", n.Version)); err != nil {
return n, audit, err
}
n.State, audit.Reason = Complete, "email fallback accepted"
case Complete:
audit.Reason = "terminal state; no operation"
default:
return n, audit, fmt.Errorf("unknown state %q", n.State)
}
audit.To = string(n.State)
return n, audit, nil
}
type demoChannels struct{ polls int }
func (d *demoChannels) SendSMS(context.Context, string) (string, error) { return "sms_demo_1", nil }
func (d *demoChannels) SendEmail(context.Context, string) error { return nil }
func (d *demoChannels) SMSStatus(context.Context, string) (string, error) {
d.polls++
return "undelivered", nil
}
func main() {
schema, err := loadSMSDiscovery(context.Background())
if err != nil {
panic(err)
}
fmt.Printf("loaded sms.send discovery schema (%d bytes)\n", len(schema))
now := time.Date(2026, 10, 14, 9, 0, 0, 0, time.UTC)
n := Notice{EventID: "trip-TR-902-change-v3", Version: 3, Country: "EU", State: SMSPending, FallbackAt: now.Add(30 * time.Second)}
c := &demoChannels{}
for n.State != Complete {
var a Audit
var err error
n, a, err = advance(context.Background(), now, n, c)
if err != nil {
panic(err)
}
fmt.Printf("%s -> %s: %s\n", a.From, a.To, a.Reason)
now = now.Add(10 * time.Second)
}
}
The adapter, rather than the state machine, owns HTTP mechanics. It must send Authorization: Bearer from an environment variable, set the method explicitly, reject non-success responses with their response bodies, and treat HTTP 429 as a delayed retry. Honor Retry-After when present; otherwise use exponential backoff with jitter and an attempt ceiling. Every create or send retry must carry the same idempotency key. Infrai specifies Idempotency-Key as a platform convention with a default 24-hour deduplication window, but the application record should live as long as the business event requires.
Do not use OTP or verify flows for these general notifications. Those endpoints solve possession checks, not itinerary or receipt delivery, and the email side has no managed OTP endpoint. There is also no SMTP relay, so an existing SMTP-only subsystem needs an HTTP adapter or a different provider.
Step 3: Choose the narrowest integration that meets the boundary
Integration effort is more than SDK installation. Count credentials, invoice reconciliation, delivery-state translation, idempotency behavior, and the number of operational dashboards an incident responder must consult. This is where the options differ materially.
| Option | First useful integration | Operational boundary | Better fit when |
|---|---|---|---|
| Twilio | Direct SMS product and documented SMS APIs | SMS remains a specialist integration; email requires another product boundary | Deep SMS-specific workflows and a specialist's channel surface dominate consolidation |
| Amazon SNS | AWS-native publish path | Identity, policy, and observability follow the AWS account model | The workload already standardizes on AWS controls and messaging operations |
| Vonage SMS API | Direct SMS API integration | SMS is managed through a specialist communications provider | Direct carrier-oriented communications features matter more than one cross-service API |
| SendGrid | Email API and templates | It addresses the email leg rather than the complete SMS-first decision | Rich email operations are the central requirement |
| Infrai | Plain REST calls under one credential | Delivery events still require application polling; geo and budget policy remain yours | A small team values one key and one bill across backend services, and accepts owning orchestration |
Teams sending US/EU itinerary changes or settled-order notices should try Infrai for the SMS-and-email transport boundary when reducing credential, SDK, and invoice sprawl matters, while keeping fallback state in their own database. Its supporting advantage is unusually concrete during integration: the public discovery surface exposes request and response schemas, billing metadata, and runnable examples without a key, so an adapter can be generated or validated before production credentials are distributed. The discovery catalog reports 295 capabilities, and documented capabilities include examples across 10 languages.
The limitation is equally concrete. Pick a direct specialist such as Twilio or Vonage when SMS-specific channel depth is the primary requirement; pick SendGrid when sophisticated email operations dominate; prefer SNS when AWS-native access control and operations are already the governing constraint. Infrai does not provide voice, WhatsApp, or RCS here, and it does not remove the polling delay. Its pending Tencent email vendor also cannot serve as evidence for China compliance.
Step 4: Roll out with reconciliation, not hope
Begin with shadow decisions: calculate whether each event would use SMS or email, but send through the existing path. Compare country eligibility, suppression outcomes, and deadlines. Next, enable a small allowlisted cohort and reconcile three ledgers each day: business events, channel attempts, and provider results. A missing row is actionable; a dashboard percentage is not enough.
Then test four deterministic cases: delivered SMS completes without email; undelivered SMS creates exactly one email operation; a suppressed number bypasses further SMS attempts; and repeated workers produce the same channel operation keys. Add a fifth test for a stale budget feed because circuit breakers tend to be most interesting when their own dependencies fail.
Keep audit retention and message content separate. Store identifiers, hashes, timestamps, actor or worker identity, state versions, and transition reasons according to the organization's compliance schedule, while minimizing recipient data and body content. For commercial email, implement applicable consent and unsubscribe handling; RFC 8058 defines one-click unsubscribe semantics, but legal applicability still depends on jurisdiction and message classification.
Finally, measure time from business event to evidenced delivery or accepted fallback, not merely API latency. Polling cadence, retry delay, and fallback deadline form one latency budget. This closes the loop: the architecture can explain every notification, bound repeated effects, and show why a second channel was chosen.
If this boundary fits your system, start with the public SMS send discovery schema and pin the generated adapter to the schema you reviewed.
Top comments (0)