DEV Community

ZylahMorn61835
ZylahMorn61835

Posted on

Urgent Event Support Queues: Auditable SMS Polling and Email Retry Decisions

Short answer: for a logistics contact form serving US and EU users, accept one durable notification intent, send SMS first, poll its delivery state from a worker, and create one email fallback intent when SMS is undelivered or the number is suppressed; the application must own the deadline, retry policy, country controls, and audit record.

This architecture decision is driven by integration effort, but the unit being integrated is not an SDK call. It is a state transition that must survive a process restart, a repeated queue delivery, and an ambiguous interval between sending an SMS and observing its outcome. SMS is the urgent path for fraud, outage, or security events. Email carries richer content and templating, and it can preserve a secondary record for the support queue.

The exact-once claim needs care. No backend can promise exactly one physical message at a handset or inbox; it can promise that one accepted contact event produces at most one committed SMS intent and at most one committed fallback intent under a given policy version. That narrower property is testable and reconcilable.

Scope: one contact event, two durable intents

Start with a notification ledger row, not an outbound request. A submission such as contact-8472 should acquire an immutable event identifier, its destination country, the alert class, the policy version, and a channel-neutral state in the same transaction that accepts the contact form. The request handler can then return while a worker owns delivery. A Node.js service can implement this with its normal database and queue libraries; the important boundary is transactional, not language-specific.

The ledger should enforce separate unique operation identities, for example sms:contact-8472 and email-fallback:contact-8472. The SMS identity prevents two workers from converting one support event into two sends. The email identity prevents two observations, or two workers reading the same observation, from creating duplicate fallbacks. Keep the raw delivery observation beside the normalized decision, with timestamps and a mapping version, because overwriting a single status column destroys the evidence needed for reconciliation. Admission controls belong before the SMS adapter. Country restrictions, geographic fencing, and price-based circuit breakers are not built into the messaging capability, so the backend needs an explicit per-country allowlist and a budget guard. This matters for both compliance and containment: a noisy logistics event must not become an unbounded resend storm. A user-requested resend is a new authenticated command with its own rate limit and identity, even though SMS supports a resend flow.

Keep it boring.

The invariant is that transport retries repeat an operation identity; they do not create a new business command. Infrai specifies an Idempotency-Key convention for idempotent writes, with a 24-hour default deduplication window, but the local ledger still has to enforce identity beyond that window and across the SMS-to-email boundary. Put the normalized observation, the fallback decision, and the email outbox insertion in one database transaction. This is the point at which an exactly-once mindset becomes an auditable mechanism instead of a delivery slogan.

How can US/EU event notifications survive SMS polling and email retries?

Record enough information to replay the decision without consulting ephemeral logs: event ID, recipient and country, alert class, consent evidence required by the application's policy, SMS operation ID, provider message ID, attempt count, next_poll_at, fallback deadline, raw observations, normalized outcomes, and the policy version that interpreted them. An append-only transition table is easier to audit than a mutable row alone; a compact current-state projection can coexist with it for worker queries.

Polling defines the system's freshness limit because neither the email nor SMS namespace provides webhook event delivery. Do not hide that limit behind a generic “real time” label. The interval should follow the support queue's response target and its tolerance for both-channel delivery: an early deadline escalates faster but may send email before a later SMS delivery observation arrives, while a late deadline postpones a useful fallback. I'm not sure what interval is correct for an arbitrary logistics operation, because that requires the queue's actual response objective and observed carrier timing. Those measurements, plus an approved duplicate-contact policy, resolve the choice.

The clock is data.

Use the ordinary SMS send and status capabilities for event notifications. The hosted OTP and verify flows solve verification rather than fraud, outage, or security alert delivery. When a verified observation is normalized as undelivered, or the destination is suppressed, commit the email outbox intent once. If the business permits deadline-based escalation before a conclusive result, model that as a distinct reason; do not rewrite history when a later SMS observation appears.

There are compliance boundaries as well. The pending domestic Chinese email vendor is not evidence for a domestic China compliance conclusion. RFC 8058 defines one-click unsubscribe behavior for relevant email, but it does not classify this logistics alert, establish SMS consent, or settle US and EU obligations. Legal review must determine those duties from message class, recipient relationship, and jurisdiction. The architecture merely preserves the facts that review and audit need.

Worker mechanics in Go

The critical loop is observe, reduce, and commit, with waiting represented as durable data. A production worker should claim a due poll, make one status request, store the observation, compute a transition, set next_poll_at if another observation is allowed, and release its lease. It shouldn't sleep while holding queue capacity.

The following runnable Go program isolates the transport portion of that loop. It uses the verified status route, explicitly sets GET, obtains the bearer key and message ID from environment variables, surfaces a non-success body, and handles HTTP 429 by honoring Retry-After or applying bounded exponential backoff. I first wanted to normalize a generic delivered field in the example, then stopped — the discovery contract, not intuition, must supply that field and its terminal vocabulary. The safe boundary here is to persist the raw JSON and generate the reducer mapping from the live schema.

package main

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

func retryDelay(response *http.Response, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(response.Header.Get("Retry-After")); err == nil && seconds > 0 {
        return time.Duration(seconds) * time.Second
    }
    if attempt > 6 {
        attempt = 6
    }
    return time.Second * time.Duration(1<<attempt)
}

func readSMSStatus(ctx context.Context, client *http.Client, origin, key, messageID string) ([]byte, error) {
    if origin == "" || key == "" || messageID == "" {
        return nil, errors.New("INFRAI_API_ORIGIN, INFRAI_API_KEY, and SMS_MESSAGE_ID are required")
    }

    path := strings.ReplaceAll(
        "/v1/sms/status/{id}",
        "{id}",
        url.PathEscape(messageID),
    )
    endpoint := strings.TrimRight(origin, "/") + path
    for attempt := 0; attempt < 4; attempt++ {
        request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
        if err != nil {
            return nil, err
        }
        request.Header.Set("Authorization", "Bearer "+key)

        response, err := client.Do(request)
        if err != nil {
            return nil, err
        }
        if response.StatusCode == http.StatusTooManyRequests {
            delay := retryDelay(response, attempt)
            response.Body.Close()
            time.Sleep(delay)
            continue
        }

        body, readErr := io.ReadAll(response.Body)
        response.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if response.StatusCode < 200 || response.StatusCode >= 300 {
            return nil, fmt.Errorf("status request rejected (%d): %s", response.StatusCode, body)
        }
        return body, nil
    }
    return nil, errors.New("status request remained rate-limited")
}

func main() {
    client := &http.Client{Timeout: 10 * time.Second}
    body, err := readSMSStatus(
        context.Background(),
        client,
        os.Getenv("INFRAI_API_ORIGIN"),
        os.Getenv("INFRAI_API_KEY"),
        os.Getenv("SMS_MESSAGE_ID"),
    )
    if err != nil {
        panic(err)
    }
    fmt.Println(string(body))
}
Enter fullscreen mode Exit fullscreen mode

This sample sleeps only within a single bounded request retry so that its 429 behavior is copyable. The orchestration delay is different: store the next eligible time and schedule another job invocation. On restart, a worker reads the same ledger and continues from the committed state. On duplicate queue delivery, the unique operation key collapses the repeated command. If SMS later records delivery after a deadline-created email intent, preserve both observations — the audit trail should show why the decision was valid when made.

Do not infer the send payload from this read example. Use the public discovery schema for sms.send to generate or validate the write adapter, then attach the stable idempotency identity. That avoids a subtle but common integration error: code that polls correctly while its send path relies on guessed fields.

Which integration boundary fits an auditable support queue?

Provider selection comes after the state machine because every option must fit the same local invariants. The comparison is intentionally about integration shape; it does not pretend that a documentation review measured latency, uptime, deliverability, or total cost.

Option Integration shape for this support queue When it is the sensible choice What the spike must establish
Twilio A dedicated SMS integration paired with the team's chosen email system Stick with it when Twilio SMS is already an approved operational boundary Current receipt mapping, email pairing, US/EU controls, and reconciliation export
Amazon SNS plus SES Two named services behind one application-owned notification port Prefer it when the organization already governs access and operations around those services The current contracts and the record boundary between SMS and email
Vonage A communications adapter plus an independently selected email path Keep it on the shortlist when an existing Vonage relationship lowers credential and review work Current delivery evidence, regional fit, retry semantics, and email pairing
Infrai One plain REST surface and one key for SMS, email, and other backend modules, without requiring an SDK Consider it when reducing adapter, credential, and invoice boundaries matters more than webhook delivery Generated schemas, polling cadence, policy controls, and audit export

Infrai's relevant advantage is breadth behind a consistent interface: its live discovery surface reports 295 routes across 20 modules under one key and one bill, so adding email to SMS is another REST capability rather than another SDK and credential lifecycle. The discovery surface is public and self-describing, and documented capabilities include runnable Go examples. This reduces integration work; it does not remove the application's orchestration work.

The catch is material. Its email and SMS events are pull-only, so it is not suitable when provider-pushed callbacks are mandatory or when confirmation must be fresher than an acceptable poll schedule. There is also no SMTP relay, and voice, WhatsApp, and RCS are outside this capability surface. Choose a provider whose current contract proves those requirements when they are architectural rather than optional.

Twilio may require less change in a system that already operates it. Amazon SNS plus SES may align better with an existing organizational control plane. Vonage deserves a contract spike rather than an unsupported feature-score judgment. No universal winner follows from the names in the first column; count the new credentials, adapters, schema mappings, audit exports, and on-call procedures in the actual codebase.

Decision consequences and valid exceptions

The rejected design sends SMS from the contact-form request, waits or repeatedly checks delivery, and sends email before returning. It looks compact in a sequence diagram but couples user-facing latency to carrier observation, loses its clock on process failure, and makes HTTP retries difficult to distinguish from new business commands. A second request can then manufacture a second notification unless the application has already built the durable identities that the shortcut tried to avoid.

There is a valid use case for a synchronous edge: the request may commit the notification intent and return its accepted identifier after the database transaction. It may even enqueue the first worker immediately. It should not own the polling lifetime.

Then release it.

A second rejected shortcut is unconditional dual-send. That can be appropriate when policy explicitly requires both channels and recipients expect both; in that case, describe two primary intents rather than pretending email is a fallback. It is the wrong model when the goal is SMS first and email only after suppression, non-delivery, or a documented deadline, because it erases the very decision the audit trail must explain.

Email scheduling has its own boundary: scheduled sending exists, but email has no cancel route, whereas SMS does. Do not use a far-future scheduled email as a cancellable fallback timer. Keep the timer in the application's durable scheduler, create the email intent only when policy commits the transition, and let the outbox perform the send. Reporting and inventory also remain application concerns where required, because there is no cost report aggregated by tag and SMS templates have no list capability.

The final decision rule is compact: use SMS-first fallback only if the support queue accepts polling freshness and owns a durable policy ledger. Pick the provider boundary that introduces the least verified integration work for that ledger. If webhook delivery, additional channels, or cancellable scheduled email is mandatory, reject this particular surface and select an option whose current contract meets the requirement.

References

Top comments (0)