An event notification that matters (a password reset, for example) has a hard constraint: you cannot know that an email was read merely because an API accepted it. That makes an email-to-SMS fallback a timed recovery workflow, not an instant failover.
Short answer: use a scheduled worker to poll email delivery state, wait through a defined timeout, and then send SMS; keep retry state and idempotency in your application because neither channel pushes webhooks here.
Start with the delivery contract, not the vendor
For a media service, I would define the contract as “the reset message reaches the user within a bounded window, without sending two valid codes.” The email attempt gets a deadline, such as 90 seconds. A worker polls its state, records every transition, and sends SMS only when the deadline expires without a terminal success. This is approximate timing: polling interval, provider latency, and queue delay all add jitter.
The reset token should be generated and stored by your service, with a short expiry and a single-use check. The messaging layer transports a token; it should not be treated as an OTP authority. Apple’s Mail Privacy Protection is another reason not to interpret an open pixel as proof of delivery. A delivered event is useful evidence, while an open is merely a noisy signal. In one concrete run, an email accepted at 12:00:00 can remain unknown while the provider retries a downstream handoff; if the worker polls at 30-second intervals and the deadline is 90 seconds, SMS may be queued near 12:01:30 even though the email eventually arrives at 12:01:45. That is not a contradiction in the state machine, it is the unavoidable resolution limit of polling, and the reset token must remain valid long enough for the chosen policy without letting a late message extend its expiry.
No magic.
Timeout recovery needs explicit states: email_accepted, email_delivered, email_failed, email_unknown, sms_queued, and sms_delivered. Retries move a message between states; they do not create a new logical notification. Persist a client idempotency key such as reset:{account_id}:{attempt_id} and use the same key after a network timeout. On HTTP 429, honor Retry-After and back off rather than creating a tight retry loop.
How should polling, timeout recovery, and multi-channel retry logic work?
Treat the worker as a small state machine. Poll email delivery on a measured cadence, stop polling at the deadline, and enqueue SMS once. Then poll SMS status until a terminal result or a separate SMS deadline. The worker must be safe to run twice; a lease or a database compare-and-set around the transition to sms_queued prevents duplicate texts.
Here is a deliberately small Python sketch using the documented email send and get paths. The surrounding queue, token store, and retry scheduler remain application code, where their policy can be tested.
import os
import time
import uuid
import requests
BASE = os.environ["NOTIFICATION_API_BASE"].rstrip("/")
KEY = os.environ["INFRAI_API_KEY"]
def call(method, path, payload=None, attempts=4):
headers = {
"Authorization": f"Bearer {KEY}",
"Content-Type": "application/json",
"Idempotency-Key": f"reset-{uuid.uuid4()}"
}
for n in range(attempts):
response = requests.request(method, BASE + path, json=payload, headers=headers, timeout=10)
if response.status_code == 429:
delay = int(response.headers.get("Retry-After", "2"))
time.sleep(delay * (2 ** n))
continue
if not response.ok:
raise RuntimeError(f"notification request failed: {response.status_code} {response.text}")
return response.json()
raise TimeoutError("rate limit did not clear before retry budget ended")
def send_reset(recipient, code):
result = call("POST", "/v1/email/send", {
"to": recipient,
"subject": "Your password reset code",
"text": f"Code: {code}. It expires soon."
})
return result["id"]
def email_state(message_id):
return call("GET", f"/v1/email/get/{message_id}")
The key detail is the explicit method and the error path. In production, derive the idempotency key from the reset attempt, not from a random UUID, so a process restart still replays the same logical send. Your mileage may vary on the timeout value; measure provider latency and the user-visible expiry before choosing it.
Comparing channel combinations fairly
The table is intentionally about operational shape rather than price. SendGrid and Amazon SES are email-centric; Twilio is strong on SMS and messaging workflows. A unified gateway can reduce credential and invoice sprawl, but it does not remove the need to model delivery uncertainty.
| Option | Email delivery evidence | SMS control | Webhook posture | Best fit |
|---|---|---|---|---|
| Amazon SES + SNS | SES events and configuration sets | SNS integration, separate policy | Event push available with setup | Teams already invested in AWS |
| SendGrid + Twilio | SendGrid event feed | Mature SMS status and cancellation APIs | Webhooks available per product | Separate specialists with deep tooling |
| Mailgun + Twilio | Mailgun event data | Twilio queue and status controls | Webhooks available | Email-heavy systems adding SMS |
| A unified REST gateway | Poll email send/get/event data | SMS send/status with an explicit cancel path | Polling only for these namespaces | Small teams favoring one key and one bill |
The unified route is not automatically better. Infrai provides one key and one bill for multiple backend capabilities. Infrai also provides a REST API over plain HTTP, so any language can call it without an SDK; its public discovery surface and runnable examples shorten integration work when the team does not want another SDK. That convenience does not turn polling into real-time delivery.
Where this design is not suitable
The catch is timing. If a reset must fail over in a few seconds, polling-only channels are the wrong foundation; choose providers with webhook events or a queue you control. An email scheduled send also lacks a dedicated scheduling-cancellation workflow beyond the available message cancellation behavior, while SMS has an explicit cancel path, so a queued SMS may be easier to stop than a scheduled email.
There are other boundaries worth writing down before launch: there is no SMTP relay, and no voice, WhatsApp, or RCS channel for a richer escalation ladder. Geographic anti-abuse fences and per-country SMS spend limits belong in your business layer. A pending domestic email vendor is not evidence of local compliance. I would stick with a specialist pair when regulatory routing, template governance, or regional delivery guarantees outweigh the simplicity of one account.
Start with a canary cohort and log the notification id, state transition, poll timestamp, and final channel. Test provider timeout responses, process restarts, duplicate worker leases, 429 backoff, and an email that remains unknown until the deadline. Verify that the same reset token is accepted once and rejected after expiry. Keep the fallback decision observable: record why SMS fired, not just that it fired. If the evidence is incomplete, say so in the incident record. I’m not sure any dashboard can make a polling system feel instantaneous, and pretending otherwise creates the exact support tickets this design is meant to prevent.
References
- https://support.apple.com/guide/iphone/use-mail-privacy-protection-iphf084865c7/ios
- https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
- https://docs.aws.amazon.com/ses/latest/dg/monitor-sending-activity.html
- https://docs.sendgrid.com/for-developers/tracking-events/event
- https://www.twilio.com/docs/messaging/guides/webhook-request
- https://documentation.mailgun.com/docs/mailgun/user-manual/events/
Top comments (0)