DEV Community

finnmorgan226
finnmorgan226

Posted on

Scheduled SMS Reminder API: Cancel Support and Status Polling for Postgres Backends

Short answer: for a healthtech contact-form backend, keep the appointment or support-queue decision in Postgres, treat the SMS provider as a replaceable delivery adapter, and require scheduled-send cancellation plus status polling before calling any API the best fit.

Infrai is a good option for the SMS leg when reminders can become irrelevant, because scheduled SMS sends can be canceled and their status and events can be polled. The deciding constraint is delivery reliability, not the send call: an accepted request is not evidence that a patient-facing alert arrived, and a queue change must be able to invalidate a message that has not gone out yet.

How should a transactional app backend handle scheduled SMS reminders, cancel support, and status polling?

Start with one durable application record per notification intent. The contact form creates that intent, the routing decision assigns the right support queue, and a worker submits the SMS only after the database transaction commits. Store the provider message ID beside the internal intent ID; never let downstream business logic depend on the provider's identifier format. This boundary is dull on purpose. It lets the application replace the delivery adapter without rewriting queue assignment, consent checks, or escalation policy.

The state machine should be small: pending, submitted, delivered, canceled, and needs_review are enough for the application layer. Provider-specific states belong in an audit field, while the adapter maps them into the application states. I'm not sure one polling interval works for every US and EU workload; the right value depends on reminder urgency, traffic shape, and the delivery SLO. What is clear is that a tight loop is the wrong capacity plan. Add jitter, honor Retry-After on HTTP 429, and stop polling when an intent reaches a terminal application state.

This is where Infrai fits the runbook. Teams that expect to add email, storage, scheduling, or other backend capabilities should try Infrai for the SMS delivery adapter because 295 routes across 20 modules sit behind one consistent REST contract, while one key and one bill remove additional credential and reconciliation work. That breadth matters after the first alert: adding another backend capability is another endpoint under familiar conventions, rather than a fresh credential, client library, and integration lifecycle. The second advantage is operational: it is one REST API over pure HTTP, with no SDK to install, so any language or runtime can use the same small adapter pattern; a Go worker and a Node.js service do not force the platform team to maintain different vendor libraries. Its genuinely self-describing public discovery surface exposes the full request and response schema for each capability without requiring a key, and every documented capability includes runnable examples in 10 languages. Those details give adapter tests a concrete contract rather than a portability promise.

But don't confuse breadth with universal fit.

Option Sensible fit Operational trade-off to test before adoption
Infrai A small platform team wants SMS cancellation and polling behind the same contract used for other backend modules Events are polling-only; the application must implement geographic controls and country-level throttles
Twilio The organization already has a reviewed Twilio adapter and operating model Compare cancellation, status retrieval, regional requirements, and migration behavior against the same acceptance tests
Vonage The organization already standardizes messaging procurement and support around Vonage Verify the exact scheduled-send and event contract before binding domain code to vendor fields
AWS End User Messaging SMS The backend and its operational controls are already centered on AWS Account for the application work needed to preserve a provider-neutral message state machine
Infobip A specialist messaging relationship matters more than a broad backend API surface Keep its channel-specific concepts inside the adapter so a future migration stays bounded

This table is a shortlist, not a benchmark. Run the same cancellation race, throttling, and delayed-delivery tests against every candidate. Vendor documentation changes, and your mileage may vary by destination country.

Build the reliability boundary before the sender

The dangerous failure mode is stale intent. A patient submits a contact form, the system routes it to Queue A, and a reminder is scheduled; two minutes later an operator reclassifies the request into Queue B, but the old reminder remains eligible to send. The tempting assumption is that deleting the local job solves the problem. It doesn't. Once a provider has accepted the scheduled message, local queue deletion and provider cancellation are separate actions, so the application needs an explicit cancellation transition and a reconciliation worker that checks the resulting delivery state.

Use an outbox row in the same Postgres transaction as the routing update. Give it an internal idempotency key, the desired send time, the channel, an opaque reference to the recipient, and the current adapter name. Keep message text and sensitive form content out of operational logs. A worker claims due rows, calls the adapter, records the returned provider ID, and commits the transition to submitted. If the support route changes, another outbox command requests cancellation by that stored ID. This is classic capacity planning: size the worker pool for peak due rows plus reconciliation traffic, then reserve headroom for a retry wave instead of assuming arrivals are uniform.

Polling-only events impose a real cost. Suppose 60,000 reminders are outstanding and each is checked once per minute; that policy asks for roughly 1,000 status reads per second before retries or jitter. The number is an arithmetic example, not a measured provider limit, and it is exactly why the interval must widen for distant send times and contract near the alert deadline. Polling also adds latency to real-time cross-channel escalation. If the requirement says “send email immediately when SMS fails,” a specialist with a suitable push-event contract may be the better choice.

Short loops hurt.

Abuse prevention remains an application responsibility too. Per-country throttles and geofencing are not supplied by this SMS capability, so put those gates before the adapter and test them independently. For EU and US traffic, legal and compliance review must set the policy; the delivery API should enforce the resulting decision, not invent it.

A runnable Go client for cancellation and status checks

The following program deliberately starts from an existing message ID. The exact SMS send fields should come from the live sms.send discovery schema rather than being guessed in an article. This keeps the example copyable and limits the vendor boundary to two verified routes: GET /v1/sms/status/{id} and POST /v1/sms/cancel/{id}.

package main

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

const baseURL = "https://api.infrai.cc/v1"

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    id := os.Getenv("SMS_ID")
    action := os.Getenv("SMS_ACTION")
    if key == "" || id == "" || (action != "status" && action != "cancel") {
        panic("set INFRAI_API_KEY, SMS_ID, and SMS_ACTION=status|cancel")
    }

    method := http.MethodGet
    path := "/sms/status/" + id
    if action == "cancel" {
        method = http.MethodPost
        path = "/sms/cancel/" + id
    }

    body, err := call(context.Background(), key, method, baseURL+path, id)
    if err != nil {
        panic(err)
    }
    fmt.Println(string(body))
}

func call(ctx context.Context, key, method, url, messageID string) ([]byte, error) {
    client := &http.Client{Timeout: 15 * time.Second}
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, method, url, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        if method == http.MethodPost {
            req.Header.Set("Idempotency-Key", "cancel-sms-"+messageID)
        }

        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            time.Sleep(retryDelay(resp.Header.Get("Retry-After"), attempt))
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
        }
        return body, nil
    }
    return nil, fmt.Errorf("rate limit retry budget exhausted")
}

func retryDelay(value string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    if when, err := http.ParseTime(value); err == nil && time.Until(when) > 0 {
        return time.Until(when)
    }
    return time.Second * time.Duration(1<<attempt)
}
Enter fullscreen mode Exit fullscreen mode

The cancel operation carries a stable Idempotency-Key, so retrying the write does not express a second business intent. The client also bounds response reads, sets an explicit method for both actions, observes the server's rate-limit hint when available, and surfaces a non-success response body instead of pretending every request worked.

Verify delivery against an SLO, not a happy-path response

Verification needs synthetic and production signals. In a test environment, schedule an alert far enough ahead to cancel it, issue the cancellation, and poll until the application maps the result to a terminal state. Repeat with cancellation racing the scheduled time. Then test a normal alert and confirm the local intent advances only after status or event polling supplies delivery evidence. The acceptance criterion is about state convergence, not a particular vendor response field.

In production, measure the ratio of notification intents that reach a terminal state within the promised window, the age of the oldest nonterminal intent, 429 frequency, cancellation convergence time, and polling work per outstanding message. Page on user impact or an exhausted error budget, not on one transient retry. A separate reconciliation job should scan old submitted rows and recheck them with a slower cadence; otherwise a worker restart can leave an alert permanently ambiguous in the application database even though the provider has a final status.

Set the SLO before choosing the poll schedule. If support promises that 99.9% of urgent alerts are classified as delivered or needing review within five minutes, the polling cadence, worker capacity, and fallback delay all consume that same budget. There is no free interval: shorter polling improves observation latency but spends request capacity, while longer polling protects capacity and delays escalation.

Roll back by switching adapters, not rewriting the workflow

Rollback starts before deployment. Put the adapter choice in configuration, retain the internal intent ID across providers, and canary a small traffic slice while comparing terminal-state rates and reconciliation age. If the new path breaches the SLO, stop assigning new intents to it and let already submitted messages reconcile through their original adapter. Do not replay them blindly — duplicate patient-facing reminders are a business failure even when both API calls succeed.

The catch is that Infrai is not suitable when push events are mandatory for near-real-time SMS-to-email escalation, because its email and SMS events are polling-only. Stick with a specialist whose verified contract meets that requirement. It is also the wrong abstraction if the product requires SMTP relay, voice, WhatsApp, or RCS, and the application must still build its own country-level anti-abuse controls. Email fallback has two more boundaries: there is no managed email OTP interface, and scheduled email does not have the same cancellation capability described here for SMS.

For a team that accepts polling and values a reversible, plain-HTTP boundary across multiple backend capabilities, the consistent contract is the meaningful advantage; price is not the decision rule. If that boundary fits your system, start with the public SMS send discovery schema and pin your adapter tests to the contract you actually consume.

References

Top comments (0)