DEV Community

zanesterling7589
zanesterling7589

Posted on

A Guide to Urgent Compliance Notifications with Delivery Polling and Fallback Logic

Short answer: for an urgent US/EU compliance notice, send SMS first, poll its delivery state, and send email only after a terminal nondelivery result or a bounded deadline; keep the policy, audit log, country allowlist, and retries in your own service.

The deciding constraint is integration effort, but “fewest API calls” is the wrong proxy. A B2B SaaS team needs one durable notice record whose transitions can be explained later: accepted for SMS, observed as delivered, or escalated to email for a stated reason. Neither a successful submission nor a provider dashboard is that record.

This is an architecture decision record for that control loop. It treats SMS as the fast path and email as a richer secondary trail, without pretending that email proves the recipient read anything. The hard part is the boundary between those channels.

Decision and invariants

Adopt an application-owned state machine with four durable states: SMS_PENDING, SMS_DELIVERED, EMAIL_PENDING, and EMAIL_SUBMITTED. Persist every transition with the notice ID, provider message ID, attempt number, timestamp, country, and reason. The initial SMS send happens before the example below; the example owns the critical polling-to-fallback path.

Three invariants matter more than vendor branding. First, one compliance notice has one stable application ID across both channels. Second, an email fallback uses a deterministic idempotency key, so a worker crash after the request cannot create another logical send. Third, only a terminal SMS result or an explicit deadline can open the email path. An arbitrary sleep cannot.

Submission is not delivery.

The ledger decides.

The primary failure boundary is therefore between the provider accepting an SMS and the network producing a useful delivery result. There are others: HTTP 429 means the client must wait rather than spin; a worker can die after a remote write but before its local commit; a number can be suppressed; and a noisy security event can enqueue thousands of equivalent notices. Use exponential backoff, honor Retry-After, deduplicate by notice and channel, and put a hard cap on resend attempts. A resend feature without an abuse limit is a message-storm feature wearing a nicer name.

US/EU routing deserves its own invariant. Keep an allowlist of permitted destination countries and a budget guard in the application, because country restrictions, geographic fencing, and country-price circuit breakers are not supplied by the messaging surface described here. Reject an unknown or disallowed destination before any send. This is also where tenant policy belongs; burying it in a provider console makes the audit trail harder to reproduce.

How should Node.js poll SMS delivery before an email fallback?

The orchestration rule is language-independent even if the surrounding service is Node.js: enqueue a poll with the SMS message ID, classify the returned state through a pinned adapter, and schedule another poll with bounded exponential delay until delivery, terminal failure, or the deadline. The adapter is important. I'm not sure which status field and terminal vocabulary your chosen provider returns until its current response schema is pinned, and guessing either would turn an example into a latent production bug.

For an urgent notice, choose the fallback deadline from the business obligation, not from an optimistic carrier estimate. A ten-second poll interval may be reasonable for one workflow and wasteful for another; your mileage may vary. Record the configured deadline in the notice row so an auditor can distinguish “email sent because SMS failed” from “email sent because SMS remained unresolved for 120 seconds.” Those are different facts.

Polling has an unavoidable freshness ceiling. There is no webhook event push in the email or SMS namespaces considered here, so the worker cadence determines how quickly the fallback reacts. Poll too aggressively and rate limits become part of the normal path. Poll too slowly and “urgent” becomes a label rather than behavior. Use jitter in a fleet, cap concurrent polls per tenant, and stop immediately on a delivered or terminal state.

The email is a secondary audit trail because it can carry richer content and use templates, but it is still a delivery channel, not your system of record. Store the rendered template version or content hash beside the notice. Also note the asymmetry: scheduled email exists without an email cancellation route, while SMS has cancellation support. Do not schedule email speculatively and assume it can always be withdrawn after late SMS delivery.

Options through the integration-effort lens

The comparison below is deliberately about ownership boundaries. Provider feature matrices change; the durable question is how much adapter, policy, and evidence code remains yours.

Option Integration boundary Good fit Catch
Twilio SMS plus an email provider Direct provider APIs behind your adapter Teams that want direct control of each vendor relationship You own the cross-channel contract, credentials, billing reconciliation, and fallback state machine
Vonage SMS plus an email provider Another direct SMS contract plus a separate email contract Existing Vonage estates that value continuity Switching either channel changes or expands your adapter surface
Amazon SNS plus an email service AWS-native messaging components and IAM Workloads already standardized on AWS operations Cloud-specific policy and observability become part of the application boundary
Infobip Communications-platform contract Teams evaluating a broader communications portfolio Validate regional policy, status semantics, and audit exports against your exact obligation
Infrai One plain REST API over HTTP, with no SDK required, plus one key and one bill across the two capabilities Small platform teams that want any language or runtime to change the vendor behind a capability without changing application code The application must poll, and it must supply geo-fencing, country budget guards, and the compliance ledger

The last row is a strong integration-effort choice when contract stability matters: the vendor behind a capability can move while the calling code stays on the same REST interface, and the shared credential reduces secret sprawl. Because that interface is ordinary HTTP rather than a required SDK, the notification worker and a separate audit repair job can use the same contract even when they run in different languages; this removes one concrete source of adapter drift, although each job still needs the same status classification policy. That does not outsource orchestration. It also does not add voice, WhatsApp, RCS, or SMTP relay, and a domestic China email vendor is pending, so this path is not evidence for China compliance. Stick with a direct provider when you need one of those channels, when procurement requires a direct carrier relationship, or when webhook-driven latency is more important than a unified API boundary.

No option removes the need for an application ledger. The honest difference is how many external contracts that ledger has to understand.

Critical path in Python

This runnable worker starts from an already submitted SMS ID, polls the verified status route, and posts the email fallback through the verified email route. It intentionally accepts the status field path and terminal values as environment configuration; obtain them from the current schema for the selected provider rather than copying an assumed response shape. The email request body is supplied as JSON for the same reason.

import json
import os
import random
import time
from email.utils import parsedate_to_datetime
from urllib.parse import quote

import requests


API_BASE_URL = os.environ["MESSAGING_API_BASE_URL"].rstrip("/")
API_KEY = os.environ["INFRAI_API_KEY"]
NOTICE_ID = os.environ["NOTICE_ID"]
SMS_ID = os.environ["SMS_ID"]
STATUS_FIELD = os.environ["SMS_STATUS_FIELD"]
DELIVERED_VALUES = set(json.loads(os.environ["SMS_DELIVERED_VALUES_JSON"]))
FAILED_VALUES = set(json.loads(os.environ["SMS_FAILED_VALUES_JSON"]))
EMAIL_PAYLOAD = json.loads(os.environ["EMAIL_PAYLOAD_JSON"])
POLL_DEADLINE_SECONDS = int(os.environ.get("POLL_DEADLINE_SECONDS", "120"))


def retry_after_seconds(value):
    if not value:
        return None
    try:
        return max(0.0, float(value))
    except ValueError:
        return max(0.0, parsedate_to_datetime(value).timestamp() - time.time())


def request_json(method, path, body=None, idempotency_key=None):
    headers = {"Authorization": f"Bearer {API_KEY}"}
    if body is not None:
        headers["Content-Type"] = "application/json"
    if idempotency_key is not None:
        headers["Idempotency-Key"] = idempotency_key

    for attempt in range(6):
        response = requests.request(
            method=method,
            url=f"{API_BASE_URL}{path}",
            headers=headers,
            json=body,
            timeout=15,
        )
        if response.status_code != 429:
            if not response.ok:
                raise RuntimeError(
                    f"{method} {path} returned {response.status_code}: {response.text}"
                )
            return response.json()

        specified_delay = retry_after_seconds(response.headers.get("Retry-After"))
        delay = specified_delay if specified_delay is not None else 2**attempt
        time.sleep(delay + random.uniform(0.0, 0.25))

    raise RuntimeError(f"{method} {path} remained rate-limited after 6 attempts")


def value_at_path(document, dotted_path):
    value = document
    for segment in dotted_path.split("."):
        value = value[segment]
    return str(value)


def run():
    deadline = time.monotonic() + POLL_DEADLINE_SECONDS
    delay = 1.0

    while time.monotonic() < deadline:
        status = request_json("GET", f"/v1/sms/status/{quote(SMS_ID, safe='')}")
        state = value_at_path(status, STATUS_FIELD)
        if state in DELIVERED_VALUES:
            return {"notice_id": NOTICE_ID, "result": "sms_delivered"}
        if state in FAILED_VALUES:
            break
        time.sleep(min(delay, max(0.0, deadline - time.monotonic())))
        delay = min(delay * 2, 15.0)

    email_result = request_json(
        "POST",
        "/v1/email/send",
        body=EMAIL_PAYLOAD,
        idempotency_key=f"compliance-notice:{NOTICE_ID}:email-fallback",
    )
    return {
        "notice_id": NOTICE_ID,
        "result": "email_submitted",
        "provider_response": email_result,
    }


if __name__ == "__main__":
    print(json.dumps(run(), separators=(",", ":")))
Enter fullscreen mode Exit fullscreen mode

The worker surfaces non-429 failures with the response body, but its caller still has to write transitions transactionally. In practice, place this behind a durable queue: claim one notice, run or reschedule one poll, commit the observed state, and acknowledge the job only after the database commit. Keep the email idempotency key stable across retries. If the process loses its response after the remote write, the same key preserves one logical fallback during the platform's documented 24-hour deduplication window.

Don't treat EMAIL_SUBMITTED as proof of receipt. Continue collecting email events into the same ledger according to the applicable retention policy, and keep content access appropriately restricted. Compliance evidence should answer who initiated the notice, which policy selected the channels, what payload version was used, and why each transition occurred; a pile of provider responses without that context is weak evidence.

Rejected option and the decision rule

We rejected “send SMS, sleep for a fixed interval, then always send email.” It is easy to code, but it creates duplicates when SMS arrives just before the sleep ends, gives no principled response to suppression or terminal failure, and makes timing depend on worker scheduling. It also encourages teams to read a successful SMS submission as a delivery event. Don't do that.

The catch is that the state machine costs more engineering time than a pair of fire-and-forget calls. For low-urgency product updates, use email alone; the richer format and simpler operational path are usually the better fit. For an obligation that requires near-real-time push and a webhook-driven response, choose a provider and contract that supply the needed event push rather than forcing this polling design beyond its limit. For an urgent US/EU compliance notice where integration effort is the main axis, keep the control loop in your service, prefer a stable channel contract when it reduces adapter churn, and test every terminal state before launch.

One test deserves disproportionate attention — lose the worker immediately after the email provider accepts the request, then redeliver the queue job. The expected result is one logical email fallback, one durable transition, and enough recorded context to explain both attempts. That is the difference between notification code and an auditable notification system.

References

Top comments (0)