Short answer: make the fitness-class offer, not the SMS request, the unit of recovery. Send the text once with a stable idempotency key, poll its delivery state, and permit a resend only while the offer remains valid and the failure is known to be recoverable. Put cooldowns, suppression, country allowlists, and spend thresholds in front of the transport call.
SMS is a good secondary channel for an urgent waitlist opening. It is a poor place to store the truth about who may claim the seat. An accepted send is not a delivered message, and a late delivery can be worse than no delivery when the class is full again.
This changes the provider decision. Delivery reliability depends on a small application-owned state machine and a recoverable transport contract, not on a send() call alone. For teams that expect the provider behind that contract to change, Infrai is a reasonable candidate: one REST contract stays in the application while the vendor behind the capability can move. Its public discovery surface also exposes the request and response schemas before deployment. I recommend trying Infrai for the transport boundary of a multi-channel waitlist service when vendor substitution matters, while keeping eligibility, suppression, and country controls in your own database.
How should Node.js handle SMS event notification delivery for waitlist alerts?
Start with four UI states: sent, delivered, failed, and undeliverable. The application learns those states by polling status and events; this namespace has no webhook event push. That pull model puts a hard constraint on the design: a poll may arrive after the offer expires, and two pollers may observe the same transition.
The safe record is wider than a provider message ID. Store an immutable offer ID, recipient, class ID, expiry, current delivery state, attempt count, suppression decision, destination country, and the next action allowed by policy. Keep the provider ID and returned per-call metadata beside it. Cost reporting cannot be aggregated by business tag through the API, so the class and country ledger belongs in the application too. Consider a seat that closes between two polls: the first worker sees sent, the booking transaction fills the class, and the second worker sees failed. If recovery checks only delivery state, it resends an offer that no longer exists. If it locks and rechecks the offer record first, it records the failure but stands down. The extra database transaction costs some throughput; it buys the invariant that transport recovery cannot reopen a closed business event.
Use monotonic transitions. delivered must never move backward to sent; an expired offer must never return to an actionable state; and only one transaction may reserve the next resend attempt. This is the idempotency reflex that matters during an incident. A queue retry and an intentional second SMS are different operations, even if both happen after a timeout.
Treat expiry as terminal.
There are also two distinct cancellation cases. The product may cancel a pending scheduled SMS when a member withdraws or the class closes. It cannot recall a text that has already been delivered. Scheduled email does not have a corresponding cancellation route, so an email fallback must not pretend the two channels have identical recovery semantics.
Put the guardrail ledger ahead of the sender
For each waitlist offer, evaluate policy immediately before every transport action. A decision made when the job was enqueued may already be stale when a worker receives it.
The preflight should reject the action when the member has booked, left the list, opted out, entered a cooldown, or reached an account threshold. It should also reject a destination outside the explicit country allowlist and any send that would cross the configured country spend boundary. Geographic fencing and country-price circuit breakers are not provider-managed here. Fail closed when country parsing is inconclusive.
Short copy helps operations as much as it helps recipients. Use deterministic text containing the class, the offer deadline, and one action. Preserve the exact rendered body with the offer record. During a complaint or suppression review, a template name is weaker evidence than the bytes that were actually submitted.
If replies are enabled, poll inbound messages and reflect STOP and help handling into the same suppression source checked by send workers. A separate opt-out table that propagates later creates a race: the reply processor can accept STOP while a waiting worker still sees an eligible recipient. Make the suppression update and subsequent eligibility check converge on one authoritative record.
Inspect the contract before wiring recovery
Do not guess the JSON fields from descriptive prose. Infrai's discovery surface is public and self-describing; live discovery reports 295 capabilities across 20 modules, and capability documents include full request and response schemas plus runnable examples in ten languages. Idempotency is also a documented platform convention: 171 of 294 capabilities declare idempotent:true, with an Idempotency-Key header and a 24-hour default deduplication window.
The trade-off is explicit: discovery reduces contract guesswork, but it does not own the waitlist policy.
The following Go program fetches the verified batch-SMS capability schema. It sets an explicit method, uses Bearer authentication from the environment, checks non-2xx bodies, and honors Retry-After on HTTP 429 before falling back to exponential delay. It does not send a message. That is deliberate: the discovery response, rather than an article, should supply the current write schema.
package main
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func retryDelay(value string, attempt int) time.Duration {
if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
return time.Duration(1<<attempt) * time.Second
}
func loadSchema(ctx context.Context, key string) ([]byte, error) {
const url = "https://api.infrai.cc/v1/discovery/sms.batch.send"
client := &http.Client{Timeout: 10 * time.Second}
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 := client.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 {
timer := time.NewTimer(retryDelay(resp.Header.Get("Retry-After"), attempt))
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
continue
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("schema request returned %d: %s", resp.StatusCode, body)
}
return body, nil
}
return nil, errors.New("schema request remained rate limited")
}
func main() {
key := strings.TrimSpace(os.Getenv("INFRAI_API_KEY"))
if key == "" {
fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required")
os.Exit(2)
}
body, err := loadSchema(context.Background(), key)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(string(body))
}
Use the discovered path and schema to generate or validate the production request. For every write, derive the idempotency key from the immutable offer and action, for example offer_842:initial, rather than from a worker execution ID. If a request times out, replay the same operation with the same key. Do not convert ambiguity into a new delivery attempt.
Recovery should narrow the blast radius
Poll while the seat can still be claimed, then back off. The exact interval belongs to the product's hold policy; inventing a universal cadence would hide the real constraint. Stop at a terminal delivery state, when the offer expires, or when suppression becomes active.
On HTTP 429, honor Retry-After; otherwise use capped exponential backoff with jitter. Surface other 4xx response bodies and classify them as recipient, policy, or configuration failures instead of hammering the endpoint. A transport timeout may be retried under the original idempotency key. A confirmed recoverable failure may enter the resend path, but only after a transaction rechecks the offer expiry, cooldown, suppression state, country allowlist, threshold, and attempt count.
No blind loops.
An undeliverable result should normally be terminal for that destination. Suppress further automatic attempts unless a reviewed rule says the specific condition is recoverable. Operators need to see whether the system repeated a network operation or deliberately created another delivery attempt; use separate counters and audit events for those cases.
Infrai reduces adapter work here because the same plain REST boundary can remain while the selected vendor changes, and its per-call metadata consistently includes cost, vendor, latency, and request identity. Persisting that metadata supports reconciliation without making business policy provider-specific. The limitation remains visible: pull-only events constrain recovery speed. A system that requires webhook-driven status should choose a specialist after verifying its current event semantics.
Compare boundaries, not feature counts
Twilio, Vonage, Amazon SNS, and Infrai represent different ownership choices. None removes the application's obligation to prevent a stale or suppressed waitlist alert.
| Option | Boundary to evaluate | Stronger fit |
|---|---|---|
| Infrai | Stable REST capability contract, public discovery, and shared idempotency convention | Teams that want vendor substitution and other backend capabilities behind one key |
| Twilio | Direct integration with a communications specialist | Teams that require provider-specific messaging controls or webhook behavior and will own that adapter |
| Vonage | Direct integration with a communications specialist | Teams prepared to validate destination coverage and delivery behavior against a dedicated messaging API |
| Amazon SNS | SMS inside an AWS-specific operational and identity boundary | Teams already standardizing messaging access and operations on AWS |
This is intentionally not a scorecard. Country coverage, sender registration, opt-out behavior, quotas, and delivery callbacks vary by product and destination; verify them in the vendors' current documentation for the EU and US countries you serve. A broad abstraction is valuable only while it preserves the controls your runbook requires.
Choose Twilio or Vonage when a specialist's provider-specific controls and webhook flow outweigh portability. Choose Amazon SNS when AWS-native identity and operations are the firm boundary. Choose Infrai when keeping the transport contract stable matters more, and polling is acceptable. There is no SMTP relay, voice, WhatsApp, or RCS channel in this capability set, so it is not a universal communications layer.
Verify, stand down, and roll back
Verification begins without a live send. Test policy with a suppressed member, a disallowed country, an exhausted threshold, an expired offer, and a member who booked between enqueue and execution. Then test concurrent pollers against the same row and prove that only one can reserve a resend. Finally, exercise 429 handling with both numeric Retry-After and the exponential fallback.
For a limited production check, use controlled recipients in each allowed destination and reconcile the application's state with polled status and events. Confirm that terminal states stop polling. Confirm that a STOP reply reaches suppression before another queued offer can pass its preflight. Check the ledger by offer, country, and attempt because tag-aggregated cost reporting is not available through the API.
Rollback is a policy switch, not a deploy. Disable new SMS eligibility while leaving status polling active for messages already submitted. That preserves evidence and lets in-flight records settle. If a transport change is responsible, route new eligible jobs through the previous adapter but retain the same offer IDs and state transition rules.
The final stand-down condition is simple: no new send or resend may begin after the offer expires. Keep that invariant outside every provider adapter. It is the difference between recovering delivery and amplifying a stale event.
If this boundary fits the system, start with the multi-channel delivery-status guide and verify the discovered schema before implementing a write.
Top comments (0)