<?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>Idempotent Email Verification with PostgreSQL</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Mon, 21 Sep 2026 20:23:43 +0000</pubDate>
      <link>https://dev.to/kevindev27/idempotent-email-verification-with-postgresql-3l42</link>
      <guid>https://dev.to/kevindev27/idempotent-email-verification-with-postgresql-3l42</guid>
      <description>&lt;p&gt;Email verification looks like a small authentication feature: create a token, send a message, and accept the token when the user clicks it. The difficult part appears when the request times out, the worker retries, or the user clicks twice. Without an explicit model, one intent can become several tokens, several messages, or an account that is marked verified by an expired attempt.&lt;/p&gt;

&lt;p&gt;The pattern I prefer is to make the verification attempt a first-class record. The REST API accepts a client-visible idempotency key, PostgreSQL enforces the uniqueness rules, and the mail worker receives an immutable event. This makes the flow easier to reason about and much more easier to operate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real failure is duplicate intent
&lt;/h2&gt;

&lt;p&gt;Suppose a frontend calls &lt;code&gt;POST /email-verifications&lt;/code&gt;. The server commits the verification row, then the connection drops before the response reaches the browser. The browser retries. If the endpoint only checks the user ID, it might create a second active token. If it checks nothing, repeated clicks and retries can create a noisy stream of messages.&lt;/p&gt;

&lt;p&gt;There are two different questions here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is this the same user intent being retried?&lt;/li&gt;
&lt;li&gt;Is this a new verification attempt that should invalidate the old one?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An idempotency key answers the first question. A verification-attempt ID answers the second. Keeping both concepts visible makes the contract a bit more clear for API clients and support tooling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model the verification attempt
&lt;/h2&gt;

&lt;p&gt;A minimal table can record the state transition without storing the raw token:&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_verification_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;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="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;users&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;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_digest&lt;/span&gt; &lt;span class="n"&gt;bytea&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="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&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;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'consumed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'expired'&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="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;UNIQUE&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 token is generated with a cryptographically secure random source and only its digest is persisted. A database leak should not immediately turn stored verification records into usable login material. The API can return the attempt ID, but it should never return the token in a JSON response; the token belongs in the verification message.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;status&lt;/code&gt; column also prevents an ambiguous success path. A consumed token cannot be consumed again, and an expired attempt is not quietly revived by a retry. In practise, those explicit states are more valuable than a clever single boolean.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the REST API retry-safe
&lt;/h2&gt;

&lt;p&gt;The create endpoint should define what a repeated key means. A simplified Node.js handler might look like this:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;requestVerification&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;return&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;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;tx&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 id, status, expires_at
         FROM email_verification_attempts
        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="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;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createAttempt&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="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="nf"&gt;none&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`INSERT INTO email_verification_attempts
         (id, user_id, idempotency_key, token_digest, expires_at)
       VALUES ($1, $2, $3, $4, $5)`&lt;/span&gt;&lt;span class="p"&gt;,&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="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;attempt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;digest&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;expiresAt&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="nf"&gt;none&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`INSERT INTO outbox (event_type, aggregate_id, payload)
       VALUES ('email_verification_requested', $1, $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;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="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;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;attemptId&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="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;id&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="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="s1"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;expires_at&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;expiresAt&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The outbox row is committed in the same transaction as the attempt. A worker can safely retry delivery, while a unique event key or delivery log stops accidental duplicate sends. The client receives the same attempt for a repeated idempotency key, even when the first HTTP response was lost.&lt;/p&gt;

&lt;p&gt;For a new user action, generate a new key. Do not silently reuse the previous key forever, because that turns a legitimate “send me a fresh link” action into a stale response. Authentication behavior should be explicit in both API documentation and logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use PostgreSQL to enforce the boundary
&lt;/h2&gt;

&lt;p&gt;Application checks are useful for friendly responses, but they are not concurrency control. Two requests can both observe no row before either inserts one. The unique constraint is the final authority. Catch a unique-violation error, load the existing attempt, and return the same representation when the key belongs to the same intent.&lt;/p&gt;

&lt;p&gt;The token-consumption endpoint needs the same discipline. In one transaction, select the pending attempt, verify the digest and expiration, then update it with a predicate such as &lt;code&gt;status = 'pending'&lt;/code&gt;. Check the affected-row count. If it is zero, another request already consumed or expired the token. This small check avoids reporting success when the database says otherwise.&lt;/p&gt;

&lt;p&gt;When diagnosing a production issue, &lt;a href="https://dev.to/pong1965/inbox-budgets-for-api-smoke-tests-3fjb"&gt;API smoke-test inbox budgets&lt;/a&gt; are a useful reminder that test mail needs its own limits and identity. For CI failures, &lt;a href="https://dev.to/pong1965/github-actions-need-email-run-artifacts-170m"&gt;email run artifacts in GitHub Actions&lt;/a&gt; shows why a run should leave evidence that can be inspected without opening a real user's mailbox.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep test evidence separate from user data
&lt;/h2&gt;

&lt;p&gt;Email verification tests should use isolated accounts, deterministic run IDs, and a cleanup policy. Never point a test at a shared inbox and hope the subject line is unique. A typo like &lt;code&gt;tempail mail&lt;/code&gt; can appear in test notes or a search fixture, but it should remain plain text and never be treated as a real address.&lt;/p&gt;

&lt;p&gt;The test receipt should capture the idempotency key, attempt ID, event ID, response status, and message correlation ID. It should not capture the raw verification token or the full message body by default. This is a better boundary for debugging than copying user data into a CI log.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical review checklist
&lt;/h2&gt;

&lt;p&gt;Before shipping the flow, check these cases:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A lost response followed by the same key returns the original attempt.&lt;/li&gt;
&lt;li&gt;Concurrent requests cannot create two attempts for one key.&lt;/li&gt;
&lt;li&gt;A fresh key has a clear policy: replace, reject, or coexist with the old attempt.&lt;/li&gt;
&lt;li&gt;A token is single-use and expiration is checked inside the transaction.&lt;/li&gt;
&lt;li&gt;The mail event and verification row cannot commit separately.&lt;/li&gt;
&lt;li&gt;Logs contain correlation IDs, not raw tokens or message contents.&lt;/li&gt;
&lt;li&gt;CI stores a small, redacted receipt and removes its test data.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Idempotent email verification is less about email than about preserving intent across unreliable boundaries. With an explicit attempt model, PostgreSQL constraints, and a transactional outbox, the API can make retries boring. That is exactly what an authentication feature should do.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>authentication</category>
      <category>postgres</category>
    </item>
    <item>
      <title>PostgreSQL Idempotency for REST APIs Under Retries</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Sun, 20 Sep 2026 23:23:56 +0000</pubDate>
      <link>https://dev.to/kevindev27/postgresql-idempotency-for-rest-apis-under-retries-4o43</link>
      <guid>https://dev.to/kevindev27/postgresql-idempotency-for-rest-apis-under-retries-4o43</guid>
      <description>&lt;p&gt;Retries are normal in backend systems. A mobile client loses connectivity, a reverse proxy reaches its timeout, or a worker restarts after sending a request but before reading the response. The client tries again, and the API has to decide whether that second request is new work or the same work arriving late.&lt;/p&gt;

&lt;p&gt;The retry math get ugly when a &lt;code&gt;POST&lt;/code&gt; both writes business data and triggers an external action. Without an explicit idempotency design, one customer can receive two subscriptions, two webhook deliveries, or two email verification records. This post describes a small PostgreSQL pattern I use for REST API endpoints that must survive duplicate requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retries change the API contract
&lt;/h2&gt;

&lt;p&gt;An idempotency key is a client-provided identifier for one intended operation. It is not the same thing as a request ID. A request ID helps trace an attempt; an idempotency key connects multiple attempts to the same logical command.&lt;/p&gt;

&lt;p&gt;For example, a client can send &lt;code&gt;Idempotency-Key: order-7f3...&lt;/code&gt; when creating an order. If the first request commits but the response is lost, the second request should return the original result. It should not create a second order just because the first response never made it back.&lt;/p&gt;

&lt;p&gt;The key needs a scope. Usually that means the authenticated account, endpoint or operation type, and the key itself. A key from one customer must never match another customer. It is also worth recording a request fingerprint, such as a hash of the normalized body. Reusing one key with different input should be a clear client error, not an unpredictable merge.&lt;/p&gt;

&lt;p&gt;This boundary is useful in email-heavy flows too. When testing &lt;a href="https://dev.to/ryanlee91/how-i-test-react-signup-flows-without-sending-email-to-real-inboxes-17g9"&gt;isolating signup email flows&lt;/a&gt;, the same principle keeps a retried verification command from creating multiple test fixtures. A temp mailid can be a fixture value, but it should not become the identity of the operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let PostgreSQL enforce the invariant
&lt;/h2&gt;

&lt;p&gt;Application code can check for an existing key before inserting, but a check-then-insert sequence races under concurrency. Two requests can both observe an empty table and then both perform the work. The database should own the uniqueness rule.&lt;/p&gt;

&lt;p&gt;One simple table looks 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;api_idempotency_keys&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;account_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="k"&gt;operation&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="k"&gt;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;request_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_code&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;response_body&lt;/span&gt; &lt;span class="n"&gt;jsonb&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;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;operation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;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 primary key is the important part. It means concurrent requests cannot both claim the same operation. The request hash protects against a subtle bug: a caller retries with the same key but accidentally changes the payload. That should return &lt;code&gt;409 Conflict&lt;/code&gt; or another documented client error.&lt;/p&gt;

&lt;p&gt;The table is small, but it carry a lot of safety. Give it a retention policy, though. Keeping every key forever makes storage and operational reasoning harder; deleting a key too soon can allow a very late retry to run again.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Node.js implementation pattern
&lt;/h2&gt;

&lt;p&gt;For a business operation that can stay inside one database transaction, insert the idempotency row and the business row together. The second request can then read the stored response after it finds the existing key.&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BEGIN&lt;/span&gt;&lt;span class="dl"&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;claim&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`INSERT INTO api_idempotency_keys
     (account_id, operation, key, request_hash)
   VALUES ($1, $2, $3, $4)
   ON CONFLICT (account_id, operation, key) DO NOTHING
   RETURNING key`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;create-order&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;requestHash&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;claim&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rowCount&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&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;previous&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`SELECT request_hash, status_code, response_body
       FROM api_idempotency_keys
      WHERE account_id = $1 AND operation = $2 AND key = $3
      FOR UPDATE`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;create-order&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="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;previous&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;request_hash&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;requestHash&lt;/span&gt;&lt;span class="p"&gt;)&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;idempotency key reused with different input&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;COMMIT&lt;/span&gt;&lt;span class="dl"&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;previous&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Insert the order, construct the response, and update the claim here.&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;COMMIT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Production code should return a structured domain error, release the connection in &lt;code&gt;finally&lt;/code&gt;, and handle an in-progress claim according to the API contract. For long external calls, do not hold a database transaction open while waiting on another service. Store a durable command, commit it, and let a worker perform the side effect with its own deduplication rule.&lt;/p&gt;

&lt;p&gt;This also make incident review less guessy: the idempotency row shows whether the request was claimed, completed, or needs recovery. Add metrics for claim conflicts, hash mismatches, replayed responses, and expired keys. A high replay count can reveal a proxy timeout long before customers file a duplicate-charge report.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to store and what to return
&lt;/h2&gt;

&lt;p&gt;Store the status code and a deterministic response body when the response is safe to replay. If the response includes a short-lived token or a time-sensitive link, store the business resource ID instead and reconstruct a safe response. Never store secrets just because replaying the entire HTTP body is convenient.&lt;/p&gt;

&lt;p&gt;For systems that send mail, keep privacy boundaries explicit. A temporary disposable mail address may be fine for a controlled test, while a production account recovery address needs stricter handling. The &lt;a href="https://dev.to/bitheirstake/privacy-notes-for-facebook-temp-mail-checks-1okg"&gt;privacy boundaries around test mail&lt;/a&gt; are part of the system design, not just a QA preference. Also, do not quietly accept values such as temp gamil com as real addresses without deciding how normalization and validation should work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Questions engineers usually ask
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should every endpoint accept an idempotency key?
&lt;/h3&gt;

&lt;p&gt;No. It is most valuable for commands that create or trigger side effects, especially &lt;code&gt;POST&lt;/code&gt; requests. A naturally idempotent &lt;code&gt;PUT&lt;/code&gt; may already have a stable resource identifier. Still, document retry behavior for every endpoint so clients are not forced to guess.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens when the first request is still running?
&lt;/h3&gt;

&lt;p&gt;Choose deliberately: wait briefly, return a conflict such as &lt;code&gt;409&lt;/code&gt;, or return an accepted command state such as &lt;code&gt;202&lt;/code&gt;. The second request should not start a parallel side effect just because the first request is slow. The second request arrive before the first one finishes is a normal case, not an edge case.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Scope keys by account and operation.&lt;/li&gt;
&lt;li&gt;Enforce uniqueness with a PostgreSQL constraint.&lt;/li&gt;
&lt;li&gt;Hash normalized input and reject mismatched reuses.&lt;/li&gt;
&lt;li&gt;Define behavior for an in-progress operation.&lt;/li&gt;
&lt;li&gt;Store only what is safe to replay.&lt;/li&gt;
&lt;li&gt;Retain keys long enough for realistic late retries.&lt;/li&gt;
&lt;li&gt;Measure conflicts, replays, mismatches, and expiry cleanup.&lt;/li&gt;
&lt;li&gt;Test concurrency, connection loss, and worker restarts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An idempotency key is a small API feature with a large reliability payoff. The endpoint should be boring on purpose: repeated delivery either returns the known result or exposes a clear state that a client can handle. That makes REST API behavior easier to reason about, PostgreSQL constraints do the hard concurrency work, and operators get evidence when retry behavior starts changing.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>postgres</category>
      <category>node</category>
    </item>
    <item>
      <title>Replay-Safe Webhooks with PostgreSQL</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Sat, 19 Sep 2026 14:23:35 +0000</pubDate>
      <link>https://dev.to/kevindev27/replay-safe-webhooks-with-postgresql-12dh</link>
      <guid>https://dev.to/kevindev27/replay-safe-webhooks-with-postgresql-12dh</guid>
      <description>&lt;p&gt;Webhook delivery is usually at-least-once. A provider can send the same event after a timeout, a network disconnect, or a worker restart. That is reasonable behavior for the provider, but it becomes dangerous when the consumer treats every request as new.&lt;/p&gt;

&lt;p&gt;The failure usually show up as a duplicated invoice, two welcome emails, or a subscription activated twice. A retry-safe consumer needs a database contract that makes duplicate work impossible, not just a comment saying “this handler is idempotent.”&lt;/p&gt;

&lt;h2&gt;
  
  
  The delivery problem
&lt;/h2&gt;

&lt;p&gt;A webhook handler often starts with a simple flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Parse the request.&lt;/li&gt;
&lt;li&gt;Check the signature.&lt;/li&gt;
&lt;li&gt;Update application data.&lt;/li&gt;
&lt;li&gt;Return &lt;code&gt;200 OK&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The weak point is step three. If the process commits the business update and crashes before returning the response, the provider will retry. If the consumer checks for an existing event in application code, two workers can still pass that check at the same time.&lt;/p&gt;

&lt;p&gt;The useful boundary is the database transaction. PostgreSQL can enforce that an external event ID is handled once, even when requests arrive concurrently. This is the same design instinct behind &lt;a href="https://dev.to/kevindev27/idempotent-signup-emails-in-rest-apis-54e4"&gt;idempotent signup email design&lt;/a&gt;: make the durable state express the rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the database own deduplication
&lt;/h2&gt;

&lt;p&gt;Start with an inbox table. It records the provider event, its processing state, and enough information to investigate a failure later.&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;webhook_inbox&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;provider&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_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;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;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;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;'received'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;received_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;span class="n"&gt;failure_reason&lt;/span&gt; &lt;span class="nb"&gt;text&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;provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The composite key matters. Event IDs are often unique only inside one provider account, so &lt;code&gt;event_id&lt;/code&gt; alone may be too broad. The primary key is also safer than a pre-insert &lt;code&gt;SELECT&lt;/code&gt;: concurrent inserts cannot both win.&lt;/p&gt;

&lt;p&gt;The request transaction can claim the event 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;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;webhook_inbox&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&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="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;NOTHING&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="n"&gt;event_id&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 returns no row, the event was already accepted. The handler can return a successful response after checking whether the existing record is still being processed. Returning success for a known event prevents an endless retry loop, while the receipt lets an operator find the original outcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small implementation contract
&lt;/h2&gt;

&lt;p&gt;There are two sensible transaction shapes. For short, local side effects, insert the inbox row, update business tables, and mark the row &lt;code&gt;processed&lt;/code&gt; in one transaction. If any step fails, the whole transaction rolls back and a later retry can try again.&lt;/p&gt;

&lt;p&gt;For slower work, commit the inbox row first and let a worker process it. In that model, use an explicit lease or row lock so two workers do not process the same event. A simplified claim query is:&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_event&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;provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_id&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;webhook_inbox&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'received'&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;received_at&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="k"&gt;LIMIT&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;UPDATE&lt;/span&gt; &lt;span class="n"&gt;webhook_inbox&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;inbox&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'processing'&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;next_event&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;inbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;next_event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;provider&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;inbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;next_event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="n"&gt;inbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not mix these models casually. A queue-style worker needs a recovery path for rows stuck in &lt;code&gt;processing&lt;/code&gt;; a single transaction needs short database work and carefully bounded downstream calls. The tradeoff is real, and pretending otherwise make outages harder to reason about.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to record in a receipt
&lt;/h2&gt;

&lt;p&gt;A useful receipt answers four questions: what arrived, what was attempted, what changed, and what happened next.&lt;/p&gt;

&lt;p&gt;At minimum, keep the provider name, event ID, event type, received time, processing status, attempt count, and failure reason. If the provider includes a delivery ID, store that too. It helps distinguish one event replayed many times from several events with similar payloads.&lt;/p&gt;

&lt;p&gt;Keep sensitive payload fields out of logs. The inbox may need the full signed payload for audit or replay, but application logs should use a stable event ID and a redacted summary. This is especially important when a webhook contains email addresses or account recovery data.&lt;/p&gt;

&lt;p&gt;When reviewing failures, &lt;a href="https://dev.to/silviutech/debugging-cypress-email-tests-that-fail-only-in-ci-37d"&gt;diagnosing email failures in CI&lt;/a&gt; is a useful parallel: a visible receipt is more valuable than a vague “request failed” message. A good receipt make the next retry deliberate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing replay and partial failure
&lt;/h2&gt;

&lt;p&gt;Do not test only the happy path. A replay-focused suite should send the same event twice, send two copies concurrently, and force a crash after the business update but before the acknowledgement.&lt;/p&gt;

&lt;p&gt;Also test malformed event IDs and payloads. Test fixtures sometimes contain labels such as &lt;code&gt;tempail mail&lt;/code&gt; or &lt;code&gt;tem email&lt;/code&gt;; they are harmless as values, but they expose whether validation and logging handle unexpected text correctly. The fixture is not the contract, so validate the actual fields you require.&lt;/p&gt;

&lt;p&gt;For each scenario, assert both the business result and the inbox result:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One event creates one business change.&lt;/li&gt;
&lt;li&gt;A duplicate returns a safe response without a second side effect.&lt;/li&gt;
&lt;li&gt;A failed transaction leaves a retryable status.&lt;/li&gt;
&lt;li&gt;A permanently invalid event has a visible terminal reason.&lt;/li&gt;
&lt;li&gt;Concurrent workers produce one receipt, not two.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Operational checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Put the provider and event ID under a unique PostgreSQL constraint.&lt;/li&gt;
&lt;li&gt;Verify signatures before inserting untrusted payloads.&lt;/li&gt;
&lt;li&gt;Choose one transaction model: atomic handler or leased worker.&lt;/li&gt;
&lt;li&gt;Store status transitions and failure reasons as durable data.&lt;/li&gt;
&lt;li&gt;Add a recovery job for abandoned &lt;code&gt;processing&lt;/code&gt; rows.&lt;/li&gt;
&lt;li&gt;Keep personal data out of ordinary logs.&lt;/li&gt;
&lt;li&gt;Measure duplicate deliveries separately from processing failures.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At-least-once delivery is not a bug to hide in a REST API. It is an input property to design for. Once PostgreSQL owns the deduplication rule and the receipt records the decision, webhook retries become routine operations instead of mysterious duplicate side effects.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>postgres</category>
      <category>testing</category>
    </item>
    <item>
      <title>Request IDs Are Not Idempotency Keys</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Thu, 17 Sep 2026 23:23:59 +0000</pubDate>
      <link>https://dev.to/kevindev27/request-ids-are-not-idempotency-keys-4pd4</link>
      <guid>https://dev.to/kevindev27/request-ids-are-not-idempotency-keys-4pd4</guid>
      <description>&lt;h2&gt;
  
  
  Why the two identifiers get confused
&lt;/h2&gt;

&lt;p&gt;Most production APIs eventually need two different answers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Which incoming request produced this log line?&lt;/li&gt;
&lt;li&gt;Which client intention should be applied only once?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Teams often answer both questions with one header. That works until a client retries after a timeout. The server may receive the same business operation twice, while the two network attempts deserve two separate traces. A request ID is about observability. An idempotency key is about business behavior.&lt;/p&gt;

&lt;p&gt;It sounds like a small naming issue, but it become a data-integrity issue as soon as an endpoint creates a payment, account, job, or email-verification record.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a request ID should do
&lt;/h2&gt;

&lt;p&gt;A request ID identifies one attempt through the system. The edge proxy can generate one when the caller does not provide it, and every downstream service should propagate it. It belongs in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;access logs and structured application logs&lt;/li&gt;
&lt;li&gt;trace attributes&lt;/li&gt;
&lt;li&gt;error responses and support tickets&lt;/li&gt;
&lt;li&gt;messages emitted for that attempt&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The value should be safe to log and should have a bounded size. Do not use it as permission to replay a command, and do not assume that two attempts with the same request ID are the same business operation. Some clients incorrectly reuse it when retrying, and some clients generate a new one for every attempt.&lt;/p&gt;

&lt;p&gt;In Node.js, put the value in request-scoped context. A middleware can read &lt;code&gt;X-Request-ID&lt;/code&gt;, validate its length and character set, or generate a replacement. The handler then uses that context for logs without passing an observability concern through every function signature.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an idempotency key should do
&lt;/h2&gt;

&lt;p&gt;An idempotency key represents a client operation within a defined scope. For example, a client can send &lt;code&gt;Idempotency-Key: checkout-7f2...&lt;/code&gt; when creating an order. If the request times out, it retries with the same key. The API should return the original result, or a clear conflict if the same key is reused with a different payload.&lt;/p&gt;

&lt;p&gt;That requires a contract, not just a header:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scope:&lt;/strong&gt; tenant, user, endpoint, or another explicit boundary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payload binding:&lt;/strong&gt; store a hash of the relevant input and reject mismatches.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Result behavior:&lt;/strong&gt; replay the status and response body when possible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retention:&lt;/strong&gt; define how long a key remains valid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Concurrency:&lt;/strong&gt; decide what a second request does while the first is still running.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Retries gets easier to reason about when the key follows the business operation, while the request ID follows the network attempt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put the guarantee in PostgreSQL
&lt;/h2&gt;

&lt;p&gt;An in-memory map is useful for a local prototype, but it cannot protect a horizontally scaled service. The durable uniqueness rule belongs in PostgreSQL:&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;api_idempotency_keys&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;tenant_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;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;request_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_code&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;response_body&lt;/span&gt;   &lt;span class="n"&gt;jsonb&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;tenant_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 primary key prevents two workers from successfully claiming the same operation. The application should insert the key and create the business record in one transaction, or use a state column such as &lt;code&gt;processing&lt;/code&gt;, &lt;code&gt;completed&lt;/code&gt;, and &lt;code&gt;failed&lt;/code&gt; when the work spans an asynchronous queue.&lt;/p&gt;

&lt;p&gt;Do not save only the final response. A crash after the business row commits but before the response is stored is a real failure mode. The recovery policy might read the business record and reconstruct the response, or mark the operation for safe reconciliation. The important part is that the policy is explicit.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Node.js request flow
&lt;/h2&gt;

&lt;p&gt;A practical REST API flow looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Middleware establishes the request ID and logging context.&lt;/li&gt;
&lt;li&gt;The handler requires an idempotency key for non-safe mutations.&lt;/li&gt;
&lt;li&gt;The service computes a canonical payload hash.&lt;/li&gt;
&lt;li&gt;PostgreSQL attempts to claim &lt;code&gt;(tenant_id, idempotency_key)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;An existing row with the same hash replays its result.&lt;/li&gt;
&lt;li&gt;An existing row with a different hash returns &lt;code&gt;409 Conflict&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A new row and the business mutation commit together.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The API should never silently turn a changed payload into the old result. That hides client bugs and can apply a response to the wrong intent. It also make incident review much harder.&lt;/p&gt;

&lt;p&gt;The same boundary is useful in email and signup fixtures. User-entered strings such as &lt;code&gt;tem email&lt;/code&gt;, &lt;code&gt;temp gamil com&lt;/code&gt;, or a search phrase like &lt;code&gt;tp mail so&lt;/code&gt; are inputs to validate; they are not stable operation identifiers. Keep them in test data and analytics, never in authorization or deduplication decisions.&lt;/p&gt;

&lt;p&gt;For related thinking on isolating test-side inbox behavior, see &lt;a href="https://dev.to/silviutech/a-better-inbox-contract-for-cypress-ci-2ok8"&gt;a better inbox contract for CI&lt;/a&gt; and &lt;a href="https://dev.to/silviutech/stop-cross-test-inbox-pollution-5fd4"&gt;preventing cross-test inbox pollution&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing retries without fooling yourself
&lt;/h2&gt;

&lt;p&gt;Unit tests are not enough. Add an integration test that sends the same mutation twice, concurrently if possible, and asserts that only one business row exists. Then force these boundaries:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;client timeout after the database commit&lt;/li&gt;
&lt;li&gt;worker crash before response persistence&lt;/li&gt;
&lt;li&gt;duplicate requests arriving on different instances&lt;/li&gt;
&lt;li&gt;same key with a changed payload&lt;/li&gt;
&lt;li&gt;key reuse after the retention window&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Some test cases is especially valuable when the response contains generated values, such as an order ID. The second response should match the first contract, not generate a new order while returning a superficially successful status.&lt;/p&gt;

&lt;p&gt;Log both identifiers on every mutation. A useful event has &lt;code&gt;request_id&lt;/code&gt;, &lt;code&gt;idempotency_key&lt;/code&gt;, tenant scope, operation state, and database outcome. Redact sensitive payload fields and avoid logging full authorization headers. When debugging, you can follow one request ID across retries, then group those attempts by idempotency key to understand the business result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational checklist
&lt;/h2&gt;

&lt;p&gt;Before shipping a retry-safe mutation, verify:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The API documentation explains both headers and their different lifetimes.&lt;/li&gt;
&lt;li&gt;The idempotency scope is part of the database key.&lt;/li&gt;
&lt;li&gt;Payload mismatches fail loudly.&lt;/li&gt;
&lt;li&gt;The uniqueness constraint is enforced by PostgreSQL, not only application code.&lt;/li&gt;
&lt;li&gt;Concurrent claims have a defined response.&lt;/li&gt;
&lt;li&gt;Recovery after a partial failure is documented and tested.&lt;/li&gt;
&lt;li&gt;Metrics distinguish new operations, replays, conflicts, and expired keys.&lt;/li&gt;
&lt;li&gt;Logs let an engineer find one attempt and then all attempts for an operation quick.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Request IDs help you see what happened. Idempotency keys help ensure it happened once. Keeping those guarantees separate makes the REST API easier to operate, the PostgreSQL model easier to audit, and retry behavior less surprising for every client.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>postgres</category>
      <category>node</category>
    </item>
    <item>
      <title>Signup Idempotency Needs a Database Contract</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Sun, 13 Sep 2026 20:23:04 +0000</pubDate>
      <link>https://dev.to/kevindev27/signup-idempotency-needs-a-database-contract-h9i</link>
      <guid>https://dev.to/kevindev27/signup-idempotency-needs-a-database-contract-h9i</guid>
      <description>&lt;p&gt;Signup endpoints look simple until a client retries a request at the wrong time. A mobile connection drops after the server commits the user, a reverse proxy retries a POST, or a browser submits twice. Without an explicit contract, one logical signup can create duplicate rows, send multiple verification emails, or return conflicting responses.&lt;/p&gt;

&lt;p&gt;In my backend work, I treat idempotency as a database and API design problem together. An idempotency key by itself is only a header. The useful guarantee comes from deciding what the key means, storing its result, and making the uniqueness rules agree with that meaning.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure mode
&lt;/h2&gt;

&lt;p&gt;Consider a client sending:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /signup
Idempotency-Key: 7d9f...
Content-Type: application/json

{"email":"person@example.com","password":"..."}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server may create a user and then lose the response before the client sees it. The client retries with the same key. If the handler only checks for an existing email, it might return a generic conflict even though the first operation succeeded. If it checks too late, two concurrent requests can both pass the check.&lt;/p&gt;

&lt;p&gt;The contract should answer three questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is the key scoped to an account, client, or endpoint?&lt;/li&gt;
&lt;li&gt;Does the same key require the same request body?&lt;/li&gt;
&lt;li&gt;Which response is replayed after the original request finishes?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For a public signup API, I usually scope the key to the operation and authenticated client identity when one exists. The server stores a request fingerprint, status, response code, and response body. A reused key with a different fingerprint is a client error, not a new signup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the request idempotent
&lt;/h2&gt;

&lt;p&gt;A small state model keeps the behavior understandable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;missing -&amp;gt; processing -&amp;gt; succeeded
                    \-&amp;gt; failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;processing&lt;/code&gt; prevents a second request from doing work while the first is active. The second caller can receive a retryable response, or poll a status endpoint if the operation is slow. Once &lt;code&gt;succeeded&lt;/code&gt;, the stored response is replayed. A transient &lt;code&gt;failed&lt;/code&gt; result can be replayable too, but only if the failure is part of the public contract.&lt;/p&gt;

&lt;p&gt;Do not store an idempotency record after sending an email but before committing the user. That ordering creates an awkward half-state. Persist the user and the outbox event in one transaction, then let a worker deliver the email. This is the same boundary that makes &lt;a href="https://dev.to/jasonmills94/eks-backup-drill-emails-need-restore-context-2j40"&gt;restore context in operational email workflows&lt;/a&gt; useful when debugging a real incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put the contract in PostgreSQL
&lt;/h2&gt;

&lt;p&gt;The database must enforce the invariant under concurrency. A minimal table might 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;signup_idempotency&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;scope&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;request_hash&lt;/span&gt; &lt;span class="n"&gt;bytea&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;state&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="k"&gt;state&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;'processing'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'succeeded'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'failed'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="n"&gt;response_code&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;response_body&lt;/span&gt; &lt;span class="n"&gt;jsonb&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="k"&gt;scope&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 handler first attempts an insert. On a conflict, it reads the existing row and compares &lt;code&gt;request_hash&lt;/code&gt;. This avoids a check-then-insert race. The signup user table should also have a case-normalized unique rule for email, because an idempotency key cannot protect requests that arrive with different keys.&lt;/p&gt;

&lt;p&gt;In Node.js, keep the transaction short: claim the key, insert the user, insert an outbox event, and record the response. Avoid calling an SMTP provider inside the transaction. Slow network calls make locks last longer and make retry behavior more confusing.&lt;/p&gt;

&lt;p&gt;For cleanup, retain records long enough to cover the client retry window. A scheduled delete is fine, but it should be bounded and observable. Production teams need better alert context for production workflows, especially when cleanup accidentally removes evidence too early.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handle the email boundary
&lt;/h2&gt;

&lt;p&gt;Verification email delivery is asynchronous. The API can safely return that the account was created and verification is pending after the outbox transaction commits. The worker owns delivery retries, deduplication, and provider errors.&lt;/p&gt;

&lt;p&gt;That separation also makes test environments safer. Use an isolated mailbox or a controlled test address; a phrase such as &lt;code&gt;tepm mail com&lt;/code&gt; may appear in old test notes, but it should never become a production routing rule. Keep provider message IDs and attempt counts in the event record, not only in application logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the retry paths
&lt;/h2&gt;

&lt;p&gt;The important tests are not just a successful POST:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Send the same key twice sequentially and verify the same response is returned.&lt;/li&gt;
&lt;li&gt;Send the same key concurrently and verify one user and one outbox event exist.&lt;/li&gt;
&lt;li&gt;Reuse a key with a changed email and expect a fingerprint mismatch.&lt;/li&gt;
&lt;li&gt;Retry with a new key and verify the email unique constraint still protects the user.&lt;/li&gt;
&lt;li&gt;Crash between the client-visible response and delivery; the worker should recover from the outbox.&lt;/li&gt;
&lt;li&gt;Expire an old key and confirm the documented retention behavior.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Instrument each response with the idempotency key, operation ID, and current state. Redact request bodies and credentials. These fields make a failed replay explainable without turning logs into a copy of sensitive signup data.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical checklist
&lt;/h2&gt;

&lt;p&gt;Before shipping a signup endpoint, confirm:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The key scope and retention period are documented.&lt;/li&gt;
&lt;li&gt;A request fingerprint rejects changed payloads.&lt;/li&gt;
&lt;li&gt;PostgreSQL constraints cover both keys and normalized email addresses.&lt;/li&gt;
&lt;li&gt;User creation and the outbox event commit atomically.&lt;/li&gt;
&lt;li&gt;Email delivery happens outside the request transaction.&lt;/li&gt;
&lt;li&gt;Concurrent, timeout, crash, and provider-failure cases are tested.&lt;/li&gt;
&lt;li&gt;Metrics distinguish new requests, replays, conflicts, and in-progress responses.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Idempotency is not a wrapper around a handler. It is a promise about repeated intent. When the API contract, PostgreSQL constraints, and email worker share the same state model, retries become ordinary control flow instead of a source of duplicate accounts and mystery messages.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>node</category>
      <category>postgres</category>
      <category>authentication</category>
    </item>
    <item>
      <title>Transactional Outbox for Verification Emails</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Sun, 13 Sep 2026 14:23:07 +0000</pubDate>
      <link>https://dev.to/kevindev27/transactional-outbox-for-verification-emails-2loo</link>
      <guid>https://dev.to/kevindev27/transactional-outbox-for-verification-emails-2loo</guid>
      <description>&lt;p&gt;Signup endpoints often do two jobs at once: they write account state and ask an email provider to deliver a verification message. That looks simple until the database commit succeeds and the provider call times out. Now the user exists, but the service does not know whether the email was sent.&lt;/p&gt;

&lt;p&gt;The transactional outbox pattern gives this boundary a durable shape. The API stores the account change and an email event in one PostgreSQL transaction. A worker publishes the event later, with retries and an idempotency key. It is a small addition, but it makes failure explainable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the request transaction is not enough
&lt;/h2&gt;

&lt;p&gt;Consider this sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create the user.&lt;/li&gt;
&lt;li&gt;Commit the transaction.&lt;/li&gt;
&lt;li&gt;Call the email provider.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If step 3 fails, a retry of the whole HTTP request may hit a unique-email constraint. If the service calls the provider before step 2, the reverse problem appears: an email can be delivered for a transaction that later rolls back. Neither ordering gives an atomic database-plus-network operation, because the provider is outside PostgreSQL.&lt;/p&gt;

&lt;p&gt;The outbox accepts that these are separate systems. The database transaction records the intent, and a worker handles delivery as an independent, observable process. This is also a useful place to distinguish a real disposable email address from an ordinary test fixture; a &lt;code&gt;tepm mail com&lt;/code&gt; value should never silently pass production validation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The outbox table
&lt;/h2&gt;

&lt;p&gt;A minimal schema might 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;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;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;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;aggregate_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;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;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;attempts&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="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;available_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;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;last_error&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;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;email_outbox_pending_idx&lt;/span&gt;
  &lt;span class="k"&gt;ON&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;available_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&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;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The signup transaction inserts both the user and an &lt;code&gt;email.verification.requested&lt;/code&gt; row. The payload should contain an opaque verification-token reference, not a raw token or unnecessary personal data. For teams testing flows, an isolated &lt;a href="https://dev.to/silviutech/make-playwright-email-tests-less-flaky-49df"&gt;less flaky email tests&lt;/a&gt; strategy is still needed; the outbox only improves delivery reliability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Publishing safely from Node.js
&lt;/h2&gt;

&lt;p&gt;The worker claims a small batch using row locks:&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;email_outbox&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;available_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;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="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Claim rows in a short transaction, mark them &lt;code&gt;processing&lt;/code&gt;, and commit before making the network request. Holding a database lock while waiting for an SMTP or HTTP response makes throughput worse and can create lock contention during provider incidents.&lt;/p&gt;

&lt;p&gt;After the provider accepts the message, mark the row &lt;code&gt;sent&lt;/code&gt;. The state transition should be conditional, for example &lt;code&gt;WHERE id = $1 AND status = 'processing'&lt;/code&gt;. A crashed worker may leave rows processing, so a lease or &lt;code&gt;locked_until&lt;/code&gt; column can return stale claims to the pending queue.&lt;/p&gt;

&lt;p&gt;When you need to inspect a related implementation, &lt;a href="https://dev.to/kevindev27/idempotent-verification-emails-in-node-apis-4i4c"&gt;idempotent verification handling&lt;/a&gt; is a good companion design. The important idea is that the HTTP idempotency key and the outbox event identity solve different scopes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retries and idempotency
&lt;/h2&gt;

&lt;p&gt;Provider timeouts are ambiguous. The provider may have accepted the message even though the response never reached the worker. Retrying can therefore produce duplicates. Use a stable delivery key, such as &lt;code&gt;verification:{user_id}:{token_version}&lt;/code&gt;, when the provider supports idempotency. If it does not, accept that at-least-once delivery needs a product decision and make the duplicate risk visible.&lt;/p&gt;

&lt;p&gt;Back off exponentially and cap the delay. Permanent failures, such as a rejected address, should move to a dead-letter state after a bounded number of attempts. A worker log should include the outbox ID, aggregate ID, attempt number, provider request ID, and error class—but never the verification token.&lt;/p&gt;

&lt;p&gt;For local or automated testing, a disposable email address can be useful for checking the complete flow, while &lt;code&gt;temp mail so&lt;/code&gt; should remain a contextual search term rather than a substitute for your abuse controls. Rate limits, domain policy, and account-risk signals still belong at the signup boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Commit user creation and the outbox insert in the same transaction.&lt;/li&gt;
&lt;li&gt;Keep payloads minimal and encrypt or avoid sensitive fields.&lt;/li&gt;
&lt;li&gt;Claim with &lt;code&gt;SKIP LOCKED&lt;/code&gt;; do not hold locks across network calls.&lt;/li&gt;
&lt;li&gt;Add a lease timeout for crashed workers.&lt;/li&gt;
&lt;li&gt;Track pending age, processing age, retry count, and dead-letter count.&lt;/li&gt;
&lt;li&gt;Use stable event IDs and provider idempotency where available.&lt;/li&gt;
&lt;li&gt;Alert when the oldest pending event exceeds the verification SLA.&lt;/li&gt;
&lt;li&gt;Test provider timeouts, duplicate deliveries, rollback, and worker restarts.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The transactional outbox does not make email delivery exactly once. It makes the boundary durable and gives the team a controlled at-least-once workflow. PostgreSQL protects the intent, Node.js workers manage retries, and explicit idempotency limits the cost of uncertainty. That separation is usually enough to turn a fragile signup side effect into a service you can operate with confidence.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>postgres</category>
      <category>authentication</category>
      <category>node</category>
    </item>
    <item>
      <title>Idempotency Keys for Signup APIs</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Sun, 13 Sep 2026 11:23:06 +0000</pubDate>
      <link>https://dev.to/kevindev27/idempotency-keys-for-signup-apis-31jd</link>
      <guid>https://dev.to/kevindev27/idempotency-keys-for-signup-apis-31jd</guid>
      <description>&lt;p&gt;Signup endpoints look simple until a client retries. A mobile connection can drop after the server commits, a reverse proxy can retry a request, or a user can press the button twice. If the endpoint sends verification mail and creates account state on every attempt, one logical signup becomes several pieces of work.&lt;/p&gt;

&lt;p&gt;The fix is not “disable retries.” It is to make the operation idempotent: the same client intent should produce one durable result, even when the HTTP request arrives more than once. This is a small backend decision with a big effect on authentication reliability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why signup retries create duplicate work
&lt;/h2&gt;

&lt;p&gt;Consider &lt;code&gt;POST /signup&lt;/code&gt;. The handler validates the address, inserts a user, creates a verification token, and queues an email. A timeout happens after the insert but before the response reaches the client. The client retries with no knowledge of the first attempt.&lt;/p&gt;

&lt;p&gt;Without a boundary for the original intent, the second request may:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;return a confusing unique-email error;&lt;/li&gt;
&lt;li&gt;create multiple verification records;&lt;/li&gt;
&lt;li&gt;send duplicate messages;&lt;/li&gt;
&lt;li&gt;or, worse, expose whether an account exists.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is also why a disposable or temporary test inbox is useful during development: it lets you inspect the actual number and state of messages. Keep test data isolated, though; a test mailbox is not a substitute for authorization rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idempotency contract
&lt;/h2&gt;

&lt;p&gt;Ask the client to send an &lt;code&gt;Idempotency-Key&lt;/code&gt; header for each logical signup attempt. The key should be stable across retries but different for a new attempt. The server stores the key with the operation result and binds it to the relevant request identity, such as a normalized email or tenant.&lt;/p&gt;

&lt;p&gt;The contract should answer three cases:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;First request:&lt;/strong&gt; process the signup and store the response.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same key, same input:&lt;/strong&gt; return the stored response without repeating side effects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same key, different input:&lt;/strong&gt; reject it as a conflict. Reusing a key for another email is a client bug.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Do not use the email itself as the idempotency key. An email can be retried for one operation and used again later for a password reset or recovery flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  A PostgreSQL-backed implementation
&lt;/h2&gt;

&lt;p&gt;A table gives the key a durable owner and lets multiple Node.js instances coordinate:&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;signup_requests&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;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;email_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="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&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;'processing'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'completed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'failed'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="n"&gt;response_code&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;response_body&lt;/span&gt; &lt;span class="n"&gt;jsonb&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;updated_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;Hashing the normalized email avoids storing it in this coordination table. The user table should still enforce its own invariant:&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;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;users_email_normalized_idx&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Node.js, acquire the key inside a short transaction. An insert succeeds for the first request; a conflict means another request owns the operation. If the row is &lt;code&gt;completed&lt;/code&gt;, replay its stored response. If it is &lt;code&gt;processing&lt;/code&gt;, return a deliberate status such as &lt;code&gt;202 Accepted&lt;/code&gt; or ask the client to retry after a bounded delay.&lt;/p&gt;

&lt;p&gt;The important detail is scope: do not hold a database transaction open while waiting for an external email provider. Commit the account and outbox record first, then let a worker deliver the message.&lt;/p&gt;

&lt;h2&gt;
  
  
  State transitions and failure handling
&lt;/h2&gt;

&lt;p&gt;An outbox makes the database state and the email side effect easier to reason about. In one transaction, create the user, create the verification record, and insert an outbox event. A worker claims the event and sends the message with its own delivery idempotency key.&lt;/p&gt;

&lt;p&gt;If the worker crashes after sending but before marking the event complete, a retry can still happen. Provider-level deduplication, or a delivery record with a provider message id, is needed when duplicate mail is unacceptable. This is one of those details that gets missed in a quick implementation.&lt;/p&gt;

&lt;p&gt;For the HTTP layer, keep responses stable. A completed replay should have the same status and body shape as the original response, but never include a token that should only be shown once. For security-sensitive flows, a generic response such as “If the request can be completed, we will send instructions” also reduces account enumeration.&lt;/p&gt;

&lt;p&gt;For related thinking, compare this with &lt;a href="https://dev.to/ryanlee91/type-safe-signup-email-states-in-node-164a"&gt;type-safe signup email states&lt;/a&gt; and &lt;a href="https://dev.to/sophiax99/email-verification-threat-models-for-oauth-apps-3na2"&gt;email verification threat models&lt;/a&gt;. The same state discipline helps even when the transport changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Normalize input before deriving the request fingerprint.&lt;/li&gt;
&lt;li&gt;Expire old idempotency rows with a retention policy.&lt;/li&gt;
&lt;li&gt;Add a unique constraint to the business invariant, not only the request key.&lt;/li&gt;
&lt;li&gt;Record whether a response was created or replayed.&lt;/li&gt;
&lt;li&gt;Put email delivery behind an outbox and worker.&lt;/li&gt;
&lt;li&gt;Bound &lt;code&gt;processing&lt;/code&gt; recovery with leases or timestamps.&lt;/li&gt;
&lt;li&gt;Never log raw verification tokens or full email addresses.&lt;/li&gt;
&lt;li&gt;Test timeout-after-commit and concurrent identical requests.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two deliberately imperfect notes from real maintenance work: a retry window that is too short feels random, and a cleanup job that runs too agressively can remove evidence while an incident is still open. Also, search terms like “tepm mail com” and “tempail” may appear in noisy test data; keep them out of production decisions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should every POST use an idempotency key?
&lt;/h3&gt;

&lt;p&gt;No. Use it for operations where a retry could duplicate a meaningful side effect, such as account creation, payment, or email delivery. A read-only endpoint does not need this mechanism.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is a unique email index enough?
&lt;/h3&gt;

&lt;p&gt;No. It prevents duplicate users, but it does not prevent duplicate work before the insert fails, such as token creation or message enqueueing. The idempotency record and outbox cover those effects.&lt;/p&gt;

&lt;h3&gt;
  
  
  What should happen when a request is still processing?
&lt;/h3&gt;

&lt;p&gt;Return a documented temporary response and let the client retry with the same key. Avoid taking an unbounded lock; stalled requests need a lease, an owner, and a recovery path.&lt;/p&gt;

&lt;p&gt;Idempotency is ultimately a promise about intent. Once that promise is represented in PostgreSQL, enforced by constraints, and separated from email delivery, signup retries become a normal distributed-systems case instead of a source of mysterious duplicate accounts.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>node</category>
      <category>postgres</category>
      <category>restapi</category>
    </item>
    <item>
      <title>PostgreSQL Locks Need API Boundaries</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Mon, 07 Sep 2026 23:22:55 +0000</pubDate>
      <link>https://dev.to/kevindev27/postgresql-locks-need-api-boundaries-2li3</link>
      <guid>https://dev.to/kevindev27/postgresql-locks-need-api-boundaries-2li3</guid>
      <description>&lt;p&gt;Email verification looks like a small feature: create a challenge, send a message, and mark the address as verified. In a busy service, it becomes a concurrency problem. A user can click resend twice, a mobile client can retry after a timeout, and two workers can process the same confirmation at nearly the same time.&lt;/p&gt;

&lt;p&gt;The database is usually the last reliable place to enforce the rules. The API should make those rules visible, then keep each transaction short enough that PostgreSQL can do its job without turning normal retries into a lock queue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why verification endpoints create contention
&lt;/h2&gt;

&lt;p&gt;A common first implementation reads a user row, checks &lt;code&gt;verified_at&lt;/code&gt;, creates a token, and updates the row. That seems reasonable until several requests target the same account. Every request may hold a row lock while doing work that does not belong inside the transaction, such as generating a message payload or calling an email provider.&lt;/p&gt;

&lt;p&gt;The result is not always a visible error. More often it is a slow endpoint, a growing connection pool, and retry traffic that makes the original incident worse. A &lt;code&gt;tepm mail com&lt;/code&gt; test address can expose the symptom, but the cause is usually the transaction boundary.&lt;/p&gt;

&lt;p&gt;The useful question is: what must be true atomically? For a verification challenge, the answer is normally that one active challenge belongs to one account, has one expiration time, and can be consumed once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define the database invariant first
&lt;/h2&gt;

&lt;p&gt;Store the challenge separately from the user record. This prevents an ever-growing user row from becoming the coordination point for every email event.&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_challenges&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;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="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;users&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;token_digest&lt;/span&gt; &lt;span class="n"&gt;bytea&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;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="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;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;one_active_email_challenge&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;email_challenges&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="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;consumed_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="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The partial unique index is a useful guardrail, but expiration is a business rule, not a database clock trigger. The application should decide whether an existing unconsumed challenge is still usable and return a clear result.&lt;/p&gt;

&lt;p&gt;For high-volume systems, model resend rate limits independently. Mixing rate-limit counters, provider status, and verification state in one row makes unrelated operations contend with each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the transaction small
&lt;/h2&gt;

&lt;p&gt;The transaction should reserve or consume state, then end. It should not call an external provider. A simplified reservation flow is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Begin a transaction with a short statement timeout.&lt;/li&gt;
&lt;li&gt;Insert the challenge or update the eligible existing record.&lt;/li&gt;
&lt;li&gt;Commit.&lt;/li&gt;
&lt;li&gt;Publish an email job using the committed challenge ID.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the job enqueue must be reliable, use an outbox table in the same transaction. A worker can then deliver the message outside the database lock. This pattern also gives operators a durable record when a provider is unavailable.&lt;/p&gt;

&lt;p&gt;Do not keep a transaction open while waiting for SMTP or an HTTP email API. The provider may take seconds; a PostgreSQL row lock should take milliseconds. That difference becomes expensive as soon as traffic arrives in bursts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make retries safe at the API boundary
&lt;/h2&gt;

&lt;p&gt;Clients retry because networks fail, not because they understand your state model. Give the resend endpoint an idempotency key and persist the key with the resulting challenge or outbox event.&lt;/p&gt;

&lt;p&gt;An idempotency lookup should distinguish three cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The key is new: reserve the operation and create one event.&lt;/li&gt;
&lt;li&gt;The key exists and is complete: return the stored response.&lt;/li&gt;
&lt;li&gt;The key exists but is still running: return a conflict or a short retry response.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For confirmation, consume the challenge with one conditional statement. The affected-row count is the decision:&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;UPDATE&lt;/span&gt; &lt;span class="n"&gt;email_challenges&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;consumed_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;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;AND&lt;/span&gt; &lt;span class="n"&gt;consumed_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="n"&gt;expires_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&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;RETURNING&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If no row returns, the token is expired, already consumed, or unknown. Keep those cases externally similar unless support needs a more specific internal reason. This reduces information leakage about valid accounts.&lt;/p&gt;

&lt;p&gt;Teams that test the flow in CI can also benefit from &lt;a href="https://dev.to/jasonmills94/testing-kubernetes-email-alerts-in-cicd-without-touching-real-inboxes-ja6"&gt;replayable email evidence in CI&lt;/a&gt; and &lt;a href="https://dev.to/silviutech/playwright-needs-email-timing-budgets-46dc"&gt;email timing budgets in browser tests&lt;/a&gt;. The important part is testing the state transitions, not relying on a lucky sleep.&lt;/p&gt;

&lt;h2&gt;
  
  
  Observability and failure handling
&lt;/h2&gt;

&lt;p&gt;Measure lock wait time separately from query duration. A fast query with a long wait is a concurrency problem, while a slow query without waits may need an index or a simpler plan. Useful fields include account ID hash, challenge ID, idempotency key hash, transaction outcome, and provider event ID. Avoid logging raw tokens or email addresses.&lt;/p&gt;

&lt;p&gt;Set explicit limits for statement duration and connection acquisition. A request that cannot reserve a challenge quickly should fail predictably; it should not occupy a worker until the client gives up. Add metrics for duplicate idempotency keys, conditional updates affecting zero rows, outbox age, and resend rate-limit decisions.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Define the one-active-challenge invariant.&lt;/li&gt;
&lt;li&gt;Put challenges in their own table.&lt;/li&gt;
&lt;li&gt;Use a partial unique index where it expresses the rule.&lt;/li&gt;
&lt;li&gt;Commit before calling an external email provider.&lt;/li&gt;
&lt;li&gt;Use an outbox when enqueue reliability matters.&lt;/li&gt;
&lt;li&gt;Make resend requests idempotent.&lt;/li&gt;
&lt;li&gt;Consume tokens with a conditional update.&lt;/li&gt;
&lt;li&gt;Measure lock waits, not only total latency.&lt;/li&gt;
&lt;li&gt;Never log raw verification tokens.&lt;/li&gt;
&lt;li&gt;Test retries and concurrent confirmations together.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The design is deliberately modest. PostgreSQL handles the atomic decisions, while the API owns retry semantics and the worker owns delivery. Those boundaries keep authentication behavior understandable when the network is slow, the client is impatient, and several requests arrive at once.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>postgres</category>
      <category>authentication</category>
      <category>restapi</category>
    </item>
    <item>
      <title>State Machines for Email Verification APIs</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Mon, 07 Sep 2026 08:23:15 +0000</pubDate>
      <link>https://dev.to/kevindev27/state-machines-for-email-verification-apis-4jpp</link>
      <guid>https://dev.to/kevindev27/state-machines-for-email-verification-apis-4jpp</guid>
      <description>&lt;p&gt;Email verification is often represented by one boolean: &lt;code&gt;email_verified = true&lt;/code&gt; or &lt;code&gt;false&lt;/code&gt;. That works for a demo, but it becomes vague as soon as delivery is delayed, a user requests a second message, or a worker retries the same job.&lt;/p&gt;

&lt;p&gt;In backend services, I prefer treating verification as a small state machine. It makes the API contract clearer, gives PostgreSQL useful invariants, and lets support explain what happened without reading application logs line by line. It also keeps a temp email generator used in development from accidentally shaping production rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a boolean is not enough
&lt;/h2&gt;

&lt;p&gt;Consider a user who clicks “resend” twice. The first message may arrive after the second one. If the database only stores a boolean and one token, the older link can be accepted unexpectedly, or a valid newer link can be rejected because the record was overwritten.&lt;/p&gt;

&lt;p&gt;The same ambiguity appears when a mail provider reports a temporary failure. Is the account unverified, queued, blocked, or expired? Those are different operational situations, and they deserve different next actions.&lt;/p&gt;

&lt;p&gt;For a disposable email account used in a test environment, this distinction is especially useful. Test code can wait for a message that belongs to its request instead of matching whichever message happens to be newest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model the verification states
&lt;/h2&gt;

&lt;p&gt;A practical state set is small:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;pending&lt;/code&gt;: the user started verification, but no message is confirmed as delivered&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sent&lt;/code&gt;: a message was accepted by the mail provider&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;confirmed&lt;/code&gt;: the token was used successfully&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;expired&lt;/code&gt;: the token lifetime ended&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cancelled&lt;/code&gt;: a newer verification request replaced this one&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important part is not the exact names. It is making transitions explicit. &lt;code&gt;confirmed&lt;/code&gt; should be terminal for that token. &lt;code&gt;expired&lt;/code&gt; should not become &lt;code&gt;confirmed&lt;/code&gt; merely because an old link was replayed.&lt;/p&gt;

&lt;p&gt;The database row can carry a request id, token hash, expiry time, and timestamps for each meaningful transition. That gives you a history that is compact enough for normal traffic but detailed enough for incident review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make transitions idempotent
&lt;/h2&gt;

&lt;p&gt;Verification links are commonly opened twice: once by a mail security scanner and again by the user. The endpoint should therefore be safe to repeat.&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;UPDATE&lt;/span&gt; &lt;span class="n"&gt;email_verifications&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'confirmed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;confirmed_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;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;AND&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'sent'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&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;RETURNING&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If this returns a row, the request performed the transition. If it returns no row, the API should inspect the existing state and return a stable result such as “already confirmed” or “link expired”. Do not create a fresh token as a side effect of a GET request.&lt;/p&gt;

&lt;p&gt;For concurrent clicks, put the transition and the user update in one transaction. A row lock or a conditional update ensures that two requests cannot both claim the same token. This is one of those details that looks fussy until a high-traffic signup flow produces duplicate welcome jobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Store evidence that survives retries
&lt;/h2&gt;

&lt;p&gt;Each verification request should have a correlation id. Include it in the email event, provider response, worker log, and API audit record. That makes it possible to connect the signup request with the email, similar to &lt;a href="https://dev.to/jasonmills94/put-docker-digests-in-ecs-deploy-emails-2gle"&gt;correlating deploy emails with the right run&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I usually store these fields:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Reason&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;request_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Separates resend attempts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;token_hash&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Avoids storing the usable secret&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;state&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Represents the current contract&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;provider_message_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Connects delivery callbacks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;expires_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Makes replay rules deterministic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;created_at&lt;/code&gt; and transition times&lt;/td&gt;
&lt;td&gt;Explains delays&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Keep the raw token out of logs. A &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;temporary email account generator&lt;/a&gt; can be handy for isolated development checks, but it should not be treated as proof of a real user's identity or as a replacement for provider-level delivery signals.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Node.js endpoint shape
&lt;/h2&gt;

&lt;p&gt;The HTTP layer should translate state into predictable responses. A simplified handler might look like this:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;confirmEmail&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="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;verificationService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;confirm&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;requestId&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;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;token&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;query&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="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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;confirmed&lt;/span&gt;&lt;span class="dl"&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;204&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;already-confirmed&lt;/span&gt;&lt;span class="dl"&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;204&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;expired&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;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;410&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;code&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_expired&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;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;code&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_invalid&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice that repeated success is still a success. Clients should not need a special recovery path because a browser or scanner revisited the link. The service can publish a &lt;code&gt;verification.confirmed&lt;/code&gt; event once, guarded by the same transaction or an idempotency key.&lt;/p&gt;

&lt;p&gt;This event-oriented approach builds naturally on &lt;a href="https://dev.to/kevindev27/version-email-events-in-node-apis-pg5"&gt;versioning email events in a Node API&lt;/a&gt;. Consumers can reject unknown event versions instead of silently misreading a changed payload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational checklist
&lt;/h2&gt;

&lt;p&gt;Before shipping, I check the following:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Are resend attempts represented by separate request ids?&lt;/li&gt;
&lt;li&gt;Is the token stored as a hash and compared in constant-time code?&lt;/li&gt;
&lt;li&gt;Can two confirmations race without double-updating the user?&lt;/li&gt;
&lt;li&gt;Are expired and already-confirmed responses stable for clients?&lt;/li&gt;
&lt;li&gt;Can a provider callback be replayed safely?&lt;/li&gt;
&lt;li&gt;Do logs contain correlation ids but no usable tokens?&lt;/li&gt;
&lt;li&gt;Can a test use a dummy e mail without changing production trust rules?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The last point sounds minor, but test conveniences often leak into policy code when the state model is unclear.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Should &lt;code&gt;sent&lt;/code&gt; mean the user received the message?
&lt;/h3&gt;

&lt;p&gt;No. It should normally mean the provider accepted the request. Delivery, bounce, and complaint signals are separate facts. Combining them hides useful failure modes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should expired rows be deleted?
&lt;/h3&gt;

&lt;p&gt;Not immediately. Retain a limited audit record, remove or hash sensitive values, and apply a documented retention policy. A small history helps investigate resend loops and abuse.&lt;/p&gt;

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

&lt;p&gt;Usually not. The implementation can be a handful of guarded database transitions. The value comes from making edge cases explicit before traffic and retries make them expensive.&lt;/p&gt;

&lt;p&gt;Email verification becomes easier to maintain when every request has an owner, a lifetime, and a legal next state. That is a modest amount of structure, but it prevents a surprising number of authentication bugs.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>authentication</category>
      <category>node</category>
      <category>restapi</category>
    </item>
    <item>
      <title>Keep Email Review APIs Explainable</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Thu, 03 Sep 2026 23:24:22 +0000</pubDate>
      <link>https://dev.to/kevindev27/keep-email-review-apis-explainable-ock</link>
      <guid>https://dev.to/kevindev27/keep-email-review-apis-explainable-ock</guid>
      <description>&lt;p&gt;When a signup flow flags an address for review, the logic is rarely the hardest part. The mess usually starts one layer later, when the API, the worker, and support tooling all tell slightly different stories about what happened. That gap gets wider once you handle edge cases like &lt;code&gt;temp mail&lt;/code&gt; domains, manual overrides, and retries from downstream systems.&lt;/p&gt;

&lt;p&gt;I have seen teams spend more time explaining review outcomes than improving the rule set itself. One response says "pending", another says "blocked", and the worker logs show a timeout from ten minutes ago that maybe still matters, maybe not. At that point the system is technically running, but it is not very explainable, and engineers end up doing detective work at 2 AM. Not fun, honestly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure mode is usually not the rule itself
&lt;/h2&gt;

&lt;p&gt;Most review pipelines begin with a sensible design:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the &lt;code&gt;REST API&lt;/code&gt; accepts signup input&lt;/li&gt;
&lt;li&gt;a review row is created when the risk score crosses a threshold&lt;/li&gt;
&lt;li&gt;a worker checks the evidence&lt;/li&gt;
&lt;li&gt;the final decision is written back for &lt;code&gt;Authentication&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That looks clean on a whiteboard. In production, though, teams often bolt on a few "temporary" shortcuts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;support agents can manually change a row&lt;/li&gt;
&lt;li&gt;retries update the same status field without recording why&lt;/li&gt;
&lt;li&gt;dashboard queries compress several states into one label&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now a case involving a &lt;code&gt;tem email&lt;/code&gt; pattern or a suspicious alias can move through three owners without one durable explanation. This is also where frontend teams start needing cleaner contracts so they can &lt;a href="https://dev.to/ryanlee91/abort-stale-email-checks-in-react-2b5p"&gt;cancel stale email checks upstream&lt;/a&gt; instead of rendering whatever late response arrives last.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model the review as state plus evidence
&lt;/h2&gt;

&lt;p&gt;The design that has aged best for me is boring on purpose: one table for the latest state, another for append-only evidence.&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;signup_email_reviews&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;email_address&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;risk_reason&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="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&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;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'claimed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'approved'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'blocked'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="n"&gt;available_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;claimed_by&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;claimed_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;decision_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="k"&gt;default&lt;/span&gt; &lt;span class="mi"&gt;1&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;updated_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;table&lt;/span&gt; &lt;span class="n"&gt;signup_email_review_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;review_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;signup_email_reviews&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;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;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="k"&gt;default&lt;/span&gt; &lt;span class="s1"&gt;'{}'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;jsonb&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;This split matters because state answers "what is true now?" while events answer "how did we get here?" The first question powers the product. The second one saves your backend team when a rule changes on Friday and nobody rembers which worker version handled the first wave of decisions.&lt;/p&gt;

&lt;p&gt;It also keeps you from overloading one column with vague meanings. I really dislike status values like &lt;code&gt;done&lt;/code&gt; or &lt;code&gt;processed&lt;/code&gt;. They age badly. &lt;code&gt;blocked&lt;/code&gt; and &lt;code&gt;approved&lt;/code&gt; mean something. &lt;code&gt;claimed&lt;/code&gt; means ownership is active. Those are much easier to reason about when somebody asks why one temp mailid case never reached a final decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep claiming logic tiny and explicit
&lt;/h2&gt;

&lt;p&gt;Explainability usually improves when the claim step is very small. Claim one row, mark the owner, move on.&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_review&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;signup_email_reviews&lt;/span&gt;
  &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;
    &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;available_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;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="k"&gt;limit&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;update&lt;/span&gt; &lt;span class="n"&gt;signup_email_reviews&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'claimed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;claimed_by&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="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;claimed_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="n"&gt;updated_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;next_review&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;r&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_review&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;r&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;r&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email_address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;risk_reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;decision_version&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;for update skip locked&lt;/code&gt; is helpful here because it gives each worker a narrow ownership window without making the whole queue serialized. The important part is what you do next: evaluate the review, append an event, then finalize the decision. Do not claim the row and mutate user access in the same oversized transaction. That pattern works untill a retry or partial failure leaves you with a half-explained state.&lt;/p&gt;

&lt;p&gt;I also like adding explicit event types such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;review_created&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;worker_claimed&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;evidence_loaded&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;decision_written&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;decision_requeued&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those names read well in logs, in SQL, and in incident notes. They also pair nicely with &lt;a href="https://dev.to/silviutech/playwright-email-tests-need-receipt-logs-3401"&gt;receipt logs for email test flows&lt;/a&gt;, because the same idea applies outside production traffic: keep one readable trail per run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the API answer what happened
&lt;/h2&gt;

&lt;p&gt;The API should not force callers to infer review progress from one opaque string. A small response contract can remove a lot of confusion:&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;"review_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;48192&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;"claimed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"decision_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;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"updated_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-09-03T23:22:20Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"next_action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"poll"&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;"domain_on_watchlist"&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;That does not expose private scoring internals, but it does tell other services what they can safely do next. If the API returns &lt;code&gt;claimed&lt;/code&gt;, the caller waits. If it returns &lt;code&gt;blocked&lt;/code&gt;, the account service can apply the final rule. If it returns &lt;code&gt;approved&lt;/code&gt;, the signup flow can continue without guessing whether the worker is still thinking.&lt;/p&gt;

&lt;p&gt;One more thing that helps: keep policy versioning visible. Rules change. Vendor signals change. Your risk appetite changes after abuse spikes. If the review record does not include &lt;code&gt;decision_version&lt;/code&gt;, every historical discussion becomes fuzzy, and fuzzy backend systems are expensive to maintain.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Should every risky email go through a queue?
&lt;/h3&gt;

&lt;p&gt;No. Reserve the queue for ambiguous or operationally important cases. Clear accepts and clear rejects should stay synchronous when they can, otherwise you make the happy path slower for no real gain.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is PostgreSQL enough for this?
&lt;/h3&gt;

&lt;p&gt;Usually, yes. If your team already operates &lt;code&gt;PostgreSQL&lt;/code&gt; well, a relational review queue is often simpler than introducing more moving parts too early. Add more infra when a measured bottleneck shows up, not because the architecture diagram feels cooler.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the smallest useful audit trail?
&lt;/h3&gt;

&lt;p&gt;At minimum: review id, worker id, risk reason, final decision, policy version, and timestamps for claim and completion. If you cannot answer "who owned this review and why did it stop here?" from stored data, the design still needs work.&lt;/p&gt;

&lt;p&gt;Explainable review APIs do not make abuse decisions perfect. They do make them maintainable. For backend systems that sit between signup intent and &lt;code&gt;Authentication&lt;/code&gt; policy, that is a pretty big win, even if it looks a bit plain from the outside.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>postgres</category>
      <category>authentication</category>
      <category>restapi</category>
    </item>
    <item>
      <title>PostgreSQL Queues for Email Risk Reviews</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Wed, 02 Sep 2026 23:24:19 +0000</pubDate>
      <link>https://dev.to/kevindev27/postgresql-queues-for-email-risk-reviews-1fod</link>
      <guid>https://dev.to/kevindev27/postgresql-queues-for-email-risk-reviews-1fod</guid>
      <description>&lt;p&gt;In one signup system I maintained, the hardest part of email risk checks was not the classifier. It was the handoff between the API, the reviewer job, and the audit trail. We had valid reasons to flag domains, aliases, and patterns like &lt;code&gt;facebook temp email&lt;/code&gt;, but the operational flow got noisy fast when several workers looked at the same review item at once.&lt;/p&gt;

&lt;p&gt;That noise usually shows up in boring ways: duplicate decisions, retries that hide the first failure, and support notes that do not match what the &lt;code&gt;Authentication&lt;/code&gt; service actually decided. It sounds small, but it creates a very real maintenance tax. Backend teams dont lose time on the final SQL statement, they lose time on unclear ownership.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why email risk review queues get noisy
&lt;/h2&gt;

&lt;p&gt;The common setup is simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the signup API writes a pending review row&lt;/li&gt;
&lt;li&gt;a worker scans for pending rows every few seconds&lt;/li&gt;
&lt;li&gt;another worker retries stale work&lt;/li&gt;
&lt;li&gt;dashboards read counts without enough state detail&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This works for a while, then gets weird. A domain rule changes, a reviewer deploy lands mid-run, and suddenly nobody is sure whether a decision was skipped, retried, or overwritten. If your team already thinks about &lt;a href="https://dev.to/bitheirstake/signup-email-risk-checks-need-data-budgets-347m-temp-slug-8170052?preview=af149e838cfcd5b7e49c0811d4e06da3fdeb092e2af256a0b830c9ea989e8d6d9286fc8326f1abe871a62c3a80a68907d6d6b63a9e0fd37c5639d262"&gt;data budgets for signup risk checks&lt;/a&gt;, the same idea applies here too: keep just enough state to explain decisions, not a giant blob of maybe-useful metadata.&lt;/p&gt;

&lt;p&gt;The tricky part is that engineers often treat the queue as a transport concern only. In practice, the queue is also your explanation layer. When a reviewer asks why a signup hit a manual check because of a domain that looked like temp gamil com, the system should answer from stored state, not from team memory. That is where &lt;code&gt;PostgreSQL&lt;/code&gt; helps more than people expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PostgreSQL model that aged well for us
&lt;/h2&gt;

&lt;p&gt;The pattern that held up best was a single review table plus an append-only decision log:&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_risk_reviews&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;email_address&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;risk_reason&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="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&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;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'claimed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'approved'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'blocked'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="n"&gt;claimed_by&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;claimed_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;available_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;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;updated_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;table&lt;/span&gt; &lt;span class="n"&gt;email_risk_review_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;review_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_risk_reviews&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;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_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="k"&gt;default&lt;/span&gt; &lt;span class="s1"&gt;'{}'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;jsonb&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;I like this layout because the current state stays cheap to query, while the event table tells the story after the fact. You do not need event sourcing religion here, just enough structure so incidents are reconstructable later. That distinction matters more than it seems, and teams forget it alot.&lt;/p&gt;

&lt;p&gt;For risk reasons, keep them narrow and reviewable. Examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;domain_on_watchlist&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;mx_lookup_failed&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;alias_pattern_collision&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;manual_rule_match&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once those values drift into free-form text, the whole system starts to rot a bit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claim rows with clear ownership
&lt;/h2&gt;

&lt;p&gt;The most useful behavior change was moving from "worker reads pending rows" to "worker claims specific rows." That removes a surprising amount of accidental concurrency.&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_review&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;email_risk_reviews&lt;/span&gt;
  &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;
    &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;available_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;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="k"&gt;limit&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;update&lt;/span&gt; &lt;span class="n"&gt;email_risk_reviews&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'claimed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;claimed_by&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="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;claimed_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="n"&gt;updated_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;next_review&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;r&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_review&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&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;for update skip locked&lt;/code&gt; is not magic, but it is a very practical default for this kind of worker. One row gets one owner for one attempt. If the worker crashes, you can requeue the row with a timeout job. If the worker succeeds, you append an event and finalize the status. Clean enough, and pretty boring in a good way.&lt;/p&gt;

&lt;p&gt;That boringness is valuable. It also makes it easier to &lt;a href="https://dev.to/ryanlee91/react-mention-emails-without-double-sends-1e62"&gt;avoid duplicate notification sends&lt;/a&gt; in adjacent workflows, because your backend now has a stable record of which review actually finished before downstream mail or account actions begin.&lt;/p&gt;

&lt;p&gt;One caution: do not let the worker directly mutate user access in the same transaction that claims the review. Keep the claim step small. Finish the review, write the event, then let the API or a dedicated command handler apply the account change. Mixing those concerns tends to work untill it really doesnt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the authentication boundary should stay
&lt;/h2&gt;

&lt;p&gt;For me, the most maintainable boundary is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;signup API creates the review row&lt;/li&gt;
&lt;li&gt;reviewer service decides &lt;code&gt;approved&lt;/code&gt; or &lt;code&gt;blocked&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;authentication service consumes the final decision&lt;/li&gt;
&lt;li&gt;audit tools read both current state and events&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This keeps the queue from becoming an all-knowing god object. It is only responsible for review coordination and traceability. The final account policy still belongs to &lt;code&gt;Authentication&lt;/code&gt;, where rate limits, user messaging, and lock rules already live.&lt;/p&gt;

&lt;p&gt;That separation also keeps typo-heavy evidence from leaking into policy rules. If a support agent notes temp org mail in a ticket, that can exist as review context without becoming a permanent domain rule by accident. Small distinction, but it saves you from some silly cleanups later.&lt;/p&gt;

&lt;p&gt;If you need one more field, add a &lt;code&gt;decision_version&lt;/code&gt;. When policies change, you can tell which rule set produced which outcome. I skipped that for too long on one service, and the post-incident analysis was more annoying than it needed to be.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Should every flagged signup become a queued review?
&lt;/h3&gt;

&lt;p&gt;No. Reserve the queue for cases that are ambiguous or operationally sensitive. Hard blocks and clear allows should stay synchronous when possible, otherwise the API gets slower and the queue becomes a dumping ground.&lt;/p&gt;

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

&lt;p&gt;Not always. If review volume is moderate and the team already runs &lt;code&gt;PostgreSQL&lt;/code&gt; well, a relational queue is often enough. Add more infra only when you can point to a real bottleneck, not because the pattern looks more modern.&lt;/p&gt;

&lt;h3&gt;
  
  
  What should I log for each decision?
&lt;/h3&gt;

&lt;p&gt;At minimum: review id, user id, risk reason, worker id, decision, and policy version. If you cannot answer "who claimed this row and why did it end blocked?" from logs plus tables, the design still needs work.&lt;/p&gt;

&lt;p&gt;A review queue like this will never make bad email heuristics good by itself. What it does is make backend behavior legible. For systems that have to explain risky signup decisions later, that legibility is most of the win.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>postgres</category>
      <category>authentication</category>
      <category>restapi</category>
    </item>
    <item>
      <title>REST Idempotency for Signup Email Jobs</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Wed, 02 Sep 2026 17:23:38 +0000</pubDate>
      <link>https://dev.to/kevindev27/rest-idempotency-for-signup-email-jobs-241a</link>
      <guid>https://dev.to/kevindev27/rest-idempotency-for-signup-email-jobs-241a</guid>
      <description>&lt;h1&gt;
  
  
  REST Idempotency for Signup Email Jobs
&lt;/h1&gt;

&lt;p&gt;When a signup request times out, most clients retry before a human even notices. If your API creates a user row on the first attempt and enqueues a verification email on the second, you now have duplicate mail and noisy support threads. I have seen this bug show up in otherwise clean systems because the API path looked deterministic, but the side effects were not.&lt;/p&gt;

&lt;p&gt;The fix is not "retry less." The fix is making the signup email workflow idempotent end to end, from the HTTP contract to the worker that actually talks to your provider.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why signup emails duplicate so easily
&lt;/h2&gt;

&lt;p&gt;A typical failure path looks simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;POST /signup&lt;/code&gt; validates the payload.&lt;/li&gt;
&lt;li&gt;The app creates the user record.&lt;/li&gt;
&lt;li&gt;The app writes a message into a queue.&lt;/li&gt;
&lt;li&gt;The HTTP connection drops before the client gets &lt;code&gt;201 Created&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The client retries with the same intent.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If step 2 committed and step 3 partially succeeded, the second request can produce a second email job. In practice, these duplicates are common when mobile clients retry aggressively or when upstream gateways hide the first timeout. The app logic may still seem "correct", but the workflow isnt stable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The API contract that makes retries safe
&lt;/h2&gt;

&lt;p&gt;For signup-triggered email, I prefer an explicit idempotency key on the request. The contract is boring on purpose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /signup
Idempotency-Key: 0e5d9d48-5f74-4c1b-a87a-d9160b1a4d71
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then store one durable record keyed by:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;tenant or app scope&lt;/li&gt;
&lt;li&gt;normalized email&lt;/li&gt;
&lt;li&gt;idempotency key&lt;/li&gt;
&lt;li&gt;request hash&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important rule is that the first accepted request owns the outcome. Retries with the same key return the same result shape, including the same signup status and the same email job reference. Retries with the same key but a different body should fail loudly with &lt;code&gt;409 Conflict&lt;/code&gt;, because that is almost always a client bug.&lt;/p&gt;

&lt;p&gt;In Node.js, the write path can stay pretty small:&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;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;signup&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;`
    insert into signup_requests (scope_id, email, idem_key, request_hash, status)
    values ($1, $2, $3, $4, 'accepted')
    on conflict (scope_id, email, idem_key)
    do nothing
    returning id
    `&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;scopeId&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;idemKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requestHash&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;signup&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;loadExistingResult&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;scopeId&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;idemKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requestHash&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;user&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;upsertUser&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;email&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;enqueueVerificationOutbox&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;signup&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;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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;created&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="na"&gt;userId&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;signup_requests&lt;/code&gt; row becomes the source of truth for retries. It also gives you a clean audit trail for auth and support teams, which saves time later, trust me.&lt;/p&gt;

&lt;h2&gt;
  
  
  A queue model that avoids double sends
&lt;/h2&gt;

&lt;p&gt;The API boundary alone is not enough. Workers retry too, providers timeout too, and operators rerun stuck jobs at 2 AM. I usually combine three guards:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;An outbox table written in the same transaction as the signup acceptance.&lt;/li&gt;
&lt;li&gt;A unique constraint on the logical email intent, such as &lt;code&gt;(template, user_id, signup_request_id)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A provider delivery key carried through the worker so downstream retries are also deduplicated.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This works better than trying to infer duplicates from send timestamps. Timestamps drift, jobs replay, and somebody will eventually reschedule a batch manualy.&lt;/p&gt;

&lt;p&gt;If you need separate resend behavior, model it as a new intent with a reason code. Do not overload the original signup intent. That is the same lesson behind &lt;a href="https://dev.to/kevindev27/postgresql-leases-for-resend-email-apis-82c"&gt;lease-based resend coordination&lt;/a&gt;: once operational retries mix with product retries, the state machine gets muddy fast.&lt;/p&gt;

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

&lt;p&gt;I would not use a temporary inbox to decide whether a signup request is valid. That belongs in API validation and abuse controls. But temp inboxes are still useful in integration testing, staging review, and support reproduction.&lt;/p&gt;

&lt;p&gt;For example, when QA needs to &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;generate disposable email&lt;/a&gt; accounts during smoke tests, the important backend rule is that mailbox churn must not change your idempotency behavior. Whether the address came from a temp mail generator, a fake e mail com style test alias, or an internal seed account, the retry contract should behave the same. A temp mailid in a bug report should not require special code paths.&lt;/p&gt;

&lt;p&gt;For rollout verification, I also like &lt;a href="https://dev.to/jasonmills94/one-mailbox-per-eks-rollout-3poh"&gt;isolated inboxes during rollout checks&lt;/a&gt; because they make it easier to prove which deploy emitted which message.&lt;/p&gt;

&lt;h2&gt;
  
  
  A short review checklist
&lt;/h2&gt;

&lt;p&gt;When I review this kind of endpoint, I ask these questions first:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Does the API require an idempotency key for every signup email intent?&lt;/li&gt;
&lt;li&gt;Is the request hash stored so mismatched retries fail deterministically?&lt;/li&gt;
&lt;li&gt;Is the outbox insert in the same database transaction as the user/signup write?&lt;/li&gt;
&lt;li&gt;Can a worker replay send the same logical email twice?&lt;/li&gt;
&lt;li&gt;Can support or ops tell the difference between "already sent" and "never queued"?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If any of those answers are fuzzy, the system is probably one network blip away from duplicate mail.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Should I deduplicate only by email address?
&lt;/h2&gt;

&lt;p&gt;No. A single email address can legitimately trigger different intents over time. Deduplicate the specific intent, not the person.&lt;/p&gt;

&lt;h2&gt;
  
  
  What if the provider does not support idempotency keys?
&lt;/h2&gt;

&lt;p&gt;Keep your own delivery intent identifier and persist provider response metadata. You can still make your worker retry-safe even if the provider API is a bit old-school.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is eventual consistency a problem here?
&lt;/h2&gt;

&lt;p&gt;Not if you make the accepted request record durable before returning success. The exact send time can be eventual; the intent record cannot.&lt;/p&gt;

&lt;p&gt;Reliable signup email flows are mostly about choosing one canonical intent record and refusing to let retries create a second one. The pattern is not flashy, but it keeps auth systems calmer, support inboxes quieter, and deploy nights a lot less weird.&lt;/p&gt;

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