<?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: Email Digit</title>
    <description>The latest articles on DEV Community by Email Digit (@emaildigit).</description>
    <link>https://dev.to/emaildigit</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%2F4238092%2F0d3b715c-0a65-48fe-9027-fca96e1a5c85.png</url>
      <title>DEV Community: Email Digit</title>
      <link>https://dev.to/emaildigit</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/emaildigit"/>
    <language>en</language>
    <item>
      <title>Invisible Unicode Characters in Email: How to Find the Ghosts in Your Templates</title>
      <dc:creator>Email Digit</dc:creator>
      <pubDate>Wed, 11 Nov 2026 20:40:35 +0000</pubDate>
      <link>https://dev.to/emaildigit/invisible-unicode-characters-in-email-how-to-find-the-ghosts-in-your-templates-32bb</link>
      <guid>https://dev.to/emaildigit/invisible-unicode-characters-in-email-how-to-find-the-ghosts-in-your-templates-32bb</guid>
      <description>&lt;p&gt;Picture a Tuesday deploy. Marketing pastes the new welcome email copy from a shared doc into your template, you ship it, and everything looks perfect in the preview. Two days later a support ticket arrives: a customer's email client showed one line of the message in a strange order, and your keyword-based content filter in staging never flagged the word it was built to catch. You diff the copy. The diff is empty. Nothing is wrong. Except something is.&lt;/p&gt;

&lt;p&gt;Welcome to &lt;strong&gt;invisible Unicode characters in email&lt;/strong&gt;: characters that take up a position in your string, draw nothing (or rearrange what's around them), and survive every copy-paste with the stealth of a ninja who also happens to be a lawyer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two families of invisible characters
&lt;/h2&gt;

&lt;p&gt;Most text never contains these, which is exactly why they surprise people.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Family&lt;/th&gt;
&lt;th&gt;Examples&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Direction controls&lt;/td&gt;
&lt;td&gt;Right-to-left override (U+202E), left-to-right embedding (U+202A), isolates (U+2066 to U+2069)&lt;/td&gt;
&lt;td&gt;Tell the renderer to lay out the following characters in a different direction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Zero-width characters&lt;/td&gt;
&lt;td&gt;Zero-width space (U+200B), word joiner (U+2060)&lt;/td&gt;
&lt;td&gt;Occupy a position in the string but render as nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both have honest jobs in typesetting. The trouble starts when the text a reader sees and the text your code stores stop being the same string.&lt;/p&gt;

&lt;h2&gt;
  
  
  How they disguise a line
&lt;/h2&gt;

&lt;p&gt;The classic demonstration is a file name. Put a right-to-left override in front of &lt;code&gt;gpj.exe&lt;/code&gt; at the end of &lt;code&gt;invoice_&lt;/code&gt;, and the name displays as &lt;code&gt;invoice_exe.jpg&lt;/code&gt;. It looks like a picture. It's a program.&lt;/p&gt;

&lt;p&gt;In 2021, security researchers showed the same trick making source code read one way to a reviewer and compile another way. That research, tracked as &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2021-42574" rel="noopener noreferrer"&gt;CVE-2021-42574&lt;/a&gt;, is why the technique is now widely called "Trojan Source".&lt;/p&gt;

&lt;p&gt;Zero-width characters are quieter, but they cause similar headaches:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Breaking up words.&lt;/strong&gt; A zero-width space inside a word means a filter looking for that word no longer matches, while a human sees nothing unusual.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Making two strings look identical.&lt;/strong&gt; A link label with an invisible character inside looks the same on screen as the real thing, but &lt;code&gt;===&lt;/code&gt; disagrees.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tagging copies.&lt;/strong&gt; Patterns of zero-width characters can watermark text so a leaked copy can be traced.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How they end up in ordinary email
&lt;/h2&gt;

&lt;p&gt;Almost nobody puts them there on purpose. They arrive by paste: from a word processor, a shared doc, a web page, a chat message, or an old template that was itself pasted from somewhere else. Nothing on screen shows them, so they hitchhike from campaign to campaign like a burr on a sock.&lt;/p&gt;

&lt;p&gt;If you build or send email from code, that means your templates, your CMS fields and your copy pipeline are all possible entry points.&lt;/p&gt;

&lt;h2&gt;
  
  
  Find them: a one-line regex
&lt;/h2&gt;

&lt;p&gt;Most editors and every language with a regex engine can search for them. This pattern matches the direction controls and the common zero-width characters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[\u200B\u2060\u202A-\u202E\u2066-\u2069]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A quick Node check over a template string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="no"&gt;INVISIBLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;\&lt;span class="n"&gt;u200B&lt;/span&gt;\&lt;span class="n"&gt;u2060&lt;/span&gt;\&lt;span class="n"&gt;u202A&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;\&lt;span class="n"&gt;u202E&lt;/span&gt;\&lt;span class="n"&gt;u2066&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;\&lt;span class="n"&gt;u2069&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;findInvisible&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&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="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;INVISIBLE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;codepoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'U+'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;m&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="nf"&gt;codePointAt&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="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'0'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nb"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;findInvisible&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Welcome\u200B aboard'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// [ { index: 7, codepoint: 'U+200B' } ]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A hit in text that's meant to be plain English is almost always safe to delete. Run it in CI against your email templates and you'll catch the next paste before a customer does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't strip everything: the characters real languages need
&lt;/h2&gt;

&lt;p&gt;Here's where the "just delete all of it" instinct goes wrong. Two invisible characters are load-bearing in real writing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero-width non-joiner (U+200C)&lt;/strong&gt;: Persian and Arabic spelling use it to stop letters connecting where they shouldn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero-width joiner (U+200D)&lt;/strong&gt;: Indic scripts use it to form the right conjuncts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's why the regex above deliberately leaves out U+200C and U+200D. Strip them and you haven't cleaned your Persian newsletter, you've misspelled it.&lt;/p&gt;

&lt;p&gt;And real right-to-left text (Arabic, Hebrew) doesn't need override characters at all. The standard HTML &lt;code&gt;dir&lt;/code&gt; attribute on a block, with embedded left-to-right runs such as prices and URLs isolated, gets direction right without any invisible controls in the text.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a sending platform can (and can't) do for you
&lt;/h2&gt;

&lt;p&gt;This is the approach Email Digit takes, described in its guide to &lt;a href="https://emaildigit.com/guides/invisible-unicode-characters-in-email" rel="noopener noreferrer"&gt;invisible Unicode characters in email&lt;/a&gt;: when an email is rendered for sending, the text of paragraphs, quotes and table cells is cleaned. Direction controls, the zero-width space, the word joiner and a few other invisible formatting codes such as the soft hyphen are removed, while the joiner and non-joiner are kept.&lt;/p&gt;

&lt;p&gt;The guide is also upfront about the limits, which are worth copying into your own mental model whatever tool you use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not every field is cleaned.&lt;/strong&gt; Subject lines, headings, list items, button labels and raw HTML blocks aren't passed through it today, so check those yourself if the copy came from somewhere you don't trust. (Subject lines are the sneaky one; a &lt;a href="https://emaildigit.com/tools/subject-line-tester" rel="noopener noreferrer"&gt;subject line tester&lt;/a&gt; helps you look at them before sending.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invisible isn't the same as lookalike.&lt;/strong&gt; A URL that swaps a Latin "a" for a Cyrillic one is made of visible characters. No invisible-character filter will catch it. Read link destinations, not just link text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It isn't a spam filter.&lt;/strong&gt; Cleaning hidden characters stops your own email carrying them by accident. It doesn't judge what the copy says.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A short checklist for your email pipeline
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Add the regex check to CI for every template and stored snippet.&lt;/li&gt;
&lt;li&gt;Normalize pasted copy at the input boundary (CMS field, admin form), not only at send time.&lt;/li&gt;
&lt;li&gt;Whitelist U+200C and U+200D so Persian, Arabic and Indic text survive.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;dir="rtl"&lt;/code&gt; on blocks instead of override characters.&lt;/li&gt;
&lt;li&gt;Hand-check subject lines and button labels, which many cleaners skip.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If a character is invisible and changes what a line means, it doesn't belong in an email. Your diff tool can't see it. Now your CI can.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I work on &lt;a href="https://emaildigit.com" rel="noopener noreferrer"&gt;Email Digit&lt;/a&gt;, a deliverability-first email platform. Sources: Email Digit's guide on invisible Unicode characters (read November 12, 2026) and the Trojan Source CVE record.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This post was drafted with AI assistance and checked against the sources above.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>email</category>
      <category>unicode</category>
      <category>security</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Idempotency Keys for Transactional Email: How to Retry Without Sending Duplicates</title>
      <dc:creator>Email Digit</dc:creator>
      <pubDate>Wed, 11 Nov 2026 01:58:13 +0000</pubDate>
      <link>https://dev.to/emaildigit/idempotency-keys-for-transactional-email-how-to-retry-without-sending-duplicates-54oa</link>
      <guid>https://dev.to/emaildigit/idempotency-keys-for-transactional-email-how-to-retry-without-sending-duplicates-54oa</guid>
      <description>&lt;p&gt;It's 2:14 a.m. A user can't log in, so they hit "Forgot password". Your API calls the email provider. The provider queues the email. And then, somewhere between their data center and yours, the response quietly dies. Timeout. Dropped connection. A deploy that picked &lt;em&gt;exactly&lt;/em&gt; this moment to restart the worker.&lt;/p&gt;

&lt;p&gt;Your code does the responsible thing and retries. The provider, having no idea it has met this request before, does the &lt;em&gt;other&lt;/em&gt; responsible thing and sends it again.&lt;/p&gt;

&lt;p&gt;The user now has two reset emails with two different links, and a growing suspicion that one of them is a phishing attempt. They click the older one. It's expired. They open a support ticket with the subject line "your app is broken???" (three question marks, which is how you know it's serious).&lt;/p&gt;

&lt;p&gt;You can't fix this by removing retries; that just trades duplicate emails for lost ones, which is worse. The real fix is to make the retry &lt;em&gt;recognisable&lt;/em&gt;. That is exactly what an &lt;strong&gt;idempotency key for transactional email&lt;/strong&gt; does, and it's one of those tiny changes that saves you from a whole category of 2 a.m. bugs.&lt;/p&gt;

&lt;p&gt;This post covers how to pick a key that actually works, what your code should do with each response, and the sneaky mistakes that break it without telling you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an idempotency key does
&lt;/h2&gt;

&lt;p&gt;An idempotency key is a string you send with the request that names the &lt;em&gt;intent&lt;/em&gt;: "the reset email for reset request 8412". The provider remembers keys it has seen:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;New key:&lt;/strong&gt; the request is processed and the email is sent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Key seen before, same payload:&lt;/strong&gt; nothing new is sent; you get the original result back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Key seen before, different payload:&lt;/strong&gt; the request should be refused, because that is almost always a bug in the caller.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole pattern rests on one rule: &lt;strong&gt;every retry of the same logical email must carry the same key.&lt;/strong&gt; Break that rule and you've built a very elaborate way of sending duplicates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing a key: derive it from your data, not the HTTP call
&lt;/h2&gt;

&lt;p&gt;The key has to be decided before the first attempt and never regenerated. That means it should come from the event that caused the email.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Key&lt;/th&gt;
&lt;th&gt;Safe?&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pwreset-8412&lt;/code&gt; (reset request id)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Every retry of that reset carries the same key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;receipt-order-5531&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;One receipt per order, however many times the job runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;crypto.randomUUID()&lt;/code&gt; at call time&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;A retry generates a new UUID, so it looks like a new email&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The recipient's address&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Their legitimate second reset next month looks like a repeat&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A random UUID is fine &lt;strong&gt;if&lt;/strong&gt; you generate it once, store it with the event, and read it back on retry. The failure mode is generating it inside the function that makes the HTTP call. That code looks perfectly reasonable in review, passes every test, and then sends two receipts the first time production has a bad day.&lt;/p&gt;

&lt;p&gt;A simple convention that scales: &lt;code&gt;&amp;lt;email-type&amp;gt;-&amp;lt;source-entity-id&amp;gt;&lt;/code&gt;, for example &lt;code&gt;welcome-user-1207&lt;/code&gt;, &lt;code&gt;invoice-inv-2026-0042&lt;/code&gt;, &lt;code&gt;shipping-order-5531-parcel-2&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A retry loop that is safe to run twice
&lt;/h2&gt;

&lt;p&gt;Here is the shape in JavaScript. The important part is that &lt;code&gt;idempotencyKey&lt;/code&gt; is computed once, outside the loop.&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;sendResetEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resetRequest&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="n"&gt;idempotencyKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sb"&gt;`pwreset-${resetRequest.id}`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// decided once&lt;/span&gt;

  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&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;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="o"&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;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;EMAIL_API_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"POST"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nc"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sb"&gt;`Bearer ${process.env.EMAIL_API_KEY}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="s2"&gt;"Content-Type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"application/json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&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="p"&gt;{&lt;/span&gt;
          &lt;span class="n"&gt;trigger_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"password_reset"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;resetRequest&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;span class="n"&gt;variables&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;action_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;resetRequest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="p"&gt;}&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="n"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="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="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&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="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sb"&gt;`retryable ${res.status}`&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handleResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 2xx and 4xx are final&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&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="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;err&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;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;250&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// backoff, same key next time&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;If the first attempt actually succeeded and only the response was lost, attempt two returns the original result instead of a second email.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handle the answers, not just the happy path
&lt;/h2&gt;

&lt;p&gt;Retrying is only correct when the outcome is unknown. Most non-2xx answers mean "fix something", not "try again". This is the table Email Digit publishes for its transactional endpoint, and it is a good template for any provider:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Response&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;Retry?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;202, status &lt;code&gt;duplicate&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Same key, same payload: original message ids returned&lt;/td&gt;
&lt;td&gt;No, record it as success&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;202, status &lt;code&gt;suppressed&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Address hard bounced or complained before; nothing sent&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;409&lt;/td&gt;
&lt;td&gt;Key reused with a different payload, or the journey isn't active&lt;/td&gt;
&lt;td&gt;No, fix the cause&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;404&lt;/td&gt;
&lt;td&gt;No journey has that trigger key&lt;/td&gt;
&lt;td&gt;No, fix the key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;422&lt;/td&gt;
&lt;td&gt;Missing recipient, or sending domain not verified&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;td&gt;API key lacks the send scope&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Timeout or 5xx&lt;/td&gt;
&lt;td&gt;Outcome unknown&lt;/td&gt;
&lt;td&gt;Yes, same key&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That 409 on "same key, different payload" is worth wiring to an alert. It usually means someone built the key from the user id instead of the event id, so two different emails collided. It's the API equivalent of a coworker saying "hey, quick question": never as quick as advertised, always worth answering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistakes that break idempotency quietly
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Regenerating the key per attempt.&lt;/strong&gt; The most common one, and the most humbling. If your logs show a different key on each retry, you have no protection, just extra steps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keying on the recipient.&lt;/strong&gt; Two genuine resets a minute apart &lt;em&gt;should&lt;/em&gt; send two emails. They need two keys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reusing a key for a different message.&lt;/strong&gt; Treat keys as permanent. Don't assume a provider forgets them after 24 hours unless it documents that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retrying 4xx errors.&lt;/strong&gt; A 422 for an unverified sending domain will fail the same way forever. Retrying just burns queue time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sharing keys between test and live.&lt;/strong&gt; Check your provider keeps them separate, or prefix keys with the environment.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Idempotency is not deduplication of events
&lt;/h2&gt;

&lt;p&gt;An idempotency key stops the &lt;em&gt;same request&lt;/em&gt; from sending twice. It does not decide whether two &lt;em&gt;different&lt;/em&gt; events should both send. If your app emits two &lt;code&gt;order.paid&lt;/code&gt; events for one order, the email API will dutifully send two receipts, because from its point of view you asked twice. You need dedupe upstream, at the event or job level, before you ever reach the email API.&lt;/p&gt;

&lt;p&gt;Idempotency keys won't fix your event bus. They will, however, let you sleep through the next network blip, and your support inbox will be noticeably less dramatic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The full walkthrough, with the exact request and response shapes: &lt;a href="https://emaildigit.com/guides/idempotent-transactional-email-api" rel="noopener noreferrer"&gt;retry a transactional email without sending it twice&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://emaildigit.com/guides/marketing-vs-transactional-email-streams" rel="noopener noreferrer"&gt;Marketing vs transactional email streams&lt;/a&gt;, and why they shouldn't share a reputation&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://emaildigit.com/guides/verify-and-replay-email-webhooks" rel="noopener noreferrer"&gt;Verify, retry and replay email webhooks safely&lt;/a&gt;, the same problem in the other direction&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;I work on &lt;a href="https://emaildigit.com" rel="noopener noreferrer"&gt;Email Digit&lt;/a&gt;, an email platform for marketing and transactional sends with managed SPF, DKIM and DMARC.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>email</category>
      <category>api</category>
      <category>webdev</category>
      <category>backend</category>
    </item>
  </channel>
</rss>
