BrockFletcher1438To set up a logistics notification system using email and SMS APIs, start with the support queue...
To set up a logistics notification system using email and SMS APIs, start with the support queue rather than the delivery vendor. The hard part is not sending two messages. It is deciding which channel is authoritative, keeping deliveries correlated, and accepting that events will be polled rather than pushed.
Short answer: normalize the form into one event, resolve its queue before delivery, send email and SMS as separate attempts, and store their provider IDs beside the event. Use the Email and SMS send APIs for dispatch, reusable templates for stable copy, and polling for status. This keeps integration effort bounded while leaving room to replace one channel later.
Start with routing, not vendors. A parcel-damage report, a missed pickup, and a customs question do not belong in the same queue. The form handler should classify the request from explicit fields first, then use a conservative fallback queue when the classification is missing. A guessed urgent route is worse than a visible manual review. I initially treated the channel choice as the main design decision; the queue decision turned out to be the thing that needed a durable record.
For a logistics operation, I would persist an event like this:
from dataclasses import dataclass
from typing import Literal
Queue = Literal["dispatch", "claims", "customs", "manual_review"]
@dataclass(frozen=True)
class ContactEvent:
event_id: str
topic: str
country: str
email: str
phone: str
queue: Queue
def choose_queue(topic: str, country: str) -> Queue:
topic_key = topic.strip().lower()
if topic_key in {"missed pickup", "route change"}:
return "dispatch"
if topic_key in {"damaged parcel", "missing parcel"}:
return "claims"
if topic_key in {"customs hold", "duty question"}:
return "customs"
return "manual_review"
event = ContactEvent(
event_id="contact-20261031-0042",
topic="damaged parcel",
country="US",
email="ops@example.test",
phone="+15551234567",
queue=choose_queue("damaged parcel", "US"),
)
print(event)
The country field matters even when it does not change the queue. SMS anti-abuse controls such as geographic fences and per-country circuit breakers belong in your business layer. I would reject or hold an attempt before calling a provider when the destination country is outside the queue's policy, and record the reason. That is the trap.
Give the form submission one stable event ID. Create two child attempts with the same ID and different channels. Each attempt should have a state such as pending, accepted, delivered, or failed; the provider's response identifier belongs on the attempt, not on the parent event. This matters when email is accepted but SMS is suppressed, or when a retry follows a temporary rate limit.
The send calls are two separate operations: POST /v1/email/send and POST /v1/sms/send. Keep request creation behind one adapter so the application owns routing and policy while channel details stay local. Use an idempotency key derived from the event ID and channel. A retry must not create a second notification for the same queue transition.
import os
import time
import requests
BASE_URL = os.environ["NOTIFICATION_API_BASE_URL"]
class DeliveryError(RuntimeError):
pass
def send_once(path: str, payload: dict, event_id: str, channel: str) -> dict:
key = f"{event_id}:{channel}"
headers = {
"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
"Content-Type": "application/json",
"Idempotency-Key": key,
}
for attempt in range(4):
response = requests.post(BASE_URL + path, json=payload,
headers=headers, timeout=10)
if response.status_code == 429:
retry_after = response.headers.get("Retry-After")
time.sleep(min(float(retry_after) if retry_after else 2 ** attempt, 30))
continue
if not 200 <= response.status_code < 300:
raise DeliveryError(f"{channel} failed: {response.status_code} {response.text}")
return response.json()
raise DeliveryError(f"{channel} remained rate limited after retries")
def dispatch(event_id: str, email_payload: dict, sms_payload: dict):
email_result = send_once("/email/send", email_payload, event_id, "email")
sms_result = send_once("/sms/send", sms_payload, event_id, "sms")
return email_result, sms_result
The decisions are deliberate: explicit POST, bearer authentication from the environment, a bounded timeout, status inspection, Retry-After support, exponential backoff, and idempotency. The example leaves message fields to the provider's current schema rather than guessing them. Validate that schema before wiring the form handler.
SendGrid is an email-focused service and fits teams whose main problem is email deliverability, templates, suppression management, and email operations. Mailgun is also email-focused and suits teams that want a mail API with domain and event tooling. Neither is a natural single integration for this two-channel queue unless SMS is handled separately.
Twilio is a broad communications incumbent for SMS and related messaging workflows. It fits an organization that already operates its account, compliance process, and phone-number inventory there. The trade-off is organizational as much as technical: email and SMS concerns may still be assembled across products or SDKs.
A unified REST surface can be attractive for a smaller backend that expects to add another capability later. Infrai's verified breadth is 295 routes across 20 modules under one key, so adding a capability is another endpoint-shaped integration rather than another client library and credential flow. That helps when a support queue may later add scheduling or AI-assisted triage, but it does not remove channel-specific policy work.
| Option | Strong fit | Integration limit |
|---|---|---|
| SendGrid | Email-first delivery and email operations | SMS requires a separate path |
| Mailgun | Email API and domain-focused workflows | It is not a complete two-channel queue system |
| Twilio | Teams standardized on its communications platform | Broader setup can mean more account and policy surface |
| A unified REST provider | One credential and consistent calls | Channel caveats and compliance remain your responsibility |
No row wins in the abstract. For a logistics form, integration effort is lowest when the organization already has a trusted sender domain, SMS registration process, and operational owner. A unified API reduces plumbing; it cannot make an unverified domain reputable or make consent optional.
Design for polling from the beginning. These namespaces do not push webhook events, so a worker can periodically query the documented email and SMS event resources and reconcile results against child attempts. Polling introduces delay, and that delay should be visible in the support console rather than disguised as real-time status.
Email has another sharp edge: there is no SMTP relay, and scheduled email has no cancellation route. SMS supports cancellation. Do not build a shared cancellation abstraction that promises symmetry. Expose cancellation only where the channel supports it, and label scheduled email as pending, not cancellable.
There is no hosted email OTP flow in this capability group, so an email verification fallback requires application-owned token generation, expiry, single-use storage, and abuse limits. There is also no voice, WhatsApp, or RCS channel. Those omissions are design inputs; they stop a roadmap from becoming unsupported assumptions.
For deliverability, publish SPF correctly and treat it as one control in a larger sender-domain program. RFC 7208 defines SPF, but SPF alone does not turn transactional mail into trusted mail. Keep suppression checks, consent records, rate limits, and content review close to queue policy.
First, ship the event ledger and queue decision without sending. Replay a small fixture set: one dispatch issue, one claim, one customs question, and one unknown topic. Check that every event has one deterministic queue and that unknown input reaches manual review.
Second, enable email for one queue and poll its event records. Then enable SMS with a country policy and a separate idempotency key. Watch for duplicate attempts, suppressed destinations, and discrepancies between accepted and delivered states. Do not use message volume as a proxy for success; a queue owner should be able to explain every notification.
Finally, add reusable email and SMS templates once event fields are stable. Keep template identifiers in configuration, and version the business event separately from the copy. The queue should survive a copy edit.
This makes the main choice explicit: pick the provider boundary that minimizes integration work for the whole workflow, then keep routing, consent, country controls, retries, and polling in your service. Email and SMS are delivery channels. The support queue is the product.
Ship the ledger first. A damaged parcel submitted at 09:14 should produce one parent event even if the claims queue sends an email, the SMS is held by a country rule, and an operator later edits the subject line. The parent event records the customer's request and why the system selected claims. Each child records its own acceptance, provider identifier, policy decision, and retry history. This separation prevents a common failure mode: a worker can time out after a successful email call and retry safely without creating another email, while a failed SMS attempt can take its own backoff path. A larger ledger is easier to operate than a queue that tells an agent only “notification failed” on a busy morning.
Then send.