Short answer: put compliance notices on a queue, assign one stable idempotency key per notice and channel, and record every attempt before any email or SMS API call.
| Delivery shape | Integration effort | Audit quality | Best fit |
|---|---|---|---|
| Send inside the request handler | Low on day one | Weak once retries begin | Disposable alerts |
| Queue plus an append-only attempt ledger | Moderate | Strong and easy to query | Compliance notices |
| Full workflow engine | High | Strong across long processes | Multi-day approval flows |
For a B2B SaaS compliance notice, choose the middle row. It adds one queue and one ledger, but it keeps rate-limit handling out of the customer-facing request and makes duplicate suppression explicit. The least code is not the fewest lines at launch. It is the fewest ambiguous states during an audit.
What actually belongs in an auditable delivery record?
An audit record should answer a narrow set of questions: which business event requested the notice, which destination and channel were selected, what content revision was used, when each attempt started, and what terminal state was observed. Store destination fingerprints rather than raw addresses when operators do not need the address itself. Keep the original business event ID separate from a provider receipt ID; they describe different boundaries.
Do not collapse accepted and delivered. An API can accept work before the channel reports its final outcome, so one Boolean forces two facts into one bit. A small state machine is clearer: pending, sending, accepted, delivered, permanent_failure, and suppressed. Every transition gets a timestamp and reason.
That distinction matters during a compliance review. Consider a concrete sequence: account acct_7F3 triggers notice terms-v4 at 09:00, the email adapter records acceptance on attempt one, and the SMS adapter asks the dispatcher to wait. At 09:02 the worker restarts. The notice is neither simply “sent” nor “failed,” and putting either label on the parent row would hide useful state. It has two channel records with independent clocks. The restarted worker can read the email terminal state, leave that channel alone, and enqueue only the pending SMS attempt under the same stable key. Later, an email delivery update can append another transition without rewriting the original acceptance record. An operator reviewing the case sees the event ID, content revision, destinations, attempts, delays, and final outcomes in order. No log correlation ritual is required. This is the boring bookkeeping that turns a notification into evidence, and it is why I would spend integration effort on the ledger before adding another transport abstraction.
DMARC belongs near this boundary, but it proves something different. RFC 7489 describes domain-based message authentication, policy, and reporting for email. It does not replace an application delivery ledger. Likewise, the WebOTP API lets a web app obtain a specially formatted one-time password from an SMS message with user consent; it is not a general receipt mechanism for compliance notices.
One row per transition wins.
How should Node.js event notifications handle email and SMS API rate limits?
Treat rate limiting as scheduling information, not as permission to spin. A channel adapter should return a typed outcome that distinguishes acceptance, a retryable delay, and a permanent rejection. The dispatcher owns the retry policy. This keeps transport-specific glue at the edge and makes the policy testable without sending anything.
Use the server-provided delay when the adapter exposes one. Otherwise, calculate capped exponential backoff with jitter. The cap prevents a busy destination from disappearing for an absurd interval; jitter prevents a fleet of workers from waking together. Do not retry forever. A compliance team needs a terminal state and an escalation path, not an immortal job.
There is a nasty crash window — after the remote system accepts a request but before the local worker stores accepted. A local transaction alone cannot close that gap because the network call is outside the database. The practical defense is a stable idempotency key passed through the adapter when the selected API supports it, plus a local uniqueness constraint on (noticeId, channel). If an API has no idempotent-send contract, serialize attempts for that key and reconcile ambiguous outcomes before sending again. That costs latency. It also avoids pretending distributed uncertainty vanished.
I'm not sure any generic retry count is defensible without the notice deadline, the API's published rate-limit behavior, and the team's escalation window. Those inputs should be configuration values with owners, not scattered constants. I don't benchmark vendor throughput in the happy path first; I benchmark queue age under throttling, because that is where an apparently tiny integration starts growing glue.
Keep the dispatcher small and the evidence explicit
The example below uses only local interfaces. The adapter can sit in front of any email or SMS API, while the dispatcher retains the policy and audit vocabulary. All times, attempt counts, and delays are inputs, which makes deterministic tests possible.
type Channel = "email" | "sms";
type NoticeJob = {
noticeId: string;
accountId: string;
channel: Channel;
destination: string;
contentRevision: string;
attempt: number;
};
type SendResult =
| { kind: "accepted"; receiptId: string }
| { kind: "rate_limited"; retryAfterMs?: number }
| { kind: "permanent_rejection"; reason: string };
interface ChannelAdapter {
send(job: NoticeJob, idempotencyKey: string): Promise<SendResult>;
}
interface AttemptLedger {
begin(job: NoticeJob, idempotencyKey: string, startedAt: string): Promise<void>;
accepted(job: NoticeJob, receiptId: string, recordedAt: string): Promise<void>;
rejected(job: NoticeJob, reason: string, recordedAt: string): Promise<void>;
}
interface RetryQueue {
enqueue(job: NoticeJob, delayMs: number): Promise<void>;
}
const MAX_ATTEMPTS = 6;
const MAX_DELAY_MS = 15 * 60 * 1_000;
function retryDelayMs(attempt: number, retryAfterMs?: number): number {
if (retryAfterMs !== undefined) {
return Math.min(retryAfterMs, MAX_DELAY_MS);
}
const exponential = Math.min(1_000 * 2 ** attempt, MAX_DELAY_MS);
return Math.floor(exponential * (0.5 + Math.random() * 0.5));
}
async function dispatch(
job: NoticeJob,
adapter: ChannelAdapter,
ledger: AttemptLedger,
queue: RetryQueue,
now: () => Date,
): Promise<void> {
const idempotencyKey = `${job.noticeId}:${job.channel}`;
await ledger.begin(job, idempotencyKey, now().toISOString());
const result = await adapter.send(job, idempotencyKey);
if (result.kind === "accepted") {
await ledger.accepted(job, result.receiptId, now().toISOString());
return;
}
if (result.kind === "permanent_rejection") {
await ledger.rejected(job, result.reason, now().toISOString());
return;
}
const nextAttempt = job.attempt + 1;
if (nextAttempt >= MAX_ATTEMPTS) {
await ledger.rejected(job, "retry_budget_exhausted", now().toISOString());
return;
}
await queue.enqueue(
{ ...job, attempt: nextAttempt },
retryDelayMs(nextAttempt, result.retryAfterMs),
);
}
The code deliberately does not claim that an acceptance receipt means a person read the notice. A later callback or polling adapter may append delivered or permanent_failure, depending on the channel contract. The business ledger remains stable even when transport details differ.
The idempotency key also excludes the attempt number. Adding it would make every retry a new logical operation, defeating duplicate suppression. If the content changes, create a new notice ID or a clearly versioned delivery intent; silently reusing a key for different content makes the audit trail impossible to explain.
Test the failure order, not just the functions
Unit tests for retryDelayMs are cheap, but they miss the expensive mistakes. Run the worker against a fake adapter that can accept a request, delay a response, rate-limit one channel, and return callbacks out of order. Then assert ledger invariants: one logical channel delivery per idempotency key, monotonically appended attempts, no transition out of a terminal state, and no retry scheduled beyond the notice deadline.
A useful load test fixes the arrival rate and progressively reduces adapter capacity. Measure p50 and p95 queue age per channel, retry volume, terminal failures, and the oldest unprocessed notice. Raw requests per second can look healthy while a compliance queue quietly ages past its deadline. I care more about that oldest item than a glossy average.
Deployment needs the same skepticism. Drain workers before replacing them, lease jobs for a bounded interval, and make the ledger write happen before the send. Alert on queue age and exhausted retry budgets. Redact message bodies and destinations from logs; the audit table should identify the content revision without becoming a second store of sensitive notice text.
Test the crash window.
For cost control, measure attempts per completed channel delivery and retention growth in the ledger. More aggressive retries consume capacity and can extend throttling, while very conservative retries may miss the business deadline. There is no magic multiplier. Pick the policy from deadline tests, then keep it visible in operational dashboards and change review.
When should you choose the runner-up instead?
The queue-plus-ledger design is not suitable when a notice is merely an optional, low-value alert and no one needs delivery evidence. In that case, an inline send can be acceptable if the request can tolerate the latency and the team accepts losing retries during process failure. Do not build an audit subsystem for a notification nobody audits.
Choose a workflow engine when the compliance process spans approvals, waiting periods, cancellations, and several dependent actions over days. The catch is integration surface: workflow definitions, worker lifecycle, state visibility, and local development all need ownership. That overhead can pay for itself when the process is genuinely long-running; for one email and one SMS fan-out, it is often config bloat wearing a reliability badge.
The decision rule is blunt. Start with the queue and ledger when duplicate sends or missing evidence have business consequences. Move up to workflow orchestration when timers and dependencies dominate the process. Move down to inline delivery only when auditability and recovery do not matter. Keep the transport adapter replaceable, but do not confuse replaceability with identical channel semantics.
References
- RFC 7489: DMARC — https://datatracker.ietf.org/doc/html/rfc7489
- MDN Web Docs: WebOTP API — https://developer.mozilla.org/en-US/docs/Web/API/WebOTP_API
Top comments (0)