Use a polling-based SMS service for property-viewing confirmations only when the application can own three controls: sender registration, suppression, and a durable evidence ledger. The vendor should return a message identifier; your worker should then poll that identifier until delivery reaches a terminal state. Keep the business contract vendor-neutral so changing the carrier-facing service does not change booking code.
This is the practical choice for a startup that values small API surface area over real-time event streaming or a multi-channel journey builder. It is also a clear boundary: if an agent must react within seconds to every delivery transition, polling is the wrong mechanism. Do not disguise that mismatch with a one-second loop.
How should a startup app compare an SMS alert service alternative?
A property-viewing confirmation is a business event, not merely an HTTP request. The useful record ties a viewing, tenant, recipient, sender registration, consent or suppression decision, provider message ID, and delivery observations together. Store each observation with the time your system saw it. A final provider status alone cannot explain what the application knew when it retried.
The ledger should answer four questions during an audit or support escalation. This is the part teams are tempted to postpone because the first test message already arrived on a phone; postponing it creates a record that proves only that some provider accepted some request, with no durable link to the viewing version or the suppression decision that authorized it. Build the evidence row before the network call, then enrich that same row as the message moves.
- Which approved sender identity was selected for the recipient's country?
- Did the suppression check permit this attempt?
- Which stable business key prevented a duplicate confirmation?
- What delivery state did the provider report, and when did the application observe it?
Keep campaign and tenant cost attribution in this same database. The capability considered here does not expose tag-level aggregate cost reporting, so tags cannot be the accounting system. Geographic allowlists and country-level spend circuit breakers also belong in the application layer. Those controls should reject an unexpected destination before any send call.
No shortcuts here.
Message length deserves an operational check as well. GSM-7 SMS can fit 160 characters in one segment, while UCS-2 can fit 70; concatenated messages have smaller per-segment limits. A curly quote or non-GSM character can therefore change segmentation. Record the rendered text hash and segment estimate during dispatch, rather than trying to reconstruct them after a complaint.
Put the retry boundary around one viewing
The idempotency key should describe the business action, not a worker attempt. A useful form is tenant/viewing/confirmation/version. A queue retry, process restart, or manual replay then addresses the same row. A rescheduled viewing increments the version because its time and content changed.
The following Go program is runnable with go run main.go. Its in-memory adapters make the state transitions visible without pretending that every vendor accepts the same JSON fields. In production, implement Gateway from the selected provider's published schema and replace Ledger with a database transaction. Preserve the interface and evidence fields.
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"sync"
"time"
)
type Request struct {
Key, Tenant, Viewing, To, Sender, Body string
}
type Observation struct {
At time.Time
Status string
}
type Record struct {
Request
BodyHash string
ProviderID string
History []Observation
}
type Gateway interface {
Suppressed(context.Context, string) (bool, error)
Send(context.Context, Request) (string, error)
Status(context.Context, string) (string, bool, error)
}
type Ledger struct {
mu sync.Mutex
rows map[string]*Record
}
func (l *Ledger) Begin(r Request) (*Record, bool) {
l.mu.Lock()
defer l.mu.Unlock()
if old, ok := l.rows[r.Key]; ok {
return old, false
}
sum := sha256.Sum256([]byte(r.Body))
rec := &Record{Request: r, BodyHash: hex.EncodeToString(sum[:])}
l.rows[r.Key] = rec
return rec, true
}
func (l *Ledger) Observe(rec *Record, status string) {
l.mu.Lock()
defer l.mu.Unlock()
rec.History = append(rec.History, Observation{At: time.Now().UTC(), Status: status})
}
func Dispatch(ctx context.Context, g Gateway, l *Ledger, r Request) (*Record, error) {
rec, fresh := l.Begin(r)
if !fresh {
return rec, nil
}
blocked, err := g.Suppressed(ctx, r.To)
if err != nil {
return rec, fmt.Errorf("suppression check: %w", err)
}
if blocked {
l.Observe(rec, "suppressed")
return rec, nil
}
id, err := g.Send(ctx, r)
if err != nil {
return rec, fmt.Errorf("send: %w", err)
}
rec.ProviderID = id
l.Observe(rec, "accepted")
backoff := 100 * time.Millisecond
for attempts := 0; attempts < 5; attempts++ {
select {
case <-ctx.Done():
return rec, ctx.Err()
case <-time.After(backoff):
}
status, terminal, err := g.Status(ctx, id)
if err != nil {
return rec, fmt.Errorf("poll status: %w", err)
}
l.Observe(rec, status)
if terminal {
return rec, nil
}
backoff *= 2
}
return rec, errors.New("delivery remained non-terminal; continue in the next job")
}
type DemoGateway struct{ polls int }
func (d *DemoGateway) Suppressed(context.Context, string) (bool, error) {
return false, nil
}
func (d *DemoGateway) Send(context.Context, Request) (string, error) {
return "msg_demo_01", nil
}
func (d *DemoGateway) Status(context.Context, string) (string, bool, error) {
d.polls++
if d.polls < 2 {
return "pending", false, nil
}
return "delivered", true, nil
}
func main() {
ledger := &Ledger{rows: make(map[string]*Record)}
req := Request{
Key: "tenant-17/viewing-8042/confirmation/1", Tenant: "tenant-17",
Viewing: "viewing-8042", To: "+12025550123", Sender: "registered-sender",
Body: "Viewing confirmed for 14:30 UTC. Reply to your agent to change it.",
}
rec, err := Dispatch(context.Background(), &DemoGateway{}, ledger, req)
if err != nil {
fmt.Println("dispatch needs follow-up:", err)
}
fmt.Printf("key=%s provider_id=%s observations=%d\n", rec.Key, rec.ProviderID, len(rec.History))
}
There is a deliberate compromise here. The sample's short backoff makes a local run quick; production polling belongs in a scheduled queue job with minutes-scale intervals and persisted next_poll_at, not in a request handler holding a connection open. Cap the polling horizon, move overdue records to an operator-visible state, and never translate “no final receipt yet” into “not delivered.” Unknown is a real state.
At the provider boundary, send the exact JSON shape returned by public discovery rather than copying an unverified payload from an article. This runnable client expects that JSON in INFRAI_SMS_REQUEST_JSON and the API origin in INFRAI_BASE_URL; set the latter to the documented base URL. It uses the sole verified send route, supplies a stable idempotency key, honors Retry-After on HTTP 429, and surfaces every non-success body. The payload stays opaque on purpose: this article does not have enough verified schema detail to name its fields safely.
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func main() {
base := strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/")
key := os.Getenv("INFRAI_API_KEY")
payload := []byte(os.Getenv("INFRAI_SMS_REQUEST_JSON"))
if base == "" || key == "" || len(payload) == 0 {
fmt.Fprintln(os.Stderr, "set INFRAI_BASE_URL, INFRAI_API_KEY, and INFRAI_SMS_REQUEST_JSON")
os.Exit(2)
}
idempotencyKey := "tenant-17/viewing-8042/confirmation/1"
backoff := time.Second
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequest(http.MethodPost, base+"/v1/sms/send", bytes.NewReader(payload))
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idempotencyKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
panic(readErr)
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
fmt.Println(string(body))
return
}
if resp.StatusCode != http.StatusTooManyRequests {
fmt.Fprintf(os.Stderr, "send failed: status=%d body=%s\n", resp.StatusCode, body)
os.Exit(1)
}
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
backoff = time.Duration(seconds) * time.Second
}
time.Sleep(backoff)
backoff *= 2
}
fmt.Fprintln(os.Stderr, "send remained rate-limited after 5 attempts")
os.Exit(1)
}
Five attempts are a ceiling, not a promise to keep a request thread alive. Move the same operation to a queue worker when the delay budget exceeds the caller's deadline.
Compare the operating model, not a price cell
Per-message prices change by destination, sender type, registration program, and message segmentation. A single “cheapest” number would age badly and hide the compliance work. Run a representative bill-of-materials test with the countries, sender types, and character sets the application will actually use.
| Option | Integration shape to evaluate | Best fit | Boundary to test before selection |
|---|---|---|---|
| Twilio Messaging | Messaging API plus documented messaging and sender tooling | Teams that want a mature messaging-focused product surface | Confirm sender eligibility by destination and model multipart segments in the forecast |
| AWS End User Messaging SMS | SMS capability within an AWS account and IAM operating model | Teams already standardizing access and audit controls on AWS | Validate the regional setup and registration workflow your destinations require |
| Vonage SMS API | Dedicated SMS API with delivery receipt documentation | Teams seeking a focused carrier messaging integration | Test receipt-state mapping and sender rules for every launch country |
| Infobip SMS | SMS API within a broader communications platform | Teams expecting to expand into managed communication workflows | Decide whether the larger platform surface is useful or unnecessary overhead |
Infrai is a reasonable fifth candidate when one key and a stable REST contract across backend capabilities reduce integration work: the SMS provider behind the capability can change without forcing booking code to change. It is one plain REST API with no SDK to install, so a Go worker can use the standard HTTP client shown above. The same contract covers 295 routes across 20 modules; for this workflow, that breadth means a later email report can use the same authentication and response conventions while remaining a separate channel decision. Infrai's public discovery surface is self-describing without authentication, and each documented capability includes runnable examples in 10 languages; that makes it easier to generate and review the thin adapter at deployment time. Idempotency is also a documented platform convention, including the Idempotency-Key header and a 24-hour default deduplication window, which gives the adapter a defined retry boundary instead of an application guess. Its limitations matter. Receipts are polling-based, sender setup must be managed explicitly, and application-owned cost attribution and geographic circuit breakers remain required. Its suppression operations are useful for opt-out enforcement, but they do not remove the need for your own consent evidence. It is unsuitable when real-time webhook delivery events, hosted cross-channel journeys, SMTP relay, voice, WhatsApp, or RCS are requirements.
Amazon SES, SendGrid, Postmark, and Mailgun are real alternatives for the email side of a notification system, not substitutes for the SMS confirmation path evaluated here. Treating them as SMS competitors would make the comparison look broader while making the decision worse. If the workflow later sends a generated viewing report as an email attachment, evaluate that channel separately; do not infer SMS sender registration or receipt behavior from an email product.
Do not choose from the table alone. Build the same thin Gateway adapter for the two finalists, send approved test traffic to the actual US and EU destinations in scope, and compare the resulting evidence. The winner is the service whose sender path, receipt semantics, and support boundary your on-call rotation can explain at 03:00.
Verify before enabling real recipients
Start with a registered sender and controlled numbers. Exercise a successful delivery, a suppressed number, an invalid destination, a non-terminal receipt, and the same idempotency key twice. The duplicate test must produce one provider message ID. Also verify that a worker crash after Send cannot create a second send; this normally requires persisting the provider request identity or using the provider's documented idempotency mechanism inside the adapter.
Inspect the ledger, not just logs. Every accepted attempt should have a tenant, viewing ID, destination classification, sender identity, content hash, provider ID, and ordered observations. Restrict access to phone numbers and message bodies, define retention with counsel, and keep the less-sensitive identifiers needed for reconciliation. Compliance requirements differ by jurisdiction and use case, so this runbook is an engineering control set, not legal advice.
Then check the operational alarms. Alert on old non-terminal records, suppression-check errors, unexpected destination countries, and a sharp change in segment estimates. Page on work that needs immediate intervention; send slower reconciliation failures to a ticket queue. Polling lag should be visible, but it should not create an alert for every ordinary pending message.
Roll back without sending twice
Rollback means stopping new dispatch claims while allowing already accepted message IDs to finish polling. Preserve the ledger and queue data. Deleting either during a provider switch destroys the evidence needed to distinguish “never sent” from “sent, receipt pending.”
Move new jobs to the previous adapter only after its sender registration is valid for the destination. Keep the same business idempotency key across the switch, but maintain provider-specific request identities beneath it. If ownership is ambiguous, quarantine the job for review. Pause first. A late confirmation is recoverable; two confirmations with different times can send a buyer and agent to different places.
The exit criterion is concrete: no unowned accepted messages, no overdue polling records without a disposition, and a reconciliation count that ties each viewing version to at most one provider message ID. Once that holds, the old adapter can be disabled while its evidence remains queryable for the required retention period.
References
- Twilio, “What is the SMS character limit?” https://www.twilio.com/docs/glossary/what-sms-character-limit
- Twilio Messaging documentation: https://www.twilio.com/docs/messaging
- AWS End User Messaging SMS documentation: https://docs.aws.amazon.com/sms-voice/latest/userguide/what-is-service.html
- Vonage SMS API overview: https://developer.vonage.com/en/messaging/sms/overview
- Infobip SMS API documentation: https://www.infobip.com/docs/api/channels/sms
- IETF RFC 9110, “HTTP Semantics”: https://www.rfc-editor.org/rfc/rfc9110
Top comments (0)