Short answer: choose an SMS API only after a small production-like trial proves that suppression, template versioning, regional delivery reporting, and bounded batch retries work under the same failure conditions as an outage.
For a property marketplace, the concrete job is deceptively plain: tell a seller that a new order needs attention. The alert path is useful during normal operation and critical when the primary application is degraded, so its SLO cannot rest on a provider's feature checklist. It needs an end-to-end objective owned by the startup, with evidence from order creation through accepted message, delivery report, or an explicit terminal state.
Don't pick on nominal throughput alone. A fast sender that duplicates 2,000 alerts after a retry, texts a suppressed number, or silently renders a stale template is an operational regression with a nice requests-per-second chart.
Why delivery reliability is an application property
The first boundary is the order event. Give each order notification a stable idempotency key, persist the intended recipient and template version, and place it in a durable queue before a worker calls any messaging API. The worker may stop between the remote acceptance and the local acknowledgement — the awkward little gap every happy-path demo ignores — so a retry must find the same logical notification rather than manufacture a second one.
At-least-once processing is manageable when deduplication is explicit. The database should enforce uniqueness on the notification key, while the sender records attempts separately from the notification's final state. An HTTP 202 can mean that a request was accepted for later processing; it does not, by itself, prove handset delivery. Conversely, a client timeout leaves the remote outcome uncertain. Treating either condition as “send again from scratch” converts ambiguity into duplicates.
Suppression belongs before template rendering and before batch assembly. The check should use the normalized destination, the communication purpose, and the current policy version, not a copy of yesterday's CSV loaded when the process started. There is a race between a seller opting out and a queued alert leaving the system; the defensible design rechecks close to dispatch and records which policy decision authorized the attempt. The exact policy will differ across US and EU operations, and I'm not sure a single global retention rule is defensible without legal review. Engineering can still make the decision point observable and auditable.
Message length is another reliability input. Twilio's character-limit documentation explains that SMS encoding and segmentation change the number of characters in a segment. That means a seemingly harmless template edit can alter segment count, latency exposure, and capacity demand. Pin the template version, render with worst-case seller and property data, then inspect the encoded result in the same release process used for application code.
Small details compound.
What should a startup test in an SMS outage alerts API?
Test the workflow, not the brochure. Use a staging destination set that covers US and EU formats, but never use real seller numbers for load generation. Start with the failure boundaries your team owns: duplicate order events, worker termination after provider acceptance, late delivery callbacks, a newly suppressed destination, an invalid template variable, and a batch that is larger than the worker's configured concurrency.
The evaluation should produce evidence against a written service level objective. A useful starting form is “99.9% of eligible seller notifications reach a terminal state within five minutes,” where eligible, terminal, and the measurement window are defined precisely. This is an example objective, not a universal target. Capacity planning then works backward from the marketplace's peak order rate, the retry budget, the expected segment count, and the maximum tolerable queue age. Your mileage may vary; a marketplace with bursty auction closes needs a different envelope from one with evenly distributed rent-payment orders.
Keep the vendor trial comparable:
| Decision area | Evidence to collect | Reject or investigate when |
|---|---|---|
| Delivery lifecycle | Accepted, delivered, failed, and unknown states tied to one notification key | A state cannot be reconciled without guessing |
| Suppression | A last-moment opt-out prevents dispatch and leaves an audit record | Suppression exists only as a manually imported list |
| Templates | Immutable version ID, validated variables, and an encoding check | A template can change beneath queued work |
| Batch behavior | Per-recipient result, bounded concurrency, and partial-retry behavior | One bad destination causes blind replay of the whole batch |
| Regional operation | US/EU test destinations, timestamped status evidence, and documented data handling | The team cannot state where operational data is processed |
| Runtime fit | Maintained Node.js support or a stable HTTP contract, timeout control, and test doubles | The client hides retry and timeout behavior from the caller |
Three pilot candidates can be Twilio, Amazon SNS, and Vonage, but their names shouldn't decide the result. Run the same harness against each candidate and record observed behavior rather than assigning points from marketing pages. Stick with an existing provider when it already meets the measured objective and the migration risk exceeds the unresolved reliability gap; switch only when the trial exposes a material control or observability boundary.
Implement the safe send path
The core interface should keep provider semantics outside the order service. Although Node.js support belongs in the selection matrix for a JavaScript startup, the following Go worker makes the reliability contract visible: reserve one notification, recheck suppression, render an immutable template version, send a bounded recipient batch, and persist each outcome. There is no invented provider route here; an adapter implements the commercial or self-hosted transport selected by the trial.
package alerts
import (
"context"
"errors"
"time"
)
type Notification struct {
Key string
SellerID string
PhoneE164 string
OrderID string
TemplateVersion string
}
type SendResult struct {
ProviderID string
State string
}
type Store interface {
Reserve(ctx context.Context, key string, lease time.Duration) (bool, error)
MarkSuppressed(ctx context.Context, key string) error
MarkResult(ctx context.Context, key string, result SendResult) error
Release(ctx context.Context, key string) error
}
type Suppression interface {
Blocked(ctx context.Context, phoneE164, purpose string) (bool, error)
}
type Templates interface {
Render(version string, data map[string]string) (string, error)
}
type Sender interface {
Send(ctx context.Context, idempotencyKey, phoneE164, body string) (SendResult, error)
}
type Worker struct {
Store Store
Suppression Suppression
Templates Templates
Sender Sender
}
func (w Worker) NotifySeller(ctx context.Context, n Notification) error {
reserved, err := w.Store.Reserve(ctx, n.Key, 30*time.Second)
if err != nil || !reserved {
return err
}
blocked, err := w.Suppression.Blocked(ctx, n.PhoneE164, "new_order")
if err != nil {
_ = w.Store.Release(ctx, n.Key)
return err
}
if blocked {
return w.Store.MarkSuppressed(ctx, n.Key)
}
body, err := w.Templates.Render(n.TemplateVersion, map[string]string{
"order_id": n.OrderID,
})
if err != nil {
_ = w.Store.Release(ctx, n.Key)
return err
}
sendCtx, cancel := context.WithTimeout(ctx, 4*time.Second)
defer cancel()
result, err := w.Sender.Send(sendCtx, n.Key, n.PhoneE164, body)
if err != nil {
_ = w.Store.Release(ctx, n.Key)
return errors.New("send outcome requires reconciliation")
}
return w.Store.MarkResult(ctx, n.Key, result)
}
The deliberately uncomfortable line is the reconciliation error. A timeout is not proof of rejection. Put that notification into a reconciliation state, query through the adapter's supported status mechanism when one exists, and alert an operator if uncertainty outlives the notification's error budget. Do not immediately create a new idempotency key.
For batch sends, concurrency should be a configured ceiling derived from measured capacity, not len(recipients) goroutines. Split work into individually identifiable notifications before dispatch so partial outcomes remain recoverable. Backoff needs jitter and a retry budget; permanent destination or policy failures should become terminal without consuming that budget. The sender adapter is also where a Node.js implementation must expose cancellation, timeouts, and raw correlation identifiers rather than swallowing them behind automatic retries.
Verify the SLO before and after deployment
A pre-production test is necessary, but the release gate needs production signals. Measure queue age, attempts per notification, suppression decisions, template-render failures, provider acceptance latency, terminal-state latency, unknown outcomes, and duplicate terminal deliveries. Partition the views by region and template version. Avoid recipient numbers or rendered message bodies in metric labels; high-cardinality personal data is a poor observability design.
Use burn-rate alerts against the notification SLO instead of paging on every individual failure. The page should include the oldest queued notification, the affected region and template version, recent deployment identifiers, and the size of the uncertain-outcome set. It should not require the on-call engineer to reconstruct a batch from provider dashboard screenshots at 03:00.
The verification sequence is short: canary one template version, hold concurrency below the measured limit, compare terminal-state latency and unknown outcomes with the previous release, then expand in controlled steps. Inject a duplicate event and terminate a test worker at the acceptance boundary. Confirm that the same notification key survives and that suppression is evaluated again before any later attempt.
No vibes. Keep the evidence.
Roll back transport changes without replaying sellers
Rollback means stopping new dispatches through the changed adapter while preserving queued notification identities. Drain or reconcile in-flight attempts before directing unsent work to the previous transport. If both adapters run during a transition, they must share the same idempotency ledger; two isolated “exactly once” claims can still send twice.
The buy-versus-build decision is mostly an ownership decision:
| Option | Sensible when | The catch |
|---|---|---|
| Managed SMS API | A small team needs carrier connectivity and can validate the required controls through an adapter | External status semantics, data handling, and rate behavior remain dependencies |
| Self-hosted orchestration over a transport | Policy, queueing, templates, and reconciliation need consistent internal ownership | The team owns more code, storage, upgrades, and on-call load |
| Fully self-managed messaging path | Regulation or specialized routing justifies deep operational control | It is not suitable when the startup cannot staff continuous telecom operations |
The recommendation is therefore conditional. Buy the transport capability when the trial satisfies the SLO and the adapter preserves your control plane; build the policy, idempotency, and observability layer that protects seller trust. Self-host more only when a documented requirement outweighs the extra operational load and lock-in reduction. If the current system already produces reconcilable outcomes inside budget, leave it alone.
Top comments (0)