Consider a healthtech startup choosing an email API versus an SMS API for event alerts when its on-call page says appointment reminders are backing up. Its dashboard shows accepted email requests, while another producer keeps scheduling messages for addresses already marked invalid. This is a hypothetical incident, not a reported outage. The on-call needs to see the affected template revision, its owner, the age of recipient state, and whether queued reminders passed a suppression check.
Short answer: Use email for routine event alerts and reserve SMS for a deliberate escalation policy. Choose the APIs around who owns templates and who enforces recipient eligibility: Amazon SES is an email candidate for a team able to absorb AWS integration work; Twilio is an SMS candidate where global coverage matters. Compare Postmark and Sendinblue against the same operating contract before committing. The useful cost comparison includes engineering time and on-call load, because accepting another send request does not establish that an invalid recipient has stopped receiving attempts.
The page is late.
The earlier signal should have connected a confirmed invalid-recipient outcome to a subsequent send decision, with enough context to identify the responsible template owner. Work backward from that mismatch. Adding another channel introduces another eligibility decision; it does not resolve the email decision that was skipped.
How should email and SMS APIs handle bounced event alerts?
Separate submission from outcome. In the proposed application contract, submission means that work entered the delivery path, while a confirmed permanent invalid-recipient outcome changes eligibility for later sends. Verify how the selected provider represents those outcomes before translating them into application state. A temporary delivery problem should not automatically become a permanent suppression.
A proposed internal record contains a synthetic recipient token, an appointment-reminder template revision, an internal notification ID, and the last processed outcome. These are application fields, not vendor request parameters. Keep addresses and appointment details out of metric labels; aggregate monitoring needs a template family and an owner, while a restricted investigation view can resolve the token when necessary. This design makes no health data compliance determination.
Template ownership matters because changing reminder content can also change which producer sends it. Give one accountable owner responsibility for the template family, release approval, and escalation policy; give the platform team responsibility for enforcing recipient eligibility across producers. Consider a scheduled reminder and a rescheduled-appointment notice that both refer to the same synthetic recipient. If only the scheduled reminder consults suppression state, placing both templates in a shared repository leaves the second producer free to repeat the invalid send. The acceptance exercise must include both producers and work already queued when the outcome arrives, with explicit rules for rechecking eligibility at dispatch rather than trusting the state captured when work was enqueued.
Suppress deliberately.
The instrumentation change is a counter for decisions blocked by confirmed invalidity, a separate counter for decisions that escaped the check, and a record of the responsible template revision. The first counter can show a control working. The second describes a correctness problem. Paging on raw suppression volume would punish the system for preventing the behavior the on-call wants stopped.
Trace the alert back to a freshness budget
A proposed SLO is that at least 99.9% of dispatch decisions use recipient state no more than 60 seconds behind the latest outcome observed by the application. These are example operating targets, not provider measurements. Count every dispatch decision in the denominator, and count missing or stale state as failing that freshness objective, even when the application safely holds the message. Otherwise, dropping unknown recipients from the denominator can make a stalled ingestion path look healthy.
Observation is a boundary, too. Track provider-to-observation delay separately, because a freshness objective starting at application observation cannot establish how quickly an external outcome became visible.
For an integration that retrieves events by polling, budget polling, processing, and queue delay together. A hypothetical 30-second polling interval plus a 10-second processing budget leaves 20 seconds within that 60-second target for queueing and variation. This arithmetic allocates a budget; it does not demonstrate that a provider meets it. Event history, pagination, and sustainable retrieval capacity need validation before the platform team promises an SLO to template owners.
Capacity follows the same path as correctness. Suppose a planning workload produces 6,000 relevant outcome updates per minute but a consumer processes only 4,000. A ten-minute burst leaves 20,000 updates waiting, ignoring other traffic. Adding send capacity will not drain that backlog. Size the outcome processor for the burst and a recovery window, account for repeated events, and make replay handling durable so the same outcome does not repeatedly consume the processing budget or accidentally undo a terminal decision.
Backlog consumes headroom.
Put the eligibility boundary where every template uses it
This runnable Go program illustrates a local policy check for two appointment templates. It sends no messages and defines no vendor schema. Its in-memory state is deliberately limited to one process; a deployed implementation needs durable shared state and coordination between suppression updates and dispatch, including a defined ordering for a new outcome that arrives while a worker is preparing a send.
package main import "fmt" type RecipientState struct { Invalid bool } type Reminder struct { Recipient string Template string } func decision(states map[string]RecipientState, recipient string) string { state, known := states[recipient] if !known { return "hold_unknown" } if state.Invalid { return "block_invalid" } return "eligible" } func main() { states := map[string]RecipientState{ "recipient-a": {Invalid: true}, "recipient-b": {Invalid: false}, } reminders := []Reminder{ {Recipient: "recipient-a", Template: "appointment-reminder-r3"}, {Recipient: "recipient-a", Template: "appointment-change-r2"}, {Recipient: "recipient-b", Template: "appointment-reminder-r3"}, {Recipient: "recipient-c", Template: "appointment-change-r2"}, } for _, reminder := range reminders { fmt.Printf("template=%s recipient=%s decision=%s\n", reminder.Template, reminder.Recipient, decision(states, reminder.Recipient)) } } Unknown state holds submission in this example. That choice has an availability cost: delayed recipient state can hold a valid appointment reminder. Track unknown-state holds separately from confirmed-invalid blocks, and require the template owner to choose an acceptable delay. The program also has no freshness timestamp; production decisions need one to enforce the proposed SLO rather than treating any existing record as current.
Eligibility is not delivery.
Keep SMS eligibility separate. An email bounce does not establish that the application has permission, a usable destination, or a business reason to send an SMS. The template owner must approve that escalation rule, including which reminder revisions participate and how geographic restrictions and destination budgets are enforced by application policy. Otherwise, a content release can expand the SMS workload without a corresponding capacity decision.
Buy delivery, build an accountable policy boundary
A buy-vs-build table should expose the work the platform team retains. These options cover different parts of the notification path; they are candidates for the same acceptance exercise, not a measured reliability ranking. Buying delivery still leaves someone responsible for the templates, the outcome consumer, and the decision to stop sending.
| Option | What to buy or evaluate | What to build or retain | Selection boundary |
|---|---|---|---|
| Amazon SES | Email delivery for routine event alerts | Template releases, outcome ingestion, suppression enforcement, and operational ownership | A fit when the team can absorb AWS integration complexity; include that engineering time in the decision. |
| Twilio | SMS notifications with broad global coverage | SMS eligibility, escalation authorization, template ownership, and dispatch policy | Evaluate when SMS reach matters; coverage alone does not establish delivery to a particular recipient. |
| Postmark | An email alternative for limited-volume requirements | The same template and recipient-state contract used for other email candidates | Compare against the actual email workload; this row does not establish a unified email-and-SMS solution. |
| Sendinblue | A unified email-and-SMS platform evaluation | Cross-channel rules, template approvals, and application eligibility | Compare the relevant service tiers when channel consolidation matters; consolidation does not assign a template owner. |
| Build the application policy layer | Shared eligibility decisions and template ownership records | Persistence, concurrency, replay handling, monitoring, and incident response | Useful when several producers need one rule; delivery remains a separate dependency. |
Infrai offers one API key, one wallet, and one bill for 295 routes across 20 modules, with a unified REST API and public, self-describing discovery. The request and response schemas plus runnable examples in 10 languages mean wiring a capability starts with reading its description rather than learning a new SDK. A separate operational advantage is the single key and unified billing: the platform team can manage a shared credential boundary and reconcile backend service usage without accumulating separate keys and invoices for each integration. That reduces credential handoffs and invoice reconciliation when notification delivery joins other backend services on the platform roadmap. Its email and SMS events use a pull model, which makes the polling budget above part of the design. The trade-off is operating a polling consumer within the freshness target; this is not a fit when the team cannot own that work. Prefer a focused Amazon SES email integration when an existing AWS operating model already meets the workload's needs, or evaluate Twilio for a dedicated SMS requirement. The template approval and suppression contract still belong to the application.
Inspect before wiring.
The next Go program reads the public SMS capability description without sending a message. Set INFRAI_API_ORIGIN to the service's HTTPS API origin without a path, and supply INFRAI_API_KEY through the environment. Discovery requires no key, but this example uses the standard Bearer header to show the authenticated adapter's credential boundary. The explicit GET checks the response status, disables redirects, limits response size, and retries rate limiting with exponential backoff while honoring a valid Retry-After. This read needs no write idempotency key. Review its returned schema and runnable examples before defining the adapter's request fields.
package main import ( "fmt" "io" "net/http" "net/url" "os" "strconv" "strings" "time" ) func run() error { base := strings.TrimRight(os.Getenv("INFRAI_API_ORIGIN"), "/") key := os.Getenv("INFRAI_API_KEY") parsed, err := url.Parse(base) if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.Path != "" || parsed.User != nil || key == "" || parsed.RawQuery != "" || parsed.Fragment != "" { return fmt.Errorf("set INFRAI_API_ORIGIN to the HTTPS API origin and supply INFRAI_API_KEY") } client := &http.Client{ Timeout: 15 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }, } for attempt := 0; attempt < 3; attempt++ { req, err := http.NewRequest(http.MethodGet, base+"/v1/discovery/sms.send", nil) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+key) resp, err := client.Do(req) if err != nil { return err } body, readErr := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) resp.Body.Close() if readErr != nil { return readErr } if resp.StatusCode == http.StatusTooManyRequests && attempt < 2 { delay := time.Second * time.Duration(1<<attempt) retryAfter := resp.Header.Get("Retry-After") if seconds, err := strconv.Atoi(retryAfter); err == nil && seconds >= 0 { suggested := time.Duration(seconds) * time.Second if suggested > delay { delay = suggested } } else if deadline, err := http.ParseTime(retryAfter); err == nil { if suggested := time.Until(deadline); suggested > delay { delay = suggested } } time.Sleep(delay) continue } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("discovery returned HTTP %d: %s", resp.StatusCode, body) } fmt.Println(string(body)) return nil } return fmt.Errorf("discovery retry budget exhausted") } func main() { if err := run(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } The template ownership axis is the tie-breaker. If content owners need to release reminders independently, require a versioned approval record and verify that every producer uses the shared eligibility boundary. If platform engineers own each template release, a simpler release process may be enough. Neither arrangement excuses an unowned outcome consumer.
Avoid selecting from a send-only demo. Give each candidate the same acceptance scenario: two template producers, a confirmed invalid recipient, queued work, a repeated outcome, and an unknown recipient. Ask who can explain every decision after a template revision changes. That exercise tests the operational contract without pretending that a successful integration example proves an uptime or compliance claim.
Close the page without suppressing useful reminders
The final dashboard should connect template revision, owner, outcome age, blocked decisions, unknown-state holds, and attempted sends after confirmed invalidity. Page on the escaped check and on sustained freshness failure according to the service's error-budget policy. Use blocked volume to investigate recipient quality, with context from the underlying outcomes, rather than treating every prevented send as an incident.
Thresholds have a cost.
A threshold low enough to page on ordinary suppression can consume on-call attention while the control works correctly; a policy that mistakes transient delivery trouble for permanent invalidity can withhold valid appointment reminders. Start by distinguishing those outcomes, then have the template owner review the resulting held and blocked decisions before tightening the paging threshold. The useful endpoint of the trace is an operator who can identify which decision needs action and which reminder is waiting, without assuming that SMS repairs every email failure.
Further reading
References for comparing provider scope and reviewing messaging obligations:
