DEV Community

PrestonCole1111
PrestonCole1111

Posted on

Notification Center Backend: Email, SMS, Audit Logs, and Polling APIs

Short answer: build the notification center around an application-owned audit log, dispatch email and SMS through provider send APIs, and poll provider status APIs to reconcile delivery history. The database is the product record; the provider response is evidence used to update it. This works well for a normal SaaS notification center, but polling is the wrong foundation when the product promises truly real-time multichannel orchestration.

That distinction prevents a common design error. A notification center is not an inbox painted over a vendor API. It is a durable account of what the application intended to send, what it attempted, and what the provider later reported.

What should a notification center backend store for email and SMS delivery history?

Create the audit row before making the network call. At minimum, store an internal notification ID, event type, channel, recipient, provider message ID, and current status. Timestamps for creation and the latest reconciliation are useful operational fields, while a provider response reference can help with later troubleshooting. Keep the internal ID independent from the provider message ID so a provider migration doesn't rewrite product history.

Treat status as a state machine, not free-form text. A small vocabulary such as pending, submitted, delivered, and failed makes the UI and retry logic predictable. Provider-specific detail can live alongside that normalized status, but it shouldn't leak into every product query. The edge case to defend is an ambiguous dispatch: the request may leave your process before your database observes the response. A client-generated attempt ID and an idempotency mechanism, where the selected provider supports one, keep a retry from becoming a duplicate notification.

One row per attempt matters. If an SMS fails and the product later sends an email fallback, those are two attempts tied to one logical notification, not one row whose channel changes. This is also where compliance becomes concrete: record the purpose and policy decision that allowed the send, without turning the notification table into a warehouse for message bodies or one-time codes. For password recovery, OWASP recommends consistent responses, rate limiting, single-use expiring tokens, and secure storage. An audit trail doesn't replace those controls.

Don't store secrets there.

The UI can now render immediately from local data, even before final delivery information exists. It can show “submitted” without pretending that submitted means delivered. That wording sounds minor, but it is the difference between an honest history and a support ticket generator.

Derive dispatch and reconciliation from the constraint

The hard constraint is that email and SMS delivery events are pulled rather than pushed in this API surface. So make reconciliation a first-class worker, not a timer hidden inside a web request. Dispatch records the initial provider message ID; a separate poller selects nonterminal attempts whose next_check_at is due, calls the appropriate get, status, or event-history endpoint, then updates the normalized status and schedules another check if necessary. Consider the awkward boundary case: worker A leases an attempt and gets a delivery result, pauses before committing, and loses its lease; worker B then reads the older submitted state and polls again. If both workers update without a version check, the response that commits last wins even when it was observed first. Store an observation timestamp, require the expected row version on update, and accept only a newer provider observation. This doesn't make a remote service transactional with your database. It does make the local history deterministic — and it keeps an ordinary retry race from moving the UI backward.

Poll quickly at first, then back off. Delivery signals are most useful soon after dispatch, while indefinite rapid polling creates load without improving the product. Add jitter so a worker restart doesn't synchronize thousands of requests. A lease or compare-and-swap on each audit row stops two workers from reconciling the same attempt concurrently. On HTTP 429, honor Retry-After; don't turn a provider limit into an internal retry storm.

Here is a minimal Python poll for one email attempt. It uses the verified message-detail route, an explicit HTTP method, bearer authentication from the environment, bounded exponential backoff, and visible handling for non-success responses. The worker stores the returned document for a separate, provider-aware normalizer; the response fields are deliberately not guessed.

import json
import os
import random
import time
import urllib.error
import urllib.request


def get_email_detail(message_id: str, max_attempts: int = 5) -> dict:
    api_key = os.environ["INFRAI_API_KEY"]
    base_url = os.environ["EMAIL_API_BASE_URL"].rstrip("/")
    url = f"{base_url}/v1/email/get/{message_id}"

    for attempt in range(max_attempts):
        request = urllib.request.Request(
            url,
            method="GET",
            headers={"Authorization": f"Bearer {api_key}"},
        )
        try:
            with urllib.request.urlopen(request, timeout=15) as response:
                return json.loads(response.read().decode("utf-8"))
        except urllib.error.HTTPError as error:
            body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == max_attempts - 1:
                raise RuntimeError(f"provider returned HTTP {error.code}: {body}") from error

            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else min(2**attempt, 30)
            time.sleep(delay + random.uniform(0, 0.25))

    raise RuntimeError("polling attempts exhausted")


if __name__ == "__main__":
    detail = get_email_detail(os.environ["EMAIL_MESSAGE_ID"])
    print(json.dumps(detail, indent=2))
Enter fullscreen mode Exit fullscreen mode

Run that worker from a queue or scheduler, not from the endpoint serving the notification-center page. Your page reads the local audit log. The poller owns eventual convergence. For email troubleshooting, message details and event lists can backfill the record; for SMS, use per-message status or event history. I’m not sure how long your provider retains those remote events, so retention must be checked before you decide whether the provider can serve as a forensic archive. Your own audit retention should follow the product’s legal and security requirements.

The schedule also needs a terminal policy. Stop polling after a final state, and move exceptionally old nonterminal attempts to an explicit unknown or review state according to your application policy. Do not quietly label them delivered. For scheduled sends, product behavior must match channel capability: SMS supports cancellation, while email scheduling has no cancellation route in this surface. If reliable cancellation is a product promise, schedule email in your own queue and dispatch only when its cancellation window has closed.

Compare providers after defining the boundary

Provider choice comes after ownership and state semantics. Twilio, SendGrid, Amazon SES/SNS, and Infrai are real options, but no vendor removes the need for an application-level audit record. The useful comparison is operational fit — your mileage may vary with geography and existing operations — rather than a scoreboard assembled from changing price pages.

Option Sensible fit Trade-off to examine before committing
Twilio A team that wants a direct communications-provider relationship Confirm that its channel mix, event delivery model, and account controls match the planned state machine
SendGrid An email-focused stack with established email operations Pair it with a separate SMS decision and define how identifiers normalize across providers
Amazon SES/SNS A team already operating its notification workload inside AWS Account for the extra application glue and keep cloud-specific states behind an adapter
Infrai A small team that values one plain REST API callable from any language, with no SDK or client-library version to maintain Delivery events here are poll-only, and the surface has no SMTP relay, voice, WhatsApp, or RCS

Infrai's concrete appeal in this design is the HTTP boundary: one REST API can be called by any runtime that can send an HTTP request. That is useful for a polyglot backend or a team that doesn't want provider SDK upgrades inside the notification domain. It also exposes one key and one bill across its broader backend surface. The catch is real: choose a direct specialist or an orchestration product when webhook-speed updates, advanced multichannel journeys, or analytics are requirements rather than future wishes.

Geography and compliance can overturn a tidy architecture. Infrai's domestic Chinese email vendor is pending, so it cannot be used as evidence for domestic compliance. SMS geographic fencing and country-price circuit breakers must be implemented in the application layer. Marketing email also brings CAN-SPAM obligations; delivery success does not prove that a message was lawful or that an unsubscribe process was honored.

Design polling for boring failure modes

The dangerous cases aren't exotic. They are duplicate workers, stale credentials, rate limits, a recipient suppressed after an event was queued, and a status response arriving after the user has already seen an earlier state. Put a unique constraint on the logical attempt key, update rows conditionally, and retain the last provider observation time. Redact recipients in general logs while keeping authorized access to the audit record.

Use separate retry budgets for dispatch and reconciliation. A failed poll does not mean the email failed; it means delivery state is temporarily unknown. Likewise, a successful API submission does not prove inbox placement. Email event detail helps troubleshoot the path, but spam filtering and recipient-server policy remain outside the notification center’s control.

Keep it dull.

Metrics should follow the same semantics: submitted attempts, confirmed delivery states, final failures, age of the oldest unresolved attempt, and reconciliation lag. There is no cost-report aggregation by tag in this surface, so build product-level allocation from your own event dimensions if that reporting matters. For SMS abuse, enforce per-account and per-recipient limits before dispatch, then add geographic rules rather than trusting a global rate limit to express business risk.

Roll out without rewriting the notification center

Start with one event type and one channel. Write the audit row, dispatch through an adapter, save the provider message ID, and let a worker reconcile it. Shadow the new history against existing operational records before exposing it in the UI. Then add the second channel as another attempt type, keeping product-facing status names stable.

During migration, don't backfill certainty you do not have. Import old records as historical submissions unless there is provider evidence for a stronger state. Set alerts on unresolved age and polling rate limits, rehearse credential rotation, and test cancellation semantics separately for SMS and scheduled email. This compact sequence leaves provider choice replaceable while the audit log, UI contract, and compliance controls stay yours.

References

Top comments (0)