<?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: kevindev</title>
    <description>The latest articles on DEV Community by kevindev (@kevindev27).</description>
    <link>https://dev.to/kevindev27</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%2F4012755%2F59157d97-36db-4340-addc-f6c6776820fb.png</url>
      <title>DEV Community: kevindev</title>
      <link>https://dev.to/kevindev27</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kevindev27"/>
    <language>en</language>
    <item>
      <title>Version Auth Emails With Attempt Receipts</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Thu, 13 Aug 2026 14:24:30 +0000</pubDate>
      <link>https://dev.to/kevindev27/version-auth-emails-with-attempt-receipts-154g</link>
      <guid>https://dev.to/kevindev27/version-auth-emails-with-attempt-receipts-154g</guid>
      <description>&lt;p&gt;In most auth systems, email delivery looks simpler than it really is. A signup or password reset request enters the &lt;code&gt;REST API&lt;/code&gt;, one row changes state, and a worker sends the message later. Then a retry happens, a cooldown rule changes, or the user submits the form twice from two tabs. Suddenly the team is arguing about whether the second email was expected or whether the first receipt is still valid.&lt;/p&gt;

&lt;p&gt;What has worked better for me is treating every auth email decision as an immutable attempt with its own receipt. Instead of one mutable &lt;code&gt;email_status&lt;/code&gt; field on the user record, the backend writes a new attempt row each time policy is evaluated. That row explains why the message was queued, skipped, or superseded. It sounds small, but it makes production debugging much less fuzzy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why auth emails need attempt receipts
&lt;/h2&gt;

&lt;p&gt;One mutable status field collapses too many decisions together:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;did the API accept this request?&lt;/li&gt;
&lt;li&gt;did cooldown policy suppress it?&lt;/li&gt;
&lt;li&gt;did a worker already claim the send?&lt;/li&gt;
&lt;li&gt;did a newer verification attempt replace it?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those questions are related, but they are not the same event. When you store them in one field, retries become hard to reason about. That is extra true when disposable inboxes or abuse heuristics are part of the flow. A support ticket might mention a strange address like &lt;code&gt;fake e mail com&lt;/code&gt;, while QA might be checking a temporary disposable mail flow in staging, and the database still only says &lt;code&gt;sent&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I prefer an append-only attempt table because it preserves the sequence. You can see that attempt 17 was skipped for cooldown, attempt 18 was queued after the window expired, and attempt 19 was invalidated because the user changed their address. That history is boring in the best way. It lets the backend answer "what happened?" without guessing from current code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What goes wrong with one mutable status field
&lt;/h2&gt;

&lt;p&gt;The common shortcut is a user or session row with fields like &lt;code&gt;last_email_sent_at&lt;/code&gt;, &lt;code&gt;verification_status&lt;/code&gt;, and maybe &lt;code&gt;email_job_id&lt;/code&gt;. That works for the first version, but it breaks down when behavior becomes slightly richer.&lt;/p&gt;

&lt;p&gt;Here is the pattern I keep seeing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The first request writes &lt;code&gt;queued&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A second request arrives before the worker finishes.&lt;/li&gt;
&lt;li&gt;The API overwrites the same row with &lt;code&gt;suppressed&lt;/code&gt; or &lt;code&gt;requeued&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Observability now reflects the latest story, not the full story.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;At that point, your team starts reading logs to rebuild state. If logs rotate, or if a worker message shows up late, the explanation gets annoyingly weak. This is also where weird strings like &lt;code&gt;tempail&lt;/code&gt; sneak into test traffic and make the investigation feel more messy than it should be.&lt;/p&gt;

&lt;p&gt;The better boundary is to make each policy evaluation durable and immutable. Then "current state" can be derived from the newest valid receipt instead of overwritten by it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A PostgreSQL model for immutable attempts
&lt;/h2&gt;

&lt;p&gt;For &lt;code&gt;PostgreSQL&lt;/code&gt;, I like an &lt;code&gt;auth_email_attempts&lt;/code&gt; table plus an outbox table keyed by the same receipt id:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;auth_email_attempts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;receipt_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;flow_type&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;normalized_email&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;attempt_number&lt;/span&gt; &lt;span class="nb"&gt;integer&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;supersedes_receipt_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;policy_snapshot&lt;/span&gt; &lt;span class="n"&gt;jsonb&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;unique&lt;/span&gt; &lt;span class="k"&gt;index&lt;/span&gt; &lt;span class="n"&gt;auth_email_attempts_active_idx&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;auth_email_attempts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flow_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempt_number&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important thing is not the exact schema. It is the shape of the evidence:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one immutable receipt per evaluated attempt&lt;/li&gt;
&lt;li&gt;one policy snapshot explaining the decision&lt;/li&gt;
&lt;li&gt;one clear link to any older receipt it replaced&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to know which email is current, query for the newest receipt that was not superseded. If you want to know why a send never happened, inspect the stored decision and snapshot. That beats reconstructing intent from four partially related tables.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing receipts inside the REST API transaction
&lt;/h2&gt;

&lt;p&gt;The receipt should be written in the same transaction that decides whether a send may happen. Do not leave policy evaluation to a later worker if you can avoid it. Otherwise the API and worker can drift, and the final record stops matching what the caller was told.&lt;/p&gt;

&lt;p&gt;A clean flow looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Normalize the submitted email and classify the flow.&lt;/li&gt;
&lt;li&gt;Load the latest active receipt for that user and flow.&lt;/li&gt;
&lt;li&gt;Evaluate cooldown, address policy, and resend rules.&lt;/li&gt;
&lt;li&gt;Insert a new receipt row with the decision snapshot.&lt;/li&gt;
&lt;li&gt;Insert an outbox row only when the decision is &lt;code&gt;queued&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That sequence gives you stable behavior under retries. If the client resubmits, the API can create a new receipt that says &lt;code&gt;suppressed_cooldown&lt;/code&gt; without destroying the earlier &lt;code&gt;queued&lt;/code&gt; receipt. If the worker crashes after claiming the outbox row, you still know which receipt it belonged to. It feels a bit more verbose at first, but it pays for itself pretty fast.&lt;/p&gt;

&lt;p&gt;I also like combining this with &lt;a href="https://dev.to/silviutech/playwright-traces-for-flaky-email-tests-483a"&gt;trace-backed inbox debugging&lt;/a&gt; because the test can assert the receipt id before it waits on the inbox. And if your team is doing &lt;a href="https://dev.to/bitheirstake/privacy-reviews-for-email-testing-in-staging-479o"&gt;privacy reviews for staging email flows&lt;/a&gt;, immutable receipts make retention boundaries easier to discuss, since the schema is already explicit about what evidence you keep.&lt;/p&gt;

&lt;h2&gt;
  
  
  How receipts improve debugging and testing
&lt;/h2&gt;

&lt;p&gt;This design helps in a few very practical ways.&lt;/p&gt;

&lt;p&gt;First, debugging gets narrower. When a user says "I got two verification emails," you can compare two receipts and see whether the backend intentionally issued both or whether one worker retried the same job. That answer is usually visible in one query, which is nice.&lt;/p&gt;

&lt;p&gt;Second, API behavior becomes easier to test. A backend test can assert that a resend creates a new receipt with &lt;code&gt;supersedes_receipt_id&lt;/code&gt; set, instead of just checking whether some timestamp moved. That is a much stronger contract, and honestly more maintainable.&lt;/p&gt;

&lt;p&gt;Third, support and product stop relying on fuzzy timelines. The receipt ids give everyone the same vocabulary. Attempt 42 was queued. Attempt 43 was suppressed. Attempt 44 replaced 42 after the cooldown. Small thing, big clarity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Q&amp;amp;A
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do I still need an outbox table?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. The receipt explains the decision; the outbox handles delivery work. Keep those concerns separate, even if they share identifiers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is this overkill for a small app?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not really. If your product has signup, reset, or magic-link flows, immutable receipts are one of those small backend habits that age suprisingly well.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I keep every receipt forever?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Usually no. Keep them long enough for support, incident review, and trend analysis, then archive or delete based on policy.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>authentication</category>
      <category>postgres</category>
      <category>restapi</category>
    </item>
    <item>
      <title>Store Email Policy Snapshots With Auth Events</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Tue, 11 Aug 2026 05:25:25 +0000</pubDate>
      <link>https://dev.to/kevindev27/store-email-policy-snapshots-with-auth-events-43ba</link>
      <guid>https://dev.to/kevindev27/store-email-policy-snapshots-with-auth-events-43ba</guid>
      <description>&lt;p&gt;In auth systems, email failures are rarely about one missing &lt;code&gt;send()&lt;/code&gt; call. The real problem is that the system forgets &lt;em&gt;why&lt;/em&gt; it decided to send, suppress, or reroute a message at that moment. Weeks later, support sees a complaint, QA has a screenshot, and the backend only has a boolean like &lt;code&gt;email_sent = true&lt;/code&gt;. That field is almost useless.&lt;/p&gt;

&lt;p&gt;What has worked better for me is storing a small policy snapshot beside each auth email event. Not just the recipient address, but the normalized address, the rule version, the reason code, and any delivery guard that shaped the decision. In a &lt;code&gt;REST API&lt;/code&gt; backed by &lt;code&gt;PostgreSQL&lt;/code&gt;, this gives you a clean audit trail without turning the user record into a junk drawer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why send flags stop being useful
&lt;/h2&gt;

&lt;p&gt;A plain send flag tells you that some code path ran. It does not tell you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;which normalization rules were active&lt;/li&gt;
&lt;li&gt;whether the address was considered risky or disposable&lt;/li&gt;
&lt;li&gt;whether resend cooldown logic suppressed the message&lt;/li&gt;
&lt;li&gt;whether the provider was skipped because the request already had a valid receipt&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That missing context matters when email policy changes over time. Maybe last month you blocked a domain pattern. Maybe you tightened alias handling. Maybe the signup form started rejecting strings that looked like &lt;code&gt;dummy e mail&lt;/code&gt; in abuse-heavy traffic. If you only keep the current rule set in code, your historical auth events become weirdly hard to explain.&lt;/p&gt;

&lt;p&gt;This is extra important for password reset and verification flows, where a small mismatch can look like an account issue. I like the framing in &lt;a href="https://dev.to/bitheirstake/recovery-emails-need-safer-evidence-19al-temp-slug-4788024?preview=6a002e502e176fcfcc58d78e4fba624d56a894827d2564055f8264b4699ce1f2c25f219dd3e383b370c8a779cde187b1dcfdaea41a812a4afc9a6119"&gt;safer recovery email evidence&lt;/a&gt;: keep enough evidence to debug the decision, but not so much that logs become their own privacy problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Snapshot the policy, not just the address
&lt;/h2&gt;

&lt;p&gt;The snapshot does not need to be huge. I usually persist a compact JSON object with the fields that explain the decision:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"normalized_email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sam@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rule_version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"email-policy-2026-08-01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"delivery_decision"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"allowed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reason_code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"default_allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cooldown_window_seconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;900&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"address_traits"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"plus_alias"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"consumer_domain"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key idea is simple: save the evaluated result, not only the raw input. If you later change canonicalization rules, you can still answer what the system believed at the time. That is often the difference between a 5 minute investigation and a frustrating hour of "it depends what code was deployed then". I have been in that hour a few times, and it is not super fun, honestly.&lt;/p&gt;

&lt;h2&gt;
  
  
  A PostgreSQL shape that stays debuggable
&lt;/h2&gt;

&lt;p&gt;I prefer keeping the snapshot on the event or receipt row rather than on the user table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;auth_email_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;flow_type&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;correlation_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;raw_email&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;normalized_email&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;policy_snapshot&lt;/span&gt; &lt;span class="n"&gt;jsonb&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;provider_status&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;index&lt;/span&gt; &lt;span class="n"&gt;auth_email_events_lookup_idx&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;auth_email_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;correlation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;jsonb&lt;/code&gt; works well here because policy data evolves a bit. You may later add &lt;code&gt;mx_check&lt;/code&gt;, &lt;code&gt;tenant_policy&lt;/code&gt;, or &lt;code&gt;alias_bucket&lt;/code&gt; without forcing a migration every time. I still keep core query fields, like &lt;code&gt;normalized_email&lt;/code&gt; and &lt;code&gt;correlation_id&lt;/code&gt;, as first-class columns so production queries stay fast and boring.&lt;/p&gt;

&lt;p&gt;One practical rule: snapshot only the decision inputs you need for debugging. Do not dump full provider responses or unrelated request payloads into the same blob. That path gets messy, and kinda expensive, pretty fast. I have seen teams mix those concerns together and then regret it later, mostly because the table stops feeling clearly seperated by purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the API should persist at write time
&lt;/h2&gt;

&lt;p&gt;The best moment to write the snapshot is inside the same transaction that creates the outbox or email event. If the worker computes policy later, you invite drift between what the API accepted and what the sender actually used.&lt;/p&gt;

&lt;p&gt;My flow usually looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Normalize and classify the submitted email in the API layer.&lt;/li&gt;
&lt;li&gt;Evaluate resend, risk, and template rules.&lt;/li&gt;
&lt;li&gt;Write the auth event with &lt;code&gt;policy_snapshot&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Insert the outbox row using the same &lt;code&gt;correlation_id&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That sequence makes incident review much cleaner. If support asks why a reset message was skipped for an address that looked like &lt;code&gt;tamp mail com&lt;/code&gt;, the answer comes from the stored event, not from guesswork about current code. If QA is &lt;a href="https://dev.to/sophiax99/how-to-test-oauth-recovery-emails-without-exposing-real-inboxes-hni"&gt;testing OAuth recovery inboxes&lt;/a&gt;, they can line up the observed message with the backend event that created it instead of trusting mailbox timing alone. When a policy change happened last week and the complaint occured today, that extra context helps alot.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this helps testing and support
&lt;/h2&gt;

&lt;p&gt;This pattern helps in three places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Support can inspect one event and see the exact policy decision.&lt;/li&gt;
&lt;li&gt;Engineers can compare old and new rule versions during a rollout.&lt;/li&gt;
&lt;li&gt;Test tooling can assert against backend evidence before waiting on inbox visibility.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point matters more than it seems. A lot of flaky email tests are really traceability problems. The mailbox is only the last hop. When the backend stores the policy snapshot and correlation id early, failures stop feeling random. You can tell whether the app suppressed the send, whether the worker processed it, or whether the inbox observer simply arrived late.&lt;/p&gt;

&lt;p&gt;It also makes search and reporting better. If product wants to know how many addresses were suppressed by a new policy, SQL can answer that directly from the stored snapshot. No log spelunking, no replay job, less pain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Q&amp;amp;A
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do I need a full policy engine for this?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. Even a small ruleset benefits from snapshots if it influences auth email behavior.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I store the raw email too?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Usually yes, but keep access scoped. The normalized form explains behavior; the raw form helps diagnose user input edge cases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Won't JSONB become a dumping ground?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It can, if you let it. Keep the snapshot small and decision-focused. Treat it like a receipt, not a warehouse.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>authentication</category>
      <category>postgres</category>
      <category>restapi</category>
    </item>
    <item>
      <title>Idempotent Signup Emails in REST APIs</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Tue, 11 Aug 2026 02:24:16 +0000</pubDate>
      <link>https://dev.to/kevindev27/idempotent-signup-emails-in-rest-apis-54e4</link>
      <guid>https://dev.to/kevindev27/idempotent-signup-emails-in-rest-apis-54e4</guid>
      <description>&lt;p&gt;Signup email delivery looks simple until retries start stacking up. A client times out, the user taps submit again, and now the same account may get two confirmation emails with different lifetimes or different audit trails. In a REST API, that is not just annoying. It makes Authentication behavior harder to explain, harder to test, and harder to support once prod traffic gets noisy.&lt;/p&gt;

&lt;p&gt;Teams searching for get temporary email flows or tp mail so are often trying to isolate inboxes for testing, but the deeper backend issue is idempotency. You want one logical signup attempt to produce one durable email intent, even if the transport layer or the worker retried more than once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why signup emails become duplicated
&lt;/h2&gt;

&lt;p&gt;Most duplicate signup emails are caused by two things happening at the same time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the API does not persist a stable request identity before enqueueing work&lt;/li&gt;
&lt;li&gt;the email worker cannot tell whether it is handling a fresh event or a replay&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That gap is usualy hidden when the happy path is fast. It appears later when mobile networks retry, reverse proxies replay on 502s, or a queue consumer restarts mid-send. If the only trace you keep is "email sent," your enviroment gives you no clean way to explain which request created which message.&lt;/p&gt;

&lt;p&gt;The same thinking behind &lt;a href="https://dev.to/kevindev27/testing-password-reset-emails-in-postgresql-backed-rest-apis-fb8"&gt;reset email isolation patterns&lt;/a&gt; applies here. An email event should be tied to durable state first, delivery second.&lt;/p&gt;

&lt;h2&gt;
  
  
  What state a REST API should persist
&lt;/h2&gt;

&lt;p&gt;For signup confirmation, I like storing one row per logical email intent rather than one row per worker attempt. That row can be small:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;signup_request_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;user_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;purpose&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;idempotency_key&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;token_hash&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;status&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;created_at&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;last_sent_at&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important part is not the exact column list. It is that your service can answer a few boring but critical questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Did this client already ask for a signup confirmation?&lt;/li&gt;
&lt;li&gt;Is the current token still the valid one?&lt;/li&gt;
&lt;li&gt;Was the email already queued or sent?&lt;/li&gt;
&lt;li&gt;If a worker retries, should it send again or mark the attempt as duplicate?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When support teams chase weird aliases from staging, they also run into garbage inputs like tepm mail com. I do not use those strings as identifiers, but I do keep the raw submitted email in a scrubbed audit record so operaters can see what the client actually sent.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small idempotent email design
&lt;/h2&gt;

&lt;p&gt;The easiest design to maintain is usually:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Accept an idempotency key from the client, or derive one from a bounded request window.&lt;/li&gt;
&lt;li&gt;Insert the signup intent in a transaction.&lt;/li&gt;
&lt;li&gt;Enqueue an outbox job that references the inserted row.&lt;/li&gt;
&lt;li&gt;Let the worker send only when the row is still in a sendable state.&lt;/li&gt;
&lt;li&gt;Mark delivery timestamps on success without changing the logical intent identity.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In Node.js, the handler can stay pretty small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&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="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;intent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signupEmailIntent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;userId_idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;idempotencyKey&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;update&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt;
    &lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;purpose&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;signup_confirm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;tokenHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;outbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;signup-email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;aggregateId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That pattern isnt fancy, but it does something valuable: retries stop creating new logical work. The worker may still retry delivery, yet the API state stays stable. This also makes &lt;a href="https://dev.to/ryanlee91/react-signup-flows-need-email-states-5c45"&gt;signup email state handling&lt;/a&gt; easier for frontend teams because the backend can expose a clearer status model.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I test retries without guessing
&lt;/h2&gt;

&lt;p&gt;My preferred test is not "did one email arrive?" It is "did the system preserve one logical signup intent while surviving repeated calls?" The flow is pretty simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a fresh user and isolated inbox alias.&lt;/li&gt;
&lt;li&gt;Call the signup-confirm endpoint twice with the same idempotency key.&lt;/li&gt;
&lt;li&gt;Assert that the database still has one logical signup intent row.&lt;/li&gt;
&lt;li&gt;Assert that only one current token is valid.&lt;/li&gt;
&lt;li&gt;Inspect the inbox and prove the observed message maps to that row.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If I need inbox verification, I keep the query narrow by alias and time window. That avoids false positives from older mailboxes and makes failures much less random. The test should also verify that a second worker retry does not produce a second valid token. Delivery may retry; meaning should not.&lt;/p&gt;

&lt;p&gt;One subtle bug shows up when the API writes the outbox event before the intent row is committed. Under load, a worker can race ahead, fetch partial state, and send an email that no longer matches the final token. Those bugs are rare, but they are exactly the kind that become expensive once alerts start waking people up at 3 AM.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist before you ship
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;One signup attempt maps to one durable email intent.&lt;/li&gt;
&lt;li&gt;The REST API can answer whether a request was new or repeated.&lt;/li&gt;
&lt;li&gt;Worker retries are recorded as attempts, not new business events.&lt;/li&gt;
&lt;li&gt;The valid confirmation token is easy to identify in storage.&lt;/li&gt;
&lt;li&gt;Inbox tests use isolated aliases and tight windows.&lt;/li&gt;
&lt;li&gt;Audit fields are readable enough that an on-call engineer can diagnose the issue fast, even if the first report is a bit messy or worded akwardly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Q&amp;amp;A
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should I block every repeated signup request?
&lt;/h3&gt;

&lt;p&gt;Not necessarily. Repeated requests are normal. What you want is stable semantics, not fragile rejection logic. Return a safe response and keep the business event deduplicated.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need an outbox if my mail provider already retries?
&lt;/h3&gt;

&lt;p&gt;Yes, if you care about explaining backend behavior. Provider retries help delivery. The outbox helps your service keep a consistent story about what should have happened.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is one inbox message always enough evidence?
&lt;/h3&gt;

&lt;p&gt;No. A readable inbox is useful, but the stronger proof comes from joining message evidence back to stored API state. Without that, duplicate sends and stale tokens are harder to seperate.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>authentication</category>
      <category>node</category>
    </item>
    <item>
      <title>Auth Logs Need Inbox Correlation IDs</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Sat, 08 Aug 2026 23:24:26 +0000</pubDate>
      <link>https://dev.to/kevindev27/auth-logs-need-inbox-correlation-ids-ah</link>
      <guid>https://dev.to/kevindev27/auth-logs-need-inbox-correlation-ids-ah</guid>
      <description>&lt;p&gt;Verification email bugs are annoying because the failure usually lands between systems. The &lt;code&gt;REST API&lt;/code&gt; says the request passed, the worker says it sent something, and QA says the inbox never showed the message. If those three views do not share one identifier, your incident review turns into log archaeology pretty fast.&lt;/p&gt;

&lt;p&gt;What has worked better for me is adding a correlation id that travels from the auth request, into PostgreSQL, through the worker, and finally into any inbox assertion data. It is a small backend habit, but it makes Authentication issues much easier to explain and fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why auth systems lose the email trail
&lt;/h2&gt;

&lt;p&gt;Many auth services already store useful bits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;user id&lt;/li&gt;
&lt;li&gt;email template&lt;/li&gt;
&lt;li&gt;provider response id&lt;/li&gt;
&lt;li&gt;retry count&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That still is not enough when support asks a simple question: "Did this exact signup request produce the message that QA checked?"&lt;/p&gt;

&lt;p&gt;The gap happens because delivery metadata and inbox metadata are often recorded in different places with different keys. A worker might log &lt;code&gt;job_id&lt;/code&gt;, while the test harness stores only the recipient address. If the address was reused, or a resend happened, your evidence gets fuzzy. I have seen this go wrong even in otherwise clean systems, and the debugging cost was higher than the schema fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The correlation id shape I keep in PostgreSQL
&lt;/h2&gt;

&lt;p&gt;I like giving every email-producing auth action one &lt;code&gt;correlation_id&lt;/code&gt; at request time. That id gets written to the outbox row and also to a receipt table.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;auth_email_receipts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;correlation_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;email_job_id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;email_jobs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="k"&gt;template&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;delivery_status&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;provider_message_id&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;inbox_observed_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;inbox_observation_source&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is not fancy SQL. It is that &lt;code&gt;correlation_id&lt;/code&gt; survives every handoff. PostgreSQL is a good fit here because inserts are cheap, ordering is obvious, and querying one incident path stays boring. Boring is good, honestly.&lt;/p&gt;

&lt;p&gt;I also keep &lt;code&gt;inbox_observation_source&lt;/code&gt; optional. Sometimes the inbox check comes from Playwright, sometimes from a manual run, and sometimes not at all. Your source of truth should still be the backend evidence trail, not whether an inbox tool happened to respond that minute.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the API and worker pass the same evidence forward
&lt;/h2&gt;

&lt;p&gt;My preferred flow is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The signup or password-reset endpoint creates &lt;code&gt;correlation_id&lt;/code&gt; before the transaction commits.&lt;/li&gt;
&lt;li&gt;The API writes the outbox row with that id.&lt;/li&gt;
&lt;li&gt;The worker copies the same id into the delivery receipt when it claims and sends the job.&lt;/li&gt;
&lt;li&gt;Test or QA tooling records inbox observations against the same id.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That means one query can answer the whole story:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;correlation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;template&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;delivery_status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;provider_message_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;inbox_observed_at&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;auth_email_receipts&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;correlation_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;asc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When teams add &lt;a href="https://dev.to/ryanlee91/abortable-email-polling-in-react-jgp"&gt;abortable inbox polling&lt;/a&gt; on the client or in QA tooling, this shared id keeps polling results attached to the right auth event instead of just "whatever arrived for this mailbox". That matters more than people expect.&lt;/p&gt;

&lt;p&gt;If I need an external mailbox for a staging check, I keep it narrow and contextual. A service like &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;email temporary free&lt;/a&gt; can help validate that the user-visible message arrived, but I still treat it as supporting evidence. The receipt and outbox data should explain the backend path first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where inbox tools fit without owning the truth
&lt;/h2&gt;

&lt;p&gt;Inbox tools are useful, but I try not to let them define correctness by themselves. Mailboxes are noisier than your database. Polling delays happen. Test addresses get reused. Someone pastes the wrong temp org mail into a manual checklist and now the investigation starts from a bad premise.&lt;/p&gt;

&lt;p&gt;That is why I separate two questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Did the auth backend create and send the intended message?&lt;/li&gt;
&lt;li&gt;Did an inbox observer later confirm the message was visible?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are related, but not identical. The first one should be answerable entirely from your application records. The second one adds user-facing confidence. For teams working on privacy-sensitive flows, &lt;a href="https://dev.to/bitheirstake/privacy-notes-for-invite-email-debugging-4cm5"&gt;invite email privacy checks&lt;/a&gt; are a good reminder that inbox validation should stay scoped and deliberate, not sprayed across logs.&lt;/p&gt;

&lt;p&gt;One more small thing: make the correlation id visible in operator tooling. If an on-call engineer can paste one id into a dashboard and see the request, outbox row, worker attempt, and inbox observation together, the mean time to understand drops a lot. That sounds obvious, but many systems almost do this and then miss by one join key. I did that once, and it was a bit embarrasing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Q&amp;amp;A
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should the user id be enough?
&lt;/h3&gt;

&lt;p&gt;Usually no. A single user can trigger several auth emails in a short window, so you need event-level identity, not just actor-level identity.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need a new table for this?
&lt;/h3&gt;

&lt;p&gt;Not always, but a dedicated receipt table keeps the timeline cleaner than packing every state change onto the user record.&lt;/p&gt;

&lt;h3&gt;
  
  
  What if the provider already gives me a message id?
&lt;/h3&gt;

&lt;p&gt;Keep it, but do not depend on it as your primary join key. You need an id created inside your system before the provider call happens.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>authentication</category>
      <category>postgres</category>
      <category>restapi</category>
    </item>
    <item>
      <title>OTP Cooldowns With Postgres Partial Indexes</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Sat, 08 Aug 2026 14:24:40 +0000</pubDate>
      <link>https://dev.to/kevindev27/otp-cooldowns-with-postgres-partial-indexes-4jn0</link>
      <guid>https://dev.to/kevindev27/otp-cooldowns-with-postgres-partial-indexes-4jn0</guid>
      <description>&lt;p&gt;I keep seeing the same bug in verification systems: the API says "resend OTP" as if it were a harmless helper, but under load it becomes a small abuse surface, a support headache, and sometimes a delivery cost leak too. The fix is usually not a fancier controller. It is a better data model.&lt;/p&gt;

&lt;p&gt;When I am working on Authentication flows, I want three properties at the same time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a user can request a fresh code without waiting forever&lt;/li&gt;
&lt;li&gt;the service can enforce cooldown rules consistently across instances&lt;/li&gt;
&lt;li&gt;support and ops can explain exactly why a request was accepted or rejected&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is where PostgreSQL partial indexes and an attempt ledger fit really well. It is not the only way, but it stays simple enough to maintain six months later, which matters more than people admit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why resend cooldowns get messy fast
&lt;/h2&gt;

&lt;p&gt;Most teams start with a timestamp on the user row, then add an &lt;code&gt;if now - last_sent_at &amp;lt; 60 seconds&lt;/code&gt; check in the API. That works for a week. Then somebody adds retries, background workers, passwordless login, or region failover and the rule starts drifting between code paths.&lt;/p&gt;

&lt;p&gt;The bigger issue is that cooldown logic is usually attached to the request handler, not to the delivery event itself. If the first transaction commits and the mail job is retried, you can get weird states: cooldown set but no email sent, or email sent twice with one accepted request. It sounds edge-casey, but it shows up pretty often in real systems.&lt;/p&gt;

&lt;p&gt;I also try to separate user abuse from product behavior. Someone testing signup with &lt;code&gt;temp mail com&lt;/code&gt; or a disposable inbox is not automatically malicious. But your API still needs boundaries. The rule should protect the system without assuming every unusual inbox is bad.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model cooldowns as data, not controller logic
&lt;/h2&gt;

&lt;p&gt;The pattern I reuse is straightforward:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Store every OTP delivery attempt in a dedicated table.&lt;/li&gt;
&lt;li&gt;Mark whether the attempt is still inside the active cooldown window.&lt;/li&gt;
&lt;li&gt;Let Postgres enforce "only one active cooldown per recipient and purpose".&lt;/li&gt;
&lt;li&gt;Expire the active flag when the window passes or the code is consumed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That gives you an append-only history and a single source of truth. The API becomes thinner, and background workers can reason about the same state as the web nodes.&lt;/p&gt;

&lt;p&gt;Here is the core table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;otp_delivery_attempts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;recipient_email&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;purpose&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;otp_hash&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;cooldown_until&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;active_cooldown&lt;/span&gt; &lt;span class="nb"&gt;boolean&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;delivery_status&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;consumed_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;unique&lt;/span&gt; &lt;span class="k"&gt;index&lt;/span&gt; &lt;span class="n"&gt;otp_active_cooldown_idx&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;otp_delivery_attempts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recipient_email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;purpose&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;active_cooldown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That partial unique index is the useful bit. At any moment, one email address and one purpose can have only one active cooldown row. Every app instance gets the same answer because the database is doing the hard part.&lt;/p&gt;

&lt;p&gt;Then the create flow becomes more boring, in a good way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="n"&gt;otp_delivery_attempts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;recipient_email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;purpose&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;otp_hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;cooldown_until&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;values&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'60 seconds'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the insert succeeds, you enqueue delivery. If it conflicts, you return the remaining wait time. This is one of those places where a boring constraint beats a clever mutex.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Postgres pattern I keep reusing
&lt;/h2&gt;

&lt;p&gt;There are two implementation details that matter more than the table name.&lt;/p&gt;

&lt;p&gt;First, I do not delete old rows. Historical rows are useful for support review, abuse heuristics, and debugging vendor incidents. If email latency spikes, you want to know whether the problem is request volume, worker lag, or upstream delivery.&lt;/p&gt;

&lt;p&gt;Second, I clear &lt;code&gt;active_cooldown&lt;/code&gt; explicitly when the code is consumed or when a sweeper job sees &lt;code&gt;cooldown_until &amp;lt; now()&lt;/code&gt;. That may feel redundant because the timestamp already exists, but keeping an indexed boolean makes the uniqueness rule cheap and predictable.&lt;/p&gt;

&lt;p&gt;For the API layer, I like returning a response shape like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"accepted"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"retry_after_seconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;23&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cooldown_active"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important thing is consistency. Your mobile app, SPA, and CLI should all see the same retry semantics. When teams skip that, the frontend invents its own timers and things get janky real fast.&lt;/p&gt;

&lt;p&gt;If your delivery workers share infrastructure with other notification jobs, keep an eye on queue contention too. This write-up on &lt;a href="https://dev.to/jasonmills94/eks-on-call-emails-need-queue-isolation-km5"&gt;queue isolation for email work&lt;/a&gt; is about a different domain, but the operational lesson carries over almost directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing the flow without burning real inboxes
&lt;/h2&gt;

&lt;p&gt;Verification systems are awkward to test because the happy path crosses API, database, and inbox state. For local and CI checks, I usually want one inbox per scenario so I can assert:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;first send succeeds&lt;/li&gt;
&lt;li&gt;immediate resend is blocked&lt;/li&gt;
&lt;li&gt;resend after cooldown succeeds&lt;/li&gt;
&lt;li&gt;consuming the code clears the active lock&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is also where &lt;a href="https://dev.to/sophiax99/passwordless-otp-needs-inbox-boundaries-1079"&gt;passwordless OTP inbox boundaries&lt;/a&gt; are worth reading. If test cases share inboxes, it gets hard to tell whether a failed assertion came from your auth logic or from polluted test state.&lt;/p&gt;

&lt;p&gt;For manual verification, a &lt;code&gt;generate throwaway email&lt;/code&gt; flow can be handy because it keeps personal inboxes out of low-value tests. When I only need to inspect formatting or timing, a service like &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;temporary disposable mail&lt;/a&gt; is enough to validate the end-to-end path without wiring a full fixture harness. That should support testing, not replace proper integration coverage, but it saves time.&lt;/p&gt;

&lt;p&gt;I also sometimes drop odd strings like &lt;code&gt;temp gamil com&lt;/code&gt; or &lt;code&gt;dummy e mail&lt;/code&gt; into test notes and seeded cases. Not as anchors, just as ugly input. It catches sanitization bugs and brittle analytics rules more often than it should, honestly.&lt;/p&gt;

&lt;p&gt;One more thing: if your provider can delay or duplicate callbacks, store provider message IDs on the attempt row. Otherwise your system may look correct from the API side while the delivery ledger is lying a little bit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Q&amp;amp;A
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why not store cooldown on the user record?
&lt;/h3&gt;

&lt;p&gt;Because resend behavior belongs to an attempt stream, not to the identity row. A single user may trigger signup verification, email change verification, and passwordless login in the same hour. Those are different purposes with different rules.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need Redis for this?
&lt;/h3&gt;

&lt;p&gt;Not always. If PostgreSQL already owns the auth state, using a partial index for this rule is perfectly reasonable. Redis can still help for high-volume counters, but I would not add another moving part before proving the database is the bottleneck.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the most common mistake?
&lt;/h3&gt;

&lt;p&gt;Treating resend as a UI concern. It is a backend contract first. The button text matters, sure, but the durable rule has to live where concurrent requests cannot dodge it.&lt;/p&gt;

&lt;p&gt;The nice part of this pattern is that it scales down as well as up. A small service can ship it in one migration, and a larger system can extend it with outbox records, provider receipts, and risk scoring later. It is not flashy, maybe a little plain even, but plain systems are often the ones that keep working.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>postgres</category>
      <category>authentication</category>
      <category>restapi</category>
    </item>
    <item>
      <title>Append-Only OTP Logs for Safer APIs</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Tue, 04 Aug 2026 05:24:10 +0000</pubDate>
      <link>https://dev.to/kevindev27/append-only-otp-logs-for-safer-apis-e2m</link>
      <guid>https://dev.to/kevindev27/append-only-otp-logs-for-safer-apis-e2m</guid>
      <description>&lt;p&gt;If your OTP endpoint rewrites the same verification row over and over, you usually lose the one thing that matters during an incident: sequence. I prefer append-only logs for verification attempts because they keep retries, provider responses, and lock decisions visible without turning the API into a mess. The pattern is simple, scales well enough for most product teams, and makes auth bugs much less annoying to debug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why append-only logs help OTP APIs
&lt;/h2&gt;

&lt;p&gt;Many teams start with a &lt;code&gt;verification_codes&lt;/code&gt; table that stores one active code per user. That works, until support asks why a customer got two emails, or why a valid code was rejected after a resend. At that point, "current state only" stops being enough.&lt;/p&gt;

&lt;p&gt;An append-only design keeps each meaningful action as a row: code requested, email queued, provider accepted, code consumed, code expired. The payoff is boring but very real:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;retries are explainable&lt;/li&gt;
&lt;li&gt;rate limits are easier to audit&lt;/li&gt;
&lt;li&gt;support can inspect one timeline instead of guessing&lt;/li&gt;
&lt;li&gt;cleanup jobs stay simple becuase history and current state are separated&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also like this model because it plays nicely with &lt;code&gt;REST API&lt;/code&gt; handlers that need idempotency. A request can fail halfway through and still leave a reliable trail for the next attempt.&lt;/p&gt;

&lt;h2&gt;
  
  
  The schema I prefer in PostgreSQL
&lt;/h2&gt;

&lt;p&gt;I usually split "attempt log" from "latest pointer". The log is append-only. A second table, or a materialized view in some stacks, tells the app which OTP attempt is current.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;otp_attempt_log&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;delivery_channel&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;purpose&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;idempotency_key&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;code_hash&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;provider_message_id&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;consumed_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;unique&lt;/span&gt; &lt;span class="k"&gt;index&lt;/span&gt; &lt;span class="n"&gt;otp_attempt_log_once&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;otp_attempt_log&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;purpose&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;create&lt;/span&gt; &lt;span class="k"&gt;index&lt;/span&gt; &lt;span class="n"&gt;otp_attempt_log_lookup&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;otp_attempt_log&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;purpose&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives me three useful properties.&lt;/p&gt;

&lt;p&gt;First, duplicate submissions with the same idempotency key become a read problem, not a branching problem. Second, older attempts remain queryable for audits. Third, expiration sweeps are cheep to implement because they only update records that crossed a boundary.&lt;/p&gt;

&lt;p&gt;For the "latest active OTP" lookup, I often project the newest non-consumed row per &lt;code&gt;(user_id, purpose)&lt;/code&gt; into a small query or cached view. That keeps write logic linear and lets &lt;code&gt;PostgreSQL&lt;/code&gt; do the sorting work it is already good at.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idempotency and retry flow
&lt;/h2&gt;

&lt;p&gt;The subtle part is deciding when a retry should reuse an old row and when it should append a new one. My rule is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;same idempotency key: return the existing attempt&lt;/li&gt;
&lt;li&gt;different key, same user and purpose, still active: expire the old attempt and append a new one&lt;/li&gt;
&lt;li&gt;provider accepted but client timed out: re-read by key before sending again&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That flow avoids duplicate sends in the common timeout case. It also means your API can answer with a stable receipt object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"attempt_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;91842&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"queued"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expires_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-08-04T05:35:00Z"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I try hard not to hide this behind magical service layers. A small transaction with a clear lock order is easier to maintain. In Node.js services, a &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; around the latest pointer plus an insert into the log is often enough. If throughput gets weird later, you can move queueing to an outbox table without rethinking the data model.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I test the pipeline without leaking inboxes
&lt;/h2&gt;

&lt;p&gt;This is where teams often bolt on hacks. Somebody uses a real mailbox in staging, somebody else uses a shared inbox, and pretty soon no one trusts the signal. For verification tests I prefer ephemeral inboxes, or a &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;free throwaway email&lt;/a&gt;, only around the edge where the app proves delivery. The core API logic should still be validated with fixtures and provider mocks.&lt;/p&gt;

&lt;p&gt;A clean test split looks like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;unit tests verify status transitions and expiry rules&lt;/li&gt;
&lt;li&gt;integration tests verify transaction boundaries and indexes&lt;/li&gt;
&lt;li&gt;thin end-to-end checks confirm the email actually arrives&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last layer benefits from stable inbox isolation, especially when debugging &lt;a href="https://dev.to/silviutech/debugging-cypress-email-tests-that-fail-only-in-ci-37d"&gt;email test failures in CI&lt;/a&gt; or &lt;a href="https://dev.to/ryanlee91/react-invite-emails-without-state-drift-14ho"&gt;keeping invite email state consistent&lt;/a&gt;. I also keep typo-heavy user input like &lt;code&gt;temp org mail&lt;/code&gt; in test fixtures, because production systems do see that kind of string and parser edges are rarely pretty.&lt;/p&gt;

&lt;p&gt;One more thing: if you store provider callbacks, keep them separate from your OTP decision row. Provider payloads are useful evidence, but mixing them directly into the active state model makes queries slower and code a bit gross.&lt;/p&gt;

&lt;h2&gt;
  
  
  Q&amp;amp;A
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Do append-only logs cost too much storage?
&lt;/h2&gt;

&lt;p&gt;Usually no. OTP traffic is small compared with analytics, app logs, or message history. Add a retention policy if needed, but do not throw away the timeline too early. It saves you hours later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should every resend expire the old code immediately?
&lt;/h2&gt;

&lt;p&gt;Most of the time, yes. Two valid OTPs for the same purpose creates support pain fast. There are edge cases, but keeping one active attempt per purpose is a very sane default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is this pattern only for email OTP?
&lt;/h2&gt;

&lt;p&gt;Nope. SMS, magic links, and admin approval tokens all benefit from the same structure. The mechanics differ a bit, the operational story stays almost the same.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>postgres</category>
      <category>authentication</category>
    </item>
    <item>
      <title>Use Outbox Leases for OTP Email Workers</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Mon, 03 Aug 2026 20:24:12 +0000</pubDate>
      <link>https://dev.to/kevindev27/use-outbox-leases-for-otp-email-workers-17gg</link>
      <guid>https://dev.to/kevindev27/use-outbox-leases-for-otp-email-workers-17gg</guid>
      <description>&lt;p&gt;OTP email pipelines often look fine until retries land from two directions at once: the API retries a request, and the worker retries an unfinished send. That is when one login challenge can fan out into two or three messages, each with slightly different timing and logs. The auth flow still kind of works, but the system gets harder to trust.&lt;/p&gt;

&lt;p&gt;The pattern I keep coming back to is simple: store a receipt for the logical send, then let workers claim the outbox row through a short lease in PostgreSQL. That keeps the REST API deterministic, gives workers a safe retry boundary, and makes incident review much less messy. It also pairs nicely with related lessons from &lt;a href="https://dev.to/sophiax99/bind-email-change-links-to-the-active-session-50oa"&gt;session-bound email change flows&lt;/a&gt; and from broader &lt;a href="https://dev.to/mrdapperx/keep-publish-retries-immutable-2pcd"&gt;immutable retry handling&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why OTP email workers duplicate messages
&lt;/h2&gt;

&lt;p&gt;The duplicate-send bug usually comes from a race between request identity and worker identity:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;POST /otp/email&lt;/code&gt; creates a challenge and writes an outbox event.&lt;/li&gt;
&lt;li&gt;The client never sees the response, so it retries.&lt;/li&gt;
&lt;li&gt;A worker picks the first event but crashes after calling the mail provider.&lt;/li&gt;
&lt;li&gt;Another worker picks the same event again because nothing recorded ownership clearly.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;At that point you have two questions that matter more than the status code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Did the API create one logical send or two?&lt;/li&gt;
&lt;li&gt;Did one worker own the send attempt, or did multiple workers race it?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you cannot answer both from database state, debugging turns into log archaeology. That is usuallly where teams start adding ad hoc cooldowns instead of fixing the contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  The receipt and lease model in PostgreSQL
&lt;/h2&gt;

&lt;p&gt;I split the problem into two durable records:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A receipt row proves the API accepted one logical send for one user and one idempotency key.&lt;/li&gt;
&lt;li&gt;A lease on the outbox row proves which worker may send it right now.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The outbox table can look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;otp_email_outbox&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;challenge_id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;lease_token&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;lease_expires_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;sent_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important bit is that the worker does not "just read pending rows". It atomically claims one row by setting &lt;code&gt;lease_token&lt;/code&gt; and &lt;code&gt;lease_expires_at&lt;/code&gt;. If the process dies, the lease expires and another worker can safely retry. If it lives, other workers leave that row alone.&lt;/p&gt;

&lt;p&gt;For OTP flows, a short lease is enough. Thirty to sixty seconds usually covers provider latency without making recovery feel slow. I would rather make that window explicit than hope the queue semantics save me.&lt;/p&gt;

&lt;h2&gt;
  
  
  A deterministic claim query for workers
&lt;/h2&gt;

&lt;p&gt;The claim step should be one statement, not a read-then-write dance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;next_job&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;otp_email_outbox&lt;/span&gt;
  &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;sent_at&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
    &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lease_expires_at&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;lease_expires_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;
  &lt;span class="k"&gt;limit&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="n"&gt;skip&lt;/span&gt; &lt;span class="n"&gt;locked&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="n"&gt;otp_email_outbox&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;lease_token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;lease_expires_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="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'45 seconds'&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;next_job&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;next_job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="n"&gt;returning&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;challenge_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lease_token&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the whole trick. &lt;code&gt;for update skip locked&lt;/code&gt; keeps workers from piling onto the same row, and the lease expiry gives you a clean recovery path after crashes. PostgreSQL is doing exactly the job it is good at: one committed truth with boring concurrency rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  Node.js API and worker example
&lt;/h2&gt;

&lt;p&gt;My API side still uses a receipt so repeated requests stay stable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;oneOrNone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`select challenge_id, outbox_event_id
       from otp_send_receipts
      where user_id = $1 and idempotency_key = $2`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&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="na"&gt;reused&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;challengeId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createOtpChallenge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;outboxEventId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;insertOtpOutbox&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;challengeId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;insertOtpReceipt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;challengeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;outboxEventId&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="na"&gt;reused&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;challengeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;outboxEventId&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the worker sends only if it still owns the lease:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;claimOtpEmailJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;job&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;mailer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendOtp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;challenge_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;none&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`update otp_email_outbox
      set sent_at = now()
    where id = $1 and lease_token = $2`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lease_token&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last predicate matters more than it looks. If the lease expired and another worker reclaimed the row, the old worker cannot mark it sent by accident. Small detail, big difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where temporary inboxes still fit
&lt;/h2&gt;

&lt;p&gt;I still use a temp mailbox in automated tests, but only as delivery evidence after the backend contract is sound. It is handy for isolating recipients, especially when a shared QA inbox gets noisy or when someone still has old notes mentioning tamp mail com in setup docs. But the inbox should confirm one accepted send, not decide whether the send was unique.&lt;/p&gt;

&lt;p&gt;That distinction saves time in test failures. If the receipt and lease data say one logical send happened, I debug provider delivery or rendering. If the database says two sends were possible, I stay in backend land and fix the contract first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Q&amp;amp;A
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why not rely only on the queue system?
&lt;/h3&gt;

&lt;p&gt;Because most queues help with delivery, not with application-level identity. The backend still needs a durable answer for "was this logical OTP request already accepted?"&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I clear the lease after sending?
&lt;/h3&gt;

&lt;p&gt;You can, but I usually just set &lt;code&gt;sent_at&lt;/code&gt; and keep the lease metadata for debugging. It costs little and helps a lot when you are tracing a weird retry path later.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does this work only for OTP emails?
&lt;/h3&gt;

&lt;p&gt;No. Password reset, verification, invite, and receipt-like email flows all benefit from the same shape. OTP just makes the race more obvious because users feel duplicate messages imediately.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>postgres</category>
      <category>authentication</category>
    </item>
    <item>
      <title>Receipt Tables for Resend Email APIs</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Mon, 03 Aug 2026 14:24:23 +0000</pubDate>
      <link>https://dev.to/kevindev27/receipt-tables-for-resend-email-apis-1k47</link>
      <guid>https://dev.to/kevindev27/receipt-tables-for-resend-email-apis-1k47</guid>
      <description>&lt;p&gt;Resend verification endpoints look simple right up until traffic, retries, and background workers all get a vote. One client taps twice, another request times out, a worker wakes up late, and now your team is debating which email was the "real" one. I keep seeing this in auth systems that behave fine in happy-path demos but get fuzzy once real networks show up.&lt;/p&gt;

&lt;p&gt;The pattern I trust most is a small PostgreSQL receipt table attached to the resend decision itself. It gives the REST API one durable answer for each logical resend attempt, keeps cooldown rules consistent, and makes support debugging way less dramatic. It also pairs nicely with frontend flows that already try to keep &lt;a href="https://dev.to/ryanlee91/ship-react-onboarding-without-email-drift-4acg"&gt;aligned onboarding email state&lt;/a&gt;, because the backend stops inventing new outcomes on every retry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why resend endpoints drift so easily
&lt;/h2&gt;

&lt;p&gt;Most resend APIs start with good intentions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check whether the account is already verified.&lt;/li&gt;
&lt;li&gt;Enforce a cooldown like 60 seconds.&lt;/li&gt;
&lt;li&gt;Generate or reuse a token.&lt;/li&gt;
&lt;li&gt;Push a send event to a queue.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The bug appears when those steps are spread across separate reads and writes. Two app nodes can both decide the cooldown expired. A late retry can reuse stale state. A worker can send a message tied to an older token version. None of this is exotic, it is just normal distributed-system mess sneaking into an endpoint that looked "tiny".&lt;/p&gt;

&lt;p&gt;What makes it extra annoying is that inbox-based testing can hide the problem. If one message eventually arrives, the test may pass even though the resend contract is inconsistent. That is why I like keeping the contract in the database first and treating delivery as downstream evidence second.&lt;/p&gt;

&lt;h2&gt;
  
  
  The receipt table I keep in PostgreSQL
&lt;/h2&gt;

&lt;p&gt;For resend flows, I want one row that answers three questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;which user asked for the resend&lt;/li&gt;
&lt;li&gt;which cooldown window decided the outcome&lt;/li&gt;
&lt;li&gt;which outbox event, if any, represents the accepted send&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My table usually looks something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;verification_resend_receipts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;request_key&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;cooldown_bucket&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;token_version&lt;/span&gt; &lt;span class="nb"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;outbox_event_id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sent'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'suppressed'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&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;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important bit is not the exact column list. The important bit is that the resend decision becomes a first-class record instead of an implied side effect buried in logs. If a request was suppressed by cooldown, I store that too. Engineers often only record successful sends, then later wonder why the API returned 202 without any matching delivery record. That gap gets ugly fast.&lt;/p&gt;

&lt;p&gt;I also prefer a request key generated by the client for retriable UI actions. It can be as simple as one UUID per button press. Repeated network attempts then land on the same receipt row, which is honestly the boring outcome you want.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep cooldown decisions in the same transaction
&lt;/h2&gt;

&lt;p&gt;The mistake I see most often is reading the last send time outside the transaction that writes the new event. That opens the door for two concurrent requests to both decide "cooldown passed" and each enqueue mail. PostgreSQL can save you from that, but only if you let it.&lt;/p&gt;

&lt;p&gt;My usual transaction does this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Lock or otherwise serialize the user verification state.&lt;/li&gt;
&lt;li&gt;Look up an existing receipt for the same &lt;code&gt;request_key&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Compute whether the current cooldown bucket allows a send.&lt;/li&gt;
&lt;li&gt;If allowed, create one token version and one outbox event.&lt;/li&gt;
&lt;li&gt;Insert exactly one receipt row that records the final decision.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That means the API can return one stable payload for retries: same decision, same cooldown information, same event reference. It feels a bit stricter up front, but it makes later ops work much calmer. If support asks why the user got no second email, you can point at a suppressed receipt instead of squinting at scattered app logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Node.js handler shape that stays understandable
&lt;/h2&gt;

&lt;p&gt;This is the compact shape I like in Node.js:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/verification-email/resend&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;requestKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-Request-Key&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;requestKey&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="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-Request-Key is required&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;oneOrNone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`select decision, token_version, outbox_event_id, cooldown_bucket
         from verification_resend_receipts
        where user_id = $1 and request_key = $2`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requestKey&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&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="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;loadAndLockVerificationState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;canResendNow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;suppressed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;tokenVersion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;outboxEventId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;tokenVersion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;rotateVerificationToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;outboxEventId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;insertVerificationOutbox&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tokenVersion&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="nf"&gt;saveReceipt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;requestKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;cooldownBucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;nextCooldownBucket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;tokenVersion&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;outboxEventId&lt;/span&gt;&lt;span class="p"&gt;,&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="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;202&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are fancier versions, sure, but this one is easy to reason about during an incident. The database tells you whether the resend was accepted or suppressed. The queue only sends events tied to accepted receipts. Even a weird report involving fake e mail com test accounts or old tamp mail com notes from QA becomes easier to untangle, because the record of truth is not the inbox anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I test this without trusting the inbox too much
&lt;/h2&gt;

&lt;p&gt;I still use a throwaway email in staging, but only to prove the final rendered message lines up with the accepted receipt. The inbox should confirm output, not define backend truth. For the cheaper end of the pyramid, I like receipt assertions plus outbox assertions in integration tests, then a small number of &lt;a href="https://dev.to/pong1965/inbox-budgets-for-api-smoke-tests-3fjb"&gt;cheap API inbox smoke tests&lt;/a&gt; to make sure delivery still works end to end.&lt;/p&gt;

&lt;p&gt;That split has saved me a lot of time, especialy when retries stack up during mobile testing. If a smoke test fails, I can ask:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;was the receipt created?&lt;/li&gt;
&lt;li&gt;was the decision &lt;code&gt;sent&lt;/code&gt; or &lt;code&gt;suppressed&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;did one outbox event exist for that receipt?&lt;/li&gt;
&lt;li&gt;did the inbox content match the accepted token version?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are crisp questions. "Did an email show up eventually?" is not. And once a team starts debugging from inbox timing alone, the conversation gets messy real quick.&lt;/p&gt;

&lt;h2&gt;
  
  
  Q&amp;amp;A
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should suppressed resends create a receipt too?
&lt;/h3&gt;

&lt;p&gt;Yes. A suppression is still a decision made by the API. If you do not persist it, retries and support tooling get much harder to explain.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need a new token on every resend?
&lt;/h3&gt;

&lt;p&gt;Not always. Some systems reuse the active token inside a short window. Others rotate every accepted resend. Either can work, but the receipt should make the choice explicit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is this overkill for a small app?
&lt;/h3&gt;

&lt;p&gt;Not really. The schema is tiny, and the payoff comes the first time duplicate email behavior shows up under retry pressure. Small tables that remove ambiguity are rarely wasted work, even if it feels a little nerdy at first.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>postgres</category>
      <category>authentication</category>
    </item>
    <item>
      <title>Replay-Safe Verification Email APIs</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Sat, 01 Aug 2026 17:24:02 +0000</pubDate>
      <link>https://dev.to/kevindev27/replay-safe-verification-email-apis-394m</link>
      <guid>https://dev.to/kevindev27/replay-safe-verification-email-apis-394m</guid>
      <description>&lt;p&gt;Verification email endpoints look simple on paper, but they get messy fast once retries, duplicate clicks, queue lag, and support tooling all meet in the same flow. In one REST API I maintained, the bug was not token generation itself. The bug was that every retry path behaved a little differntly, so logs stopped matching what users actually saw. That kind of drift is usualy what turns a harmless auth feature into an on-call annoyance.&lt;/p&gt;

&lt;p&gt;What helped most was treating the flow as a state machine instead of "send email if user is not verified." It sounds obvious, but the explicit model forced us to seperate token issuance, delivery attempts, and verification completion. Once those boundaries were clear, the API became easier to reason about and a lot easier to test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why verification email retries get messy fast
&lt;/h2&gt;

&lt;p&gt;Three things tend to collapse into one endpoint:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;issue or reuse a valid verification token&lt;/li&gt;
&lt;li&gt;send a message through an async delivery system&lt;/li&gt;
&lt;li&gt;report a user-facing status that makes sense after a replay&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the handler mutates all three concerns at once, retries become squishy. A second call may create a new token, enqueue a duplicate message, or return a 200 that hides the fact that delivery was skipped becuase the account is already verified. None of those are dramatic alone, but together they create teh kind of auth behavior that feels random from the outside.&lt;/p&gt;

&lt;p&gt;I like to document the states first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;pending_verification&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;verified&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;delivery_scheduled&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;delivery_failed&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;expired_token&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That list does not need to be perfect on day one. It just needs enough aligment that product, backend, and support can point at the same transitions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model the workflow as explicit states
&lt;/h2&gt;

&lt;p&gt;The useful split for me is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;token lifecycle&lt;/li&gt;
&lt;li&gt;delivery lifecycle&lt;/li&gt;
&lt;li&gt;account verification lifecycle&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The token is a security object. The delivery attempt is an operational object. The account state is a business object. When those are stored separately, the replay rules get boring in a good way.&lt;/p&gt;

&lt;p&gt;Here is the shape I reach for in Node.js or any backend stack with a queue:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;POST&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;verification&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;emails&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;verified&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;204&lt;/span&gt;

&lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;findReusableToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nf"&gt;createToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createDeliveryAttempt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;202&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter more than the syntax.&lt;/p&gt;

&lt;p&gt;First, the idempotency key belongs to the delivery attempt, not the token. That lets me safely replay the request without inflating email volume. Second, the token can be reused within a short validity window, which avoids issuing a fresh secret every time a mobile client retries after a flaky network hop.&lt;/p&gt;

&lt;p&gt;This is also where I keep search-driven language contained. If a content note or support doc needs to mention odd query terms like &lt;code&gt;temp mail mail&lt;/code&gt;, I keep them outside API names and state labels. Production semantics should stay boring and crisp.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep delivery attempts separate from token issuance
&lt;/h2&gt;

&lt;p&gt;This split solves more problems than people expect.&lt;/p&gt;

&lt;p&gt;When token creation and delivery are bundled into one write, you lose the ability to answer simple questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Did we create a valid token?&lt;/li&gt;
&lt;li&gt;Did we schedule delivery?&lt;/li&gt;
&lt;li&gt;Did the provider accept the message?&lt;/li&gt;
&lt;li&gt;Was the inbox ever checked during test runs?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I prefer a small table for verification tokens and another for delivery attempts. That gives you cleaner retention rules, cleaner metrics, and probly the most underrated win: support can inspect failures without touching secret material.&lt;/p&gt;

&lt;p&gt;For test environments, I often pair this with a disposable inbox strategy. A lightweight service like &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;tempmailso&lt;/a&gt; is useful when engineers need to verify delivery behavior without routing mail into personal accounts. The operational point is not "temporary inboxes are cool." The point is that lower-friction inboxes make replay testing cheaper, which means teams actualy run the tests.&lt;/p&gt;

&lt;p&gt;If you need broader guidance around safe scratch inbox handling, this &lt;a href="https://dev.to/bitheirstake/privacy-checklist-for-disposable-email-workflows-3amg"&gt;disposable inbox privacy checklist&lt;/a&gt; covers the policy side well.&lt;/p&gt;

&lt;h2&gt;
  
  
  How inbox testing fits without polluting production logic
&lt;/h2&gt;

&lt;p&gt;One mistake I still see is embedding test-only inbox logic into the main Authentication service. That ages badly. The API should publish facts about attempts and outcomes; test harnesses should consume those facts from the edge.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the API returns &lt;code&gt;202 Accepted&lt;/code&gt; with an attempt id&lt;/li&gt;
&lt;li&gt;the worker records provider acceptance or rejection&lt;/li&gt;
&lt;li&gt;the test harness polls a scratch inbox separately&lt;/li&gt;
&lt;li&gt;assertions join on attempt id or correlation id&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That pattern keeps production code clean while still making room for ugly real-world test inputs like &lt;code&gt;temp mailid&lt;/code&gt; or &lt;code&gt;temp org mail&lt;/code&gt;, which show up in internal notes more often than we'd like. For browser suites, &lt;a href="https://dev.to/silviutech/parallel-playwright-email-tests-without-cross-talk-5aap"&gt;parallel inbox isolation&lt;/a&gt; is worth reading because concurrency bugs are where these flows start lying to you.&lt;/p&gt;

&lt;p&gt;I also keep one explicit escape hatch in tooling docs for teams comparing inbox providers. If they are evaluating a &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;fake email generator&lt;/a&gt; during QA setup, I want that choice documented in test infrastructure, not leaked into the REST API contract itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Q&amp;amp;A
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should a resend endpoint create a new token every time?
&lt;/h3&gt;

&lt;p&gt;Not by default. Reuse within a short window is simpler to reason about and reduces duplicate messages. Rotate only when policy or risk signals say you should.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why return &lt;code&gt;202&lt;/code&gt; instead of &lt;code&gt;200&lt;/code&gt;?
&lt;/h3&gt;

&lt;p&gt;Because delivery is asynchronous. &lt;code&gt;202 Accepted&lt;/code&gt; communicates intent honestly, and it leaves room for the worker to succeed or fail later.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the smallest change that improves reliability?
&lt;/h3&gt;

&lt;p&gt;Persist delivery attempts separately from tokens. It gives you better observability almost imediately, even before you refine the full state model.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>authentication</category>
      <category>node</category>
    </item>
    <item>
      <title>Idempotency Keys for Verification Email APIs</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Fri, 31 Jul 2026 14:24:45 +0000</pubDate>
      <link>https://dev.to/kevindev27/idempotency-keys-for-verification-email-apis-1o5i</link>
      <guid>https://dev.to/kevindev27/idempotency-keys-for-verification-email-apis-1o5i</guid>
      <description>&lt;p&gt;Verification email bugs are often retry bugs wearing a different hat. The API times out, the client retries, the worker sees two jobs, and suddenly one user gets multiple links with slightly different token state. The app may still "work", but the delivery contract is fuzzy and that usually comes back later in support or incident review.&lt;/p&gt;

&lt;p&gt;The pattern I trust most is simple: treat email dispatch as an idempotent backend side effect with a receipt in PostgreSQL. That gives the REST API a stable answer on retries, keeps token state aligned with delivery intent, and makes debugging much less painful. I still borrow a few ideas from broader &lt;a href="https://dev.to/jasonmills94/docker-checks-for-aws-config-drift-emails-156e"&gt;delivery drift checks&lt;/a&gt; and from auth-focused &lt;a href="https://dev.to/sophiax99/magic-link-emails-need-redirect-guardrails-3lfk"&gt;magic link guardrails&lt;/a&gt;, but the main win is keeping request identity and email identity tied together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why verification email retries create duplicate state
&lt;/h2&gt;

&lt;p&gt;This failure mode shows up in a lot of stacks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;POST /verification-email&lt;/code&gt; creates or rotates a token.&lt;/li&gt;
&lt;li&gt;The API publishes a send job.&lt;/li&gt;
&lt;li&gt;The client retries because it never saw the first 202 response.&lt;/li&gt;
&lt;li&gt;The second request rotates the token again or enqueues a second send.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Now you have ambiguity. Which link is current? Which email should the test click? Which request should your logs point to? If the system does not answer those questions deterministicaly, the retries are not safe even if the status codes look fine.&lt;/p&gt;

&lt;p&gt;What makes this bug annoying is that local testing can miss it. A single request path looks clean. The trouble starts when mobile networks, frontend retries, or queue lag enter the picture. That is why I like making idempotency an explicit API contract instead of a best-effort worker behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PostgreSQL contract I use for idempotent sends
&lt;/h2&gt;

&lt;p&gt;My baseline is one idempotency key per logical send attempt, stored with a durable receipt row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;verification_send_receipts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;idempotency_key&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;token_version&lt;/span&gt; &lt;span class="nb"&gt;integer&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;outbox_event_id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&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;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&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="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The request transaction does three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Resolve the user and current verification state.&lt;/li&gt;
&lt;li&gt;Reuse an existing receipt if the same idempotency key already exists.&lt;/li&gt;
&lt;li&gt;Otherwise rotate the token once, insert one outbox event, and insert one receipt row.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That third step is the part worth protecting. If the token rotates but the receipt insert is not part of the same transaction, you can still drift. If the outbox write sits outside the transaction, you can still send twice. PostgreSQL is very good at giving you one committed truth, so I try not to get clever around it.&lt;/p&gt;

&lt;p&gt;I also like returning the same response body for a repeated key. A repeated request should feel boring. Same status, same receipt identifier, same cooldown metadata, done. Fancy retry handling is where systems start to get weird, and not in a fun way.&lt;/p&gt;

&lt;h2&gt;
  
  
  A REST API flow that stays deterministic under retries
&lt;/h2&gt;

&lt;p&gt;Here is the behavior I aim for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The client sends &lt;code&gt;Idempotency-Key&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The API validates the caller and rate limits before touching email state.&lt;/li&gt;
&lt;li&gt;Inside one transaction, the API either reuses the receipt or creates one new token version and one new outbox event.&lt;/li&gt;
&lt;li&gt;The worker sends only the event referenced by the committed receipt.&lt;/li&gt;
&lt;li&gt;Observability points back to the receipt row first, then to the mail provider log.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This gives me a stable path for incident review. If a user says they got two messages, I can answer whether the duplicate came from the API layer, the worker, or the provider. Without that receipt row, people end up inferring from logs that were never meant to prove identity.&lt;/p&gt;

&lt;p&gt;There is also a practical testing benefit. In CI, I can assert that one idempotency key yields one receipt and one accepted email for that recipient alias. That is a much stronger signal than "an email arrived eventually". Teams sometimes patch over this with shared inbox filters or notes about tamp mail com in test docs, but those workarounds usually hide the actual contract gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Node.js example with request receipts
&lt;/h2&gt;

&lt;p&gt;This is the compact version of the handler shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/verification-email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;idempotencyKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Idempotency-Key&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;idempotencyKey&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="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Idempotency-Key is required&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;oneOrNone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`select token_version, outbox_event_id
         from verification_send_receipts
        where user_id = $1 and idempotency_key = $2`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existing&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="na"&gt;reused&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tokenVersion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;rotateVerificationToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;outboxEventId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;insertVerificationOutbox&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;tokenVersion&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;none&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`insert into verification_send_receipts
         (user_id, idempotency_key, token_version, outbox_event_id)
       values ($1, $2, $3, $4)`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tokenVersion&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;outboxEventId&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="na"&gt;reused&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tokenVersion&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;outboxEventId&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="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;202&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not the only valid design, but it is easy to reason about. I can replay the same request and know whether the system reused work or created new work. I can inspect PostgreSQL and see the exact token version attached to the send. If a queue consumer retries, I still have a durable reference point.&lt;/p&gt;

&lt;p&gt;One small detail that helps a lot: keep a short retention window for idempotency receipts, but not so short that normal mobile retries fall out of it. Twenty four hours is often enough for verification flows. Less than that can be okay, but you should decide it on purpose, not accidentaley.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where inbox-based checks still help
&lt;/h2&gt;

&lt;p&gt;I still use a temporary inbox in non-production tests, but only as evidence of final delivery. It should not be the system of record for request identity. The record lives in PostgreSQL; the inbox confirms the rendered message matched that record.&lt;/p&gt;

&lt;p&gt;That distinction matters when you are debugging flaky automation. A temp email generator can isolate recipients and reduce noise. Searchers may land here for terms like temp mail so, temp email generator, or even tempail, and fair enough. The useful lesson, though, is that inbox isolation helps only after the API contract is solid. Otherwise you are just watching duplicate sends more cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Q&amp;amp;A
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should the idempotency key belong to the frontend or backend?
&lt;/h3&gt;

&lt;p&gt;Usually the client should generate it for retriable user actions. That preserves identity across network retries. If your backend invents the key after receiving the request, it is already too late for some failure cases.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I still need rate limits?
&lt;/h3&gt;

&lt;p&gt;Yes. Idempotency prevents duplicate work for the same logical request. It does not replace abuse protection, cooldowns, or account-level verification policies.&lt;/p&gt;

&lt;h3&gt;
  
  
  What if I already have an outbox table?
&lt;/h3&gt;

&lt;p&gt;Great, keep it. Add a receipt layer that maps request identity to one outbox event. The outbox proves delivery intent; the receipt proves request deduplication. Those are related, but they are not quite the same thing.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>postgres</category>
      <category>authentication</category>
    </item>
    <item>
      <title>Facebook Signup APIs Need Inbox Isolation</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Wed, 29 Jul 2026 08:24:34 +0000</pubDate>
      <link>https://dev.to/kevindev27/facebook-signup-apis-need-inbox-isolation-1468</link>
      <guid>https://dev.to/kevindev27/facebook-signup-apis-need-inbox-isolation-1468</guid>
      <description>&lt;p&gt;When a product supports email signup plus Facebook-based identity flows, the backend usually looks stable long before the test evidence does. I have seen signup checks pass because an old verification email was still sitting in a shared inbox, while the current API run had actually failed to enqueue anything. The code path looked green. The user path was not.&lt;/p&gt;

&lt;p&gt;For teams testing temp mail for facebook scenarios, I think the safest pattern is to treat the inbox as part of the API contract. A signup request should produce one clearly attributable verification message, tied to one request window, with enough metadata to prove the email belongs to the current run. That sounds strict, but it keeps auth debugging from turning into inbox archaeology.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Facebook signup email tests go flaky
&lt;/h2&gt;

&lt;p&gt;The flakiness is usually not in Facebook itself. It is in the boundary between your &lt;code&gt;POST /signup&lt;/code&gt; flow and the mailbox used to verify the result.&lt;/p&gt;

&lt;p&gt;Common failure modes I keep seeing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one shared inbox stores messages from several test runs&lt;/li&gt;
&lt;li&gt;a retry sends a second email but the test opens the first one&lt;/li&gt;
&lt;li&gt;the verification link is valid, but the token belongs to a previous request&lt;/li&gt;
&lt;li&gt;async workers lag behind the API response, so the check polls too early&lt;/li&gt;
&lt;li&gt;a fake e mail com style address gets copied into fixtures and nobody notices until parsing breaks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is why I liked the idea behind &lt;a href="https://dev.to/sophiax99/safer-oauth-emails-start-with-link-boundaries-3ele"&gt;keeping auth links inside explicit boundaries&lt;/a&gt;. The same mindset applies here: the email needs a boundary marker, not just a subject line that sort of looks right.&lt;/p&gt;

&lt;h2&gt;
  
  
  The backend contract I validate first
&lt;/h2&gt;

&lt;p&gt;Before I care about HTML rendering or click automation, I validate a smaller contract:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the REST API returns a request or signup identifier&lt;/li&gt;
&lt;li&gt;the worker persists the outbound email event with that identifier&lt;/li&gt;
&lt;li&gt;the email body contains a verification link bound to the same user and request window&lt;/li&gt;
&lt;li&gt;the inbox assertion only passes when it sees that exact run marker&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is close to how I think about idempotent auth flows in production. If a verification email cannot be tied back to a specific request, support and incident review get messy realy fast. The message may still arrive, but you cannot prove what caused it.&lt;/p&gt;

&lt;p&gt;If your queue is eventually consistent, store a short receipt row before handoff. I normally want &lt;code&gt;user_id&lt;/code&gt;, &lt;code&gt;signup_id&lt;/code&gt;, &lt;code&gt;provider&lt;/code&gt;, &lt;code&gt;template_version&lt;/code&gt;, and &lt;code&gt;expires_at&lt;/code&gt;. That gives the test something objective to compare against instead of trusting mailbox timing alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I isolate one inbox per signup run
&lt;/h2&gt;

&lt;p&gt;My preferred rule is simple: one test run, one inbox lease, one assertion window.&lt;/p&gt;

&lt;p&gt;That means the test should:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;create a mailbox specifically for the current run&lt;/li&gt;
&lt;li&gt;attach the run ID to the signup payload or internal metadata&lt;/li&gt;
&lt;li&gt;poll only for messages newer than the request timestamp&lt;/li&gt;
&lt;li&gt;reject any email that does not include the expected run marker&lt;/li&gt;
&lt;li&gt;delete or expire the inbox quickly after the check&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the same reason &lt;a href="https://dev.to/jasonmills94/a-low-noise-aws-alarm-email-check-for-cicd-pipelines-1epk"&gt;isolating noisy email checks in one run&lt;/a&gt; works for ops alerts too. Old inbox state creates accidental green builds. Once you remove shared history from the equation, the test gets a lot more honest, even if it feels a bit harsher at first.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small Node.js and REST API example
&lt;/h2&gt;

&lt;p&gt;Here is the smallest version I reach for in Node.js:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;runId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.example.com/signup&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;facebook&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;testRunId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;runId&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;verifyInbox&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;after&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;signup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requestedAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;expectText&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;runId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;expectUserId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;signup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important bit is not the fetch call. It is the shared identifier crossing API, queue, and inbox layers. I do not want a test that merely proves some verification email exists. I want proof that this request generated this message for this user. That extra precision saves time later, especialy when failures only show up under queue pressure.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I review before trusting the result
&lt;/h2&gt;

&lt;p&gt;When a run still feels suspicious, I check these in order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;whether the signup receipt row and email event share the same identifier&lt;/li&gt;
&lt;li&gt;whether the verification token was rotated after a retry&lt;/li&gt;
&lt;li&gt;whether the polling window starts from request time, not test suite start&lt;/li&gt;
&lt;li&gt;whether the worker template includes enough plain-text clues for fast assertions&lt;/li&gt;
&lt;li&gt;whether fixtures accidentally re-use old inbox credentials&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This also keeps SEO-shaped keyword pressure in the right place. Yes, people search for temp mail for facebook, but the useful backend lesson is about attribution, isolation, and request-scoped Authentication evidence. If the article only talks about throwaway inboxes, it misses the harder engineering problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Q&amp;amp;A
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Do I need a fresh inbox for every signup test?
&lt;/h2&gt;

&lt;p&gt;Not for every local unit test. For end-to-end signup checks, though, I think yes. Reused inboxes are one of the easiest ways to get a pass that you did not earn.&lt;/p&gt;

&lt;h2&gt;
  
  
  What breaks first in most systems?
&lt;/h2&gt;

&lt;p&gt;In my experiance, timestamp windows and retry behavior break first. The API is fine, the email gets sent, but the assertion layer is reading the wrong message or matching too loosely.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>authentication</category>
      <category>node</category>
    </item>
    <item>
      <title>Version Email Events in Node APIs</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Mon, 27 Jul 2026 11:24:26 +0000</pubDate>
      <link>https://dev.to/kevindev27/version-email-events-in-node-apis-pg5</link>
      <guid>https://dev.to/kevindev27/version-email-events-in-node-apis-pg5</guid>
      <description>&lt;p&gt;Email bugs are often blamed on templates or providers, but a lot of them start earlier. The API changes a signup payload, the worker still expects yesterday's fields, and the email system keeps running just well enough to hide the mismatch for a while. Then a release lands on Friday and verification emails get weird in a very expensive way.&lt;/p&gt;

&lt;p&gt;In Node.js backends, I have had better results when email jobs carry an explicit event version from day one. It sounds small, maybe even fussy, but it gives PostgreSQL outboxes and consumers a stable contract while your REST API keeps moving. That contract matters more than most teams think.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why email payloads drift before teams notice
&lt;/h2&gt;

&lt;p&gt;Signup flows evolve fast. Product teams add locale, referral source, device hints, or a different verification path. The API deploys first because it owns the request surface. The email worker deploys later because it lives in another repo or another pipeline. Nothing explodes imediately, which is why the problem sneaks through.&lt;/p&gt;

&lt;p&gt;The common failure modes look like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the worker assumes &lt;code&gt;displayName&lt;/code&gt; exists but the API renamed it to &lt;code&gt;profile.name&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;a new template needs &lt;code&gt;locale&lt;/code&gt;, but older queued jobs do not have it&lt;/li&gt;
&lt;li&gt;one service serializes booleans as strings and another treats that as valid enough&lt;/li&gt;
&lt;li&gt;QA proves delivery with a &lt;code&gt;temp org mail&lt;/code&gt; inbox, but the content contract is already drifting&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is where &lt;a href="https://dev.to/pong1965/golden-traces-for-email-api-regressions-50b8"&gt;golden traces around email APIs&lt;/a&gt; are useful. They show the real payload crossing boundaries, not the payload everybody swears they sent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add a version to every email event
&lt;/h2&gt;

&lt;p&gt;The pattern is simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a small event envelope.&lt;/li&gt;
&lt;li&gt;Put an integer &lt;code&gt;version&lt;/code&gt; inside it.&lt;/li&gt;
&lt;li&gt;Keep event-specific data in a &lt;code&gt;payload&lt;/code&gt; object.&lt;/li&gt;
&lt;li&gt;Make consumers branch on version deliberately, not accidental field presence.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I usually store something close to this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"signup_verification_requested"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"occurredAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-07-27T11:20:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"payload"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"userId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"usr_123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"person@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"locale"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"verificationUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://app.example.com/verify?token=abc"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Version &lt;code&gt;1&lt;/code&gt; might have used &lt;code&gt;verificationToken&lt;/code&gt; instead of a full URL. Version &lt;code&gt;2&lt;/code&gt; can support prebuilt links without breaking old jobs still sitting in the outbox. That one choice makes rollouts much less dramatic, and honestly a bit less annoying too.&lt;/p&gt;

&lt;p&gt;What I do not like is "soft versioning" where a worker checks &lt;code&gt;if (payload.locale)&lt;/code&gt; and guesses what shape it got. That grows into a brittle pile fast. Be direct about compatiblity.&lt;/p&gt;

&lt;h2&gt;
  
  
  A PostgreSQL outbox shape that ages well
&lt;/h2&gt;

&lt;p&gt;For PostgreSQL, I prefer one outbox table with immutable event data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;email_outbox&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;event_type&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;event_version&lt;/span&gt; &lt;span class="nb"&gt;integer&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;aggregate_id&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="n"&gt;jsonb&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;processed_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter a lot.&lt;/p&gt;

&lt;p&gt;First, &lt;code&gt;event_version&lt;/code&gt; should be its own column even if the payload also contains a version. That makes queries and dashboards much easier when you need to answer "how many v1 jobs are still alive?" under pressure.&lt;/p&gt;

&lt;p&gt;Second, do not mutate old payloads in place. If you rewrite old rows to look new, you lose evidence and make incident review murkier. PostgreSQL JSONB is flexible enough that you can keep the original contract and still migrate consumers gradually (&lt;a href="https://www.postgresql.org/docs/current/datatype-json.html" rel="noopener noreferrer"&gt;https://www.postgresql.org/docs/current/datatype-json.html&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;On the Node.js side, a narrow dispatcher is usually enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;event_version&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sendSignupEmailV1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sendSignupEmailV2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Unsupported email event version: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;event_version&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is not glamorous code, but it is debuggable code. I trust that far more than clever inference.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I test compatibility before shipping
&lt;/h2&gt;

&lt;p&gt;I want two checks before merging:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;fixture tests for each supported version&lt;/li&gt;
&lt;li&gt;one end-to-end run that proves the newest version renders correctly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For fixtures, keep frozen payloads in the repo and verify the worker still accepts them. For end-to-end runs, I like &lt;a href="https://dev.to/mrdapperx/testing-webhook-emails-without-polluting-real-inboxes-3hjj"&gt;isolated webhook email testing&lt;/a&gt; so delivery evidence stays scoped to one test case. When I need a human-readable inbox for quick review, I may use the &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;best throwaway email&lt;/a&gt; flow as a final smoke check, but only after the contract tests pass. Mailbox checks should support the backend design, not replace it.&lt;/p&gt;

&lt;p&gt;This is also where &lt;code&gt;tempail&lt;/code&gt;-style typo traffic can teach you something. If users copy addresses badly, or your support notes include messy input, the event contract still needs to fail predictably and log the right reason. Sloppy real-world edges are part of the system, even when we wish they were not.&lt;/p&gt;

&lt;p&gt;If you want one metric, track the percentage of sends by event version for a week after rollout. A visible version histogram catches stuck consumers surprisingly early. Stripe has written well about versioned API contracts and gradual change management, and the same engineering lesson carries over here (&lt;a href="https://stripe.com/blog/api-versioning" rel="noopener noreferrer"&gt;https://stripe.com/blog/api-versioning&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Q&amp;amp;A
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should I keep old versions forever?
&lt;/h3&gt;

&lt;p&gt;No. Keep them while queued jobs, retries, or rollback windows still need them. Then remove the branch on purpose, with data showing usage is gone.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is this overkill for a small service?
&lt;/h3&gt;

&lt;p&gt;Not if the service sends account emails. Signup and reset flows are user trust surfaces. A tiny version field is cheap insurance, realy.&lt;/p&gt;

&lt;h3&gt;
  
  
  What if producer and consumer deploy together?
&lt;/h3&gt;

&lt;p&gt;Even then, queues, retries, and rollback timing can separate them. Event versioning protects the time gap between "we shipped" and "every running process agrees."&lt;/p&gt;

</description>
      <category>backend</category>
      <category>node</category>
      <category>postgres</category>
      <category>restapi</category>
    </item>
  </channel>
</rss>
