<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: DonovanPierce4012</title>
    <description>The latest articles on DEV Community by DonovanPierce4012 (@donovanpierce4012).</description>
    <link>https://dev.to/donovanpierce4012</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4064944%2F42de1ec8-8530-45d1-9413-c4dceacfb27f.png</url>
      <title>DEV Community: DonovanPierce4012</title>
      <link>https://dev.to/donovanpierce4012</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/donovanpierce4012"/>
    <language>en</language>
    <item>
      <title>Why Node.js Notification Backends Need Attempt-Level Delivery, Polling, and Audit Records</title>
      <dc:creator>DonovanPierce4012</dc:creator>
      <pubDate>Thu, 06 Aug 2026 01:37:52 +0000</pubDate>
      <link>https://dev.to/donovanpierce4012/why-nodejs-notification-backends-need-attempt-level-delivery-polling-and-audit-records-4pmp</link>
      <guid>https://dev.to/donovanpierce4012/why-nodejs-notification-backends-need-attempt-level-delivery-polling-and-audit-records-4pmp</guid>
      <description>&lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; build the notification center around immutable delivery attempts, not mutable message rows; accept each event once, create channel-specific attempts, append every state change to an audit log, and expose a cursor-based polling API over that history.&lt;/p&gt;

&lt;p&gt;That answer is less glamorous than picking an email or SMS provider. It is also the part that keeps an OTP retry from becoming two texts, a marketing opt-out from becoming an argument with compliance, and a dashboard from claiming “sent” when all the backend really did was enqueue work.&lt;/p&gt;

&lt;p&gt;I treat the center as an accounting system for communication. The event says what the application wanted. An attempt says what one channel worker tried. History says what changed and when. Keep those three ideas separate and the Node.js implementation becomes ordinary queue, database, and HTTP work. Blur them together and every delivery gap turns into archaeology.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should a Node.js notification center backend record for email, SMS, event delivery history, and polling?
&lt;/h2&gt;

&lt;p&gt;Start with the constraint: delivery is asynchronous, while product code wants an immediate answer. The POST that accepts an event cannot truthfully promise that an email reached an inbox or an SMS reached a handset. It can promise a narrower result: the event was accepted under an idempotency key and has a stable identifier. That distinction belongs in the API contract.&lt;/p&gt;

&lt;p&gt;I use four records. &lt;code&gt;notification_event&lt;/code&gt; holds the business intent, recipient reference, template version, policy class, and idempotency key. &lt;code&gt;delivery_attempt&lt;/code&gt; represents one dispatch through one channel, with its own state and provider-neutral external reference. &lt;code&gt;delivery_transition&lt;/code&gt; is an append-only account of state changes. &lt;code&gt;preference_snapshot&lt;/code&gt; records the consent and suppression decision used when the attempt was created. The current status on an attempt is a cache for fast reads; the transitions are the audit evidence.&lt;/p&gt;

&lt;p&gt;The names matter less than the boundaries. Never overwrite the original recipient, template version, or policy decision to make a retry look tidy. A retry is a new attempt linked to the same event. Likewise, don't put email and SMS status columns on the event row. That model breaks as soon as one channel retries, another is suppressed, or an OTP expires before a delayed worker handles it.&lt;/p&gt;

&lt;p&gt;For password recovery and OTP flows, I apply the OWASP guidance at the policy edge: return consistent responses, rate-limit requests, generate tokens with an appropriate random source, store them securely, make them single-use, and expire them. The audit record should describe the policy outcome without storing the secret itself. For commercial email, the FTC guidance makes sender identity and opt-out handling part of the design, not copywriting cleanup after launch.&lt;/p&gt;

&lt;p&gt;One hard rule follows: &lt;strong&gt;“accepted” is not “delivered.”&lt;/strong&gt; Make that visible in names, metrics, and API responses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate intent, attempts, and transitions
&lt;/h2&gt;

&lt;p&gt;The write path should be short. In one database transaction, deduplicate the incoming event, save its policy snapshot, create eligible channel attempts, and append their initial transitions. Commit before workers can claim anything. A queue message should carry an attempt ID, not the full email body or phone number; the worker reloads the authoritative record and checks that the attempt is still claimable.&lt;/p&gt;

&lt;p&gt;Here is the core contract in Python-shaped pseudocode. My production service can still be Node.js; this example deliberately shows the data flow rather than tying the design to a web framework or queue client.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Protocol&lt;/span&gt;


&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EventRequest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;idempotency_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;recipient_ref&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;template_version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...]&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Protocol&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;find_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idempotency_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;insert_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;EventRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;accepted_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;policy_snapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;recipient_ref&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;insert_attempt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;append_transition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempt_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;page_transitions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;recipient_ref&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;accept_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;EventRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;idempotency_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;accepted&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;duplicate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;accepted_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;channel&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;policy_snapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;recipient_ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;eligible&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;
            &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert_attempt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append_transition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;queued&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;accepted&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;duplicate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;poll_history&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;recipient_ref&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;bounded_limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;next_cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;page_transitions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;recipient_ref&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;recipient_ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;bounded_limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;next_cursor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;next_cursor&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Race it deliberately.&lt;/p&gt;

&lt;p&gt;The idempotency check needs a database uniqueness constraint as its final referee; an application-level lookup alone can race. Workers need a comparable claim operation so two consumers don't send the same attempt. In a test environment, pause two event handlers after their lookups, release them together, and verify that one insert wins while the other returns the existing event. Repeat the same exercise at worker claim time. Then kill a worker after it claims an attempt but before handoff, and again immediately after handoff, because those two crash points demand different recovery decisions. Keep provider payloads out of the public history response, store only the diagnostic fields your team has deliberately approved, redact addresses and phone numbers from logs, and set retention by policy class rather than keeping everything forever. This is fussy work — exactly the sort I want finished before an urgent OTP incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  How should the polling API expose audit and delivery history?
&lt;/h2&gt;

&lt;p&gt;A polling endpoint is a read model, not a dump of your operational tables. I return transitions ordered by a stable cursor composed from the transition timestamp and unique ID. A client supplies &lt;code&gt;after&lt;/code&gt;, receives a bounded page, and persists &lt;code&gt;next_cursor&lt;/code&gt;. Offset pagination is awkward here because new rows arrive while a client walks the list; a cursor gives the client a clear continuation point based on the ordering contract.&lt;/p&gt;

&lt;p&gt;Each item should identify the event, attempt, channel, public state, transition time, and a safe reason category such as &lt;code&gt;suppressed_by_policy&lt;/code&gt; or &lt;code&gt;expired&lt;/code&gt;. It should not expose an OTP, raw provider response, provider credential, full message body, or an unrestricted recipient identifier. Authentication answers who may call the endpoint; authorization still has to scope which user's records that caller may see.&lt;/p&gt;

&lt;p&gt;Keep the state vocabulary small and define it yourself. I usually need accepted intent, queued attempt, handed-off attempt, a terminal delivery outcome, suppression, and expiry. Provider-specific statuses map into that vocabulary behind the adapter. Preserve the raw external reference privately when operations needs reconciliation, but don't make the UI understand every downstream dialect.&lt;/p&gt;

&lt;p&gt;Polling also needs an explicit freshness promise. Return the server time and document that the history is eventually updated; don't have the client infer delivery from a missing row. Use conditional requests or a backoff schedule if the UI may sit open for hours. Your mileage may vary on the exact interval — OTP screens and an admin archive have very different urgency — but adding random jitter prevents every browser tab from waking at the same instant. This is where edge cases earn their keep: a callback can arrive before the worker commits its handoff transition, two callbacks can report the same state, and an older status can arrive after a newer one. Append all accepted observations with their source time, then calculate the public state using a monotonic transition policy. Don't rewrite history to make arrival order look causal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which delivery strategy fits after the constraints are clear?
&lt;/h2&gt;

&lt;p&gt;Once the records and invariants are clear, the transport choice gets easier. Polling is a reasonable default for a notification center UI because the reader controls authentication, pagination, and rendering. Push mechanisms reduce visible delay, but they add connection lifecycle and replay questions. Provider callbacks are useful on the ingest side, where they update attempts; they are not automatically the right interface for your product clients.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;th&gt;Good fit&lt;/th&gt;
&lt;th&gt;Main trade-off&lt;/th&gt;
&lt;th&gt;Failure handling I expect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cursor polling&lt;/td&gt;
&lt;td&gt;History pages, admin tools, ordinary web clients&lt;/td&gt;
&lt;td&gt;Repeated reads and bounded freshness&lt;/td&gt;
&lt;td&gt;Resume from the last committed cursor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server-sent events&lt;/td&gt;
&lt;td&gt;A signed-in screen needing quicker updates&lt;/td&gt;
&lt;td&gt;Long-lived connections and reconnect state&lt;/td&gt;
&lt;td&gt;Reconnect with a last-event marker, then reconcile&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebSocket&lt;/td&gt;
&lt;td&gt;Existing bidirectional real-time infrastructure&lt;/td&gt;
&lt;td&gt;More session and authorization machinery&lt;/td&gt;
&lt;td&gt;Reconnect, reauthorize, and refill gaps from history&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client webhook&lt;/td&gt;
&lt;td&gt;Machine-to-machine consumers&lt;/td&gt;
&lt;td&gt;Public endpoint security and retry ownership&lt;/td&gt;
&lt;td&gt;Sign deliveries, retry safely, and expose replay controls&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The catch is that polling is not suitable when a verified alert must appear within a very tight latency budget and stale reads are unacceptable. Use a push channel there, but retain the history endpoint for recovery. Stick with polling when the product can tolerate bounded delay and the operational value of a simple stateless read path is higher than live updates.&lt;/p&gt;

&lt;p&gt;Cost belongs in this comparison, though I don't lead with it. I got burned by a &lt;strong&gt;$1,842 messaging bill&lt;/strong&gt; after a retrying event consumer created fresh send jobs instead of reusing the idempotency key; the dashboard showed healthy throughput while customers received duplicates, and I had assumed its rising line meant the queue was merely catching up. I ended up tracing a single business event across the consumer, job table, and handoff records before the duplicate fan-out became obvious. The fix was architectural: uniqueness at event acceptance, attempt claims in the database, and separate counts for accepted events, created attempts, and billable handoffs. That incident is why I distrust a single “messages sent” chart.&lt;/p&gt;

&lt;p&gt;It stung.&lt;/p&gt;

&lt;p&gt;Watch queue age, attempts per event, suppression rate by policy class, time from acceptance to handoff, time from handoff to terminal observation, callback deduplication, and polling lag. I'm not sure why teams still start with vendor success rate; without your own denominators and state definitions, that percentage can't explain where the gap began.&lt;/p&gt;

&lt;h2&gt;
  
  
  How can I roll out this model without duplicate sends?
&lt;/h2&gt;

&lt;p&gt;Migration should begin in shadow mode. Keep the current sender authoritative while writing new event, attempt, and transition records beside it. Reconcile counts by channel and policy class, inspect mismatches, and verify that the read model never exposes secrets or cross-tenant data. No new worker sends yet.&lt;/p&gt;

&lt;p&gt;Next, choose one low-risk notification class and make the new acceptance path authoritative for it. Pin the idempotency key to a business action, not an HTTP request ID, because an application retry represents the same intent. Deploy consumers with claim-before-send behavior, then test process crashes immediately before and after the handoff. The system should either leave an attempt safely claimable or preserve evidence that prevents an automatic second handoff.&lt;/p&gt;

&lt;p&gt;OTP deserves a separate rollout lane. Exercise repeated requests, expired tokens, used tokens, suppressed recipients, and responses for known versus unknown accounts. Keep the user-facing response consistent with the OWASP guidance while giving authorized operators enough categorized history to diagnose delivery gaps. For commercial email, test preference changes and suppression between event acceptance and worker claim; decide in writing whether the snapshot or latest policy wins for each message class.&lt;/p&gt;

&lt;p&gt;Finally, move the UI to the cursor API and compare its results with the old screen before retiring old status fields. Make rollback disable new claims without deleting history. Short version: &lt;strong&gt;migrate the ledger first, the sender second, and the UI last.&lt;/strong&gt; That order gives the team evidence before it takes on delivery risk.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;OWASP, “Forgot Password Cheat Sheet”: &lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html" rel="noopener noreferrer"&gt;https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Federal Trade Commission, “CAN-SPAM Act: A Compliance Guide for Business”: &lt;a href="https://www.ftc.gov/business-guidance/resources/can-spam-act-compliance-guide-business" rel="noopener noreferrer"&gt;https://www.ftc.gov/business-guidance/resources/can-spam-act-compliance-guide-business&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>node</category>
      <category>notifications</category>
      <category>backend</category>
    </item>
  </channel>
</rss>
