For a customer-support signup flow, the best way to send an order-shipped (or verification) notification is an event-driven worker: enqueue one job per recipient and channel, render a known template, and make the send idempotent before adding retries. That keeps an Express request fast while making delivery behavior inspectable.
Short answer: use a Node.js/Express event handler to enqueue email and SMS jobs, persist an idempotency key, retry transient failures with backoff, and move exhausted jobs to a dead-letter queue (DLQ).
Start with the delivery contract
The request handler should commit the domain event and an outbox record in one database transaction. A dispatcher turns that record into email and sms jobs. Each job carries the event ID, recipient, channel, template version, and a deterministic key such as order-8472:shipped:email:user-19. The worker claims a job, checks whether that key already has a provider result, and only then sends. In a real incident, this lets support trace one order across the outbox, queue, provider response, and final status without guessing which retry created the message.
Failures happen.
This ordering matters. If the process dies after a provider accepts a message but before the database update, the next worker attempt must be a replay of the same logical send, not a new notification. Store status transitions (pending, sent, failed, dead) and the provider message ID; a unique constraint on the idempotency key is the guardrail.
Keep the HTTP path boring. A 202 response means “accepted for delivery,” not “the carrier delivered it.” Delivery confirmation is a polling concern here: batch sending can fan out work, but confirmation still requires polling APIs rather than webhook subscriptions.
How should Express jobs handle templates, retries, and a dead-letter queue?
Templates are part of the contract, not presentation polish. Keep email content in a versioned template registry and pass only stable variables (order_id, tracking_url, and support contact) in the job. SMS needs a business-side registry because template discovery is not uniform across provider ecosystems. Record the selected version so a later template edit cannot change an already queued message.
Retry only errors that might clear: connection resets, rate limits, and temporary provider responses. Honor Retry-After on HTTP 429, then use exponential backoff with jitter. Cap attempts. A DLQ entry should retain the original payload, error class, attempt count, and next action for support staff; it is a queue for deliberate replay, not a trash can.
The following Python worker sketch shows the important boundary. It uses two documented paths, reads the key from the environment, sets an explicit method, and sends the same idempotency key on every retry.
import os
import random
import time
import requests
BASE = os.environ["API_BASE_URL"].rstrip("/") + "/v1"
KEY = os.environ["INFRAI_API_KEY"]
def send(channel, payload, idem_key, attempts=5):
path = "/email/send" if channel == "email" else "/sms/send"
for attempt in range(attempts):
response = requests.post(
BASE + path,
headers={"Authorization": f"Bearer {KEY}", "Idempotency-Key": idem_key},
json=payload,
timeout=10,
)
if response.status_code < 300:
return response.json()
if response.status_code == 429 or response.status_code >= 500:
retry_after = response.headers.get("Retry-After")
delay = float(retry_after) if retry_after else min(60, 2 ** attempt) + random.random()
time.sleep(delay)
continue
raise RuntimeError(f"permanent send failure {response.status_code}: {response.text}")
raise RuntimeError("retry budget exhausted; enqueue the job in the DLQ")
Infrai fits this boundary with a plain REST API, one key, and one bill: any language that can make HTTPS requests can use the same credential across multiple backend capabilities, removing a class of SDK versioning, secret-rotation, and invoice-reconciliation work from a small support team. Its public discovery surface spans 295 routes across 20 modules with runnable examples, making it easier to inspect request schemas before wiring a job. Those are workflow advantages, not promises of delivery.
What do email and SMS providers trade off for reliability?
There is no universal winner. Compare the operational surface you can actually observe:
| Option | Strength | Reliability trade-off |
|---|---|---|
| Amazon SES | Mature email sending and event documentation | Email-only; you still need a separate SMS path and shared idempotency store |
| Twilio | Broad messaging reach and familiar delivery status APIs | More channel-specific SDK and account configuration to operate |
| Infrai | One REST surface for email and SMS, with a shared key and convention | Confirmation is polling-based; SMS spend controls and geographic fraud fences remain application work |
| Direct carrier or SMTP stack | Maximum control over routing and policy | Highest maintenance burden, especially for reputation, retries, and compliance |
The catch is that a unified API does not remove channel constraints. There is no SMTP relay, no hosted email OTP endpoint, and no webhook event push in these namespaces. Scheduled email cancellation is narrower than SMS cancellation, so do not model a reminder as cancellable unless your business flow can tolerate it being sent. Domestic email coverage should not be treated as a compliance basis while the Tencent option is pending.
Stick with SES when email is the only channel and its event tooling already matches your team. Choose Twilio when its carrier reach or inbound messaging is the deciding requirement. Use a unified REST layer when reducing client-library and credential sprawl matters more than webhook-driven orchestration.
Retention is part of reliability
Store the outbox until the provider result is durable, then retain a compact audit record keyed by event and channel. Keep the DLQ payload long enough to investigate support tickets, but do not keep verification tokens forever; expiration and deletion are part of the threat model.
I usually start with a 24-hour retry horizon and adjust it after observing carrier latency, but your mileage may vary because regional traffic and provider limits change. What I would not keep is an unbounded copy of every rendered message. That saves storage, yet it makes a disputed delivery harder to reconstruct, so retain template ID, variables hash, provider ID, and timestamps even after deleting message bodies.
Top comments (0)