Short answer: keep suppression state in your own durable store, make the send decision before every welcome or order message, and treat unsubscribe, hard bounce, and transient failure as different state transitions. The transactional email API is only the transport.
For a marketplace, the concrete event is a new order that should wake up a seller with a welcome-style transactional message. Integration effort is the useful decision axis: a design that takes one afternoon but loses an opt-out under retry is not simple; it is deferred incident work. I care about that distinction because storage systems make failure visible long after the code that caused it has shipped.
The bill is mostly retention, not the API call
The dominant cost in this workflow is usually retained event and delivery data: message bodies, provider responses, webhook payloads, and audit records copied into several tables. A single send request is small. Keeping every payload forever is not.
Start with a retention inventory. Keep a compact message ledger (recipient hash, template version, event id, decision, timestamps, and provider message id) for the period your support and compliance teams actually need. Store the raw body only when a documented investigation requires it, encrypt it, and expire it on a separate schedule. The change that moves the bill is deleting unnecessary payload bytes, not shaving a millisecond off an HTTP request.
| Data | Why retain it | Sensible default to review | Failure trade-off |
|---|---|---|---|
| Suppression record | Prevents future sends | Until an explicit re-subscribe policy allows removal | A mistaken delete can re-mail an opted-out seller |
| Message ledger | Idempotency and support | Months, then aggregate | Less context for old disputes |
| Full rendered body | Legal or content review | Short, encrypted window | Harder to reconstruct an old message |
| Webhook payload | Delivery diagnosis | Short window plus normalized status | Provider detail is unavailable later |
I would rather lose an old HTML body than lose the fact that seller@example.com opted out. Retention is a product decision with an operational price tag.
It failed.
What should a welcome email suppression list and bounce handler do?
Use an append-only-ish state transition model, even if the implementation is a normal relational table. A recipient can be allowed, unsubscribed, hard_bounced, or temporarily_failed; the send path reads the strongest applicable state. unsubscribed and hard_bounced are terminal for this stream. A temporary failure schedules a retry with a bounded attempt count.
That is the whole gate.
The unsubscribe endpoint should verify a signed token, record the event atomically, and return success even when the address is already suppressed. That idempotency matters: mailbox clients and webhook delivery systems repeat requests. Never infer consent from a missing row; an absent record means “not yet decided,” so the policy for a new seller must be explicit.
Bounce handling belongs on the webhook side, not in a request handler waiting for delivery. Normalize provider-specific events into a small internal vocabulary, preserve the original event id for deduplication, and reject stale transitions. A late “delivered” event must not erase a later hard bounce.
Here is a deliberately small Python sketch. The same HTTP boundaries can be called from Node.js; the important part is the ordering and state check, not an SDK.
from dataclasses import dataclass
from typing import Literal
Status = Literal["allowed", "unsubscribed", "hard_bounced", "temporarily_failed"]
@dataclass
class RecipientState:
status: Status
version: int
TERMINAL = {"unsubscribed", "hard_bounced"}
def can_send(state: RecipientState | None) -> bool:
return state is None or state.status not in TERMINAL
def apply_delivery_event(state: RecipientState, event: str) -> RecipientState:
if event == "unsubscribe":
return RecipientState("unsubscribed", state.version + 1)
if event == "hard_bounce":
return RecipientState("hard_bounced", state.version + 1)
if event == "temporary_failure" and state.status == "allowed":
return RecipientState("temporarily_failed", state.version + 1)
return state
One production trap is a race between the eligibility read and the insert into the outbox. I initially treated those as two harmless operations; later, a concurrent unsubscribe made the gap obvious. Use a transaction, a uniqueness constraint on (event_id, recipient), and an outbox worker that rechecks suppression immediately before dispatch.
How can a Node.js transactional email API example survive retries?
The API call needs an idempotency key derived from your immutable order event, not from a random request id. Persist the outbox row first, then let a worker claim it with a lease. On timeout, the worker may send again, so the downstream API must accept the same key without creating a second message. If the API has no idempotency contract, choose a provider or relay that does, or accept duplicate risk and surface it honestly.
Retry only what is plausibly temporary: connection resets, rate limits, and documented 5xx responses. A malformed address, policy rejection, unsubscribe, or hard bounce should move to a terminal state. Exponential backoff with jitter prevents a provider incident from turning every seller notification into a synchronized retry storm.
Measure the transitions, not vanity totals. Useful counters include suppression decisions before dispatch, duplicate webhook events, retry age, hard-bounce rate by template, and the number of outbox leases that expire. Alert on a rising temporary-failure ratio and on any send recorded after a terminal suppression timestamp.
Three implementation patterns are common. A managed transactional API minimizes integration code but ties webhook semantics and idempotency details to its contract. A self-hosted SMTP relay offers control, yet deliverability operations become your team’s job. A queue plus a thin HTTP adapter keeps application code portable, at the cost of operating another durable component. None removes the need for a suppression ledger.
The catch: when this design is not suitable
This approach is not suitable when the message is promotional, consent rules differ by region, or the seller needs a synchronous guarantee before an order is accepted. Use a dedicated marketing-consent system for campaigns, and keep order acceptance independent of email availability. It is also a poor fit for teams unwilling to retain an audit trail: without one, you cannot explain why a message was suppressed or sent.
Your mileage may vary on retention windows because legal, support, and privacy requirements conflict. I’m not sure one universal duration exists; have counsel and incident responders agree on the deletion schedule, then encode it as a testable policy.
The decision rule is plain: choose the transport with the smallest integration surface that still exposes stable webhook categories, idempotency, and exportable logs. Keep consent and delivery state under your control, and stop retaining data that does not help those decisions.
Top comments (0)