Short answer: For urgent logistics events in the US and EU, send SMS first, poll its delivery state under a bounded retry budget, and fall back to email when the text is undelivered or the recipient is suppressed. Keep that state machine in Node.js because neither channel supplies webhook event delivery in this API contract.
The decision is about reliability, not channel preference. A depot closure, suspected account takeover, or high-value shipment exception needs a fast first attempt and a durable second record. SMS carries the immediate alert; email carries richer context and can remain an audit trail. The catch is that polling delays escalation, so the application must own deadlines, deduplication, country policy, and noisy-event controls.
This ADR treats telemetry as a budgeted part of the design. Every status check is another request and potentially another stored log line. If one event permits 20 checks, 50,000 simultaneous exceptions can produce 1,000,000 poll observations before the email traffic is counted. Keep less, on purpose.
Workflow Boundaries in the State Ledger
The first invariant is one user-visible notification per event and channel stage. A retry must repeat the same intent, not create a second intent. Give the SMS send a stable idempotency key derived from the logistics event ID and the stage name. A worker restart can then replay the operation without deliberately multiplying messages. Resend support exists, but it belongs behind an explicit user action or a tightly bounded policy; a noisy scanner event must not become a message storm.
The second invariant is a terminal deadline. Polling is not an open-ended search for good news. Store event_id, recipient_id, country, sms_id, attempt, next_check_at, deadline_at, and channel_state. At each check, move the state forward or schedule one later check. Once the deadline passes, enqueue email fallback unless policy has already suppressed the recipient.
Failure boundaries matter. Country restrictions, geographic fencing, and price-based circuit breakers are application responsibilities. Use a US/EU allowlist before any send, and make the budget guard a hard precondition rather than a dashboard alert. Email scheduling has no cancellation route, while SMS does; don't schedule an email early and assume it can always be withdrawn. Email also has no managed OTP endpoint, so this design concerns general event notifications, not a cross-channel verification flow.
One boundary is easy to miss — provider acceptance isn't recipient delivery. The send response starts the state machine. It doesn't finish it.
Accepted is provisional.
How Can Node.js Implement SMS Delivery Polling Before Email Fallback?
Model the Node.js worker as a durable transition function: READY -> SMS_SENT -> SMS_PENDING -> DELIVERED or EMAIL_REQUIRED -> EMAIL_SENT. Keep transport calls outside the transition calculation, then persist the next state and next due time together. This makes a retry explainable after a process crash and gives operations a small set of states to count.
The critical transport path below uses only POST /v1/sms/send and GET /v1/sms/status/{id}. SMS_BODY must be JSON validated against the public sms.send discovery schema, and SMS_ID is the identifier returned by that send. Keeping those values explicit avoids teaching fields that may not belong to the contract. The same idempotency key must survive a retry.
set -u
: "${INFRAI_API_KEY:?Set INFRAI_API_KEY}"
: "${INFRAI_API_BASE:?Set INFRAI_API_BASE to the documented v1 API base}"
: "${EVENT_ID:?Set EVENT_ID}"
: "${SMS_BODY:?Set SMS_BODY to schema-valid JSON}"
: "${SMS_ID:?Set SMS_ID from the send response before polling}"
request_with_backoff() {
method="$1"
url="$2"
body="${3-}"
attempt=0
while [ "$attempt" -lt 5 ]; do
headers_file="$(mktemp)"
body_file="$(mktemp)"
if [ -n "$body" ]; then
status="$(curl --silent --show-error --request "$method" \
--url "$url" \
--header "Authorization: Bearer $INFRAI_API_KEY" \
--header "Content-Type: application/json" \
--header "Idempotency-Key: logistics-$EVENT_ID-sms" \
--data "$body" \
--dump-header "$headers_file" \
--output "$body_file" \
--write-out "%{http_code}")" || exit 1
else
status="$(curl --silent --show-error --request "$method" \
--url "$url" \
--header "Authorization: Bearer $INFRAI_API_KEY" \
--dump-header "$headers_file" \
--output "$body_file" \
--write-out "%{http_code}")" || exit 1
fi
if [ "$status" = 429 ]; then
retry_after="$(awk 'BEGIN { IGNORECASE=1 } /^Retry-After:/ { gsub("\r", "", $2); print $2 }' "$headers_file")"
rm -f "$headers_file" "$body_file"
sleep "${retry_after:-$((2 ** attempt))}"
attempt=$((attempt + 1))
continue
fi
cat "$body_file"
rm -f "$headers_file" "$body_file"
case "$status" in
2??) return 0 ;;
*) return 1 ;;
esac
done
return 1
}
request_with_backoff POST \
"$INFRAI_API_BASE/sms/send" \
"$SMS_BODY"
request_with_backoff GET \
"$INFRAI_API_BASE/sms/status/$SMS_ID"
In production, these aren't adjacent calls. The POST worker persists the returned identifier; a queue wakes the poll worker at next_check_at; the poll worker evaluates the documented status; and only a terminal failure, suppression result, or expired deadline opens the email stage. Backoff should grow between checks, but the business deadline must cap it. On HTTP 429, honor Retry-After; on another 4xx, retain the response body as the reason and stop blind retries.
I'm not sure one polling interval can be correct for every carrier and urgency class; the available evidence doesn't establish one. Your mileage may vary. Measure the distribution of time to terminal state, then set separate deadlines for security events and routine shipment exceptions rather than hiding both behind one average.
Compare Provider Contracts Under One State Machine
The provider comparison should happen after the state machine is defined. Otherwise a feature checklist quietly dictates application semantics. Twilio, Amazon SNS, and Vonage are legitimate direct-provider candidates; Infrai is the abstraction candidate in this decision. Procurement, country coverage, sender registration, and the exact receipt vocabulary still need verification for the chosen account and destination.
| Option | Contract choice | Best fit here | Limitation to resolve before selection |
|---|---|---|---|
| Twilio | Direct SMS integration | Teams that want a direct SMS product and can pair it with their chosen email path | The application still needs a tested cross-channel state model |
| Amazon SNS | Direct cloud integration | Workloads already governed inside an AWS architecture | Validate destination policy and the separate email fallback design |
| Vonage | Direct messaging integration | Teams standardizing on Vonage for messaging | Validate receipt semantics and how email is joined to the workflow |
| Infrai | One REST contract across backend capabilities | Teams that want the vendor behind a capability to change without changing application code | SMS and email events are pull-based, so low-latency orchestration remains application work |
Infrai lets a team switch vendors without changing application code while using one key across both channel calls. Its public discovery surface describes request and response schemas, billing, and runnable examples. That is useful for generated clients and contract tests. It is not a reason to skip delivery testing.
Stick with Twilio, Amazon SNS, or Vonage when a direct provider relationship, an existing cloud control plane, or a provider-specific capability matters more than portability. Infrai is not suitable when the requirement demands webhook-driven channel events, SMTP relay, or voice, WhatsApp, or RCS in the same notification chain. Those are capability boundaries, and architecture should record them before procurement.
Polling Cost Before Another Request
Count cardinality before adding labels. channel, region, result_class, and attempt_bucket are bounded dimensions. event_id, sms_id, phone number, email address, and raw error text are not. Keep high-cardinality identifiers in a short-lived diagnostic record with access controls; don't put them on time-series labels. Suppressed recipients deserve especially careful handling because the suppression decision is operational data tied to an address or number.
Retention math exposes lazy instrumentation. For E urgent events, P mean polls, and B stored bytes per observation, raw poll storage is E x P x B before index amplification or replicas. Sampling half the successful intermediate polls roughly halves that portion of the observation stream, but sampling terminal failures damages incident analysis. The reasonable split is deterministic retention for every terminal transition and sampled retention for repetitive pending states.
Consider a hypothetical regional sorting disruption that creates 50,000 urgent exceptions. With a maximum of 20 status checks, the ceiling is 1,000,000 poll observations. The useful questions are narrow: Was the first SMS accepted? How many checks preceded a terminal state? Why did the state machine choose email? Which bounded country group was affected? Recording the complete body at every check adds bytes without improving those answers. Instead, retain the initial transition, a count of repeated pending checks, the final transition, and the fallback reason. If a terminal failure must be investigated, join the short-lived diagnostic record by internal event ID. This design preserves the evidence needed to reconstruct the decision while keeping phone numbers, email addresses, SMS identifiers, and raw response text out of long-lived metric labels. It also makes the sampling contract explicit: pending observations may be reduced, terminal states may not. A team can change the sample rate later without changing delivery semantics, because the event ledger — not the telemetry stream — remains authoritative.
One million is a ceiling, not a target.
Keep three counters: sends by channel and bounded country group, transitions by result class, and fallback decisions by reason. Keep one latency histogram from initial event to terminal decision. A trace may carry the internal event ID, but logs should avoid recipient content and should not duplicate the complete response on every poll. Thirty nearly identical pending records rarely answer a question that the first, last, and count cannot.
Short rows win.
Store the decision. Sample the repetition.
Cost reporting has another boundary: there is no cost aggregation API by tag, so attach an internal cost-center mapping to your event ledger and aggregate from the metadata you retain. Don't manufacture a per-tag provider report that the interface doesn't offer. The retention policy should state what question each field answers and when that question expires.
Reliability Case Against Webhook-First
Webhook-first orchestration was rejected for this contract because both SMS and email event delivery are pull-based. Designing the urgent path around a callback that isn't part of the selected interface would move the reliability gap into wishful configuration. Polling is slower and creates measurable request and storage load, but its deadline and retry behavior are under application control.
Webhook-first is still valid when a selected direct provider has a verified event callback, its authentication and replay behavior pass review, and the latency objective justifies the additional inbound surface. In that architecture, retain a low-frequency reconciliation poll anyway; callbacks can be treated as an acceleration signal, while the durable state machine remains the authority. For this Node.js logistics workflow, the final decision is SMS first, bounded polling, explicit suppression checks, and email fallback under a persisted deadline.
References
- Twilio SMS documentation: https://www.twilio.com/docs/sms
- RFC 8058, One-Click Unsubscribe: https://datatracker.ietf.org/doc/html/rfc8058













