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_I
Comments
No comments yet. Start the discussion.