Short answer: Prefer provider delivery events, use bounded API polling only as a fallback, and send every 429 through one retry scheduler that honors the server's delay before applying exponential backoff.
| Choice | Integration effort | Best fit | Main limitation |
|---|---|---|---|
| Delivery events plus fallback polling | Medium | A contact form that must route notifications without losing status updates | Requires a public event receiver and deduplication |
| Polling only | Low at first | A small internal tool with no inbound endpoint | Adds repeated requests and slower status discovery |
| Accept-and-forget | Lowest | Noncritical notices where delivery evidence has no operational value | Cannot distinguish accepted, delivered, and failed work |
For a logistics contact form, choose delivery events plus slow fallback polling. The evidence is operational: an event can move a shipment exception into the right support queue quickly, while polling repairs a missed event without turning every pending notification into a tight loop. The catch is the inbound endpoint. If deploying and securing one costs more than the workflow is worth, polling only is the sensible runner-up.
What should an email and SMS API polling loop do with delivery status, retry backoff, rate limits, and 429 responses?
Treat provider acceptance and recipient delivery as different states. The form handler should create its own notification record first, enqueue the send, and return to the browser. A worker then submits the email or SMS, saves the provider message ID, and waits for a delivery event. A separate reconciliation worker polls records that remain nonterminal after a delay. That split keeps a slow communications API away from the contact form's response time and gives the support-routing logic one durable place to read status.
The polling loop needs three exits: a terminal delivery state, a terminal failure state, or a local deadline. It also needs one reschedule path for rate limiting. Don't let each call site invent its own retry math. A 429 belongs back on the queue with a future run time; it shouldn't hold a worker open, and it definitely shouldn't trigger an immediate recursive request.
Status vocabulary is the awkward part. One API may report queued, another accepted, and another a channel-specific name. I'm not sure any universal mapping can preserve every provider's nuance. The practical resolution is to retain the raw value for diagnosis and map it into a deliberately small local state machine: pending, delivered, or failed. Only provider-documented terminal values should close the record.
Keep uncertainty visible.
Two criteria decide the architecture
The first criterion is integration effort across the whole lifecycle, not the number of lines in the initial request. Polling looks cheaper because it avoids an inbound route. It gets expensive in attention when the application must schedule checks, cap concurrency, interpret rate limits, stop stale jobs, and explain why a support notification is still pending. Event delivery moves effort toward signature verification, idempotency, and endpoint operations. For a one-person SaaS, the revenue-per-hour question is blunt: which bundle can be built once, observed cheaply, and left alone while features ship each week?
The second criterion is recovery behavior. Events are fast but can be duplicated or arrive after a local timeout. Polling is slower but asks the source for its current view. Combining them works only if both paths call the same idempotent transition function. Store an event ID when one exists, reject duplicate state transitions, and never move a terminal record back to pending. This is ordinary queue hygiene, yet it matters more than the transport choice because a duplicate delivered event must not route the same contact form to two agents.
Authentication is a separate boundary. Delivery receipts aren't proof that the person submitting the form owns an email address or phone number. NIST's authenticator guidance is useful when the workflow crosses into account authentication, but a routine support notification should not quietly become an identity claim. For email, DMARC defines domain-level policy and reporting around message authentication; it does not replace application delivery tracking. Keep those concerns apart — otherwise a green transport status acquires meaning it never had.
Implement the queue and status poller
The example below uses a generic adapter so the application owns scheduling and state while channel-specific API details stay at the edge. The contact form carries a logistics reason, such as shipment_delayed or address_change; that reason selects the support queue, while customer preference selects email or SMS. The worker is intentionally boring. Undifferentiated plumbing should stay small enough to outsource or replace.
type Channel = "email" | "sms";
type LocalStatus = "pending" | "delivered" | "failed";
type Notification = {
id: string;
channel: Channel;
destination: string;
supportQueue: "delivery" | "billing" | "account";
providerMessageId?: string;
status: LocalStatus;
attempts: number;
nextCheckAt?: number;
deadlineAt: number;
rawStatus?: string;
};
type StatusResult = {
rawStatus: string;
terminal: boolean;
delivered: boolean;
};
interface NotificationApi {
send(message: Notification): Promise<{ messageId: string }>;
getStatus(messageId: string): Promise<StatusResult>;
}
interface NotificationStore {
save(message: Notification): Promise<void>;
enqueue(messageId: string, runAt: number): Promise<void>;
}
const MAX_POLL_ATTEMPTS = 8;
const MAX_BACKOFF_MS = 15 * 60_000;
function retryDelayMs(attempt: number, retryAfter?: string): number {
if (retryAfter) {
const seconds = Number(retryAfter);
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1_000;
const dateDelay = Date.parse(retryAfter) - Date.now();
if (Number.isFinite(dateDelay) && dateDelay > 0) return dateDelay;
}
const exponential = 2 ** Math.max(0, attempt - 1) * 1_000;
const jitter = Math.floor(Math.random() * 500);
return Math.min(exponential + jitter, MAX_BACKOFF_MS);
}
function isRateLimit(error: unknown): error is Error & { status: 429; retryAfter?: string } {
return error instanceof Error &&
"status" in error &&
(error as { status?: number }).status === 429;
}
async function pollDelivery(
message: Notification,
api: NotificationApi,
store: NotificationStore,
): Promise<void> {
if (message.status !== "pending" || !message.providerMessageId) return;
if (Date.now() >= message.deadlineAt || message.attempts >= MAX_POLL_ATTEMPTS) {
await store.save({ ...message, status: "failed" });
return;
}
try {
const result = await api.getStatus(message.providerMessageId);
const updated: Notification = {
...message,
attempts: message.attempts + 1,
rawStatus: result.rawStatus,
status: result.terminal
? result.delivered ? "delivered" : "failed"
: "pending",
};
await store.save(updated);
if (updated.status === "pending") {
await store.enqueue(updated.id, Date.now() + retryDelayMs(updated.attempts));
}
} catch (error) {
if (!isRateLimit(error)) throw error;
const attempts = message.attempts + 1;
const nextCheckAt = Date.now() + retryDelayMs(attempts, error.retryAfter);
await store.save({ ...message, attempts, nextCheckAt });
await store.enqueue(message.id, nextCheckAt);
}
}
The adapter should convert an API's rate-limit response into the typed error used here and translate documented statuses into StatusResult. Everything else remains channel-neutral. Use a durable queue in production; an in-memory timer loses scheduled work on deploy, which is a bad bargain for a workflow meant to recover missing status updates.
There is one subtle failure mode in the sample: the store update and queue enqueue are two operations. A crash between them can leave a pending record without a scheduled check. Fix that with a transactional outbox when the database and queue cannot share a transaction, or run a periodic sweeper that selects overdue pending records. The sweeper is also why nextCheckAt belongs in storage rather than only in queue metadata. This is a longer paragraph because this gap causes the sort of quiet failure that consumes an afternoon: the send succeeded, no exception is visible, the queue is empty, and the support team sees an unresolved shipment contact with no trustworthy delivery state.
Ship the state machine first.
Test failure paths before deployment
Unit-test the scheduler with a fake clock. Cover a numeric retry delay, a date retry delay, exponential fallback, the cap, and eight-attempt termination. Then run an integration test in which the adapter returns two 429 responses before a pending status and a terminal delivery status. The assertion that matters is not merely the final state; verify that no request occurs before its scheduled time.
Deployment needs a few plain counters: sends attempted by channel, terminal outcomes, rate-limit responses, pending records past nextCheckAt, and age of the oldest pending record. Log the local notification ID and provider message ID together, but keep destinations and message bodies out of routine logs. Alert on growing overdue work rather than on every retry. Retries are expected. A queue that stops draining is not.
The decision rule for routing should be deterministic and testable apart from delivery. For example, shipment_delayed goes to the delivery queue, invoice_question goes to billing, and an unknown reason goes to account triage. Notification delivery then informs the record; it doesn't get to rewrite the business route. That separation lets a support case exist even if every communication attempt reaches its deadline.
When is polling only the better choice?
Stick with polling only when the application cannot expose a stable inbound endpoint, notification volume is low enough for conservative intervals, and delayed status has little business impact. It is also reasonable during a short prototype, provided the local record and deadline exist from day one. The limitation is latency and request volume, so set a slow minimum interval and stop at a firm deadline.
Accept-and-forget is suitable only when nobody will act on delivery state. It is not suitable for shipment exceptions that promise a follow-up, authentication messages, or any workflow where support needs to distinguish “submitted” from “reached the recipient.” In those cases, the missing state becomes manual investigation.
Avoid treating channel redundancy as an automatic fallback. Sending SMS because an email remains pending can duplicate a message that is merely delayed, and it may violate the user's channel preference. Define the fallback trigger, consent rule, and maximum sends as business policy before adding a second channel. If those rules are unclear, fail into a visible support task rather than improvising another notification.
References
- RFC 7489: Domain-based Message Authentication, Reporting, and Conformance (DMARC): https://datatracker.ietf.org/doc/html/rfc7489
- NIST SP 800-63B, Digital Identity Guidelines: Authentication and Lifecycle Management: https://pages.nist.gov/800-63-3/sp800-63b.html
Top comments (0)