Keep property-viewing message templates in the application, put suppression and send intent in Postgres, and treat the SMS provider as a replaceable delivery adapter. TL;DR: for a healthtech startup coordinating property viewings, the simplest defensible design is an app-owned template rendered into an immutable message snapshot, an idempotent send command, and a polling worker that records delivery evidence. Choose a polling-oriented API when uncomplicated wiring matters more than live event streams or multi-channel journeys.
This decision keeps the business contract stable while the vendor behind delivery changes. It also draws a hard line around what a delivery receipt proves: transport state, not that a patient, caregiver, or agent read the message.
Which SMS alert service alternative should a startup app choose?
The first invariant is suppression before dispatch. A recipient who has opted out, or whose number has been classified as invalid, must not receive another viewing confirmation merely because a job was retried. Maintain the provider suppression state, but mirror the decision in an application ledger so that the send path has a local, auditable gate.
The second invariant is that one business event creates one send intent. Use a stable key such as viewing-confirmation:{viewing_id}:{recipient_id}:{template_version}; store it under a unique constraint before making the network call, and carry an idempotency key through any provider adapter that supports one. Exactly-once delivery over a network is not a credible promise. Exactly-once intent plus deduplicated side effects is the useful target.
The third invariant is evidence preservation. Store the rendered body, template version, consent basis, destination in the minimum form your retention policy permits, provider message identifier, state transitions, and timestamps. Do not overwrite sent with delivered; append a transition. Compliance limits still apply: delivery logs can contain personal data, so retention, access, redaction, and regional handling need explicit policy rather than an indefinite debug table.
Failure boundaries follow from those invariants. The database owns eligibility and intent; the adapter owns submission; the receipt worker owns observations. A timeout after submission is ambiguous, so a blind second send is unsafe. A missing receipt is unknown, not failed. Keep it that way.
No guesswork.
Template ownership decides the architecture
The central choice is not the smallest advertised per-message rate. It is who owns the template contract and therefore controls versioning, review, localization, evidence, and migration.
| Option | Template ownership | Receipt model | Best fit | Boundary to accept |
|---|---|---|---|---|
| Twilio Messaging | Application or provider-managed content, depending on the product selected | Status callbacks are documented | Teams wanting a mature messaging-specific surface and push-style status handling | The application still needs its own consent, suppression, and audit model |
| AWS End User Messaging SMS | Application-centered message construction with AWS-managed origination resources | Delivery event destinations are documented | AWS-centered systems that already operate IAM, CloudWatch, or event destinations | More cloud resource configuration enters the delivery path |
| Vonage SMS API | Application-owned message content | Delivery receipts are documented | Teams wanting a focused SMS API with asynchronous receipt handling | Vendor-specific receipt semantics belong behind an adapter |
| Infobip SMS | Application content plus platform messaging features | Delivery reports are documented | Programs likely to grow into broader communications workflows | The larger platform surface may exceed a small alert service's needs |
| Infrai | Application-owned template snapshot behind one REST API | Polling, with no webhook event push | A startup prioritizing one credential, explicit sender setup, suppression APIs, and a stable contract when the underlying vendor changes | It is a poor fit when webhook receipts, voice, WhatsApp, RCS, SMTP relay, or provider-hosted journeys are required; receipt latency is polling-bound and cost attribution stays local |
Sender setup is part of the system, not an onboarding footnote. Registration rules differ by destination and use case, so model a sender as a deployable dependency with states such as requested, active, and retired; never let production choose an arbitrary sender string. Geographic anti-abuse limits and country-level spend circuit breakers also belong in the application layer. That is particularly important when US and EU traffic share one product workflow but do not share one regulatory regime.
The comparison favors different answers under different constraints. Twilio or Vonage is a natural fit when callback-driven receipts are a firm requirement. AWS is coherent when operational ownership is already concentrated there. Infobip earns consideration when SMS is likely to become a wider engagement program. SendGrid, Postmark, Resend, Mailgun, and Amazon SES solve email delivery, so they are not substitutes for an SMS alert service; they belong in a separately owned fallback path if email is required. A polling-first unified API is reasonable when a small team values a stable backend contract and accepts delayed state convergence, but its limitations rule it out when immediate event push is a product requirement.
The critical path in Go
The first runnable program below makes the actual SMS request without inventing a vendor request schema. Set INFRAI_SMS_JSON to a JSON object produced against the current public discovery schema; the program verifies that it is an object, submits it with Bearer authentication and an idempotency key, checks every response, and backs off on HTTP 429. INFRAI_BASE_URL is explicit because this independent article does not link to the service. The response is retained as raw evidence rather than decoded into fields that were not specified here.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func main() {
baseURL := os.Getenv("INFRAI_BASE_URL")
key := os.Getenv("INFRAI_API_KEY")
body := []byte(os.Getenv("INFRAI_SMS_JSON"))
if baseURL == "" || key == "" || len(body) == 0 {
panic("INFRAI_BASE_URL, INFRAI_API_KEY, and INFRAI_SMS_JSON are required")
}
var object map[string]any
if err := json.Unmarshal(body, &object); err != nil || object == nil {
panic("INFRAI_SMS_JSON must be a JSON object matching the current discovery schema")
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
client := &http.Client{Timeout: 15 * time.Second}
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/v1/sms/send", bytes.NewReader(body))
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "viewing-confirmation-viewing-482-recipient-19-v3")
resp, err := client.Do(req)
if err != nil {
panic(err)
}
responseBody, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
panic(readErr)
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
fmt.Println(string(responseBody))
return
}
if resp.StatusCode != http.StatusTooManyRequests {
panic(fmt.Sprintf("send failed: status=%d body=%s", resp.StatusCode, responseBody))
}
delay := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
delay = time.Duration(seconds) * time.Second
}
select {
case <-time.After(delay):
case <-ctx.Done():
panic(ctx.Err())
}
}
panic("send remained rate limited after five attempts")
}
The next program demonstrates the application contract boundary. The in-memory maps stand in for Postgres unique constraints and append-only tables, making the ordering visible without hiding it in framework code. Swap MockAdapter for the HTTP adapter above; the domain service remains unchanged.
package main
import (
"context"
"errors"
"fmt"
"sync"
"time"
)
type State string
const (
Accepted State = "accepted"
Delivered State = "delivered"
Bounced State = "bounced"
)
type SendCommand struct {
IntentKey string
Recipient string
RenderedBody string
TemplateVersion string
}
type Receipt struct {
ProviderID string
State State
ObservedAt time.Time
}
type SMSAdapter interface {
Send(context.Context, SendCommand) (string, error)
Receipt(context.Context, string) (Receipt, error)
}
type Ledger struct {
mu sync.Mutex
suppressed map[string]bool
providerIDs map[string]string
transitions map[string][]Receipt
}
func (l *Ledger) reserve(cmd SendCommand) (string, bool, error) {
l.mu.Lock()
defer l.mu.Unlock()
if l.suppressed[cmd.Recipient] {
return "", false, errors.New("recipient suppressed")
}
if id, exists := l.providerIDs[cmd.IntentKey]; exists {
return id, false, nil
}
l.providerIDs[cmd.IntentKey] = "pending"
return "", true, nil
}
func (l *Ledger) recordID(intentKey, providerID string) {
l.mu.Lock()
defer l.mu.Unlock()
l.providerIDs[intentKey] = providerID
}
func (l *Ledger) appendReceipt(r Receipt) {
l.mu.Lock()
defer l.mu.Unlock()
l.transitions[r.ProviderID] = append(l.transitions[r.ProviderID], r)
}
type MockAdapter struct{}
func (MockAdapter) Send(_ context.Context, cmd SendCommand) (string, error) {
return "msg-" + cmd.IntentKey, nil
}
func (MockAdapter) Receipt(_ context.Context, id string) (Receipt, error) {
return Receipt{ProviderID: id, State: Delivered, ObservedAt: time.Now().UTC()}, nil
}
func dispatch(ctx context.Context, ledger *Ledger, sms SMSAdapter, cmd SendCommand) (string, error) {
id, reserved, err := ledger.reserve(cmd)
if err != nil || !reserved {
return id, err
}
id, err = sms.Send(ctx, cmd)
if err != nil {
return "", err
}
ledger.recordID(cmd.IntentKey, id)
return id, nil
}
func main() {
ledger := &Ledger{
suppressed: map[string]bool{},
providerIDs: map[string]string{},
transitions: map[string][]Receipt{},
}
cmd := SendCommand{
IntentKey: "viewing-confirmation:viewing-482:recipient-19:v3",
Recipient: "+15555550199",
RenderedBody: "Your property viewing is confirmed for 10:30 AM.",
TemplateVersion: "v3",
}
id, err := dispatch(context.Background(), ledger, MockAdapter{}, cmd)
if err != nil {
panic(err)
}
receipt, err := (MockAdapter{}).Receipt(context.Background(), id)
if err != nil {
panic(err)
}
ledger.appendReceipt(receipt)
fmt.Printf("%s %s\n", receipt.ProviderID, receipt.State)
}
Production Postgres changes two details. Reserve the intent with INSERT ... ON CONFLICT, and use an outbox worker so a committed intent cannot be stranded between the transaction and the network call. Encrypt or tokenize recipient identifiers according to the threat model; a hash alone may still be personal data when the input space is enumerable.
That ordering is the design.
Polling needs restraint. Schedule the next check with backoff and jitter, stop at a documented terminal state, and cap the observation window. On HTTP 429, honor Retry-After when present; otherwise back off exponentially. The worker must persist each raw observation before advancing its cursor, because audit trails assembled after the fact are usually incomplete.
Bounces, invalid numbers, and cost attribution
“Bounce” is email vocabulary, but the operational response maps cleanly to SMS: normalize a terminal invalid-recipient result into a suppression reason, record its source, and block future sends before dispatch. Keep provider-specific status strings in the evidence record while exposing a small internal state machine to product code. That preserves detail for reconciliation without coupling every caller to a vendor taxonomy.
Do not suppress on every non-delivery. Temporary carrier conditions and an invalid destination have different consequences, and the adapter must translate only documented terminal outcomes. Likewise, an opt-out suppression should not be silently removed by a later profile edit. Require a new, auditable consent event.
Cost attribution needs its own ledger because the unified option does not expose tag-level aggregated cost reporting. Record tenant, campaign, intent key, and the returned per-call cost metadata in the same reconciliation stream; aggregate locally. Finance can then reconcile totals against the provider bill without making mutable message tags the accounting system of record.
Why reject provider-owned templates here?
Provider-owned templates can be valid. They reduce application rendering code, and they are attractive when non-engineering teams must edit copy inside an established communications platform, especially for multi-step journeys with provider-side approval workflows.
I would reject them for this confirmation path because a property-viewing event already originates in the application, while the audit record must preserve the exact content and consent decision associated with that event. Splitting template history between the database and a provider console complicates replay analysis and vendor migration. It also makes the adapter contract larger than Send(rendered message).
There is a limit to this decision. If a jurisdiction or message class requires provider-hosted, pre-approved content, compliance wins; store the provider template identifier and immutable version evidence beside the intent. Application ownership is an architectural preference, not permission to bypass sender registration or local messaging rules.
The final rule is compact: own eligibility, content snapshots, idempotency, and audit history; rent transport. Select the transport whose receipt model and sender-registration workflow match the operational team. For this startup-shaped alert path, polling is an acceptable trade when it buys a smaller integration surface and the product does not promise real-time delivery state.
References
- Twilio, “Outbound Message Status in Status Callbacks”: https://www.twilio.com/docs/messaging/guides/outbound-message-status-in-status-callbacks
- Twilio, “What is the SMS character limit?”: https://www.twilio.com/docs/glossary/what-sms-character-limit
- AWS, “Amazon SNS SMS delivery status”: https://docs.aws.amazon.com/sns/latest/dg/sms_stats.html
- AWS, “AWS End User Messaging SMS User Guide”: https://docs.aws.amazon.com/sms-voice/latest/userguide/what-is-service.html
- Vonage, “SMS delivery receipts”: https://developer.vonage.com/en/messaging/sms/guides/delivery-receipts
- Infobip, “SMS delivery reports”: https://www.infobip.com/docs/sms/delivery-reports
- PostgreSQL, “INSERT”: https://www.postgresql.org/docs/current/sql-insert.html
- IETF, “Retry-After” in HTTP Semantics (RFC 9110): https://www.rfc-editor.org/rfc/rfc9110.html#name-retry-after
Top comments (0)