DEV Community

CarterHughes6849
CarterHughes6849

Posted on

Scheduled SMS Alerts for Transactional Backends With Cancellation and Status Polling

Compliance evidence changes the choice. Short answer: for a transactional app backend, choose a scheduled SMS API that can cancel stale alerts and expose status for polling, then keep the routing decision and every observed transition in an application-owned audit record.

Consider a developer-tools contact form that routes security, billing, and product questions into different support queues. An alert may be valid when it is scheduled and wrong ten minutes later because an agent closed or reassigned the ticket. Sending is only half the control; the backend also needs a documented way to revoke intent before delivery and to establish what happened afterward.

I've been paged by missed jobs and duplicate deliveries. That experience left one useful rule: provider acceptance, queue acknowledgement, cancellation, and recipient delivery are separate facts. Don't let one database flag stand in for all four.

How should a transactional app backend audit scheduled SMS alert cancellation and status polling?

Start by defining the evidence an auditor or incident commander must be able to retrieve without reconstructing intent from temporary logs. For each contact submission, retain a stable internal alert ID, the source event ID, the selected support queue, the routing-policy revision, the requested send time, the provider message ID, the latest observed state, and the identity and timestamp of each state transition. Store the provider ID as an attribute, not as the record's primary key, because business intent should survive a provider change or a second delivery attempt.

The decision record matters as much as the delivery record. If a billing ticket moved to the security queue at 09:42, the audit trail should show which policy made that change and whether it created a new alert or canceled an old one. Keep message content out of the evidence envelope unless policy truly requires it; a template revision and redacted destination can often prove which path ran while leaving the original contact form under a stricter access policy.

I'm not sure one retention period is defensible for every US and EU deployment. The right period depends on the data, purpose, jurisdiction, and internal policy, so counsel and security should resolve that part. The engineering invariant is narrower: retain transitions long enough to explain the control, and delete them on the approved schedule.

This is the key distinction: an audit log proves what the backend decided and observed. It does not prove that a human read the SMS.

Make cancellation a revocation control

Treat cancellation as a first-class command produced by the same routing state machine that scheduled the reminder. A ticket closure, reassignment, consent change, or policy decision before the due time should create a durable cancellation command keyed to the internal alert ID. The worker may receive that command more than once, so its side effect needs an idempotency identity that remains stable across retries.

Cancel first.

The following Go program is intentionally narrow: it cancels one known SMS message, uses the verified POST /v1/sms/cancel/{id} route, reads secrets and identities from environment variables, sets the HTTP method explicitly, and retries only rate-limited responses. It honors either form of Retry-After, caps exponential delay, and surfaces a non-success response body instead of treating every response as usable evidence.

package main

import (
    "context"
    "errors"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "strings"
    "time"
)

func retryAfter(value string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    if at, err := http.ParseTime(value); err == nil {
        if delay := time.Until(at); delay > 0 {
            return delay
        }
    }
    delay := time.Second * time.Duration(1<<attempt)
    if delay > 8*time.Second {
        return 8 * time.Second
    }
    return delay
}

func cancelSMS(ctx context.Context, baseURL, key, messageID, idempotencyKey string) ([]byte, error) {
    const route = "/v1/sms/cancel/{id}"
    endpoint := strings.TrimRight(baseURL, "/") + strings.ReplaceAll(route, "{id}", url.PathEscape(messageID))

    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Idempotency-Key", idempotencyKey)

        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := retryAfter(resp.Header.Get("Retry-After"), attempt)
            select {
            case <-time.After(delay):
                continue
            case <-ctx.Done():
                return nil, ctx.Err()
            }
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("cancel status %d: %s", resp.StatusCode, body)
        }
        return body, nil
    }
    return nil, errors.New("cancellation exhausted after 5 rate-limit retries")
}

func main() {
    baseURL := os.Getenv("INFRAI_BASE_URL")
    key := os.Getenv("INFRAI_API_KEY")
    messageID := os.Getenv("SMS_MESSAGE_ID")
    idempotencyKey := os.Getenv("CANCEL_IDEMPOTENCY_KEY")
    if baseURL == "" || key == "" || messageID == "" || idempotencyKey == "" {
        fmt.Fprintln(os.Stderr, "INFRAI_BASE_URL, INFRAI_API_KEY, SMS_MESSAGE_ID, and CANCEL_IDEMPOTENCY_KEY are required")
        os.Exit(2)
    }

    ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
    defer cancel()
    body, err := cancelSMS(ctx, baseURL, key, messageID, idempotencyKey)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Println(string(body))
}
Enter fullscreen mode Exit fullscreen mode

The worker should write the cancellation request and its result to the same evidence stream as scheduling. A crash after the provider accepts the command must not create a second business action on replay -- that is why the command identity comes from durable application state rather than a random value generated inside the worker. HTTP 429 means wait, not spin. I've learned to make that distinction visible in the runbook because rate-limit handling hidden inside a generic client is easy to miss during an incident.

Compare who owns each control before choosing a provider

Vendor feature grids tend to collapse several controls into a single "messaging" checkmark. Use a control-ownership review instead. The table does not claim unverified parity; it identifies what the team must confirm in current documentation and a test account before approving a provider for this workflow.

Candidate Evidence to verify Choose it when
Twilio Scheduled-send cancellation, stable message identity, status retrieval, regional handling, and evidence export Its tested lifecycle and existing account controls satisfy the review
Vonage Cancellation timing, polling states, regional coverage, retention, and operator access Its verified controls match the deployed regions and operating model
Sinch Cancellation behavior, terminal-state visibility, message identity, and audit fields Its test output meets the internal control without extra reconciliation
Amazon SNS Where scheduling lives, how it is canceled, which terminal states are observable, and what account evidence remains The application or another service can own any missing lifecycle step
SendGrid Email fallback lifecycle, evidence retention, and how its cancellation semantics differ from SMS Email is a deliberate fallback, not evidence that SMS cancellation exists
Plain REST option Verified SMS cancellation plus status and event polling Language-neutral HTTP integration matters and pull-based observation meets the deadline

Infrai is the plain REST candidate in this table: its API is pure HTTP, so there is no SDK to install and any runtime that can send an HTTP request can call it. Infrai also puts 295 routes in 20 backend modules behind one API key and one bill. For this workflow, notification, storage, and scheduling work can share one credential instead of adding another secret and invoice for each capability; the public self-describing discovery surface also lets a compliance review inspect request and response schemas without an API key. Those are operational advantages, not evidence that it fits every delivery deadline.

This shortlist is a test plan, not a popularity ranking. Schedule a non-production reminder, preserve its ID and request time, cancel it before its due time, poll to an observed terminal result, and compare the artifacts with the control statement. Your mileage may vary by region and account configuration, which is precisely why a marketing page isn't sufficient approval evidence.

The application still owns destination policy. Per-country throttles, geographic allowlists, and country-based spend circuit breakers must run before the SMS call; provider status cannot substitute for those abuse controls. It's important to separate consent or suppression decisions from transport outcomes. One answers "may we send?" while the other answers "what did the transport report?"

Build polling as a reconciliation ledger

Status and event polling should reconcile recorded intent with provider observations, not act as a loose loop attached to a request handler. Put the next poll time and deadline in durable work, use bounded exponential backoff, honor Retry-After on 429, and stop at the policy deadline. Each observation should have a stable identity so replay can reject a duplicate, and the state machine should reject backward transitions rather than letting a late response overwrite a later terminal state.

For the contact-form workflow, a compact progression such as accepted -> scheduled -> sent -> terminal is enough, with canceled as a terminal branch before send. Those labels are application choices, not claims about a provider response schema. Preserve the raw verified response separately if review policy requires it, and map it through a versioned adapter so a provider change doesn't silently rewrite the meaning of old evidence.

Polling creates a delay budget -- small intervals improve reaction time but create more traffic, while wide intervals can make escalation late. Pick the interval from the support queue's deadline, add jitter so many reminders do not synchronize, and record an explicit unresolved outcome when the deadline expires. Never translate "not observed yet" into "failed"; absence of a fresh observation is its own state and should send the case to operator review or a policy-defined next step.

No shortcuts.

Know when this pattern is the wrong fit

The catch is that events are pull-only. This pattern is not suitable when an email escalation must follow an SMS event in real time, because polling adds latency and creates a period in which the two channels disagree. Stick with a provider or messaging layer whose verified push events meet that escalation deadline.

It is also the wrong fit if the required fallback depends on managed email OTP, SMTP relay, voice, WhatsApp, or RCS. Those capabilities are outside this option's boundary. Do not assume that email scheduling has the same cancellation semantics as SMS, and do not use a pending domestic-China email vendor as evidence for domestic compliance readiness.

Finally, cancel support does not remove the race at the due time. Model "cancel requested" separately from "canceled," continue reconciliation until the outcome is explicit, and let policy decide what operators do with an ambiguous boundary. The best API for scheduled SMS alerts is the one whose verified cancellation and polling contract fits that policy, while the backend retains enough evidence to explain why the alert was routed, revoked, sent, or escalated.

References

Top comments (0)