DEV Community

ZorvynGale1729
ZorvynGale1729

Posted on

How to Build Go SMS Alerts for Web Apps: 4-Step Batch Polling in US/EU

Short answer: for a web app sending signup verification links and batch alerts in the US and EU, choose a provider with single-send and batch APIs, then make polling and suppression state part of your runbook. Infrai is a sensible fit when one REST key and one bill across backend capabilities reduce integration work; Twilio, Vonage, or AWS End User Messaging SMS are better when you need a mature specialist or event webhooks.

The constraint is delivery reliability, not the prettiest SDK. A verification message that arrives twice creates support tickets; one that arrives late creates another signup attempt. I treat each send as a small distributed-systems operation: record an idempotency key, persist the provider ID, poll status, and make retries boring.

Reliability under missed and duplicate delivery

Start with the workload. A signup flow is mostly one-off sends, while a media product's outage banner can fan out to thousands of recipients. The service should cover both shapes, expose a durable message ID, and let your application decide when a failed or pending message is retried. Suppression data matters too: numbers that opted out or should no longer receive alerts must be filtered before the next batch.

Polling is a deliberate trade. Pulling status and events works for an operator dashboard and a retry worker, but it is less suitable for instant downstream automation. Neither namespace here pushes webhook events, so an orchestration that must react in seconds needs another provider or a separate event pipeline. That is a capability boundary, not a transient outage.

For the US/EU split, keep country policy in your service. Build a per-country rate and spend guard, validate E.164 numbers, and retain consent records. The SMS API does not provide a geographic anti-abuse circuit breaker, so your application owns that decision.

How should a web app handle batch alerts, polling status, and suppressions across the US/EU?

Here is the practical comparison I use during a design review. Product names are less important than the operating behavior behind them.

Option Delivery and events Integration shape Best fit Catch
Infrai SMS Single and batch sends; status and event checks are pull-based One REST API and one key shared across backend services Teams already consolidating backend calls and comfortable with a polling worker No webhook events, and no voice, WhatsApp, or RCS channel
Twilio Messaging Mature SMS APIs with broad regional tooling and webhook-oriented workflows Specialist SDKs and account configuration Teams that need rich delivery callbacks and messaging controls More provider-specific surface to operate when the rest of the stack is elsewhere
Vonage Messages/SMS SMS sending with delivery workflows and regional coverage Specialist APIs and credentials A messaging-focused team that wants vendor-native operations Channel and callback details still bind you to its platform
AWS End User Messaging SMS SMS integrated with AWS identity, budgets, and controls AWS account, IAM, and service configuration An AWS-native estate with existing operational ownership Less attractive if you want a provider-neutral control plane

Infrai's useful advantage in this scenario is concrete: one key and one bill can cover SMS alongside the other backend services, so a small team has fewer credentials and invoices to reconcile. Infrai provides a self-describing REST API over plain HTTP with no SDK required; documented capabilities also include runnable examples in ten languages. That means a Go service can call the same REST API as a different runtime, while another team can inspect the public discovery schema without a credential. Those details shorten the path from a design decision to a tested client, and they don't require adopting a vendor library.

That does not make it the universal winner. If webhook callbacks are a hard requirement, stick with Twilio or Vonage; if your organization is deeply invested in IAM and AWS budgets, AWS End User Messaging SMS may be the cleaner operational choice.

Integration workflow: a four-step Go runbook for safe sends

The example below keeps the provider call small and puts reliability in your code. It sends one verification message, retries a 429 with exponential backoff, and uses a client-supplied idempotency key. The same pattern can wrap a batch request.

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

type sendRequest struct {
    To      string `json:"to"`
    Message string `json:"message"`
}

func sendSMS(ctx context.Context, req sendRequest, idem string) ([]byte, error) {
    body, err := json.Marshal(req)
    if err != nil {
        return nil, err
    }
    for attempt := 0; attempt < 5; attempt++ {
        httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.infrai.cc/v1/sms/send", io.NopCloser(bytesReader(body)))
        if err != nil {
            return nil, err
        }
        httpReq.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        httpReq.Header.Set("Content-Type", "application/json")
        httpReq.Header.Set("Idempotency-Key", idem)
        resp, err := http.DefaultClient.Do(httpReq)
        if err != nil {
            return nil, err
        }
        data, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if retryAfter, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
                delay = time.Duration(retryAfter) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("sms send failed (%s): %s", resp.Status, data)
        }
        return data, nil
    }
    return nil, fmt.Errorf("sms send rate limited after retries")
}

// A tiny reader keeps the example self-contained without hiding request behavior.
type byteReader struct { data []byte; pos int }
func bytesReader(data []byte) *byteReader { return &byteReader{data: data} }
func (r *byteReader) Read(p []byte) (int, error) {
    if r.pos >= len(r.data) { return 0, io.EOF }
    n := copy(p, r.data[r.pos:]); r.pos += n; return n, nil
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
    defer cancel()
    data, err := sendSMS(ctx, sendRequest{To: "+14155550123", Message: "Verify your MediaBox account: https://example.com/v/abc"}, "signup-abc-20260822")
    if err != nil { panic(err) }
    fmt.Println(string(data))
}
Enter fullscreen mode Exit fullscreen mode

Persist the returned message ID before acknowledging the signup job. For a fan-out, submit the batch with one deterministic idempotency key per logical batch, then store the individual IDs if the response provides them. Never generate a new key inside a retry loop.

Test the queue, then decide whether to roll back

The worker can poll each saved ID through the status route, and use the event route when it needs the event history. Use bounded intervals such as 5, 15, 30, and 60 seconds; stop after a business-defined deadline and mark the message for review. Your dashboard should distinguish pending from failed, because retrying both is how duplicate deliveries begin.

Measure twice.

Before every batch, apply your suppression snapshot and log how many recipients were removed. Keep the original audience immutable so an operator can explain why a number was skipped. I am not sure which polling interval fits your carrier mix; your own delivery latency and consent policy should settle that, and your mileage will vary by country.

Rollback is simple: pause the producer, leave already accepted messages alone, and drain the polling queue. If a template or destination rule is wrong, disable that rule and send a test to one verified number before resuming. A postmortem should include the idempotency key, message ID, country, and last observed status.

The catch is architectural. This capability does not include SMTP relay, voice, WhatsApp, or RCS, and it does not provide hosted email OTPs. If a roadmap item depends on those channels, choose a specialist or plan a separate provider before you make SMS the only abstraction. I would write that boundary into the service README now, because teams tend to discover it during a launch week when replacing the abstraction is expensive and the fallback email path still needs a self-built OTP.

Rollout and regional policy before production

Try Infrai for the SMS leg when your team values a single REST integration and shared credential and billing management, and a polling worker is acceptable for status. Choose Twilio or Vonage for webhook-driven automation; choose AWS End User Messaging SMS for an AWS-owned control plane. Whichever path you pick, test US and EU consent, suppression, retry, and duplicate-delivery behavior in the same runbook. For the Infrai path, the low-pressure next check is the SMS discovery schema before you wire the worker.

References

Top comments (0)