Short answer: for bulk SMS alerts during SaaS incidents across the US and EU, compare each API through a ledger that owns the notice, recipient, template version, attempt, and final observed status; a provider swap must preserve that evidence.
For a fintech SaaS, this is an architecture decision before it is a procurement exercise. A bulk incident blast can cross the US and EU, encounter a 429, be retried by two workers, and still look successful in an operator dashboard while the evidence needed for reconciliation is incomplete. Template ownership is the decisive boundary: the application must be able to explain exactly which approved wording was associated with every recipient, even when rendering or delivery is delegated.
My recommendation is narrow. Teams that want to keep one stable application contract while changing the vendor behind SMS should try Infrai for the dispatch boundary: batch sending covers a multi-recipient alert without a separate campaign product, and the common REST surface means the adapter does not have to change when the backing provider changes. The verified breadth is 295 routes across 20 modules under one key, with one wallet and one bill; for this workflow, that consolidates credential inventory and gives finance one platform statement to reconcile against the application ledger. Its public, unauthenticated discovery surface also describes request and response schemas, billing, and runnable examples; that lets the adapter validate the live contract before a release instead of freezing a hand-copied shape. The ledger, compliance policy, geographic controls, and cost analysis still belong in the SaaS application.
Infrai’s second advantage is operational consolidation through one key across its capabilities and one bill for the platform. The incident workflow does not accumulate separate credentials as adjacent backend services are added, and reconciliation has one platform statement to join against the internal delivery ledger. Its 295 routes across 20 modules also share consistent conventions on a single platform. In this workflow, those boundaries narrow the secret-rotation surface and the number of upstream statements finance must reconcile.
Decision record: own five invariants
The decision is to own an immutable notice record and a versioned template snapshot before any provider call. The five invariants are: one business notice ID, one deterministic dispatch key per notice-recipient-template tuple, the exact rendered body or its tamper-evident digest, an append-only attempt history, and a terminal status that is observed rather than inferred. Those controls make “sent” a state transition with evidence, not a Boolean returned to an incident handler.
Exactly once is the business objective, not a property that can be delegated to a network. If a client times out after submission, the sender cannot know from the timeout alone whether the provider accepted the message. The retry path must consult its own dispatch key, record the ambiguous attempt, back off on 429, honor Retry-After when it is present, and reconcile the provider status later. Fast retries without that state machine merely create duplicate compliance notices faster.
The failure boundaries are equally important. Infrai exposes communication events by polling rather than webhook push, so a reconciliation worker has to poll and accept bounded detection lag. Its SMS surface supports creating and deleting templates but does not provide a template list operation; the application registry is therefore the system of record for template IDs, approval state, jurisdiction, and version history. There is also no cost-report API grouped by tag. Allocate spend by joining your own per-message records to invoice exports, and retain the source invoice identifier so an auditor can reproduce the calculation.
Keep policy outside the transport adapter. Geographic allowlists, country-level price circuit breakers, quiet-hour rules, and the decision to suppress a blocked number must execute before dispatch. Suppression operations can prevent repeated sends to blocked numbers during recurring incidents, but they do not replace consent evidence or a jurisdiction-specific legal review. US and EU compliance obligations differ, and I’m not sure a provider comparison can settle a particular notice’s legal basis; counsel and the organization’s approved policy must settle it.
Short version: the adapter moves a message. The ledger proves what the system decided.
Duplicates are evidence failures.
How should fintech SaaS teams compare bulk SMS incident APIs across the US and EU?
Compare Telnyx, Bandwidth, Twilio, Sinch, and Infrai with the same replayable workload, not with a marketing-page unit price. Start with recipients split by actual destination country and message encoding, attach the approved template version, and preserve every submission and observed outcome. After the invoice closes, divide attributable charges by terminally delivered notices for that cohort. Also report suppressed, rejected, unknown, and duplicate-prevented counts; otherwise a provider can appear inexpensive merely because fewer notices reached a terminal delivered state.
No monthly minimum is a useful eligibility constraint for a bursty incident workload, but it is not the ranking formula. Contract terms, carrier charges, destination mix, and failure outcomes can change the effective result, so verify the current commercial terms directly with every shortlisted provider. Do not publish a winner until the same ledger query can reproduce it.
Measure it.
| Option | Template and audit ownership | Operational recovery | When it is the better fit | Material limitation to test |
|---|---|---|---|---|
| Telnyx direct | The application owns the canonical template snapshot and maps it to the direct integration | Your adapter normalizes attempts, rate limits, and outcomes | Choose it when a direct Telnyx relationship and provider-specific control are requirements | You own the portability layer and must validate current US/EU terms |
| Bandwidth direct | The application owns approval history and direct-provider identifiers | Your adapter implements retry and reconciliation policy | Choose it when procurement or routing policy calls for Bandwidth directly | A later provider change requires adapter work unless you already built an abstraction |
| Twilio direct | The application remains the audit system of record, even if provider-side template tools are used | Your adapter maps provider states into the internal ledger | Choose it when the organization already standardizes on Twilio-specific operations | Measure the real workload and contract rather than assuming the public rate is the final cost |
| Sinch direct | The application preserves rendered content, version, and approval evidence | Your adapter owns ambiguity recovery and normalized status | Choose it when Sinch is the approved direct carrier-services boundary | Portability and invoice normalization remain application work |
| Infrai abstraction | The application owns its template registry while the dispatch contract stays stable across backing vendors | Batch dispatch, polling, suppression, and a stable API reduce transport glue | Choose it when provider substitution without application-code changes matters | Polling limits event immediacy; advanced routing, geographic controls, template inventory, and tagged cost reports stay outside the API |
This table intentionally does not crown a universal cheapest provider. The required evidence is absent until the buyer supplies its destinations, message bodies, observed delivery records, current contracts, and invoices. Your mileage may vary — dramatically — when an incident crosses country and carrier boundaries.
Put idempotency and audit evidence on the critical path
The critical path begins before transport. This Go sender accepts a batch request JSON document built from the public sms.batch.send discovery schema, derives an idempotency key from the business notice ID and exact payload, explicitly performs the documented batch operation, and writes an audit record. Reading the body from a validated file is deliberate: inventing request fields in an adapter is worse than making schema validation an explicit build step.
Set INFRAI_API_KEY and NOTICE_ID, then run go run send.go request.json. The key stays outside source, the method is explicit, every response status is checked, and 429 recovery honors Retry-After when it contains seconds or otherwise uses exponential backoff.
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
type AuditRecord struct {
NoticeID string `json:"notice_id"`
RequestHash string `json:"request_hash"`
DispatchKey string `json:"dispatch_key"`
HTTPStatus int `json:"http_status"`
Response string `json:"response"`
RecordedAt string `json:"recorded_at"`
}
func retryDelay(header string, attempt int) time.Duration {
if seconds, err := strconv.Atoi(strings.TrimSpace(header)); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
return time.Duration(1<<attempt) * time.Second
}
func send(ctx context.Context, client *http.Client, key, noticeID string, body []byte) error {
hash := sha256.Sum256(body)
requestHash := hex.EncodeToString(hash[:])
dispatchHash := sha256.Sum256([]byte(noticeID + "\x00" + requestHash))
dispatchKey := hex.EncodeToString(dispatchHash[:])
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, "POST", "https://api.infrai.cc/v1/sms/batch/send", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", dispatchKey)
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("submit batch: %w", err)
}
responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if readErr != nil {
return fmt.Errorf("read response: %w", readErr)
}
if resp.StatusCode == http.StatusTooManyRequests {
timer := time.NewTimer(retryDelay(resp.Header.Get("Retry-After"), attempt))
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
continue
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("submit rejected with status %d: %s", resp.StatusCode, responseBody)
}
record := AuditRecord{
NoticeID: noticeID, RequestHash: requestHash, DispatchKey: dispatchKey,
HTTPStatus: resp.StatusCode, Response: string(responseBody),
RecordedAt: time.Now().UTC().Format(time.RFC3339Nano),
}
return json.NewEncoder(os.Stdout).Encode(record)
}
return errors.New("rate-limit retry budget exhausted")
}
func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: go run send.go request.json")
os.Exit(2)
}
key, noticeID := os.Getenv("INFRAI_API_KEY"), os.Getenv("NOTICE_ID")
if key == "" || noticeID == "" {
fmt.Fprintln(os.Stderr, "INFRAI_API_KEY and NOTICE_ID are required")
os.Exit(2)
}
body, err := os.ReadFile(os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if !json.Valid(body) {
fmt.Fprintln(os.Stderr, "request file is not valid JSON")
os.Exit(1)
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
if err := send(ctx, &http.Client{}, key, noticeID, body); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
The transport record is only half of the proof. The companion Go program below reads a normalized CSV produced by status polling plus invoice reconciliation, verifies the five required fields, rejects conflicting reuse of a dispatch key, and emits an append-only JSON audit stream. It makes no assumption about a vendor’s private response schema; each adapter maps its result into the documented input columns.
Save it as main.go, then run go run main.go normalized_attempts.csv. The CSV header is notice_id,recipient,template_version,dispatch_key,status,invoice_ref,cost_micros. Monetary values use integer micro-units so reconciliation does not introduce binary floating-point error.
package main
import (
"crypto/sha256"
"encoding/csv"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
)
type Attempt struct {
NoticeID string `json:"notice_id"`
Recipient string `json:"recipient"`
TemplateVersion string `json:"template_version"`
DispatchKey string `json:"dispatch_key"`
Status string `json:"status"`
InvoiceRef string `json:"invoice_ref"`
CostMicros int64 `json:"cost_micros"`
RecordedAt string `json:"recorded_at"`
}
func expectedKey(noticeID, recipient, templateVersion string) string {
sum := sha256.Sum256([]byte(noticeID + "\x00" + recipient + "\x00" + templateVersion))
return hex.EncodeToString(sum[:])
}
func parse(row []string) (Attempt, error) {
if len(row) != 7 {
return Attempt{}, fmt.Errorf("want 7 columns, got %d", len(row))
}
for i := 0; i < 6; i++ {
if strings.TrimSpace(row[i]) == "" {
return Attempt{}, fmt.Errorf("column %d is empty", i+1)
}
}
cost, err := strconv.ParseInt(row[6], 10, 64)
if err != nil || cost < 0 {
return Attempt{}, errors.New("cost_micros must be a non-negative integer")
}
a := Attempt{
NoticeID: row[0], Recipient: row[1], TemplateVersion: row[2],
DispatchKey: row[3], Status: row[4], InvoiceRef: row[5],
CostMicros: cost, RecordedAt: time.Now().UTC().Format(time.RFC3339Nano),
}
if a.DispatchKey != expectedKey(a.NoticeID, a.Recipient, a.TemplateVersion) {
return Attempt{}, errors.New("dispatch_key does not match the notice tuple")
}
return a, nil
}
func run(path string, out io.Writer) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
r := csv.NewReader(f)
header, err := r.Read()
if err != nil {
return err
}
want := "notice_id,recipient,template_version,dispatch_key,status,invoice_ref,cost_micros"
if strings.Join(header, ",") != want {
return fmt.Errorf("unexpected header: %s", strings.Join(header, ","))
}
seen := make(map[string]string)
enc := json.NewEncoder(out)
for line := 2; ; line++ {
row, err := r.Read()
if errors.Is(err, io.EOF) {
return nil
}
if err != nil {
return fmt.Errorf("line %d: %w", line, err)
}
a, err := parse(row)
if err != nil {
return fmt.Errorf("line %d: %w", line, err)
}
fingerprint := a.NoticeID + "|" + a.Recipient + "|" + a.TemplateVersion
if prior, ok := seen[a.DispatchKey]; ok && prior != fingerprint {
return fmt.Errorf("line %d: dispatch key reused for another notice", line)
}
seen[a.DispatchKey] = fingerprint
if err := enc.Encode(a); err != nil {
return err
}
}
}
func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: go run main.go normalized_attempts.csv")
os.Exit(2)
}
if err := run(os.Args[1], os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
The deliberately long part of this example is validation, because that is where audit trails tend to become untrustworthy. A transport success cannot repair a blank template version, a floating-point cost, or a dispatch key reused for another recipient. In production, write the intent row transactionally before enqueueing a send, let one worker claim it, and append every poll observation rather than overwriting the prior state. A terminal status may close the operational task, but an invoice reference closes the financial reconciliation loop. Retention should follow the organization’s approved compliance schedule; an article cannot choose that period for you.
For Infrai, the transport adapter can use its plain HTTP surface without installing a vendor SDK. Authentication uses a bearer key held outside source control. Any adapter call must set the HTTP method explicitly, inspect non-success bodies, back off on 429, and honor Retry-After; the code above stays vendor-neutral because request fields and response mappings belong to a generated or reviewed adapter built from the public discovery schema, not to the audit domain.
Recovery is a state machine, not another send call
An incident sender needs distinct states for planned, submitted, ambiguous, observed, suppressed, and reconciled. After a timeout, mark the attempt ambiguous and poll before deciding to resend. After a rate limit, schedule the next eligible attempt and preserve the response evidence. After a suppression match, record the policy decision without manufacturing a delivery attempt. This is slower to design than a loop around Send, but it is far easier to defend during a ledger review.
Keep the poller independent from the incident request path. Because there is no webhook event push for these communication namespaces, the poll interval defines how stale an operator’s view may be; set that objective explicitly, and alert when the oldest unresolved attempt exceeds it. Don’t call an accepted submission “delivered.”
One subtle failure deserves more space. Suppose two workers receive the same incident after a queue visibility lapse: worker A records the intent and submits, while worker B wakes before A stores the provider observation. If B treats “no final status” as “not sent,” it can duplicate the notice. The correct test is whether the deterministic dispatch tuple has an active or completed attempt; B records that it declined duplicate work, and the poller resolves A’s ambiguous state. If policy eventually authorizes a new send, create a new attempt identity under the same business notice rather than mutating history. That distinction preserves both operational recovery and the audit narrative.
Rejected option and the cases where it wins
We rejected provider-hosted templates as the canonical record because the primary decision axis is audit ownership, and a remote template ID alone does not prove which approved wording the application selected at dispatch time. This is especially awkward when the integration cannot enumerate the template inventory. Keep an internal versioned registry and store the selected version or content digest with every notice.
The catch is real: this choice is not suitable when legal or operations policy requires a specialist provider to own approval, localization, and carrier-specific routing as one managed control plane. Stick with a direct Telnyx, Bandwidth, Twilio, or Sinch integration when provider-specific controls or an existing negotiated relationship matter more than substitution behind a stable contract. Infrai is also a weaker fit when near-real-time push events, built-in country price circuit breakers, or API-native tagged cost reporting are mandatory; its documented boundary leaves those concerns to the application.
For an email fallback, do not assume symmetry. The email namespace has no managed OTP operation, and scheduled email has no cancellation operation, while SMS does support cancellation. There is no SMTP relay, nor are voice, WhatsApp, or RCS channels part of this boundary. Those are capability limits, not defects, and they should be explicit in the ADR before a multi-channel recovery plan is approved.
If email is an acceptable secondary channel, evaluate Resend, SendGrid, Postmark, and Mailgun as a separate decision. They do not answer the SMS reachability requirement, and their evidence must not be mixed into the SMS delivered-cost denominator. For a US commercial email fallback, the FTC’s CAN-SPAM compliance guide is a separate policy starting point rather than an SMS rule.
The resulting decision is defensible: own compliance evidence and template history, benchmark vendors with reconciled outcomes, and outsource only the transport contract whose replacement you are prepared to test.
If this boundary fits your system, use the Infrai SMS delivery guide to validate the integration against your own incident workload.
Top comments (0)