DEV Community

YannickSterling6563
YannickSterling6563

Posted on

Urgent Travel Notifications Explained — SMS First, Email Fallback, and Delivery Evidence

Short answer: send an urgent itinerary-change SMS first, wait for a provider delivery state, and fall back to email when the SMS is rejected, expires, or misses your deadline. Store every attempt as evidence, use idempotency keys, and let a bounded retry policy protect the traveler's attention as well as your SLO.

That ordering is a policy decision, not a transport trick. A gate change at 02:10 in the traveler's local time has a different urgency from a seat-map update, and compliance teams will ask what you sent, when you sent it, which channel accepted it, and why you escalated. I model those questions before choosing an API.

The incident pattern: a receipt-like message is not proof of delivery

A marketplace itinerary service can publish an ItineraryChanged event after payment or ticket reissue settles. The notification worker consumes it, writes a durable notification record, and attempts SMS. The provider may answer 202 Accepted; that means the request entered a queue, not that the phone received anything.

The dangerous shortcut is to mark the notification complete when the HTTP call returns. During a rollout, a carrier rejection arrived several minutes after the initial response, while the email fallback was never scheduled because the database row already said sent. The traveler saw nothing, and the audit trail could not explain the gap. The same shape appears with an email provider that accepts a message and later reports a policy rejection, with a worker that crashes after sending but before committing its transaction, and with a poller that reads a stale replica and concludes that nothing is pending. In each case, a single boolean has collapsed at least three events into one guess. The repair is to write an immutable attempt before the network call, attach the provider's correlation ID after acceptance, and append a terminal observation when the status endpoint or webhook supplies one. That sequence gives an operator a timeline they can replay, lets a compensating worker choose email without duplicating SMS, and makes a compliance export explain both the successful path and the reason a fallback was chosen.

The invariant is simple: request acceptance, delivery state, and human acknowledgement are three different facts. Keep them as separate fields.

It failed.

A useful record has notification_id, itinerary_version, channel, attempt, provider_message_id, requested_at, status, next_poll_at, and evidence_uri. The itinerary_version prevents an old gate change from overwriting a newer one. The idempotency key, built from notification ID and channel, prevents a redelivered event from sending two identical texts.

One sentence is enough here: retries are part of the design.

How should urgent travel itinerary changes trigger SMS, email fallback, polling, and retries?

Use an explicit state machine rather than a chain of sleeps in a web request. A worker can move queued -> sms_requested -> sms_pending -> sms_delivered or sms_failed -> email_requested. A poller owns the pending state and records each transition with a timestamp. If no terminal state arrives by the notification deadline, the policy can schedule email and retain SMS polling for evidence.

For a 30-minute disruption window, I might allow three SMS attempts at 0, 30, and 120 seconds, then make one email attempt immediately after a terminal SMS failure. Those numbers are policy inputs, not universal defaults; tune them against carrier latency, local quiet hours, and the SLO for urgent changes. Never retry a request whose outcome is unknown unless the provider supports idempotency.

The worker should treat 429 as backpressure, not as a business failure. Honor Retry-After when present, add bounded jitter, and stop before the itinerary deadline. A 400 caused by an invalid destination is terminal for that channel and should move directly to the fallback decision. A 5xx or network timeout is ambiguous: persist the attempt, then use the same idempotency key when retrying.

Here is a small Go sketch. It uses generic HTTP endpoints so the policy remains portable; the important part is the durable transition around each call.

package notify

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

type Attempt struct {
    ID             string    `json:"id"`
    Channel        string    `json:"channel"`
    IdempotencyKey string    `json:"idempotency_key"`
    Status         string    `json:"status"`
    ProviderID     string    `json:"provider_id"`
    UpdatedAt      time.Time `json:"updated_at"`
}

type Store interface {
    MarkRequested(context.Context, string, string) error
    MarkResult(context.Context, string, string, string) error
    ScheduleEmail(context.Context, string, time.Time) error
}

func sendSMS(ctx context.Context, client *http.Client, store Store, baseURL string, a Attempt, payload any) error {
    if err := store.MarkRequested(ctx, a.ID, a.IdempotencyKey); err != nil {
        return err
    }
    body, err := json.Marshal(payload)
    if err != nil {
        return err
    }
    req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/messages", bytes.NewReader(body))
    if err != nil {
        return err
    }
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Idempotency-Key", a.IdempotencyKey)
    resp, err := client.Do(req)
    if err != nil {
        return fmt.Errorf("ambiguous SMS outcome: %w", err)
    }
    defer resp.Body.Close()
    if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
        if seconds, parseErr := strconv.Atoi(retryAfter); parseErr == nil {
            _ = seconds // the scheduler persists this delay
        }
    }
    if resp.StatusCode == http.StatusBadRequest {
        return store.MarkResult(ctx, a.ID, "failed", "invalid destination")
    }
    if resp.StatusCode >= 500 || resp.StatusCode == http.StatusTooManyRequests {
        return fmt.Errorf("retryable SMS response: %s", resp.Status)
    }
    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        return fmt.Errorf("terminal SMS response: %s", resp.Status)
    }
    var accepted struct {
        ID string `json:"id"`
    }
    if err := json.NewDecoder(resp.Body).Decode(&accepted); err != nil {
        return err
    }
    return store.MarkResult(ctx, a.ID, "pending", accepted.ID)
}
Enter fullscreen mode Exit fullscreen mode

The sketch deliberately returns pending after a successful submission. A separate poller reads pending attempts, asks the provider for status, and records delivered, failed, or expired. If the poller is down, the durable row still tells you which evidence is missing; a cron retry can resume without guessing.

What evidence and safeguards make the fallback defensible?

Compliance evidence is about reconstruction. Keep the rendered message hash, template version, locale, consent basis, destination classification, and provider response metadata. Encrypt phone numbers and email addresses at rest, redact them from logs, and assign a retention period that matches the applicable policy. A dashboard showing delivered without the original content is weak evidence.

For email, include a one-click unsubscribe mechanism for non-transactional mail and keep urgent operational mail clearly classified. RFC 8058 specifies the List-Unsubscribe and List-Unsubscribe-Post pattern for one-click requests; an itinerary change that is required to complete a trip should not be disguised as a marketing campaign.

Polling has its own failure modes. Poll too aggressively and you create rate pressure; poll too slowly and the fallback arrives after the gate closes. Use exponential delays with a ceiling, spread jobs with jitter, and measure the age of the oldest pending attempt. A practical SLO might be “99% of urgent changes reach one channel within 60 seconds,” with a second objective for evidence completeness.

Do not put customer-visible content in a retry exception. Store the provider's opaque ID, response code, and correlation ID, then let an operator inspect a redacted event. The traveler needs a clear message, not an internal stack trace.

Which implementation boundary fits a marketplace team?

The choice is mostly about control and operational load.

Boundary What it buys you Cost or limitation
Direct SMS and email integrations Provider-specific delivery states and controls Two contracts, two credential sets, and more fallback code to maintain
A communications gateway One HTTP contract, shared idempotency and observability patterns You inherit its feature boundaries and must verify that its delivery evidence is exportable
Self-hosted queue plus adapters Full control over retention, routing, and replay Your team owns carrier changes, scaling, on-call, and security updates

A gateway is not suitable when a regulator requires a provider-specific audit export that the gateway cannot preserve. Self-hosting is a poor fit for a small team with no rotation for 03:00 incidents. Stick with direct integrations when one channel's specialized controls outweigh the maintenance of multiple adapters.

Your mileage may vary. Carrier behavior differs by country, sender type, and destination, and I am not sure any static timeout can represent every US and EU route. Make those unknowns visible in a canary and in the runbook instead of promising a universal delivery time.

Decision rule

For urgent travel itinerary changes, implement SMS-first as a state machine with durable evidence, bounded polling, and an email fallback that is triggered by a terminal SMS outcome or a clear deadline. Keep event versions and idempotency keys in the data model, separate acceptance from delivery, and alert on pending age rather than raw request volume.

The recommendation changes when the scenario changes: marketing messages need consent and unsubscribe handling, low-urgency updates may start with email, and destinations with unreliable SMS coverage may justify email-first plus an alternate channel. The reliable design is the one whose failure path is explicit and auditable.

References

Top comments (0)